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
50 changes: 50 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,56 @@ jobs:
exit 1
fi

dry:
name: Quality / DRY
# Near-duplicate functions that the PR makes (scripts/dry/). Informational
# only: scripts/dry/ci.ts always exits 0, every step is soft, and Required
# does not list this job. It needs no test results, so it starts at once.
needs: [changes]
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
continue-on-error: true
timeout-minutes: 10
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
continue-on-error: true
timeout-minutes: 3
with:
persist-credentials: false
# The merge commit's first parent, as in Quality / CRAP above.
- name: Fetch the merge base
id: base
continue-on-error: true
timeout-minutes: 2
run: |
set -euo pipefail
sha=$(git cat-file -p HEAD | sed -n 's/^parent //p' | head -n 1)
git fetch --no-tags --depth=1 origin "$sha"
echo "sha=$sha" >>"$GITHUB_OUTPUT"
- uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
continue-on-error: true
timeout-minutes: 2
with:
bun-version: 1.4.3
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
continue-on-error: true
timeout-minutes: 2
with:
enable-cache: false
- name: DRY report
continue-on-error: true
timeout-minutes: 6
env:
BASE_REF: ${{ steps.base.outputs.sha }}
run: |
if ! command -v bun >/dev/null; then
echo "::warning title=DRY::bun setup failed, so no DRY report"
exit 0
fi
bun scripts/dry/ci.ts

test-storybook:
name: Test / Storybook
needs: [changes]
Expand Down
45 changes: 45 additions & 0 deletions .xum/skills/gauntlet/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
name: gauntlet
description: Read and fix DRY gate findings (make dry-check, Quality / DRY). Use when a dry-check report names a new near-duplicate, or before you copy a function and edit the copy.
---

# Gauntlet gates

These gates sit next to the CRAP gate (`crap-gate` skill). They only warn:
the reader of the finding decides. They score only the functions that your
change touched, since the merge-base with `origin/main`.

## DRY gate

`make dry-check` (or `bun scripts/dry/check.ts --base <ref>`) compares each
changed function with every production function in `src/`. The scorer is
[dryer](https://github.com/unclebob/dryer): it drops names, literals and
locals, then scores the Jaccard similarity of the structure. Exit 0 no
findings, 1 findings, 2 tool error. CI shows the same report in the
`Quality / DRY` job summary.

```
warning new-duplicate score 1.00 src/x.ts:41-46 feeBucket
matches src/x.ts:34-39 centsBucket
```

- A finding needs a score of 0.82 or more (the dryer default), at least 4
lines and 20 syntax nodes.
- Only pairs that the change made are findings: a new function that copies
another one, or an edit that makes two functions match. An edit to one side
of a pair that already matched on main is quiet. The gate does not track
moves across files: a function moved into another file reports its old
pairs again. Say so in the PR; it is not a new duplicate.
- Files under test, story and fixture paths do not count (the CRAP gate's
production-file rule).

### Repair

1. If both functions must change together (one rule, one format, one
protocol), extract the shared shape into one function and call it from both
places.
2. If they only look alike and must change apart (two different domains that
happen to share a shape today), keep both. Say why in the PR.

Never merge two functions only to clear a DRY finding. A wrong abstraction
costs more than a copy.
26 changes: 20 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ include fmt.mk
.PHONY: all build dev start clean help
.PHONY: build-renderer version build-icons build-static build-docker-runtime verify-docker-runtime-artifacts
.PHONY: lint lint-eslint lint-full lint-fix check-oxlint-config typecheck static-check static-check-full
.PHONY: crap crap-check test-crap-scripts
.PHONY: crap crap-check test-crap-scripts tree-sitter-grammars dry-check test-dry-scripts
.PHONY: test-bugbash-repros test-bugbash-known-failures bugbash-sandbox-key bugbash-sandbox-image bugbash-sandbox-publish bug-bash-sandbox-recover bug-bash-sandbox-check test-bugbash-repros-sandbox
.PHONY: test test-unit test-unit-ci test-integration test-watch test-coverage test-e2e test-e2e-perf perf-tape-replay smoke-test
.PHONY: dist dist-mac dist-win dist-linux install-mac-arm64 ensure-mac-sharp-runtime-deps check-appimage-icons check-mac-attach-file-runtime
Expand Down Expand Up @@ -543,7 +543,7 @@ STATIC_CHECKS := typecheck fmt-check check-startup-imports check-react-compiler

static-check: lint $(STATIC_CHECKS) ## Run fast local static checks

static-check-full: lint-full $(STATIC_CHECKS) check-bench-agent test-bench-scripts test-crap-scripts check-docs-links ## Run the full CI static check suite
static-check-full: lint-full $(STATIC_CHECKS) check-bench-agent test-bench-scripts test-crap-scripts test-dry-scripts check-docs-links ## Run the full CI static check suite

# Harbor version for benchmark-terminal and the adapter tests below, so CI tests the
# adapter against the same Harbor API the scheduled benchmark runs.
Expand Down Expand Up @@ -574,17 +574,31 @@ crap-check: ## CRAP gate on files changed since BASE (default: merge-base with o

CRAP_PYTHON := PYTHONDONTWRITEBYTECODE=1 uv run --quiet --no-project --with pytest --with-requirements scripts/crap/crapper_report.py python

test-crap-scripts: ## Test the Crapper adapter (hand-counted fixtures) and crap-check end to end (needs uv)
@# tree-sitter-language-pack downloads its grammars from GitHub releases on
@# first use. This target is in static-check-full (Required), so retry a
@# transient failure there (seen: HTTP 504) before the tests use them.
# tree-sitter-language-pack downloads its grammars from GitHub releases on
# first use. The script tests are in static-check-full (Required), so retry a
# transient failure there (seen: HTTP 504) once, before any test uses them.
tree-sitter-grammars:
@for attempt in 1 2 3; do \
$(CRAP_PYTHON) -c 'from tree_sitter_language_pack import download; download(["typescript", "tsx", "javascript"])' && break; \
[ $$attempt -lt 3 ] || exit 1; echo "grammar download failed, retrying ($$attempt/3)" >&2; sleep 10; \
done

test-crap-scripts: tree-sitter-grammars ## Test the Crapper adapter (hand-counted fixtures) and crap-check end to end (needs uv)
@$(CRAP_PYTHON) -m pytest -q -p no:cacheprovider scripts/crap/crapper_report_test.py
@bun test ./scripts/crap/check.test.ts ./scripts/crap/agentGate.test.ts

# DRY gate (scripts/dry/): near-duplicate functions that the changes since
# BASE make. It only warns; see .xum/skills/gauntlet. The script exits 0 no
# findings, 1 findings, 2 tool error; CI calls it through scripts/dry/ci.ts.
dry-check: ## DRY gate on functions changed since BASE (default: merge-base with origin/main) [DRY_CHECK_ARGS=--json]
@bun scripts/dry/check.ts --base "$(or $(BASE),$$(git merge-base origin/main HEAD))" $(DRY_CHECK_ARGS)

DRY_PYTHON := PYTHONDONTWRITEBYTECODE=1 uv run --quiet --no-project --with pytest --with-requirements scripts/dry/dryer_report.py python
Comment thread
ThomasK33 marked this conversation as resolved.

test-dry-scripts: tree-sitter-grammars ## Test the dryer adapter and dry-check end to end (needs uv)
@$(DRY_PYTHON) -m pytest -q -p no:cacheprovider scripts/dry/dryer_report_test.py
@bun test ./scripts/dry/check.test.ts

check-test-routing: node_modules/.installed ## Fail when a *.test.ts(x) file is run by no CI lane (or by two)
@./scripts/check-test-routing.sh

Expand Down
1 change: 1 addition & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ description: Agent instructions for AI assistants working on the Xum codebase
- Xum sources `.xum/tool_env` before every `bash` tool call; in other harnesses, `source .xum/tool_env` first. Use `run_and_report <step_name> <command...>` when running multiple validation steps in one call.
- Do not pipe/redirect/wrap `run_and_report` output; keep helper markers intact so Xum can show clean step status.
- CRAP gate: `make crap-check` scores the functions you changed. When it (or the report after an agent `git commit`/`git push`) names a function, read the `crap-gate` skill (`.xum/skills/crap-gate/SKILL.md`) before fixing it. In block mode, `Quality / CRAP` violations fail `Required`. Agents must not add the `crap-debt` label or a `Crap-Debt:` trailer without the maintainer's explicit approval for that PR: the label skips every CI block (the trailer skips only local checks: the commit hook and `make crap-check`), and agent PRs run as the maintainer's account, so GitHub cannot tell who added them.
- DRY gate: `make dry-check` reports near-duplicate functions that your change makes. It only warns. Before you fix a finding, read the `gauntlet` skill (`.xum/skills/gauntlet/SKILL.md`).
- `./scripts/wait_pr_ready.sh <pr_number>` is the preferred tail-end helper after local validation and after you've exhausted useful local work.
- `./scripts/wait_pr_checks.sh <pr_number>` is the checks watcher; `wait_pr_ready.sh` must execute `wait_pr_checks.sh --once` on each loop iteration.
- `./scripts/wait_pr_codex.sh <pr_number>` is the Codex gate used by `wait_pr_ready.sh`.
Expand Down
1 change: 1 addition & 0 deletions scripts/check-test-routing.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ other_routes=(
$'scripts/check-test-seam-comments.test.ts\tmake check-test-seam-comments (static-check), right before its checker'
$'scripts/crap/check.test.ts\tmake test-crap-scripts (static-check-full): needs uv, which Test / Unit does not install'
$'scripts/crap/agentGate.test.ts\tmake test-crap-scripts (static-check-full): needs uv, which Test / Unit does not install'
$'scripts/dry/check.test.ts\tmake test-dry-scripts (static-check-full): needs uv, which Test / Unit does not install'
$'tests/ui/domIsolation.radixOrder.child.test.tsx\tspawned in a fresh process by tests/ui/domIsolation.test.ts'
$'vscode/src/api/orpcConnection.integration.test.ts\tnot in CI: needs a live xum server; run with TEST_INTEGRATION=1 bun test ./vscode/src/api/'
)
Expand Down
139 changes: 139 additions & 0 deletions scripts/crap/changes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// The files and lines changed since a base commit, shared by the quality
// gates (crap/check.ts, dry/check.ts, mutate/check.ts) so they agree on what
// "changed" means: the working tree against `base`, with renames paired.
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { diffHeaders, parseChangedLines, type ChangedLines, type Hunk } from "./policy";

export function git(root: string, args: string[]): string {
return execFileSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
}

// Cheap pre-filter so binaries and lockfiles never reach a scorer. The
// adapters apply the full production-file rule (crapper_report.py).
export const SCORED = /^src\/.*\.[cm]?[jt]sx?$/;

/**
* One changed file since `base`. `basePath` is null for an added file,
* `headPath` null for a deleted one. `-M` pairs a renamed file's two paths so
* its functions keep their identity (the policy keys on the path).
*/
export interface ChangedFile {
basePath: string | null;
headPath: string | null;
}

export function parseNameStatus(output: string): ChangedFile[] {
const fields = output.split("\0");
const files: ChangedFile[] = [];
let i = 0;
while (i < fields.length && fields[i] !== "") {
const status = fields[i];
// `-M` without `-C` reports renames (R<similarity>) but never copies.
if (status.startsWith("R")) {
files.push({ basePath: fields[i + 1], headPath: fields[i + 2] });
i += 3;
} else {
const path = fields[i + 1];
files.push({
basePath: status === "A" ? null : path,
headPath: status === "D" ? null : path,
});
i += 2;
}
}
return files.filter((file) => [file.basePath, file.headPath].some((p) => p && SCORED.test(p)));
}

/**
* Hunks per head path, both sides, so a gate can map a head line back to its
* base line (dry/dryer_report.py). Paths follow parseChangedLines in policy.ts.
*/
export function parseHunks(diff: string): Map<string, Hunk[]> {
const hunks = new Map<string, Hunk[]>();
let current: Hunk[] | null = null;
for (const header of diffHeaders(diff)) {
if (header.kind === "file") {
current = header.path === null ? null : [];
if (current && header.path !== null) hunks.set(header.path, current);
} else if (current) {
current.push(header.hunk);
}
}
return hunks;
}

export interface Changes {
root: string;
/** The resolved base commit SHA. */
base: string;
files: ChangedFile[];
changed: ChangedLines;
hunks: Map<string, Hunk[]>;
}

export function changesSince(cwd: string, baseRef: string): Changes {
const root = git(cwd, ["rev-parse", "--show-toplevel"]).trim();
const base = git(root, [
"rev-parse",
"--verify",
"--end-of-options",
`${baseRef}^{commit}`,
]).trim();
// The working tree is the head: unstaged edits count, untracked files do not.
// Pin the output format against user config (diff.mnemonicPrefix,
// diff.noprefix, core.quotePath) so paths match the scorer's.
const diffArgs = [
"-c",
"core.quotePath=false",
"diff",
"-M",
"--default-prefix",
"--no-color",
"--no-ext-diff",
// diff.interHunkContext would merge nearby hunks with context lines.
"--inter-hunk-context=0",
base,
];
const files = parseNameStatus(git(root, [...diffArgs, "--name-status", "-z"])).filter(
(file) => file.headPath === null || existsSync(join(root, file.headPath))
);
const pathspec = files.flatMap((file) =>
[file.basePath, file.headPath].filter((p) => p !== null)
);
const diff =
pathspec.length === 0 ? "" : git(root, [...diffArgs, "-U0", "--", ...new Set(pathspec)]);
Comment thread
ThomasK33 marked this conversation as resolved.
return { root, base, files, changed: parseChangedLines(diff), hunks: parseHunks(diff) };
}

/**
* Writes the base content of every changed file that existed at `base` into a
* new temp tree, at its base path. The caller removes the tree.
*/
export function writeBaseTree(
root: string,
base: string,
files: readonly ChangedFile[]
): { tree: string; paths: string[] } {
const tree = mkdtempSync(join(tmpdir(), "gate-base-"));
const paths: string[] = [];
try {
for (const { basePath } of files) {
if (basePath === null) continue;
mkdirSync(dirname(join(tree, basePath)), { recursive: true });
const blob = execFileSync("git", ["show", `${base}:${basePath}`], {
cwd: root,
maxBuffer: 256 * 1024 * 1024,
});
writeFileSync(join(tree, basePath), blob);
paths.push(basePath);
}
} catch (error) {
// The caller owns the tree only once this returns.
rmSync(tree, { recursive: true, force: true });
throw error;
}
return { tree, paths };
}
Loading
Loading