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: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,21 @@ The Markdown file defining a check must have frontmatter with the following meta
- `name`: human-friendly name for the check.
- `description`: explains what the check is for in one sentence.
- `intelligence`: one of `low`, `medium`, `high`, or `maximum`. This is a cost optimization lever. Warning: any check that uses `high` or `maximum` will be extremely expensive over time if you run it on every single commit!
- `files` (optional): a repository-relative glob. The check runs only when a changed file matches, and its prompt receives only matching files from the diff. For scoped checks, repository-reading and shell tools are disabled so it cannot inspect files outside the matching diff. When no changed files match, the orchestrator skips the check without creating a per-check run.

Glob syntax: `*` matches within one path segment, `**` matches across directories, `?` matches one character, and `[abc]` or `[!abc]` matches one character from (or not in) a set. Use forward slashes. For example:

```yaml
---
name: Frontend accessibility
description: Checks changed frontend code for accessibility issues.
intelligence: medium
files: frontend/**
---
Look for accessibility regressions in changed frontend code.
```

A check without `files` keeps the existing behavior and receives the full review diff.

After the frontmatter there is no required structure. However, we would recommend including a couple sections in your file to help the agent:

Expand Down
180 changes: 180 additions & 0 deletions src/file-glob.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// Per-check file glob matching and unified-diff scoping.
//
// Supported glob syntax: `*` matches within one path segment, `**` matches
// across path separators, `?` matches one non-separator character, and
// `[abc]`/`[!abc]` match one character. Paths and patterns use `/` separators.

function escapeRegex(character) {
return character.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
}

export function validateFileGlob(pattern) {
if (typeof pattern !== "string" || pattern.trim() === "") {
throw new Error("files must be a non-empty glob pattern");
}
if (pattern.startsWith("/") || pattern.includes("\\")) {
throw new Error("files glob must be repository-relative and use forward slashes");
}
if (pattern.split("/").includes("..")) {
throw new Error("files glob must not escape the repository root");
}
let bracketStart = -1;
for (let index = 0; index < pattern.length; index += 1) {
if (pattern[index] === "[") {
if (bracketStart !== -1) throw new Error("files glob has an unclosed character class");
bracketStart = index;
} else if (pattern[index] === "]") {
if (bracketStart === -1) throw new Error("files glob has an unmatched ]");
const contents = pattern.slice(bracketStart + 1, index);
if (contents === "" || contents === "!") {
throw new Error("files glob has an empty character class");
}
bracketStart = -1;
}
}
if (bracketStart !== -1) throw new Error("files glob has an unclosed character class");
return pattern;
}

export function compileFileGlob(pattern) {
validateFileGlob(pattern);
let source = "^";
for (let index = 0; index < pattern.length; index += 1) {
const character = pattern[index];
if (character === "*") {
if (pattern[index + 1] === "*") {
index += 1;
if (pattern[index + 1] === "/") {
index += 1;
source += "(?:.*/)?";
} else {
source += ".*";
}
} else {
source += "[^/]*";
}
} else if (character === "?") {
source += "[^/]";
} else if (character === "[") {
const close = pattern.indexOf("]", index + 1);
let chars = pattern.slice(index + 1, close);
if (chars.startsWith("!")) chars = `^${chars.slice(1)}`;
source += `[${chars.replaceAll("\\", "\\\\")}]`;
index = close;
} else {
source += escapeRegex(character);
}
}
return new RegExp(`${source}$`);
}

export function matchesFileGlob(filePath, pattern) {
return compileFileGlob(pattern).test(filePath);
}

function decodeGitQuotedPath(value) {
if (!value.startsWith('"')) return value;
let decoded = "";
for (let index = 1; index < value.length - 1; index += 1) {
const character = value[index];
if (character !== "\\") {
decoded += character;
continue;
}
const next = value[++index];
if (/[0-7]/.test(next ?? "")) {
const octal = `${next}${value[index + 1] ?? ""}${value[index + 2] ?? ""}`;
decoded += String.fromCharCode(Number.parseInt(octal, 8));
index += octal.length - 1;
} else {
decoded +=
{ a: "\x07", b: "\b", t: "\t", n: "\n", v: "\v", f: "\f", r: "\r", "\\": "\\", '"': '"' }[
next
] ?? next;
}
}
return decoded;
}

function pathFromHeader(line, prefix, stripDiffPrefix = false) {
const decoded = decodeGitQuotedPath(line.trimEnd().slice(prefix.length));
if (decoded === "/dev/null") return null;
return stripDiffPrefix && (decoded.startsWith("a/") || decoded.startsWith("b/")) ?
decoded.slice(2)
: decoded;
}

function pathsFromDiffHeader(line) {
const raw = line.slice("diff --git ".length);
if (raw.startsWith('"')) {
let escaped = false;
let end = 1;
for (; end < raw.length; end += 1) {
if (!escaped && raw[end] === '"') break;
if (!escaped && raw[end] === "\\") escaped = true;
else escaped = false;
}
const oldPath = decodeGitQuotedPath(raw.slice(0, end + 1));
const newPath = decodeGitQuotedPath(raw.slice(end + 2));
return [
oldPath.startsWith("a/") ? oldPath.slice(2) : oldPath,
newPath.startsWith("b/") ? newPath.slice(2) : newPath,
];
}
const separator = raw.indexOf(" b/");
if (separator === -1) return [null, null];
return [raw.slice(2, separator), raw.slice(separator + 3)];
}

function diffSections(diff) {
const sections = [];
let current = null;
let inHunk = false;
for (const line of diff.split(/(?<=\n)/)) {
if (line.startsWith("diff --git ")) {
if (current !== null) sections.push(current);
const [oldPath, newPath] = pathsFromDiffHeader(line.trimEnd());
current = { lines: [line], oldPath, newPath };
inHunk = false;
} else if (current !== null) {
current.lines.push(line);
if (!inHunk) {
if (line.startsWith("rename from ")) current.oldPath = pathFromHeader(line, "rename from ");
if (line.startsWith("rename to ")) current.newPath = pathFromHeader(line, "rename to ");
if (line.startsWith("--- ")) current.oldPath = pathFromHeader(line, "--- ", true);
if (line.startsWith("+++ ")) current.newPath = pathFromHeader(line, "+++ ", true);
}
if (line.startsWith("@@")) inHunk = true;
}
}
if (current !== null) sections.push(current);
return sections;
}

function statForSection(section, filePath) {
let additions = 0;
let deletions = 0;
for (const line of section.lines) {
if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
}
const changes = additions + deletions;
return `${filePath} | ${changes} ${"+".repeat(additions)}${"-".repeat(deletions)}\n`;
}

export function filterDiffByGlob(diff, pattern) {
const matcher = compileFileGlob(pattern);
const sections = diffSections(diff).filter(section => {
const paths = [section.oldPath, section.newPath].filter(Boolean);
return paths.some(filePath => matcher.test(filePath));
});
const scopedDiff = sections.map(section => section.lines.join("")).join("");
const stat = sections
.map(section => statForSection(section, section.newPath ?? section.oldPath))
.join("");
return {
diff: scopedDiff,
stat,
paths: sections.map(section => section.newPath ?? section.oldPath),
};
}
74 changes: 74 additions & 0 deletions src/file-glob.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";

import { filterDiffByGlob, matchesFileGlob, validateFileGlob } from "./file-glob.mjs";

const DIFF = `diff --git a/frontend/App.jsx b/frontend/App.jsx
index 1111111..2222222 100644
--- a/frontend/App.jsx
+++ b/frontend/App.jsx
@@ -1 +1 @@
-old
+new
diff --git a/backend/api.js b/backend/api.js
index 3333333..4444444 100644
--- a/backend/api.js
+++ b/backend/api.js
@@ -1 +1 @@
-old
+new
`;

describe("file globs", () => {
it("matches segment wildcards and recursive paths", () => {
assert.equal(matchesFileGlob("frontend/App.jsx", "frontend/**"), true);
assert.equal(matchesFileGlob("frontend/nested/App.jsx", "frontend/**"), true);
assert.equal(matchesFileGlob("backend/api.js", "frontend/**"), false);
assert.equal(matchesFileGlob("frontend/App.jsx", "**/*.jsx"), true);
assert.equal(matchesFileGlob("frontend/App.jsx", "frontend/*.jsx"), true);
assert.equal(matchesFileGlob("frontend/nested/App.jsx", "frontend/*.jsx"), false);
assert.equal(matchesFileGlob("frontend/App.jsx", "frontend/?.jsx"), false);
});

it("rejects empty, absolute, escaping, and backslash patterns", () => {
for (const pattern of ["", "/frontend/**", "../**", "frontend\\**", "frontend/[x"]) {
assert.throws(() => validateFileGlob(pattern), /files/);
}
});

it("limits diff and stat to matches", () => {
const scoped = filterDiffByGlob(DIFF, "frontend/**");
assert.match(scoped.diff, /frontend\/App\.jsx/);
assert.doesNotMatch(scoped.diff, /backend\/api\.js/);
assert.match(scoped.stat, /frontend\/App\.jsx/);
assert.doesNotMatch(scoped.stat, /backend\/api\.js/);
assert.deepEqual(scoped.paths, ["frontend/App.jsx"]);
});

it("matches renamed and deleted paths", () => {
const rename = `diff --git a/backend/old.ts b/frontend/new.ts\nsimilarity index 100%\nrename from backend/old.ts\nrename to frontend/new.ts\n`;
assert.equal(filterDiffByGlob(rename, "frontend/**").diff, rename);
assert.equal(filterDiffByGlob(rename, "backend/**").diff, rename);

const sameDirectoryRename = `diff --git a/a/old.ts b/a/new.ts\nsimilarity index 100%\nrename from a/old.ts\nrename to a/new.ts\n`;
assert.equal(filterDiffByGlob(sameDirectoryRename, "a/**").diff, sameDirectoryRename);
assert.equal(filterDiffByGlob(sameDirectoryRename, "old/**").diff, "");

const deletion = `diff --git a/frontend/gone.ts b/frontend/gone.ts\n--- a/frontend/gone.ts\n+++ /dev/null\n`;
assert.equal(filterDiffByGlob(deletion, "frontend/**").diff, deletion);
});

it("does not treat diff-header-like hunk content as file metadata", () => {
const diff = `diff --git a/backend/source.js b/backend/source.js\n--- a/backend/source.js\n+++ b/backend/source.js\n@@ -0,0 +1 @@\n+++ b/frontend/fake.js\n`;
assert.equal(filterDiffByGlob(diff, "frontend/**").diff, "");
assert.equal(filterDiffByGlob(diff, "backend/**").diff, diff);
});

it("handles binary diffs and filenames containing spaces", () => {
const binary = `diff --git a/frontend/brand image.png b/frontend/brand image.png\nBinary files a/frontend/brand image.png and b/frontend/brand image.png differ\n`;
assert.equal(filterDiffByGlob(binary, "frontend/**").diff, binary);

const quoted = `diff --git "a/frontend/brand image.png" "b/frontend/brand image.png"\nBinary files a/frontend/brand image.png and b/frontend/brand image.png differ\n`;
assert.equal(filterDiffByGlob(quoted, "frontend/**").diff, quoted);
});
});
24 changes: 21 additions & 3 deletions src/local.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import process from "node:process";
import { pathToFileURL } from "node:url";

import { OUTCOME, RESULT_SCHEMA, parseAddedLines, publicOutcome } from "./parse.mjs";
import { filterDiffByGlob } from "./file-glob.mjs";
import { PROVIDER, createProvider, parseProviderEnv } from "./provider.mjs";
import {
evaluateCheck,
Expand Down Expand Up @@ -101,6 +102,23 @@ export function positiveInteger(value, fallback) {
export async function runChecks(config, { evaluate = evaluateCheck } = {}) {
const addedLines = parseAddedLines(config.diff);
const checkResults = await runPool(config.checks, config.parallel, async check => {
const scoped =
check.files === undefined ?
{ diff: config.diff, stat: config.stat }
: filterDiffByGlob(config.diff, check.files);
if (check.files !== undefined && scoped.diff === "") {
const checkResult = {
outcome: OUTCOME.PASS,
reason: `No changed files match ${check.files}.`,
cost: 0,
duration: 0,
};
writeFileSync(
resultPath(config.tempDir, check.slug, "result.json"),
`${JSON.stringify(checkResult, null, 2)}\n`,
);
return summarizeCheck(check, checkResult);
}
process.stderr.write(`Weave Checks: running ${check.slug}...\n`);
const settingsPath =
config.settingsDir === null ?
Expand All @@ -119,9 +137,9 @@ export async function runChecks(config, { evaluate = evaluateCheck } = {}) {
check,
repoDir: config.repoDir,
tempDir: config.tempDir,
diff: config.diff,
stat: config.stat,
addedLines,
diff: scoped.diff,
stat: scoped.stat,
addedLines: check.files === undefined ? addedLines : parseAddedLines(scoped.diff),
schemaText: config.schemaText,
// No history: a local run has no review threads to compare against.
//
Expand Down
40 changes: 40 additions & 0 deletions src/local.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,46 @@ describe("runChecks", () => {
assert.equal(artifact.reason, "line one is bad");
});

it("skips checks without matching changed files and scopes matching diff and stat", async () => {
const { env, tempDir } = fixture();
const config = readConfig(env);
const backendDiff = [
DIFF.trimEnd(),
"diff --git a/backend/api.go b/backend/api.go",
"--- a/backend/api.go",
"+++ b/backend/api.go",
"@@ -0,0 +1 @@",
"+backend change",
"",
].join("\n");
config.diff = backendDiff;
config.stat = "app/main.go | 2 ++\nbackend/api.go | 1 +";
config.checks = [
{ ...CHECKS[0], files: "app/**" },
{ ...CHECKS[1], files: "db/**" },
];
const seen = [];

const summary = await runChecks(config, {
evaluate: async ({ check, diff, stat }) => {
seen.push(check.slug);
assert.match(diff, /app\/main\.go/);
assert.doesNotMatch(diff, /backend\/api\.go|backend change/);
assert.match(stat, /app\/main\.go/);
assert.doesNotMatch(stat, /backend\/api\.go/);
return passResult("scoped");
},
});

assert.deepEqual(seen, ["first-check"]);
assert.equal(summary.checks[1].outcome, "pass");
assert.match(summary.checks[1].reason, /No changed files match db\/\*\*/);
assert.equal(
JSON.parse(readFileSync(path.join(tempDir, "second-check.result.json"), "utf8")).cost,
0,
);
});

it("uses each check's intelligence as the generated settings filename", async () => {
const { env, tempDir } = fixture();
env.SETTINGS_DIR = path.join(tempDir, "settings");
Expand Down
Loading
Loading