pmat 3.30.1

PMAT - Zero-config AI context generation and code quality toolkit (CLI, MCP)
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
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
//! Three rules the MCP surface broke, pinned here.
//!
//! R13 — `quality_gate` read `let project_path = &paths[0]` and dropped every
//! other path on the floor. `{"paths":["ok.rs","a.sh"]}` answered
//! `{"passed":true,"score":90.0,"grade":"A","not_measured":[],"files_analyzed":1}`:
//! one of two paths measured, and `not_measured: []` — the field a reader
//! consults to learn what a verdict does NOT cover — asserting full coverage.
//!
//! R17 — the verdict was `tdg_passed && satd.is_empty()`, so ANY finding of ANY
//! severity decided it. Nine unchanged Rust files (the `enforce_handlers`
//! sources) scored 81.67/B+ and came back `passed:false` on one
//! `severity:"info"` row whose "finding" was the literal text `// TODO` quoted
//! inside a sentence describing a CLI requirement.
//!
//! R18 — `analyze_deep_context`'s `include_patterns` parsed into
//! `_include_patterns` and was thrown away. `{"paths":[dir]}` and
//! `{"paths":[dir],"include_patterns":["*.py"]}` both answered `file_count: 3`
//! over a directory holding `a.go app.ts main.py`, while the tool's own schema
//! advertised the argument as "accepted but not yet applied as a filter".

use crate::mcp_pmcp::tool_functions::{
    analyze_deep_context, check_quality_gate_file, check_quality_gates, quality_gate_baseline,
    quality_gate_summary,
};
use serde_json::Value;
use std::path::{Path, PathBuf};

fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
    let path = dir.join(name);
    std::fs::write(&path, body).expect("write fixture");
    path
}

/// A documented function with a bare `TODO:` — the SATD detector classifies it
/// `severity:"info"`, and TDG grades the file in the 90s.
const ADVISORY_TODO: &str =
    "/// Adds.\npub fn add(a: i32, b: i32) -> i32 {\n    // TODO: handle overflow\n    a + b\n}\n";

/// The same file with a `FIXME:` — `severity:"error"`.
const BLOCKING_FIXME: &str =
    "/// Adds.\npub fn add(a: i32, b: i32) -> i32 {\n    // FIXME: handle overflow\n    a + b\n}\n";

fn severities(json: &Value) -> Vec<String> {
    json["violations"]
        .as_array()
        .expect("violations is an array")
        .iter()
        .map(|v| v["severity"].as_str().unwrap_or_default().to_string())
        .collect()
}

fn not_measured(json: &Value) -> Vec<String> {
    json["not_measured"]
        .as_array()
        .expect("not_measured is an array")
        .iter()
        .map(|v| v.as_str().unwrap_or_default().to_string())
        .collect()
}

// ---------------------------------------------------------------------------
// R13 — every path is measured, or named as a hole with its reason
// ---------------------------------------------------------------------------

/// The exact repro: two paths in, one graded, `not_measured: []`.
#[tokio::test]
async fn a_path_this_gate_cannot_grade_is_named_not_averaged_away() {
    let dir = tempfile::tempdir().expect("tempdir");
    let graded = write(dir.path(), "ok.rs", "/// Doc.\npub fn a() -> i32 { 1 }\n");
    let ungraded = write(dir.path(), "a.sh", "#!/bin/sh\na() { echo 1; }\n");

    let json = check_quality_gates(&[graded, ungraded.clone()], false)
        .await
        .expect("quality_gate reports");

    assert!(
        not_measured(&json).contains(&ungraded.display().to_string()),
        "a path the gate could not grade must appear in not_measured, not vanish: {json}"
    );
    assert_eq!(
        json["passed"],
        Value::Bool(false),
        "half a verdict is not a pass: {json}"
    );

    // "…with its reason": the hole is a row a client can read, not a bare path.
    let reason = json["violations"]
        .as_array()
        .expect("violations")
        .iter()
        .find(|v| v["check_type"] == "not_graded" && v["file"] == ungraded.display().to_string())
        .cloned()
        .unwrap_or_else(|| panic!("no not_graded row for the ungraded path: {json}"));
    assert!(
        reason["message"]
            .as_str()
            .is_some_and(|m| m.contains(".sh")),
        "the reason must name what could not be graded: {reason}"
    );
}

/// The other half of the same defect: a path after `paths[0]` that CAN be
/// graded was never analysed at all, so it could not fail either.
#[tokio::test]
async fn every_path_the_caller_passed_is_actually_analysed() {
    let dir = tempfile::tempdir().expect("tempdir");
    let first = write(
        dir.path(),
        "first.rs",
        "/// Doc.\npub fn a() -> i32 { 1 }\n",
    );
    let second = write(
        dir.path(),
        "second.rs",
        "/// Doc.\npub fn b() -> i32 { 2 }\n",
    );

    let json = check_quality_gates(&[first, second], false)
        .await
        .expect("quality_gate reports");

    assert_eq!(
        json["files_analyzed"], 2,
        "two gradable paths in, two graded out: {json}"
    );
}

/// And a blocking finding under a later path must reach the verdict, rather
/// than being invisible because it was not `paths[0]`.
#[tokio::test]
async fn a_blocking_finding_under_a_later_path_still_fails_the_gate() {
    let dir = tempfile::tempdir().expect("tempdir");
    let clean = write(
        dir.path(),
        "clean.rs",
        "/// Doc.\npub fn a() -> i32 { 1 }\n",
    );
    let dirty = write(dir.path(), "dirty.rs", BLOCKING_FIXME);

    let json = check_quality_gates(&[clean, dirty], false)
        .await
        .expect("quality_gate reports");

    assert_eq!(
        json["passed"],
        Value::Bool(false),
        "a FIXME under paths[1] must fail the gate exactly as it does under paths[0]: {json}"
    );
}

/// `quality_gate_summary` averages the same population and reported
/// `not_measured: []` for it, because it read `ProjectScore::not_measured` —
/// a field that is only ever non-empty when NOTHING graded.
#[tokio::test]
async fn the_summary_names_the_files_its_average_left_out() {
    let dir = tempfile::tempdir().expect("tempdir");
    write(dir.path(), "ok.rs", "/// Doc.\npub fn a() -> i32 { 1 }\n");
    write(dir.path(), "a.sh", "#!/bin/sh\na() { echo 1; }\n");

    let json = quality_gate_summary(&[dir.path().to_path_buf()])
        .await
        .expect("summary");
    let summary = &json["summary"];

    assert_eq!(summary["total_files"], 1, "one of two graded: {json}");
    assert!(
        not_measured(summary)
            .iter()
            .any(|entry| entry.ends_with("a.sh")),
        "the file the average left out must be named: {json}"
    );
    assert!(
        summary["ungraded_files"]
            .as_array()
            .expect("ungraded_files")
            .iter()
            .any(|row| row["reason"].as_str().is_some_and(|r| r.contains(".sh"))),
        "…with the reason it was left out: {json}"
    );
}

/// R13 residual: the summary was fixed to NAME the files its average left out,
/// but it was still reading `let project_path = &paths[0];` and never looking at
/// `paths[1..]` — the very sentence the R13 fix claimed to have removed. The
/// test above could not see it because it passes a SINGLE path, so the drop had
/// nothing to drop. Multi-path is the case that has to be written down.
#[tokio::test]
async fn the_summary_measures_every_path_not_just_the_first() {
    let dir = tempfile::tempdir().expect("tempdir");
    let first = write(
        dir.path(),
        "first.rs",
        "/// Doc.\npub fn a() -> i32 { 1 }\n",
    );
    let second = write(
        dir.path(),
        "second.rs",
        "/// Doc.\npub fn b() -> i32 { 2 }\n",
    );

    let json = quality_gate_summary(&[first, second])
        .await
        .expect("summary");
    let summary = &json["summary"];

    assert_eq!(
        summary["total_files"], 2,
        "two gradable paths in, two summarised out — paths[1..] was dropped: {json}"
    );
    assert_eq!(
        summary["passed_files"].as_u64().expect("passed_files"),
        2,
        "both clean files must be counted: {json}"
    );
}

/// R13 residual: the summary's file branch hardcoded `analyze_file(paths[0])?`,
/// so a path TDG has no grade for was a HARD ERROR out of `quality_gate.summary`
/// while `quality_gate` reported the same path as a disclosed `not_measured`
/// row. One tool family, two answers to one question.
#[tokio::test]
async fn an_ungradable_file_is_disclosed_by_the_summary_not_an_error() {
    let dir = tempfile::tempdir().expect("tempdir");
    let script = write(dir.path(), "a.sh", "#!/bin/sh\na() { echo 1; }\n");

    let json = quality_gate_summary(std::slice::from_ref(&script))
        .await
        .expect("a language TDG does not grade is a hole to disclose, not bad input");
    let summary = &json["summary"];

    assert!(
        not_measured(summary)
            .iter()
            .any(|entry| entry == &script.display().to_string()),
        "the file with no grade must be named: {json}"
    );

    // …and the other entry point of the same tool family must say the same
    // thing about the same path.
    let gate = check_quality_gates(std::slice::from_ref(&script), false)
        .await
        .expect("quality_gate reports");
    assert!(
        not_measured(&gate).contains(&script.display().to_string()),
        "quality_gate must disclose it too: {gate}"
    );
}

/// R13 residual, the load-bearing one: a path that produced NO measurement was
/// absorbed whenever some *other* path in the same call did measure. The
/// disclosure guard read `total_files == 0 && ungraded.is_empty()`, so it only
/// ever fired when the whole call measured nothing.
///
/// `["ok.rs", "emptydir"]` answered
/// `{"passed":true,"score":95.0,"not_measured":[],"files_analyzed":1}` while
/// `["emptydir"]` alone answered `passed:false` — the empty directory graded
/// clean by the company it kept.
#[tokio::test]
async fn a_path_that_measured_nothing_is_disclosed_even_when_a_sibling_measured() {
    let dir = tempfile::tempdir().expect("tempdir");
    let graded = write(dir.path(), "ok.rs", "/// Doc.\npub fn a() -> i32 { 1 }\n");
    let empty = dir.path().join("emptydir");
    std::fs::create_dir(&empty).expect("mkdir");

    let alone = check_quality_gates(std::slice::from_ref(&empty), false)
        .await
        .expect("quality_gate reports");
    assert_eq!(
        alone["passed"],
        Value::Bool(false),
        "baseline: an unmeasured path alone must not pass: {alone}"
    );

    let together = check_quality_gates(&[graded, empty.clone()], false)
        .await
        .expect("quality_gate reports");
    assert!(
        not_measured(&together).contains(&empty.display().to_string()),
        "a path that measured nothing must be named even when a sibling did: {together}"
    );
    assert_eq!(
        together["passed"],
        Value::Bool(false),
        "the verdict for an unmeasured path cannot depend on what else was in \
         the list — alone={alone} together={together}"
    );
}

/// The same rule at the other entry point: the summary must not average away a
/// path it measured nothing for either.
#[tokio::test]
async fn the_summary_names_a_path_it_measured_nothing_for() {
    let dir = tempfile::tempdir().expect("tempdir");
    let graded = write(dir.path(), "ok.rs", "/// Doc.\npub fn a() -> i32 { 1 }\n");
    let empty = dir.path().join("emptydir");
    std::fs::create_dir(&empty).expect("mkdir");

    let json = quality_gate_summary(&[graded, empty.clone()])
        .await
        .expect("summary");
    let summary = &json["summary"];

    assert!(
        not_measured(summary).contains(&empty.display().to_string()),
        "the summary must name the path its average covers nothing of: {json}"
    );
}

/// The THIRD copy of the rule in the same file. `quality_gate_baseline` had its
/// own `let project_path = &paths[0];` and its own is_dir/is_file split, so a
/// baseline taken over two paths recorded the first and silently omitted the
/// second — and `quality_gate.compare` then calls every omitted file
/// "unchanged", because neither side ever measured one.
#[tokio::test]
async fn the_baseline_records_every_path_not_just_the_first() {
    let dir = tempfile::tempdir().expect("tempdir");
    let first = write(
        dir.path(),
        "first.rs",
        "/// Doc.\npub fn a() -> i32 { 1 }\n",
    );
    let second = write(
        dir.path(),
        "second.rs",
        "/// Doc.\npub fn b() -> i32 { 2 }\n",
    );
    let out = dir.path().join("baseline.json");

    let json = quality_gate_baseline(&[first, second], Some(&out))
        .await
        .expect("baseline");

    assert_eq!(
        json["baseline"]["summary"]["total_files"], 2,
        "two gradable paths in, two recorded — paths[1..] was dropped: {json}"
    );
}

/// …and a path it could not record must be named, for the same reason: a file
/// absent from a baseline is indistinguishable from one that did not move.
#[tokio::test]
async fn the_baseline_names_the_paths_it_could_not_record() {
    let dir = tempfile::tempdir().expect("tempdir");
    let graded = write(dir.path(), "ok.rs", "/// Doc.\npub fn a() -> i32 { 1 }\n");
    let script = write(dir.path(), "a.sh", "#!/bin/sh\na() { echo 1; }\n");
    let out = dir.path().join("baseline.json");

    let json = quality_gate_baseline(&[graded, script.clone()], Some(&out))
        .await
        .expect("baseline");

    let names: Vec<String> = json["baseline"]["not_measured"]
        .as_array()
        .expect("not_measured is an array")
        .iter()
        .map(|v| v.as_str().unwrap_or_default().to_string())
        .collect();
    assert!(
        names.contains(&script.display().to_string()),
        "the path with no entry must be named: {json}"
    );
}

/// Naming the same file twice must not change the measurement: `ungraded` was
/// sorted and deduped while `graded` was not, so `[a.rs, a.rs]` reported one
/// more file than exists on disk and moved the average with it.
#[tokio::test]
async fn naming_the_same_file_twice_does_not_change_the_score() {
    let dir = tempfile::tempdir().expect("tempdir");
    let file = write(dir.path(), "ok.rs", "/// Doc.\npub fn a() -> i32 { 1 }\n");

    let once = check_quality_gates(std::slice::from_ref(&file), false)
        .await
        .expect("quality_gate reports");
    let twice = check_quality_gates(&[file.clone(), file.clone()], false)
        .await
        .expect("quality_gate reports");

    assert_eq!(
        once["files_analyzed"], twice["files_analyzed"],
        "the same file on disk weighs once: once={once} twice={twice}"
    );
    assert_eq!(
        once["score"], twice["score"],
        "naming a file twice must not move the average: once={once} twice={twice}"
    );
}

/// R13 residual, the duplicate underneath it: `quality_tools.rs` carried its own
/// `satd_violations_for_file` — a THIRD copy of the detector-severity mapping
/// (`Critical|High => "error"`, `Medium => "warning"`, `Low => "info"`) beside
/// the CLI's, with its own message format. So the SAME `FIXME` on the SAME line
/// of the SAME file was described one way when the caller named the file and
/// another way when the caller named the directory holding it — the file branch
/// said `"{category}: {text}"` and the directory branch (which already went
/// through `check_satd`) said `"{category}: {text} (at column {column})"`.
///
/// Both branches now call `crate::cli::analysis_utilities`, so there is no
/// second mapping left to drift.
#[tokio::test]
async fn one_finding_is_described_the_same_way_whether_the_file_or_its_directory_is_named() {
    let dir = tempfile::tempdir().expect("tempdir");
    let file = write(dir.path(), "lib.rs", BLOCKING_FIXME);

    let by_file = check_quality_gates(std::slice::from_ref(&file), false)
        .await
        .expect("quality_gate reports");
    let by_dir = check_quality_gates(&[dir.path().to_path_buf()], false)
        .await
        .expect("quality_gate reports");

    let satd_rows = |json: &Value| -> Vec<(String, String)> {
        json["violations"]
            .as_array()
            .expect("violations is an array")
            .iter()
            .filter(|v| v["check_type"] == "satd")
            .map(|v| {
                (
                    v["severity"].as_str().unwrap_or_default().to_string(),
                    v["message"].as_str().unwrap_or_default().to_string(),
                )
            })
            .collect()
    };

    let named = satd_rows(&by_file);
    let walked = satd_rows(&by_dir);
    assert!(
        !named.is_empty(),
        "the FIXME must be reported when the file is named: {by_file}"
    );
    assert_eq!(
        named, walked,
        "one detector, one severity scale, one wording — naming the file and \
         naming its directory must describe the same finding identically: \
         by_file={by_file} by_dir={by_dir}"
    );
}

/// R13 residual, the other duplicate: this file wrote its own refusal sentence
/// (`"TDG does not grade .{ext}"`), which is the WRONG rule for a `tests/*.rs` —
/// perfectly gradable Rust that TDG deliberately leaves out of the population.
/// `pmat tdg` publishes "test-or-bench file: TDG does not grade test sources"
/// for it, so one build described one refusal two ways.
///
/// Multi-path on purpose: the single-path shape is what let the last residual
/// survive.
#[tokio::test]
async fn the_reason_a_path_is_not_graded_is_the_one_the_rest_of_the_build_gives() {
    let dir = tempfile::tempdir().expect("tempdir");
    let graded = write(dir.path(), "ok.rs", "/// Doc.\npub fn a() -> i32 { 1 }\n");
    std::fs::create_dir(dir.path().join("tests")).expect("mkdir");
    let test_source = write(
        &dir.path().join("tests"),
        "it.rs",
        "/// Doc.\npub fn t() -> i32 { 1 }\n",
    );
    let script = write(dir.path(), "a.sh", "#!/bin/sh\na() { echo 1; }\n");

    let json = check_quality_gates(&[graded, test_source.clone(), script.clone()], false)
        .await
        .expect("quality_gate reports");

    let message_for = |path: &Path| -> String {
        json["violations"]
            .as_array()
            .expect("violations is an array")
            .iter()
            .find(|v| v["check_type"] == "not_graded" && v["file"] == path.display().to_string())
            .and_then(|v| v["message"].as_str())
            .unwrap_or_else(|| panic!("no not_graded row for {}: {json}", path.display()))
            .to_string()
    };

    // The authority, asked directly. Every surface must quote it verbatim.
    for path in [&test_source, &script] {
        let authority = crate::tdg::analyzer_simple::not_gradable_reason(path)
            .unwrap_or_else(|| panic!("{} must be refused by the shared rule", path.display()));
        assert_eq!(
            message_for(path),
            authority,
            "the gate must report the build's own refusal, not a sentence of its \
             own: {json}"
        );
    }

    // And the specific contradiction: a test source is not "a language TDG does
    // not grade" — it is Rust, deliberately out of the population.
    assert!(
        message_for(&test_source).contains("test"),
        "a tests/ file must be refused as test source, not as an ungraded \
         language: {json}"
    );

    // The reason is quoted once, not once plus a suffix repeating it.
    assert_eq!(
        message_for(&script)
            .matches("not part of the score")
            .count(),
        1,
        "the reason must not be stated twice in one message: {json}"
    );
}

/// The single-file entry point quotes the same authority, so `quality_gate`
/// with `file:` and `quality_gate` with `paths:` cannot describe one refusal
/// two ways.
#[tokio::test]
async fn both_gate_entry_points_quote_the_same_refusal() {
    let dir = tempfile::tempdir().expect("tempdir");
    let script = write(dir.path(), "a.sh", "#!/bin/sh\na() { echo 1; }\n");

    let single = check_quality_gate_file(&script, false)
        .await
        .expect("file gate reports");
    let listed = check_quality_gates(std::slice::from_ref(&script), false)
        .await
        .expect("quality_gate reports");

    let reason = |json: &Value| -> String {
        json["violations"]
            .as_array()
            .expect("violations is an array")
            .iter()
            .find(|v| v["check_type"] == "not_graded")
            .and_then(|v| v["message"].as_str())
            .unwrap_or_else(|| panic!("no not_graded row: {json}"))
            .to_string()
    };

    assert_eq!(
        reason(&single),
        reason(&listed),
        "one refusal, one sentence: single={single} listed={listed}"
    );
}

// ---------------------------------------------------------------------------
// R17 — one severity rule, one place, both entry points
// ---------------------------------------------------------------------------

/// The repro, reduced: an `info` finding must be REPORTED and must not decide.
#[tokio::test]
async fn an_informational_finding_is_reported_but_does_not_decide_the_verdict() {
    let dir = tempfile::tempdir().expect("tempdir");
    let file = write(dir.path(), "advisory.rs", ADVISORY_TODO);

    let json = check_quality_gates(&[file], false)
        .await
        .expect("quality_gate reports");

    assert_eq!(
        severities(&json),
        vec!["info".to_string()],
        "fixture must produce exactly one advisory finding and nothing else: {json}"
    );
    assert_eq!(
        json["passed"],
        Value::Bool(true),
        "an informational finding must not flip a verdict: {json}"
    );
    assert_eq!(
        json["blocking_violations"], 0,
        "the count that decided the verdict must be stated, not inferred: {json}"
    );
    assert!(
        !json["violations"]
            .as_array()
            .expect("violations")
            .is_empty(),
        "not deciding is not the same as hiding — the finding stays on the wire: {json}"
    );
}

/// The rule must not become "SATD never fails the gate".
#[tokio::test]
async fn an_error_severity_finding_still_fails_the_gate() {
    let dir = tempfile::tempdir().expect("tempdir");
    let file = write(dir.path(), "blocking.rs", BLOCKING_FIXME);

    let json = check_quality_gates(&[file], false)
        .await
        .expect("quality_gate reports");

    assert!(
        severities(&json).contains(&"error".to_string()),
        "fixture must produce an error-severity finding: {json}"
    );
    assert_eq!(
        json["passed"],
        Value::Bool(false),
        "an actionable finding must still fail: {json}"
    );
}

/// `quality_gate`'s two entry points must apply ONE rule. They were
/// `tdg_passed && satd.is_empty()` and `tdg_passed && violations.is_empty()` —
/// two spellings of one rule, free to drift the moment either was touched.
#[tokio::test]
async fn both_entry_points_apply_the_same_severity_rule() {
    let dir = tempfile::tempdir().expect("tempdir");
    for (name, body) in [("a.rs", ADVISORY_TODO), ("b.rs", BLOCKING_FIXME)] {
        let file = write(dir.path(), name, body);
        let by_paths = check_quality_gates(std::slice::from_ref(&file), false)
            .await
            .expect("quality_gate reports");
        let by_file = check_quality_gate_file(&file, false)
            .await
            .expect("quality_gate_file reports");
        assert_eq!(
            by_paths["passed"], by_file["passed"],
            "{name}: one tool, one verdict — paths={by_paths} file={by_file}"
        );
        assert_eq!(
            by_paths["blocking_violations"], by_file["blocking_violations"],
            "{name}: the same findings must be verdict-bearing on both entry points"
        );
    }
}

/// A hole in the verdict is `severity:"error"`, so the severity rule must not
/// have quietly re-opened the "unmeasured passes" defect.
#[tokio::test]
async fn an_unmeasured_path_is_still_not_a_pass() {
    let dir = tempfile::tempdir().expect("tempdir");
    let file = write(dir.path(), "a.sh", "echo hi\n");

    let json = check_quality_gates(&[file], false)
        .await
        .expect("quality_gate reports");

    assert_eq!(
        json["passed"],
        Value::Bool(false),
        "a gate with no measurement must not pass: {json}"
    );
}

// ---------------------------------------------------------------------------
// R18 — include_patterns is refused, not ignored
// ---------------------------------------------------------------------------

fn three_language_dir(dir: &Path) {
    write(dir, "a.go", "package main\n\nfunc a() int { return 1 }\n");
    write(dir, "app.ts", "export const a = (): number => 1;\n");
    write(dir, "main.py", "def a():\n    return 1\n");
}

/// The repro: the same `file_count` with and without the filter.
#[tokio::test]
async fn include_patterns_is_refused_rather_than_silently_ignored() {
    let dir = tempfile::tempdir().expect("tempdir");
    three_language_dir(dir.path());
    let paths = vec![dir.path().to_path_buf()];

    let unfiltered = analyze_deep_context(&paths, None)
        .await
        .expect("deep context without a filter still works");
    let baseline = unfiltered["results"]["file_count"].clone();
    assert_eq!(baseline, 3, "fixture must hold three files: {unfiltered}");

    let err = analyze_deep_context(&paths, Some(vec!["*.py".to_string()]))
        .await
        .expect_err("a filter this pipeline cannot apply must not be accepted in silence");
    let message = err.to_string();
    assert!(
        message.contains("include_patterns"),
        "the refusal must name the argument it refused: {message}"
    );
    assert!(
        message.contains("*.py"),
        "the refusal must echo what was asked for: {message}"
    );
}

/// An empty list asks for nothing, so it is not a refusal.
#[tokio::test]
async fn an_empty_include_patterns_list_is_not_a_refusal() {
    let dir = tempfile::tempdir().expect("tempdir");
    three_language_dir(dir.path());
    let paths = vec![dir.path().to_path_buf()];

    let json = analyze_deep_context(&paths, Some(Vec::new()))
        .await
        .expect("an empty filter asks for nothing and changes nothing");
    assert_eq!(json["results"]["file_count"], 3, "{json}");
}

/// The schema must not advertise a knob wired to nothing. It described
/// `include_patterns` as "accepted but not yet applied as a filter" — a defect
/// annotated and shipped.
#[test]
fn the_schema_no_longer_advertises_a_filter_the_pipeline_cannot_apply() {
    use pmcp::ToolHandler;

    let info = crate::mcp_pmcp::analyze_handlers::AnalyzeDeepContextTool::new()
        .metadata()
        .expect("analyze_deep_context publishes metadata");
    let schema = serde_json::to_value(&info.input_schema).expect("schema serialises");
    let properties = schema["properties"]
        .as_object()
        .expect("inputSchema has properties");

    assert!(
        !properties.contains_key("include_patterns"),
        "an argument the tool refuses must not be advertised as one it takes: {schema}"
    );
    assert!(
        properties.contains_key("paths"),
        "…and removing it must not have taken `paths` with it: {schema}"
    );
}