fairlead 0.19.0

Guardrails and a guided path for coding agents: rules enforced as the agent works, tests scoped to what a change can reach, capped project memory, and KPIs.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
//! What each release changed that `fairlead migrate` can't change for you,
//! and the table of config keys by the release that added them. Every
//! release in CHANGELOG.md has an entry here, empty when nothing needs a person.

use std::path::Path;

use fairlead_core::config::Loaded;
use fairlead_core::pattern::Pattern;

use crate::test_bench::{is_bun_test, Mode};

pub struct Context<'a> {
    pub loaded: &'a Loaded,
    pub root: &'a Path,
    /// Whether Claude Code hooks from Fairlead are in the repository.
    pub claude_hooks: bool,
}

pub struct Note {
    pub text: &'static str,
    /// Whether the note concerns this repository; notes that can't tell say yes.
    pub applies: fn(&Context) -> bool,
}

fn always(_: &Context) -> bool {
    true
}

fn go_manifests_in_run_all(cx: &Context) -> bool {
    cx.loaded
        .config
        .plan
        .run_all
        .items()
        .iter()
        .flat_map(|entry| entry.globs())
        .any(|p| p.contains("go.mod") || p.contains("go.sum"))
}

fn has_needs(cx: &Context) -> bool {
    !cx.loaded.config.needs.items().is_empty()
}

fn has_claude_hooks(cx: &Context) -> bool {
    cx.claude_hooks
}

/// Whether a layer other than the built-in defaults sets a key under `prefix`.
fn set(cx: &Context, prefix: &str) -> bool {
    cx.loaded
        .origins
        .iter()
        .any(|(key, layer)| key.starts_with(prefix) && layer != "default")
}

fn has_owners(cx: &Context) -> bool {
    !cx.loaded.config.tests.owners.items().is_empty()
}

fn has_empty_list_override(cx: &Context) -> bool {
    cx.loaded
        .warnings
        .iter()
        .any(|w| w.message.contains("replace = []"))
}

fn uses_replay(cx: &Context) -> bool {
    set(cx, "replay")
}

fn has_next(cx: &Context) -> bool {
    let manifest: serde_json::Value = std::fs::read_to_string(cx.root.join("package.json"))
        .ok()
        .and_then(|t| serde_json::from_str(&t).ok())
        .unwrap_or_default();
    ["dependencies", "devDependencies"]
        .iter()
        .any(|k| manifest[k].get("next").is_some())
}

fn several_runners(cx: &Context) -> bool {
    cx.loaded.config.tests.runners.items().len() > 1
}

fn extends_next(cx: &Context) -> bool {
    cx.loaded
        .origins
        .values()
        .any(|layer| layer.ends_with("pack next"))
}

fn run_all_covers_a_barrier(cx: &Context) -> bool {
    let config = &cx.loaded.config;
    names_only_barriers(config.plan.run_all.items(), config.graph.barrier.items())
}

/// Whether a `run_all` entry is a barrier entry itself, or a plain path a
/// barrier glob matches: one that covered only a barrier's own changes.
fn names_only_barriers(run_all: &[fairlead_core::config::RunAll], barrier: &[String]) -> bool {
    let globs: Vec<Pattern> = barrier
        .iter()
        .filter_map(|g| Pattern::new(g).ok())
        .collect();
    run_all.iter().flat_map(|entry| entry.globs()).any(|entry| {
        barrier.contains(entry)
            || (!entry.contains(['*', '?', '[', '{']) && globs.iter().any(|g| g.is_match(entry)))
    })
}

fn has_go(cx: &Context) -> bool {
    cx.root.join("go.mod").is_file() || cx.root.join("go.work").is_file()
}

fn has_jvm_build(cx: &Context) -> bool {
    [
        "pom.xml",
        "build.gradle",
        "build.gradle.kts",
        "settings.gradle",
        "settings.gradle.kts",
    ]
    .iter()
    .any(|f| cx.root.join(f).is_file())
}

/// Whether a runner still runs `bun test` one file after another; whether a
/// parallel mode is faster depends on the suite, so the bench decides.
fn serial_bun_test(cx: &Context) -> bool {
    cx.loaded
        .config
        .tests
        .runners
        .items()
        .iter()
        .any(|r| is_bun_test(&r.command) && Mode::of(&r.command) == Mode::Serial)
}

/// Each release, oldest first, with what it changed that needs a decision.
pub const RELEASES: &[(&str, &[Note])] = &[
    ("0.1.0", &[]),
    ("0.1.1", &[]),
    ("0.2.0", &[]),
    ("0.3.0", &[]),
    (
        "0.4.0",
        &[Note {
            text: "`[[guard.external]]` refuses `stages = [\"write\"]`, since the file isn't written yet when the write hook runs. Use `commit` or `ci` for such a rule.",
            applies: always,
        }],
    ),
    ("0.4.2", &[]),
    (
        "0.5.0",
        &[
            Note {
                text: "`plan.ignore` applies to source files too: a changed source nothing imports that matches it selects nothing, where it used to widen to every test. Under the defaults that's `docs/**`. Keep sources whose tests you want run out of `plan.ignore`.",
                applies: always,
            },
            Note {
                text: "An owner rule whose `match` names no test file no longer counts as covering its paths: they fall to `tests.unreached`. `config check` warns about each one. Make a rule meant to select nothing a `plan.ignore` entry.",
                applies: has_owners,
            },
            Note {
                text: "`key = []` over a list that already has items changes nothing, since lists append; `{ replace = [] }` clears it. `config check` warns where a layer does this.",
                applies: has_empty_list_override,
            },
            Note {
                text: "A Go module's `go.mod` and `go.sum` are graph edges instead of `plan.run_all` defaults, so a dependency bump selects that module and its importers. Your `plan.run_all` still lists them, which keeps running everything on them; drop them to get the narrower plan.",
                applies: go_manifests_in_run_all,
            },
            Note {
                text: "Go and Python files are in the import graph. A Go or Python repository that ran everything through a `[[checks]]` entry can give its tests a runner instead (see Import graph).",
                applies: has_go,
            },
            Note {
                text: "Replay datasets fetched before 0.5.0 keep no lines for a bun test file that failed to load; fetch them again to count those failures.",
                applies: uses_replay,
            },
        ],
    ),
    (
        "0.5.1",
        &[Note {
            text: "A changed path matching `plan.ignore` that reaches no test is ignored even when other files import it, where 0.5.0 sent it to `tests.unreached`. Plans for such changes get smaller.",
            applies: always,
        }],
    ),
    (
        "0.6.0",
        &[Note {
            text: "`hooks install` adds a Stop hook, which sends an agent back while `fairlead done` hasn't passed, and a nudge after an edit made with no brief. `migrate --write` adds both to hooks installed before; set `done.on_stop = \"off\"` or `brief.nudge = false` first to leave one out.",
            applies: has_claude_hooks,
        }],
    ),
    (
        "0.7.0",
        &[Note {
            text: "Java and Kotlin files are in the import graph. A Maven or Gradle build that ran everything through a `[[checks]]` entry can give its tests a runner instead, with `{class}` or `{classes}` in the command (see Java and Kotlin).",
            applies: has_jvm_build,
        }],
    ),
    (
        "0.7.1",
        &[Note {
            text: "When everything runs, a runner's command runs with no files, so a tool that finds its own tests can run more than the runner's `match` claims. `all_command` gives the command for that case, and `exclude_arg` keeps it whole around a test `[[quarantine]]` holds (see Configuration).",
            applies: always,
        }],
    ),
    (
        "0.8.0",
        &[
            Note {
                text: "`hooks install` adds `Skill` to the edit hook's matcher, so loading a skill counts as a use, and a SessionStart hook that runs `fairlead resume --hook`. `migrate --write` brings both to hooks installed before; set `brief.resume = false` first to leave out the SessionStart hook.",
                applies: has_claude_hooks,
            },
            Note {
                text: "Lessons, skill routes and the AGENTS.md block are new and stay off until a repository adds them. `fairlead import lessons` and `import rules` start from a lessons document and rule files a team already keeps (see Lessons and Skills).",
                applies: always,
            },
            Note {
                text: "CI stages stay off until the config sets `[stages]` or a step's `from`. Then `ci plan` reads the stage from the GitHub event, and `fairlead ci workflow` writes a staged workflow, or prints the `if:` lines for one you keep (see Plans in CI). A bun dependency's version bump, a Fairlead config edit and a workflow that only runs by hand or on a schedule no longer select every test.",
                applies: always,
            },
        ],
    ),
    (
        "0.9.0",
        &[
            Note {
                text: "`replay fetch --event schedule` records the default branch's scheduled runs, so a project whose pushes run the plan and whose full suite runs on a schedule can count what the nightly caught and no push ran (see Replay).",
                applies: uses_replay,
            },
            Note {
                text: "`fairlead graph why` names edge kinds in words (`type import`, `path literal`) where it printed `typeimport` and `pathliteral`; a script that reads its output needs the new names.",
                applies: always,
            },
        ],
    ),
    (
        "0.10.0",
        &[
            Note {
                text: "Replay sets recurring failures aside: a test failing in one job across three or more unrelated pull requests leaves adjusted recall, so a gate can pass that failed before; confirm the `[[replay.quarantine]]` entries it lists (see Replay).",
                applies: uses_replay,
            },
            Note {
                text: "`fairlead doctor --score` rates the setup out of 100, and `fairlead ci workflow --insights --write` adds a weekly summary of how it works (see Readiness score, Insights).",
                applies: always,
            },
        ],
    ),
    (
        "0.11.0",
        &[
            Note {
                text: "Sessions in a linked worktree count now: every worktree writes to the main checkout's `.git/fairlead/events.jsonl`, so `doctor --score`, `insights` and `tokens report` may show more than before (see Guard).",
                applies: always,
            },
            Note {
                text: "This is a Next.js app: `extends = [\"next\"]` makes the layouts and `_app` above a route reach the tests under it (see Framework packs).",
                applies: has_next,
            },
            Note {
                text: "Building Fairlead from source needs Rust 1.95; the release binaries and the npm package are unaffected.",
                applies: always,
            },
        ],
    ),
    (
        "0.12.0",
        &[
            Note {
                text: "The `next` pack no longer ties every end-to-end spec that calls `page.goto(` to every route and the middleware: that rule is the `next-e2e` pack now. Add `\"next-e2e\"` to `extends` to keep it, or leave it out when owner rules already map pages to specs (see Framework packs).",
                applies: extends_next,
            },
            Note {
                text: "When everything runs, a runner whose folder holds another runner's tests names its own tests instead of leaving the tool to find them, so its command line lists them; give it `all_command` to keep the tool's own discovery (see Planning).",
                applies: several_runners,
            },
        ],
    ),
    (
        "0.13.0",
        &[Note {
            text: "A changed `graph.barrier` file selects the tests of the files that import it, as any changed file does; a change below it still stops there. A `plan.run_all` entry that covered only a barrier's own changes can go, and leaving it keeps running everything when the barrier changes (see Barriers).",
            applies: run_all_covers_a_barrier,
        }],
    ),
    (
        "0.14.0",
        &[Note {
            text: "A runner runs `bun test` one file after another. Bun 1.4.4 adds `--parallel` and `--parallel --no-isolate`, which can be much faster or slower depending on the suite and tend to turn up order-dependent tests; `fairlead test bench` times the three on this repository's tests and says which to use. The runner is left as it is (see Measuring Bun's test modes).",
            applies: serial_bun_test,
        },
        Note {
            text: "A named import through a barrel (`import { a } from \"./lib\"`) now depends on the file that declares `a` and the barrels on the way, not on everything the barrel re-exports, so plans get narrower; `graph why` and `test --explain` print `re-export` for those steps. A barrel with a module behind it that runs code when it loads keeps its importers on the whole barrel. `graph.reexports = false` turns this off (see Named imports through re-exports).",
            applies: always,
        }],
    ),
    ("0.14.1", &[]),
    ("0.14.2", &[]),
    ("0.15.0", &[]),
    ("0.15.1", &[]),
    (
        "0.15.2",
        &[Note {
            text: "`fairlead done` reads `[[needs]]`: a need that isn't met leaves out the tests and checks it covers, and the gate passes without them only when the need names `proved_in`. Give each need whose tests CI proves a `proved_in` (see Needs).",
            applies: has_needs,
        }],
    ),
    (
        "0.18.0",
        &[Note {
            text: "The guard's secret-path rule is on with no config: a commit may not add a file named like a secret, such as `.env`, `*.jks`, `*.p12`, `*.pem`, `*.key` or `client_secret*`, and `.env.example`, `*.pub` and the like are allowed. Files already tracked are warnings at `guard check` until `[guard.paths]` sets `ratchet`, and files under test and fixture folders are always warnings. Add a path to `[guard.paths] allow` to keep it, or set `guard.paths = false` to turn the rule off (see Secret paths).",
            applies: always,
        }],
    ),
    (
        "0.19.0",
        &[Note {
            text: "`fairlead done` runs under a budget, `done.budget_seconds` (300): the tests nearest the change run until it's spent, and what else the change reaches is recorded as owed to CI, so no suite holds the gate until `done --full`. Give a slow suite's runner a `smoke` set, the few files that stand for it when only a `run_all` path or a wide `[[graph.edges]]` rule reaches it. `done.budget_seconds = 0` keeps the gate as it was.",
            applies: always,
        }],
    ),
];

/// Config tables and keys by the release that added them. The top-level
/// tables older than all of these are `BASE`; a test holds every other
/// top-level table to having an entry here. A key inside a list of tables,
/// such as `tests.runners.all_command`, counts when any item sets it.
pub const SINCE: &[(&str, &str)] = &[
    ("graph.edges", "0.3"),
    ("graph.barrier", "0.3"),
    ("graph.reexports", "0.14"),
    ("guard", "0.4"),
    ("hooks", "0.4"),
    ("graph.providers", "0.5"),
    ("graph.coverage", "0.5"),
    ("done", "0.6"),
    ("brief", "0.6"),
    ("brief.resume", "0.8"),
    ("ci", "0.6"),
    ("quarantine", "0.7"),
    ("tests.runners.all_command", "0.7.1"),
    ("tests.runners.exclude_arg", "0.7.1"),
    ("memory", "0.8"),
    ("skills", "0.8"),
    ("agents", "0.8"),
    ("stages", "0.8"),
    ("tests.runners.from", "0.8"),
    ("checks.from", "0.8"),
    ("guard.commands.unless", "0.8"),
    ("extends", "0.10"),
    ("graph.edges.find", "0.10"),
    ("workspace", "0.10"),
    ("tracker", "0.10"),
    ("workspace.edges", "0.11"),
    ("plan.run_all.on", "0.13"),
    ("needs", "0.15"),
    ("phase", "0.15"),
    ("tests.runners.all_from", "0.15"),
    ("checks.all_from", "0.15"),
    ("plan.whole_share", "0.15"),
    ("stages.nightly", "0.15"),
    ("profile", "0.18"),
    ("pr", "0.18"),
    ("guard.paths", "0.18"),
    ("guard.external.preset", "0.18"),
    ("guard.migrations.fetch", "0.18"),
    ("guard.external.diff", "0.18"),
    ("needs.os", "0.18"),
    ("tests.runners.cheap", "0.18"),
    ("checks.cheap", "0.18"),
    ("done.cheap_seconds", "0.18"),
    ("done.pre_push", "0.18"),
    ("done.budget_seconds", "0.19"),
    ("tests.runners.smoke", "0.19"),
    ("plan.blanket_share", "0.19"),
];

/// Top-level tables every released config format has had.
#[cfg(test)]
pub const BASE: &[&str] = &[
    "fairlead", "modules", "graph", "tests", "checks", "plan", "replay",
];

#[cfg(test)]
mod tests {
    use super::{names_only_barriers, serial_bun_test, Context};
    use fairlead_core::config::RunAll;

    #[test]
    fn the_bench_note_is_for_a_bun_test_runner_with_no_parallel_mode() {
        let applies = |command: &str| {
            let text =
                format!("[[tests.runners]]\nid = \"t\"\nmatch = [\"**\"]\ncommand = {command}\n");
            let dir =
                std::env::temp_dir().join(format!("fairlead-notes-bench-{}", std::process::id()));
            std::fs::create_dir_all(&dir).unwrap();
            std::fs::write(dir.join("fairlead.toml"), text).unwrap();
            let loaded = fairlead_core::config::load(&dir, &Default::default()).unwrap();
            let _ = std::fs::remove_dir_all(&dir);
            serial_bun_test(&Context {
                loaded: &loaded,
                root: &dir,
                claude_hooks: false,
            })
        };
        assert!(applies(r#"["bun", "test", "{files}"]"#));
        assert!(!applies(r#"["bun", "test", "--parallel", "{files}"]"#));
        assert!(!applies(
            r#"["bun", "test", "--parallel", "--no-isolate", "{files}"]"#
        ));
        assert!(!applies(r#"["vitest", "run", "{files}"]"#));
    }

    fn strings(items: &[&str]) -> Vec<String> {
        items.iter().map(|s| s.to_string()).collect()
    }

    fn globs(items: &[&str]) -> Vec<RunAll> {
        items.iter().map(|s| RunAll::Glob(s.to_string())).collect()
    }

    #[test]
    fn a_run_all_entry_counts_when_it_is_a_barrier_entry_or_a_path_one_matches() {
        let barrier = strings(&["src/http/**", "src/loader.ts"]);
        assert!(names_only_barriers(&globs(&["src/loader.ts"]), &barrier));
        assert!(names_only_barriers(&globs(&["src/http/**"]), &barrier));
        assert!(names_only_barriers(
            &globs(&["migrations/**", "src/http/server.ts"]),
            &barrier
        ));
        assert!(!names_only_barriers(&globs(&["src/**"]), &barrier));
        assert!(!names_only_barriers(&globs(&["src/db.ts"]), &barrier));
        assert!(!names_only_barriers(&globs(&["src/loader.ts"]), &[]));
        let kinds: Vec<RunAll> = toml::from_str::<toml::Table>(
            "e = { paths = [\"src/loader.ts\"], on = [\"modified\"] }",
        )
        .unwrap()["e"]
            .clone()
            .try_into()
            .map(|e| vec![e])
            .unwrap();
        assert!(
            names_only_barriers(&kinds, &barrier),
            "a kind-limited entry counts too"
        );
    }
}