klasp 0.4.0

Block AI coding agents on the same quality gates your humans hit. See https://github.com/klasp-dev/klasp
Documentation
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
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
//! Integration tests for `klasp init --adopt`.
//!
//! Covers issue #97 acceptance criteria:
//!   - inspect prints findings without writing files
//!   - mirror writes klasp.toml that mirrors detected gates
//!   - mirror never modifies hook configs (husky, lefthook, pre-commit, plain git hooks)
//!   - chain mode is rejected with an explanatory message (exit code 2)
//!   - package-manager-aware lint-staged command selection
//!   - force semantics for mirror mode when klasp.toml already exists
//!
//! Each test creates its own TempDir. No global state is touched.

use std::fs;
use std::path::Path;
use std::process::{Command, Output};

// ─── Helpers ────────────────────────────────────────────────────────────────

/// Run `klasp init --adopt` with extra args from `dir`.
fn run_init_adopt(dir: &Path, extra_args: &[&str]) -> Output {
    Command::new(env!("CARGO_BIN_EXE_klasp"))
        .current_dir(dir)
        .arg("init")
        .arg("--adopt")
        .args(extra_args)
        .env_remove("CLAUDE_PROJECT_DIR")
        .output()
        .expect("spawn klasp")
}

/// Build a minimal git repo fixture (`.git/` + `.git/hooks/`) using `git init`.
///
/// If `git` is not available on PATH, returns `None` — callers should skip
/// the test rather than panic (guard with `let Some(dir) = fixture_repo() else { return; }`).
fn fixture_repo() -> Option<tempfile::TempDir> {
    if which::which("git").is_err() {
        eprintln!("git not on PATH — skipping test");
        return None;
    }
    let dir = tempfile::tempdir().expect("tempdir");
    let status = Command::new("git")
        .args(["init", "-q"])
        .current_dir(dir.path())
        .status()
        .expect("spawn git");
    if !status.success() {
        eprintln!("git init failed — skipping test");
        return None;
    }
    Some(dir)
}

fn stdout(out: &Output) -> String {
    String::from_utf8_lossy(&out.stdout).into_owned()
}

fn stderr(out: &Output) -> String {
    String::from_utf8_lossy(&out.stderr).into_owned()
}

// ─── inspect mode ───────────────────────────────────────────────────────────

/// AC: inspect mode prints "No existing gates detected." when the repo has
/// no recognisable gate infrastructure. No klasp.toml is written.
#[test]
fn inspect_no_gates_prints_no_existing_gates_message() {
    let Some(dir) = fixture_repo() else { return };

    let out = run_init_adopt(dir.path(), &["--mode", "inspect"]);

    assert!(
        out.status.success(),
        "expected exit 0\nstdout: {}\nstderr: {}",
        stdout(&out),
        stderr(&out)
    );

    let so = stdout(&out);
    assert!(
        so.contains("No existing gates detected"),
        "stdout should report no gates detected:\n{so}"
    );

    assert!(
        !dir.path().join("klasp.toml").exists(),
        "inspect must not write klasp.toml"
    );
}

/// AC: inspect mode prints the pre-commit finding (gate type, mirror snippet,
/// Next: block) without writing klasp.toml.
#[test]
fn inspect_pre_commit_only_prints_finding() {
    let Some(dir) = fixture_repo() else { return };

    fs::write(dir.path().join(".pre-commit-config.yaml"), "repos: []\n").unwrap();

    let out = run_init_adopt(dir.path(), &["--mode", "inspect"]);

    assert!(
        out.status.success(),
        "expected exit 0\nstdout: {}\nstderr: {}",
        stdout(&out),
        stderr(&out)
    );

    let so = stdout(&out);
    assert!(
        so.contains("pre-commit framework") || so.contains("pre_commit"),
        "stdout should mention pre-commit framework:\n{so}"
    );
    assert!(
        so.contains("pre_commit"),
        "stdout should show the proposed mirror type:\n{so}"
    );
    assert!(
        so.contains("Next:"),
        "stdout should include a Next: block:\n{so}"
    );

    assert!(
        !dir.path().join("klasp.toml").exists(),
        "inspect must not write klasp.toml"
    );
}

/// AC: inspect mode does not modify the filesystem. Directory tree before and
/// after running inspect must be identical.
#[test]
fn inspect_does_not_modify_filesystem() {
    let Some(dir) = fixture_repo() else { return };

    // Seed all three fixture types.
    fs::write(dir.path().join(".pre-commit-config.yaml"), "repos: []\n").unwrap();
    fs::create_dir_all(dir.path().join(".husky")).unwrap();
    fs::write(
        dir.path().join(".husky/pre-commit"),
        "#!/bin/sh\nnpx --no -- lint-staged\n",
    )
    .unwrap();
    fs::write(
        dir.path().join("lefthook.yml"),
        "pre-commit:\n  commands:\n    lint:\n      run: pnpm lint\n",
    )
    .unwrap();

    // Snapshot directory before.
    let before = collect_dir_snapshot(dir.path());

    run_init_adopt(dir.path(), &["--mode", "inspect"]);

    let after = collect_dir_snapshot(dir.path());

    assert_eq!(
        before, after,
        "inspect must not modify any files in the repo"
    );
}

/// Collect a sorted list of (relative path, contents) pairs for asserting
/// directory identity. Skips `.git/` internals that git modifies on access.
fn collect_dir_snapshot(root: &Path) -> Vec<(String, Vec<u8>)> {
    let mut entries = Vec::new();
    collect_dir_snapshot_inner(root, root, &mut entries);
    entries.sort_by(|a, b| a.0.cmp(&b.0));
    entries
}

fn collect_dir_snapshot_inner(root: &Path, dir: &Path, out: &mut Vec<(String, Vec<u8>)>) {
    let Ok(rd) = fs::read_dir(dir) else { return };
    for entry in rd.flatten() {
        let path = entry.path();
        let rel = path
            .strip_prefix(root)
            .unwrap()
            .to_string_lossy()
            .into_owned();
        // Skip .git internals — git may update index/HEAD on read.
        if rel.starts_with(".git/") || rel == ".git" {
            continue;
        }
        if path.is_dir() {
            collect_dir_snapshot_inner(root, &path, out);
        } else {
            let contents = fs::read(&path).unwrap_or_default();
            out.push((rel, contents));
        }
    }
}

// ─── mirror mode ────────────────────────────────────────────────────────────

/// AC: mirror mode writes klasp.toml containing exactly one [[checks]] block
/// with `source.type = "pre_commit"` when .pre-commit-config.yaml is present.
/// AC: mirror mode never modifies .pre-commit-config.yaml.
#[test]
fn mirror_pre_commit_writes_klasp_toml() {
    let Some(dir) = fixture_repo() else { return };

    let pre_commit_yaml = "repos: []\n";
    fs::write(dir.path().join(".pre-commit-config.yaml"), pre_commit_yaml).unwrap();

    let out = run_init_adopt(dir.path(), &["--mode", "mirror"]);

    assert!(
        out.status.success(),
        "expected exit 0\nstdout: {}\nstderr: {}",
        stdout(&out),
        stderr(&out)
    );

    let toml_path = dir.path().join("klasp.toml");
    assert!(toml_path.exists(), "mirror mode must write klasp.toml");

    let toml_str = fs::read_to_string(&toml_path).unwrap();

    // Must parse as valid ConfigV1.
    let config = klasp_core::ConfigV1::from_file(&toml_path)
        .expect("written klasp.toml must parse via ConfigV1");

    // Must contain exactly one check with source type pre_commit.
    let pre_commit_checks: Vec<_> = config
        .checks
        .iter()
        .filter(|ch| matches!(ch.source, klasp_core::CheckSourceConfig::PreCommit { .. }))
        .collect();
    assert_eq!(
        pre_commit_checks.len(),
        1,
        "expected exactly one pre_commit check, got {} checks\ntoml:\n{}",
        pre_commit_checks.len(),
        toml_str
    );

    // .pre-commit-config.yaml must be byte-identical.
    let yaml_after = fs::read_to_string(dir.path().join(".pre-commit-config.yaml")).unwrap();
    assert_eq!(
        yaml_after, pre_commit_yaml,
        "mirror mode must not modify .pre-commit-config.yaml"
    );
}

/// AC: Husky + lint-staged detector picks a package-manager-aware command.
/// With pnpm-lock.yaml present, the shell command must be `pnpm exec lint-staged`.
#[test]
fn mirror_husky_lint_staged_uses_pkg_manager_command() {
    let Some(dir) = fixture_repo() else { return };

    // Create .husky/pre-commit referencing lint-staged.
    fs::create_dir_all(dir.path().join(".husky")).unwrap();
    fs::write(
        dir.path().join(".husky/pre-commit"),
        "#!/bin/sh\nnpx --no -- lint-staged\n",
    )
    .unwrap();

    // package.json with lint-staged config.
    fs::write(
        dir.path().join("package.json"),
        r#"{"lint-staged": {"*.ts": "tsc --noEmit"}}"#,
    )
    .unwrap();

    // pnpm lockfile — should make the detector pick pnpm exec lint-staged.
    fs::write(
        dir.path().join("pnpm-lock.yaml"),
        "lockfileVersion: '6.0'\n",
    )
    .unwrap();

    let out = run_init_adopt(dir.path(), &["--mode", "mirror"]);

    assert!(
        out.status.success(),
        "expected exit 0\nstdout: {}\nstderr: {}",
        stdout(&out),
        stderr(&out)
    );

    let toml_path = dir.path().join("klasp.toml");
    assert!(toml_path.exists(), "mirror mode must write klasp.toml");

    let toml_str = fs::read_to_string(&toml_path).unwrap();

    assert!(
        toml_str.contains("pnpm exec lint-staged"),
        "expected `pnpm exec lint-staged` in klasp.toml (pnpm-lock.yaml present):\n{toml_str}"
    );
}

/// AC: Lefthook detector emits a per-command shell check.
/// `lefthook.yml` with `pre-commit: commands: lint: run: pnpm lint`
/// should produce `[[checks]] name = "lint"` with `command = "pnpm lint"`.
#[test]
fn mirror_lefthook_emits_per_command_check() {
    let Some(dir) = fixture_repo() else { return };

    fs::write(
        dir.path().join("lefthook.yml"),
        "pre-commit:\n  commands:\n    lint:\n      run: pnpm lint\n",
    )
    .unwrap();

    let out = run_init_adopt(dir.path(), &["--mode", "mirror"]);

    assert!(
        out.status.success(),
        "expected exit 0\nstdout: {}\nstderr: {}",
        stdout(&out),
        stderr(&out)
    );

    let toml_path = dir.path().join("klasp.toml");
    assert!(toml_path.exists(), "mirror mode must write klasp.toml");

    let toml_str = fs::read_to_string(&toml_path).unwrap();

    assert!(
        toml_str.contains("pnpm lint"),
        "expected `pnpm lint` shell command in klasp.toml from lefthook:\n{toml_str}"
    );

    // Also verify it parses cleanly.
    klasp_core::ConfigV1::from_file(&toml_path)
        .expect("lefthook-mirror klasp.toml must parse via ConfigV1");
}

/// AC: plain `.git/hooks/pre-commit` user script must never be overwritten by
/// mirror mode. The hook file must be byte-identical post-run.
#[test]
fn mirror_plain_git_hook_does_not_overwrite_hook() {
    let Some(dir) = fixture_repo() else { return };

    let hooks_dir = dir.path().join(".git/hooks");
    fs::create_dir_all(&hooks_dir).unwrap();

    let original_hook = b"#!/bin/sh\necho 'my custom hook'\nexit 0\n";
    let hook_path = hooks_dir.join("pre-commit");
    fs::write(&hook_path, original_hook).unwrap();

    let out = run_init_adopt(dir.path(), &["--mode", "mirror"]);

    // May succeed or fail, but must not modify the hook.
    let hook_after = fs::read(&hook_path).unwrap();
    assert_eq!(
        hook_after, original_hook,
        "mirror mode must not overwrite .git/hooks/pre-commit"
    );

    // stdout/stderr are informational only — no assertion on exit code here
    // since a plain hook with no other gates might produce no klasp.toml.
    let _ = out;
}

/// AC: mirror mode without --force must exit non-zero and report
/// "klasp.toml already exists" when a klasp.toml is already present.
#[test]
fn mirror_existing_klasp_toml_without_force_errors() {
    let Some(dir) = fixture_repo() else { return };

    fs::write(dir.path().join(".pre-commit-config.yaml"), "repos: []\n").unwrap();
    fs::write(dir.path().join("klasp.toml"), "# existing content\n").unwrap();

    let out = run_init_adopt(dir.path(), &["--mode", "mirror"]);

    assert!(
        !out.status.success(),
        "expected non-zero exit when klasp.toml exists and --force omitted\nstdout: {}\nstderr: {}",
        stdout(&out),
        stderr(&out)
    );

    let se = stderr(&out);
    assert!(
        se.contains("already exists"),
        "stderr must mention 'already exists':\n{se}"
    );

    // Original content must be preserved.
    let on_disk = fs::read_to_string(dir.path().join("klasp.toml")).unwrap();
    assert_eq!(on_disk, "# existing content\n");
}

/// AC: mirror mode with --force overwrites an existing klasp.toml and the
/// result parses cleanly.
#[test]
fn mirror_existing_klasp_toml_with_force_overwrites() {
    let Some(dir) = fixture_repo() else { return };

    fs::write(dir.path().join(".pre-commit-config.yaml"), "repos: []\n").unwrap();
    fs::write(dir.path().join("klasp.toml"), "# existing content\n").unwrap();

    let out = run_init_adopt(dir.path(), &["--mode", "mirror", "--force"]);

    assert!(
        out.status.success(),
        "expected exit 0 with --force\nstdout: {}\nstderr: {}",
        stdout(&out),
        stderr(&out)
    );

    let toml_path = dir.path().join("klasp.toml");
    assert!(toml_path.exists());

    // Must parse — the overwritten file is a valid klasp.toml.
    klasp_core::ConfigV1::from_file(&toml_path)
        .expect("overwritten klasp.toml must parse via ConfigV1");

    let on_disk = fs::read_to_string(&toml_path).unwrap();
    assert!(
        !on_disk.contains("# existing content"),
        "force should overwrite the old content:\n{on_disk}"
    );
}

/// AC: a Husky hook with two substantive commands (e.g. `pnpm lint\npnpm test`)
/// must produce two `[[checks]]` entries in klasp.toml — one per command.
#[test]
fn mirror_husky_multi_command_emits_multiple_checks() {
    let Some(dir) = fixture_repo() else { return };

    fs::create_dir_all(dir.path().join(".husky")).unwrap();
    fs::write(
        dir.path().join(".husky/pre-commit"),
        "#!/bin/sh\n. \"$(dirname -- \"$0\")/_/husky.sh\"\npnpm lint\npnpm test\n",
    )
    .unwrap();

    let out = run_init_adopt(dir.path(), &["--mode", "mirror"]);

    assert!(
        out.status.success(),
        "expected exit 0\nstdout: {}\nstderr: {}",
        stdout(&out),
        stderr(&out)
    );

    let toml_path = dir.path().join("klasp.toml");
    assert!(toml_path.exists(), "mirror mode must write klasp.toml");

    let config = klasp_core::ConfigV1::from_file(&toml_path)
        .expect("written klasp.toml must parse via ConfigV1");

    let shell_checks: Vec<_> = config
        .checks
        .iter()
        .filter(|ch| matches!(ch.source, klasp_core::CheckSourceConfig::Shell { .. }))
        .collect();

    assert_eq!(
        shell_checks.len(),
        2,
        "expected 2 shell checks for 2-command hook body, got {}:\n{}",
        shell_checks.len(),
        fs::read_to_string(&toml_path).unwrap()
    );

    let names: Vec<&str> = shell_checks.iter().map(|c| c.name.as_str()).collect();
    assert!(
        names.contains(&"lint"),
        "expected a 'lint' check; got: {names:?}"
    );
    assert!(
        names.contains(&"test"),
        "expected a 'test' check; got: {names:?}"
    );
}

// ─── chain mode ─────────────────────────────────────────────────────────────

/// AC: chain mode is rejected with exit code 2 and an explanatory message that
/// mentions "chain mode is not supported" and suggests --mode mirror.
#[test]
fn chain_mode_rejects_with_explanatory_message() {
    let Some(dir) = fixture_repo() else { return };

    fs::write(dir.path().join(".pre-commit-config.yaml"), "repos: []\n").unwrap();

    let out = run_init_adopt(dir.path(), &["--mode", "chain"]);

    assert_eq!(
        out.status.code(),
        Some(2),
        "chain mode must exit with code 2\nstdout: {}\nstderr: {}",
        stdout(&out),
        stderr(&out)
    );

    let se = stderr(&out);
    assert!(
        se.to_lowercase().contains("chain") && se.to_lowercase().contains("not supported"),
        "stderr must mention that chain mode is not supported:\n{se}"
    );
    assert!(
        se.contains("mirror"),
        "stderr must suggest --mode mirror as the alternative:\n{se}"
    );
}