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
21 changes: 15 additions & 6 deletions .github/workflows/cache-warm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:
- name: Build frontend assets for Tauri config validation
run: npm run build

- name: Install Rust stable
- name: Install Rust 1.98.1
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # pinned for #2234
with:
components: clippy
Expand Down Expand Up @@ -110,8 +110,17 @@ jobs:
if: steps.cache.outputs.cache-hit != 'true'
run: npm run build:prod:no-bundle

verify-debug-cache:
name: verify-debug-cache
# The release test harnesses pull in dev-dependencies and their features,
# which the binary build above never compiles. Warm them too, so
# issue-1850-release-windows and issue-1850-profile-windows (release)
# restore them instead of compiling them on every pull request.
- name: cargo test --release (compile only)
if: steps.cache.outputs.cache-hit != 'true'
working-directory: src-tauri
run: cargo test --locked --release --lib --test issue_1850_default_root --no-run

verify-warm-debug:
name: verify-warm-debug
needs: warm-debug
runs-on: windows-latest
permissions:
Expand All @@ -120,7 +129,7 @@ jobs:
steps:
- uses: actions/checkout@v5

- name: Install Rust stable
- name: Install Rust 1.98.1
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # pinned for #2234
with:
components: clippy
Expand All @@ -142,8 +151,8 @@ jobs:
Write-Error "No exact gate-debug cache entry exists after the warm job. The warm job's save step failed silently or the key diverged. PR gates will run cold until this is fixed."
exit 1

verify-release-cache:
name: verify-release-cache
verify-warm-release:
name: verify-warm-release
needs: warm-release
runs-on: windows-latest
permissions:
Expand Down
644 changes: 411 additions & 233 deletions .github/workflows/pr-regression-gates.yml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1067,7 +1067,7 @@ jobs:
# minimum glibc, which is the user's decision, and the second is
# unnecessary (measured) and would put 1.0.5 headers in front of a
# 0.3.48 .so at link time.
- name: Use noble libspa-0.2 headers on ubuntu-22.04 (IS #1915)
- name: "Use noble libspa-0.2 headers on ubuntu-22.04 (IS #1915)"
if: matrix.runner == 'ubuntu-22.04'
shell: bash
env:
Expand Down
12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,18 @@ The workflow publishes a status check named `validate-branch-name` on the branch
git push -u origin <new-name>
```

## Check locally before pushing

`npm run ci:local` runs the cheap pull-request checks on your machine: every check of the
`test-debt` job, `cargo fmt --check`, and clippy on the pinned Rust version. The
cognitive-complexity check only warns, as it does in CI. A red check here is a red pull request
avoided.

- Run `npm ci` and `git fetch origin main` first, and commit your work: the ratchet checks compare
your last commit with `origin/main`, as CI does.
- `npm run ci:local -- --no-rust` skips cargo fmt and clippy.
- Clippy builds into `target/ci-local-<version>`, so your normal `target/` is not rebuilt.

<!-- Status: as of issue #93, Phase 1 only. Phase 2 (UI dropdown) and Phase 3 (live reload via tracing-subscriber) are aspirational and may or may not ship. -->

## Log filter precedence
Expand Down
29 changes: 16 additions & 13 deletions docs/quality/cognitive-complexity-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ The gate has two parts:

- `test-debt` runs the suppression scan and the baseline ratchet through
`scripts/check-cognitive-complexity.mjs`.
- each of the three Rust legs - `rust-regression` (Windows), `clippy-linux` (Linux) and
- each of the three Rust legs - `rust-regression-windows` (Windows), `clippy-linux` (Linux) and
`rust-regression-macos` - runs

```bash
Expand Down Expand Up @@ -292,17 +292,18 @@ Not every run has all three legs. `.github/workflows/pr-regression-gates.yml` sp
two tiers, decided by its `ci-tier` job:

- **Light tier, on every event:** `test-debt` (suppression scan and baseline ratchet),
`rust-regression` (Windows leg), `clippy-linux` (Linux leg) and `rust-fmt`.
`rust-regression-windows` (Windows leg), `clippy-linux` (Linux leg), `rust-fmt`,
`rust-slow-test-windows` and `issue-1850-release-windows` (no clippy, so no leg).
- **Full tier, only when `ci-tier` sets `full=true`:** everything else, including
`rust-regression-macos` (macOS leg). `rust-regression-linux` is also full-tier only, but it no
longer carries the gate; the Linux leg is `clippy-linux`.

`ci-tier` sets `full=true` when any of these holds:

- the run was started by `workflow_dispatch`;
- the pull request carries the `ci:full` label. Adding the label starts a run by itself, because
the workflow also triggers on `labeled`, and while the label stays on, every later push runs the
full tier too;
- the run was started by adding the `ci:full` label (the workflow also triggers on `labeled`).
The label is one-shot: later pushes run the light tier even while it stays on, so to get a full
run on a newer head, remove the label and add it again;
- the workflow's `github.run_number` is a multiple of 10. The counter is shared by every run of
this workflow in the repository, so it is roughly one run in ten, not one pull request in ten,
and nobody chooses which.
Expand Down Expand Up @@ -335,9 +336,10 @@ If that happens to your pull request:
(for `NEW`), or that removes the entry under the same rule (for `STALE`), labelled `ci:full` so
its macOS leg runs. Once it merges, merge main into your pull request and rerun.

To avoid causing this, add the `ci:full` label **before merging** any pull request that changes
code under a macOS-only `cfg`, or that edits or removes baseline entries whose sites only macOS
observes. Its checks then run all three legs.
To avoid causing this, add the `ci:full` label **after the last push, before merging** any pull
request that changes code under a macOS-only `cfg`, or that edits or removes baseline entries whose
sites only macOS observes (if the label is already on, remove it and add it again). That run checks
the final head on all three legs.

### 6.3 One invocation on macOS

Expand All @@ -351,18 +353,19 @@ excluded on all three platforms at once (section 2.3).

## 7. Bumping the pinned Rust version

The pin is local to six CI invocations and frozen nowhere else:
The pin is local to seven CI invocations and frozen nowhere else:

- four `toolchain:` inputs in `.github/workflows/pr-regression-gates.yml`, for `rust-regression`,
`clippy-linux`, `rust-regression-linux` and `rust-regression-macos`;
- two in `.github/workflows/cache-warm.yml`, for `warm-debug` and `verify-debug-cache`.
- five `toolchain:` inputs in `.github/workflows/pr-regression-gates.yml`, for
`rust-regression-windows`, `rust-slow-test-windows`, `clippy-linux`, `rust-regression-linux` and
`rust-regression-macos`;
- two in `.github/workflows/cache-warm.yml`, for `warm-debug` and `verify-warm-debug`.

No `rust-toolchain.toml` exists: local development, `release.yml`, `bundle-validation.yml` and the
other workflows stay on floating stable, and the pin must not be widened to them.

To bump:

1. Change the version in the six places above, and in the three run-time assertions in
1. Change the version in the seven places above, and in the three run-time assertions in
`pr-regression-gates.yml` that compare `rustc -vV` with the pin (one in each gate leg) - otherwise each leg fails with
`toolchain pin did not take effect`.
2. Regenerate the baseline from the three platforms' emissions, merged by the script's `--merge`
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"test": "vitest run",
"test:debt": "node scripts/check-test-debt.mjs",
"test:debt:self": "node scripts/check-test-debt.mjs --self-test",
"ci:local": "node scripts/ci-local.mjs",
"check:skills": "node scripts/01-skills-checker.mjs",
"check:skills:self": "node scripts/01-skills-checker.mjs --self-test",
"record:arcs": "node scripts/02-module-arc-record.mjs",
Expand Down
151 changes: 151 additions & 0 deletions scripts/ci-local.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#!/usr/bin/env node
// Runs the cheap pull-request gates locally before a push: every command of the
// `test-debt` job, `cargo fmt --check` (the `rust-fmt` job) and the clippy run with
// its cognitive-complexity check (the clippy leg of rust-regression-windows,
// clippy-linux and rust-regression-macos). It runs every check, even after a
// failure, so one run lists every problem; it exits 1 if any check failed. The
// complexity check only warns, because CI runs it with continue-on-error.
//
// The test-debt commands are read from the workflow on each run, so this list
// cannot drift from CI. Clippy uses the toolchain pinned in
// cognitive-complexity.baseline.json, in its own target directory so switching
// toolchains does not rebuild your normal target/.
//
// The ratchet checks (`--base-ref`) compare the last commit with the base, as CI
// does, so commit your work before running this or they will not see it.
//
// Usage: npm run ci:local [-- --no-rust] [-- --base-ref <ref>]

import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const WORKFLOW = path.join(ROOT, '.github', 'workflows', 'pr-regression-gates.yml');
const BASELINE = path.join(ROOT, 'cognitive-complexity.baseline.json');
const PLATFORM = { win32: 'windows', linux: 'linux', darwin: 'macos' }[process.platform];

const USAGE = `Usage: node scripts/ci-local.mjs [--no-rust] [--base-ref <ref>]

--no-rust Skip cargo fmt and clippy.
--base-ref <ref> The ref the ratchet checks compare against; default
origin/main. Run \`git fetch origin main\` first so it is current.
--help Print this usage and exit 0.`;

function parseArgs(argv) {
const opts = { rust: true, baseRef: 'origin/main' };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--no-rust') opts.rust = false;
else if (arg === '--base-ref') {
opts.baseRef = argv[i + 1];
i += 1;
if (!opts.baseRef) throw new Error('--base-ref requires a value');
} else if (arg === '--help') {
console.log(USAGE);
process.exit(0);
} else throw new Error(`unknown argument ${JSON.stringify(arg)}\n${USAGE}`);
}
return opts;
}

// Every `npm run` / `npm test` line inside the test-debt job, with the base-ref
// expression CI computes replaced by the local base ref.
export function testDebtCommands(workflowText, baseRef) {
const lines = workflowText.split(/\r?\n/);
const start = lines.indexOf(' test-debt:');
if (start < 0) throw new Error('no test-debt job in pr-regression-gates.yml');
const commands = [];
for (let i = start + 1; i < lines.length && !/^ {2}[a-z0-9-]+:\s*$/.test(lines[i]); i += 1) {
const match = /^\s+(?:run:\s+)?(npm (?:run|test) .*)$/.exec(lines[i]);
if (match) commands.push(match[1].replaceAll('"${GATE_BASE_REF:-origin/main}"', `"${baseRef}"`));
}
if (commands.length === 0) throw new Error('found no npm commands in the test-debt job');
const unresolved = commands.find((c) => c.includes('${'));
if (unresolved) throw new Error(`cannot run a CI expression locally: ${unresolved}`);
return commands;
}

function run(command, options = {}) {
const started = Date.now();
console.log(`\n=== ${command}`);
const result = spawnSync(command, { cwd: ROOT, shell: true, stdio: 'inherit', ...options });
return { ok: result.status === 0, status: result.status ?? 1, seconds: (Date.now() - started) / 1000 };
}

function capture(command, cwd) {
const result = spawnSync(command, { cwd, shell: true, encoding: 'utf8' });
return result.status === 0 ? result.stdout : null;
}

function rustChecks(results) {
const srcTauri = path.join(ROOT, 'src-tauri');
results.push(['cargo fmt --all -- --check', run('cargo fmt --all -- --check', { cwd: srcTauri })]);

const pin = JSON.parse(fs.readFileSync(BASELINE, 'utf8')).toolchain;
const toolchains = capture('rustup toolchain list', ROOT) ?? '';
const pinned = toolchains.split(/\r?\n/).some((line) => line.startsWith(`${pin}-`));
if (!pinned) {
console.log(`\nRust ${pin} (the CI pin) is not installed; run \`rustup toolchain install ${pin} --component clippy\`.`);
results.push([`cargo +${pin} clippy`, { ok: false, seconds: 0 }]);
return;
}
const targetDir = path.join(ROOT, 'target', `ci-local-${pin}`);
const jsonl = path.join(os.tmpdir(), `ac-ci-local-cog-${process.pid}.jsonl`);
const clippy = `cargo +${pin} clippy --locked --workspace --all-targets --message-format=json `
+ `--target-dir "${targetDir}" -- -D warnings --force-warn clippy::cognitive_complexity > "${jsonl}"`;
const clippyResult = run(clippy, { cwd: srcTauri });
// The JSON capture holds clippy's human text; the check reprints it and applies the baseline.
const cog = `node scripts/check-cognitive-complexity.mjs --capture "${jsonl}" `
+ `--clippy-exit ${clippyResult.status} --platform ${PLATFORM} --rustc-version ${pin}`;
const cogResult = run(cog);
fs.rmSync(jsonl, { force: true });
// CI runs the complexity step with continue-on-error, so a red result warns and does not fail.
results.push(
[`cargo +${pin} clippy`, clippyResult],
['complexity check (advisory, as in CI)', { ...cogResult, advisory: true }],
);
}

function summaryLabel({ ok, advisory }) {
if (ok) return 'ok ';
return advisory ? 'WARN' : 'FAIL';
}

function main() {
const opts = parseArgs(process.argv.slice(2));
if (!fs.existsSync(path.join(ROOT, 'node_modules', '.bin'))) {
throw new Error('node_modules is missing; run `npm ci` first');
}
const base = capture(`git rev-parse --short ${opts.baseRef}`, ROOT);
if (!base) throw new Error(`cannot resolve ${opts.baseRef}; run \`git fetch origin main\` or pass --base-ref`);
console.log(`Comparing against ${opts.baseRef} (${base.trim()}).`);
if (capture('git status --porcelain --untracked-files=no', ROOT)?.trim()) {
console.log('Note: you have uncommitted changes. The --base-ref checks read the last commit, not them.');
}

const results = [];
for (const command of testDebtCommands(fs.readFileSync(WORKFLOW, 'utf8'), opts.baseRef)) {
results.push([command, run(command)]);
}
if (opts.rust) rustChecks(results);

console.log('\n=== Summary');
for (const [name, result] of results) {
console.log(`${summaryLabel(result)} ${result.seconds.toFixed(0).padStart(4)}s ${String(name)}`);

Check warning on line 137 in scripts/ci-local.mjs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'name' may use Object's default stringification format ('[object Object]') when stringified.

See more on https://sonarcloud.io/project/issues?id=mblua_AgentsCommander&issues=AaFqf33Njdls-0Cr_mwF&open=AaFqf33Njdls-0Cr_mwF&pullRequest=3185
}
const failed = results.filter(([, r]) => !r.ok && !r.advisory).length;
console.log(failed === 0 ? 'All checks passed.' : `${failed} check(s) failed.`);
return failed === 0 ? 0 : 1;
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
process.exitCode = main();
} catch (err) {
console.error(`ci-local: ${err.message}`);
process.exitCode = 2;
}
}
2 changes: 1 addition & 1 deletion src-tauri/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -739,7 +739,7 @@ mod tests {
/// class whose failure mode is omission, so this walks clap's own tree.
///
/// It needs no built binary and no process spawn, so it runs under
/// `cargo test`. It runs on the `rust-regression` (windows) leg only,
/// `cargo test`. It runs on the `rust-regression-windows` leg only,
/// because that is the only CI leg that runs the Rust test suite.
#[test]
fn no_clap_printed_help_carries_the_retired_token() {
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/pty/job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,7 @@ mod stub_impl {

// #632 LOW-2 - the ONE automated proof that the job kills the whole tree (child AND
// grandchild). Deliberately NOT #[ignore] and gated #[cfg(windows)] so it runs by
// default in the `rust-regression` CI lane (runs-on: windows-latest, executes
// default in the `rust-regression-windows` CI lane (runs-on: windows-latest, executes
// `cargo test --lib --bins --tests`). Real processes; written defensively (generous
// polls) to avoid flake. Do NOT add #[ignore] - that removes all executed coverage
// of Part A.
Expand Down
Loading