Skip to content

Commit 21a7f1b

Browse files
authored
Merge pull request #31 from thinkgrid-labs/dev
fix: skip agent/ignore/doc files, report dropped rule sets, add rules…
2 parents f4dffd0 + 6389f8a commit 21a7f1b

16 files changed

Lines changed: 965 additions & 38 deletions

File tree

CHANGELOG.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,40 @@
44

55
Config is not code, and reviewing it as though it were produced confident
66
nonsense. Reported from the field: every file under `.claude/` came back HIGH.
7+
Rule sets had the mirror-image problem — they could stop applying without ever
8+
saying so.
79

8-
### Fixed
10+
### Fixed — rule sets
11+
12+
- **A rule set dropped for prompt budget is now reported**, with the budget and
13+
the reason. Previously it vanished silently — the failure this feature was
14+
otherwise built to avoid.
15+
- **The lowest severity is dropped first.** Rule sets load in filename order, so
16+
overflow used to shed whichever sorted last: a `high` security set lost to a
17+
style guide on the letter `s`. Severity is the only ranking the author
18+
declared, so it decides. Surviving sets still *render* in filename order, so
19+
the cache key does not move.
20+
21+
### Added
22+
23+
- **`!` exclusion globs in `scope`**`["src/**", "!src/legacy/**"]`, the way
24+
CodeRabbit's `path_filters` reads. An exclusion beats every include.
25+
- **`always: true`** to apply a rule set to every file regardless of `scope`.
26+
An empty `scope` already meant this; now it can be said out loud, and
27+
`rules list` marks it.
28+
- **`description:`** — one line for humans, shown by `rules list`, never sent to
29+
the model.
30+
- **`globs:` and `alwaysApply:` accepted as aliases**, so a rule set ported from
31+
`.cursor/rules/` loads unedited.
32+
- **`diffmind rules check`** — validates every rule set and pattern rule and
33+
exits non-zero, for CI. Catches parse errors, duplicate ids, identical bodies,
34+
scopes that match nothing, and invalid regex in `rules.toml`.
35+
- **A `scope` of only `!` exclusions is now a parse error.** It matched no file
36+
at all, which is a rule set that silently does no work.
37+
- **`examples/nextjs-app-router/`** — a complete, working rule set for a
38+
React/Next.js project: three scoped `.md` rule sets and 13 regex rules.
39+
40+
### Fixed — what gets reviewed
941

1042
- **Coding-agent and editor config is no longer reviewed.** `.claude/`,
1143
`.cursor/`, `.windsurf/`, `.aider/`, `.vscode/`, `.idea/`, `.zed/` and

README.md

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,42 @@ Your repository's own `.gitignore` is never touched.
396396

397397
## Your team's standards
398398

399+
Everything lives in `.diffmind/`, committed to your repo:
400+
401+
```
402+
.diffmind/
403+
├── config.toml # settings — model, base branch, gate thresholds
404+
├── rules.toml # regex rules: free, deterministic, never vary
405+
└── rules/*.md # written rules: judgement, read by the model
406+
```
407+
408+
### Set it up
409+
410+
```bash
411+
diffmind rules init # writes a starter .diffmind/rules/default.md
412+
# edit it, then:
413+
diffmind rules list # shows what loads, its severity ceiling and its globs
414+
diffmind # review with them applied
415+
```
416+
417+
**A complete worked example lives in
418+
[`examples/nextjs-app-router/`](examples/nextjs-app-router/)** — three scoped
419+
rule sets plus 13 regex rules for a real React/Next.js project. Copy the folder
420+
and edit.
421+
422+
### Which file does a rule go in?
423+
424+
**If a regex can decide it, it goes in `rules.toml`.** It costs nothing, never
425+
changes between runs, and is safe to block a build on. Only judgement goes in a
426+
`.md`.
427+
428+
| | `rules.toml` | `rules/*.md` |
429+
| --- | --- | --- |
430+
| Read by | regex, before the model runs | the model, in the prompt |
431+
| Cost | free | tokens, every review |
432+
| Same answer every run | always | depends on the model |
433+
| Good for | `next/router` in `app/`, `@ts-ignore`, hardcoded secrets | "is this abstraction earning its keep" |
434+
399435
### Written rules — `.diffmind/rules/*.md`
400436

401437
For rules that need judgement instead of a pattern. They live in the repo and
@@ -405,7 +441,8 @@ people carry.
405441

406442
```markdown
407443
---
408-
scope: ["src/api/**/*.ts"]
444+
description: Conventions for the public API layer
445+
scope: ["src/api/**/*.ts", "!src/api/legacy/**"]
409446
severity: high
410447
---
411448
@@ -416,11 +453,25 @@ severity: high
416453
- Reject changes that widen a response struct without a version bump.
417454
```
418455
419-
`scope` is a glob for the files the rule set applies to (leave it out for the
420-
whole repo). `severity` is a **maximum** for findings from that rule set — it can
421-
lower a finding's severity but never raise it. `id` defaults to the filename.
422-
Create a starter file with `diffmind rules init`, and see what loads with
423-
`diffmind rules list`.
456+
| Field | Meaning |
457+
| --- | --- |
458+
| `id` | Suppression handle, `rulebook.<id>`. Defaults to the filename. |
459+
| `description` | One line, for humans. Shown by `rules list`; never sent to the model. |
460+
| `scope` | Globs this governs. Omit for the whole repo. A `!` prefix excludes, and beats every include. |
461+
| `always` | Apply to every file regardless of `scope`. Paid for on every review — make it deliberate. |
462+
| `severity` | A **maximum** for findings from this set. Can lower a finding's severity, never raise it. Also the drop order when the budget is tight. |
463+
464+
`globs:` and `alwaysApply:` are accepted as aliases, so a rule ported from
465+
`.cursor/rules/` loads without an edit.
466+
467+
Create a starter file with `diffmind rules init`, see what loads with
468+
`diffmind rules list`, and validate the lot with `diffmind rules check` —
469+
which exits non-zero, so it belongs in CI:
470+
471+
```bash
472+
diffmind rules check # parse errors, duplicate ids, identical bodies,
473+
# dead globs, invalid regex in rules.toml
474+
```
424475

425476
A finding from a rule set gets the ID `rulebook.<id>` and can be silenced like
426477
any other. If the model credits a rule set that does not apply to that file, the
@@ -432,6 +483,32 @@ which rule sets apply, so every review in a group starts with an identical
432483
prefix. That is what makes prompt-prefix caching possible later. A rule set that
433484
fails to parse is reported and skipped, never ignored quietly.
434485

486+
#### Scope them, or they get dropped
487+
488+
Every rule set matching a file is pasted into that file's prompt, and the prompt
489+
shares a byte budget with the diff and the symbol context — the diff always keeps
490+
at least half. **When the rule sets do not all fit, whole ones are dropped**
491+
(never truncated: half a rule reads as a complete rule that says something else).
492+
493+
Two things make that safe rather than mysterious:
494+
495+
- **Lowest `severity` goes first**, so a `high` security set outlives a style
496+
guide. Unranked sets go before ranked ones.
497+
- **You are told.** The run prints which sets did not fit and why.
498+
499+
Still, the real lever is `scope`, not brevity. Scoped well, only two or three
500+
sets ever apply at once:
501+
502+
```
503+
react.md scope: ["**/*.tsx"]
504+
nextjs-app-router.md scope: ["app/**", "src/app/**"]
505+
server-actions-security.md scope: ["**/actions.ts", "**/route.ts"]
506+
```
507+
508+
Two rules of thumb: keep each set to a page or two — attention dilutes, and
509+
twenty pages of rules makes the model worse at each one — and run
510+
`diffmind rules list` after editing to confirm the globs are what you meant.
511+
435512
### Pattern rules — `.diffmind/rules.toml`
436513

437514
Regex, checked against added lines before the model runs: instant, always the

apps/tui-cli/src/cli.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,8 @@ pub enum RulesAction {
297297
Init,
298298
/// List the rule sets that would be loaded, and what they govern
299299
List,
300+
/// Validate every rule set and pattern rule. Exit 1 on a problem — for CI.
301+
Check,
300302
}
301303

302304
#[derive(Subcommand, Debug)]

apps/tui-cli/src/daemon.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,8 @@ mod tests {
667667
rules: vec![],
668668
rulebooks: vec![core_engine::Rulebook {
669669
id: "api".into(),
670+
description: None,
671+
always: false,
670672
scope: vec!["src/api/**".into()],
671673
severity: Some(core_engine::Severity::High),
672674
body: "- Handlers return ApiError.".into(),

apps/tui-cli/src/main.rs

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,16 +297,22 @@ fn run_command(
297297
return Ok(0);
298298
}
299299
for b in &books {
300-
let scope = if b.scope.is_empty() {
300+
let scope = if b.always {
301+
"always (every file)".to_string()
302+
} else if b.scope.is_empty() {
301303
"whole repository".to_string()
302304
} else {
303305
b.scope.join(", ")
304306
};
305307
let severity = b.severity.map(|s| s.as_str()).unwrap_or("unset");
306308
println!(" {:<24} {severity:<7} {scope}", b.id);
309+
if let Some(d) = &b.description {
310+
println!(" {:<24} {:<7} {d}", "", "");
311+
}
307312
}
308313
Ok(0)
309314
}
315+
cli::RulesAction::Check => rules::check(project_root),
310316
},
311317

312318
cli::Commands::Cache { action } => {
@@ -573,6 +579,39 @@ impl ProjectRules {
573579
}
574580
}
575581

582+
/// Say so when the prompt budget cannot fit every rule set.
583+
///
584+
/// The alternative is what shipped before: a rule set stops applying and
585+
/// nothing anywhere says it did. The check is deliberately at the *widest*
586+
/// budget — depth 0, before any retry shrinks it — so this warns about the
587+
/// rule sets that can never apply, not about a transient squeeze on one
588+
/// oversized unit.
589+
fn warn_on_dropped_rulebooks(backend: &dyn ReviewBackend, settings: &Settings, books: &[Rulebook]) {
590+
if books.is_empty() {
591+
return;
592+
}
593+
let budget = core_engine::section_budget_bytes(
594+
backend.context_tokens(),
595+
settings.max_tokens as usize,
596+
0,
597+
);
598+
let dropped = core_engine::rulebooks_dropped(books, budget);
599+
if dropped.is_empty() {
600+
return;
601+
}
602+
603+
eprintln!(
604+
" ! {} rule set(s) do not fit the prompt budget ({budget} bytes) and were \
605+
dropped: {}",
606+
dropped.len(),
607+
dropped.join(", ")
608+
);
609+
eprintln!(
610+
" Lowest severity is dropped first. Narrow their `scope`, shorten them, \
611+
or use a backend with a larger context window."
612+
);
613+
}
614+
576615
pub fn build_analyzer(
577616
backend: Box<dyn ReviewBackend>,
578617
settings: &Settings,
@@ -581,6 +620,8 @@ pub fn build_analyzer(
581620
ticket: Option<String>,
582621
project: ProjectRules,
583622
) -> ReviewAnalyzer {
623+
warn_on_dropped_rulebooks(&*backend, settings, &project.books);
624+
584625
let mut analyzer = ReviewAnalyzer::new(backend)
585626
.with_unit_grouper(unit_grouper(project_root))
586627
.with_languages(detect_languages(diff))

apps/tui-cli/src/rules.rs

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,116 @@ fn warn_on_duplicate_ids(rules: &[CustomRule]) {
128128
}
129129
}
130130

131+
/// Validate `.diffmind/rules/` and `.diffmind/rules.toml`. Returns the exit code.
132+
///
133+
/// Exists because every other check in this module warns on stderr during a
134+
/// review, where it scrolls past behind the findings. A rule set that stopped
135+
/// working deserves to fail CI, not to be mentioned in passing.
136+
pub fn check(project_root: &Path) -> anyhow::Result<i32> {
137+
let mut problems = 0usize;
138+
139+
let dir = project_root.join(".diffmind").join("rules");
140+
// Name files, not ids: when two rule sets collide the ids are identical, so
141+
// an id-based message tells the reader nothing about which file to edit.
142+
let mut parsed: Vec<(std::path::PathBuf, Rulebook)> = Vec::new();
143+
144+
if dir.is_dir() {
145+
let mut entries: Vec<std::path::PathBuf> = walkdir::WalkDir::new(&dir)
146+
.into_iter()
147+
.filter_map(|e| e.ok())
148+
.filter(|e| e.file_type().is_file())
149+
.map(|e| e.path().to_path_buf())
150+
.filter(|p| p.extension().is_some_and(|x| x == "md"))
151+
.collect();
152+
entries.sort();
153+
154+
for path in entries {
155+
let path = path.as_path();
156+
let stem = path
157+
.file_stem()
158+
.map(|s| s.to_string_lossy().to_string())
159+
.unwrap_or_default();
160+
let text = match std::fs::read_to_string(path) {
161+
Ok(t) => t,
162+
Err(e) => {
163+
println!(" ✗ {}: {e}", path.display());
164+
problems += 1;
165+
continue;
166+
}
167+
};
168+
match core_engine::rulebook::parse(&stem, &text) {
169+
Ok(book) => {
170+
// A glob nobody can satisfy is the silent-no-op this whole
171+
// command exists to surface.
172+
for glob in &book.scope {
173+
if glob.trim().is_empty() {
174+
println!(" ✗ {}: empty glob in `scope`", path.display());
175+
problems += 1;
176+
}
177+
}
178+
println!(" ✓ {:<28} {}", book.id, path.display());
179+
parsed.push((path.to_path_buf(), book));
180+
}
181+
Err(e) => {
182+
println!(" ✗ {}: {e}", path.display());
183+
problems += 1;
184+
}
185+
}
186+
}
187+
}
188+
189+
// Two rule sets saying the same thing cost tokens twice and give the model
190+
// two chances to report the same finding.
191+
for i in 0..parsed.len() {
192+
for j in (i + 1)..parsed.len() {
193+
if parsed[i].1.body == parsed[j].1.body {
194+
println!(
195+
" ✗ {} and {} have identical bodies — delete one.",
196+
parsed[i].0.display(),
197+
parsed[j].0.display()
198+
);
199+
problems += 1;
200+
}
201+
}
202+
}
203+
204+
// Counted from what was parsed above rather than by calling
205+
// `load_rulebooks`, which would re-parse every file and print its own copy
206+
// of the warnings this command has already reported.
207+
let mut ids: std::collections::HashMap<&str, Vec<&std::path::Path>> = Default::default();
208+
for (path, book) in &parsed {
209+
ids.entry(book.id.as_str()).or_default().push(path);
210+
}
211+
let mut collisions: Vec<_> = ids.iter().filter(|(_, v)| v.len() > 1).collect();
212+
collisions.sort_by_key(|(id, _)| *id);
213+
for (id, paths) in collisions {
214+
let names: Vec<String> = paths.iter().map(|p| p.display().to_string()).collect();
215+
println!(
216+
" ✗ {} rule sets share the id '{id}' ({}) — set an explicit `id`.",
217+
paths.len(),
218+
names.join(", ")
219+
);
220+
problems += 1;
221+
}
222+
223+
// Pattern rules: an invalid regex is skipped at review time with a warning
224+
// nobody reads. Here it is a failure.
225+
for rule in load_custom_rules(project_root) {
226+
if let Err(e) = regex::Regex::new(&rule.pattern) {
227+
println!(" ✗ rule '{}': invalid regex — {e}", rule.effective_id());
228+
problems += 1;
229+
}
230+
}
231+
232+
if problems == 0 {
233+
println!("\n {} rule set(s), no problems.", parsed.len());
234+
Ok(0)
235+
} else {
236+
println!("\n {problems} problem(s).");
237+
Ok(1)
238+
}
239+
}
240+
131241
#[cfg(test)]
132242
mod tests {
133243
use super::*;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Written by diffmind. Generated state — not worth committing.
2+
# Deliberately absent: rules/, rules.toml, config.toml, baseline.json.
3+
cache/
4+
runs/
5+
models/
6+
graph.db
7+
graph.db-wal
8+
graph.db-shm
9+
symbols.json
10+
daemon.json

0 commit comments

Comments
 (0)