gruff-rs 0.3.0

Rust static analyzer and quality linter for CI: dead-code, complexity, security, secrets, and architecture rules with deterministic SARIF/JSON output and baseline support.
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
use super::*;

#[test]
pub(crate) fn summary_json_pillar_shape_includes_canonical_fields_with_penalty() {
    // The canonical `gruff.summary.v2` pillar exposes 9 fields (cross-port contract).
    // `penalty` is the raw unclamped value subtracted from 100 before clamping, so a
    // saturated pillar still surfaces the underlying penalty for worst-pillar ranking.
    let mut findings: Vec<Finding> = (0..200)
        .map(|index| {
            test_finding(
                "docs.todo-density",
                &format!("src/many_{index}.rs"),
                1,
                Severity::Advisory,
                Pillar::Documentation,
            )
        })
        .collect();
    findings.push(test_finding(
        "complexity.cyclomatic",
        "src/complex.rs",
        1,
        Severity::Error,
        Pillar::Complexity,
    ));
    let report = sample_report_with(findings, Vec::new());
    let decoded: Value =
        serde_json::from_str(&crate::summary::render(&report, 5, SummaryFormat::Json, 1))
            .expect("summary json");
    assert_eq!(decoded["schemaVersion"], "gruff.summary.v2");
    let pillars = decoded["pillars"].as_array().expect("pillars array");
    let find_pillar = |slug: &'static str| {
        pillars
            .iter()
            .find(|pillar| pillar["pillar"] == slug)
            .unwrap_or_else(|| panic!("{slug} pillar present"))
    };

    let documentation = find_pillar("documentation");
    let fields: BTreeSet<&str> = documentation
        .as_object()
        .expect("pillar object")
        .keys()
        .map(String::as_str)
        .collect();
    let expected: BTreeSet<&str> = [
        "advisory",
        "applicable",
        "error",
        "findings",
        "grade",
        "penalty",
        "pillar",
        "score",
        "warning",
    ]
    .into_iter()
    .collect();
    assert_eq!(
        fields, expected,
        "JSON pillar must expose 9 canonical fields"
    );

    // Documentation: 200 advisory * (1.5 * 1.0) = 300.0 unclamped; score clamps to 0.
    assert_eq!(documentation["score"].as_f64(), Some(0.0));
    assert_eq!(documentation["penalty"].as_f64(), Some(300.0));
    assert_eq!(documentation["grade"], "F");
    assert!(documentation["applicable"].is_boolean());
    // Complexity: 1 error * (8.0 * 1.0) = 8.0; score 92.0.
    let complexity = find_pillar("complexity");
    assert_eq!(complexity["penalty"].as_f64(), Some(8.0));
    assert_eq!(complexity["score"].as_f64(), Some(92.0));
    assert!(complexity["applicable"].is_boolean());
    // Empty pillar still carries `penalty: 0.0` (no negative-zero leak).
    let security = find_pillar("security");
    assert_eq!(security["penalty"].as_f64(), Some(0.0));
    assert_eq!(security["score"].as_f64(), Some(100.0));
    assert!(security["applicable"].is_boolean());
}

#[test]
pub(crate) fn non_score_pillars_are_inapplicable_and_excluded_from_composite() {
    // A custom rule emitting `Pillar::Waste` (not in SCORE_PILLARS) must surface in the
    // pillar list with `applicable: false` AND must not drag down `composite`. Otherwise
    // downstream consumers that trust `applicable` recompute a different composite.
    let waste = test_finding(
        "custom.waste",
        "src/wasteful.rs",
        1,
        Severity::Error,
        Pillar::Waste,
    );
    let report = sample_report_with(vec![waste], Vec::new());

    // Composite must ignore Waste's 8.0 penalty: every SCORE_PILLARS pillar is 100.0,
    // and Waste is filtered out before averaging.
    assert_eq!(report.score.composite, 100.0);

    let decoded: Value =
        serde_json::from_str(&crate::summary::render(&report, 5, SummaryFormat::Json, 1))
            .expect("summary json");
    let waste_pillar = decoded["pillars"]
        .as_array()
        .expect("pillars array")
        .iter()
        .find(|pillar| pillar["pillar"] == "waste")
        .expect("waste pillar present");
    assert_eq!(waste_pillar["applicable"], false);
    assert_eq!(waste_pillar["penalty"].as_f64(), Some(8.0));
}

#[test]
pub(crate) fn pillar_ties_sort_by_canonical_label_not_enum_order() {
    // Tie-break contract is `pillar ASC by kebab-case label`, not enum declaration order.
    // Size (enum index 0) and Complexity (enum index 1) with equal finding counts would
    // sort as `size, complexity` under the derived `Ord`; the canonical contract is
    // `complexity, size`.
    let findings = vec![
        test_finding(
            "size.function-length",
            "src/big.rs",
            1,
            Severity::Warning,
            Pillar::Size,
        ),
        test_finding(
            "complexity.cyclomatic",
            "src/complex.rs",
            1,
            Severity::Warning,
            Pillar::Complexity,
        ),
    ];
    let report = sample_report_with(findings, Vec::new());
    let markdown = render_report(&report, OutputFormat::Markdown);

    let complexity_pos = markdown.find("| complexity |").expect("complexity row");
    let size_pos = markdown.find("| size |").expect("size row");
    assert!(
        complexity_pos < size_pos,
        "tied pillars must sort by kebab-case label (complexity < size):\n{markdown}"
    );
}

#[test]
pub(crate) fn html_pillars_section_matches_canonical_contract() {
    // Construct a report with multiple pillars at different finding counts so we can verify
    // (1) the seven canonical columns (pillar, grade, score, findings, advisory, warning, error)
    // and (2) the canonical sort order: findings DESC, then pillar ASC.
    let findings = vec![
        test_finding(
            "complexity.cyclomatic",
            "src/complex.rs",
            1,
            Severity::Warning,
            Pillar::Complexity,
        ),
        test_finding(
            "complexity.cyclomatic",
            "src/complex.rs",
            2,
            Severity::Advisory,
            Pillar::Complexity,
        ),
        test_finding(
            "complexity.cyclomatic",
            "src/complex.rs",
            3,
            Severity::Error,
            Pillar::Complexity,
        ),
        test_finding(
            "naming.snake_case",
            "src/named.rs",
            1,
            Severity::Advisory,
            Pillar::Naming,
        ),
        test_finding(
            "docs.missing",
            "src/docs.rs",
            1,
            Severity::Advisory,
            Pillar::Documentation,
        ),
        test_finding(
            "docs.missing",
            "src/docs.rs",
            2,
            Severity::Warning,
            Pillar::Documentation,
        ),
    ];
    let report = sample_report_with(findings, Vec::new());
    let html = render_report(&report, OutputFormat::Html);

    // Canonical table shell: `<table class="pillar-list">` with the seven canonical
    // headers in order. Matches gruff-go / gruff-ts / gruff-py / gruff-php.
    assert!(
        html.contains("<table class=\"pillar-list\">"),
        "missing canonical pillar-list table"
    );
    for header in [
        "<th scope=\"col\">pillar</th>",
        "<th scope=\"col\" class=\"num\">grade</th>",
        "<th scope=\"col\" class=\"num\">score</th>",
        "<th scope=\"col\" class=\"num\">findings</th>",
        "<th scope=\"col\" class=\"num\">advisory</th>",
        "<th scope=\"col\" class=\"num\">warning</th>",
        "<th scope=\"col\" class=\"num\">error</th>",
    ] {
        assert!(
            html.contains(header),
            "missing pillar table header {header:?}"
        );
    }

    // Pillar name cells use the canonical `<td class="pillar-name">` marker (lowercase
    // pillar slug). Cover all three pillars seeded by the fixture.
    for pillar in ["complexity", "documentation", "naming"] {
        let marker = format!("<td class=\"pillar-name\">{pillar}</td>");
        assert!(
            html.contains(&marker),
            "missing pillar-name cell for {pillar}"
        );
    }

    // Card-grid artefacts must not leak: no `<div class="pillar">` cards, no
    // `key`/`val`/`name`/`breakdown` plumbing, no plural severity labels.
    for stale in [
        "<div class=\"pillar\">",
        "class=\"pillar-grid\"",
        "<span class=\"key\">",
        "<div class=\"breakdown\">",
        ">advisories<",
        ">warnings<",
        ">errors<",
    ] {
        assert!(
            !html.contains(stale),
            "stale card-grid markup found: {stale}"
        );
    }

    // Grade is rendered inside a `<span class="grade-pill {letter}">` pill (canonical
    // shape). Spot-check the complexity row's grade-pill exists.
    assert!(
        html.contains("<span class=\"grade-pill "),
        "pillar table should render grades inside .grade-pill"
    );

    // Sort contract: findings DESC, then pillar ASC (matches `pillar_digests` in
    // summary.rs, which is the Phase 2 canonical contract). Complexity has 3 findings
    // (highest), Documentation has 2, Naming has 1.
    let complexity_pos = html
        .find("<td class=\"pillar-name\">complexity</td>")
        .expect("complexity row");
    let documentation_pos = html
        .find("<td class=\"pillar-name\">documentation</td>")
        .expect("documentation row");
    let naming_pos = html
        .find("<td class=\"pillar-name\">naming</td>")
        .expect("naming row");
    assert!(
        complexity_pos < documentation_pos,
        "complexity (3 findings) should come before documentation (2)"
    );
    assert!(
        documentation_pos < naming_pos,
        "documentation (2 findings) should come before naming (1)"
    );

    // Score must render with two decimal places (canonical contract). Spot-check that
    // the score cell carries a ".NN<" suffix for at least one pillar (complexity 86.50
    // when the fixture seeds three findings: 1 advisory, 1 warning, 1 error).
    assert!(
        html.contains(">86.50<"),
        "expected complexity score 86.50 in HTML, html = {html}"
    );

    // Per-severity cells use the tier class only when count > 0; zero stays neutral.
    // Complexity has advisory=1 (note), warning=1 (warn), error=1 (fail).
    assert!(
        html.contains("<td class=\"num note\">1</td>"),
        "expected non-zero advisory cell to carry .note tier class"
    );
    assert!(
        html.contains("<td class=\"num warn\">1</td>"),
        "expected non-zero warning cell to carry .warn tier class"
    );
    assert!(
        html.contains("<td class=\"num fail\">1</td>"),
        "expected non-zero error cell to carry .fail tier class"
    );
    // Naming has advisory=1, warning=0, error=0 — the zero cells must be neutral.
    assert!(
        html.contains("<td class=\"num\">0</td>"),
        "expected zero-count cells to stay neutral (no tier class)"
    );
}

#[test]
pub(crate) fn markdown_pillars_section_matches_canonical_contract() {
    // Construct a report with multiple pillars at different finding counts so we can verify
    // (1) the canonical `## Pillars` heading, (2) the seven canonical columns, and
    // (3) the canonical sort: findings DESC, then pillar ASC.
    let findings = vec![
        test_finding(
            "complexity.cyclomatic",
            "src/complex.rs",
            1,
            Severity::Warning,
            Pillar::Complexity,
        ),
        test_finding(
            "complexity.cyclomatic",
            "src/complex.rs",
            2,
            Severity::Advisory,
            Pillar::Complexity,
        ),
        test_finding(
            "complexity.cyclomatic",
            "src/complex.rs",
            3,
            Severity::Error,
            Pillar::Complexity,
        ),
        test_finding(
            "naming.snake_case",
            "src/named.rs",
            1,
            Severity::Advisory,
            Pillar::Naming,
        ),
        test_finding(
            "docs.missing",
            "src/docs.rs",
            1,
            Severity::Advisory,
            Pillar::Documentation,
        ),
        test_finding(
            "docs.missing",
            "src/docs.rs",
            2,
            Severity::Warning,
            Pillar::Documentation,
        ),
    ];
    let report = sample_report_with(findings, Vec::new());
    let markdown = render_report(&report, OutputFormat::Markdown);

    // Canonical heading.
    assert!(
        markdown.contains("\n## Pillars\n"),
        "missing canonical `## Pillars` heading"
    );

    // Canonical 7-column header + separator (cross-port harmonised contract).
    assert!(
        markdown.contains("| Pillar | Grade | Score | Findings | Advisory | Warning | Error |"),
        "missing canonical pillar table header in markdown:\n{markdown}"
    );
    assert!(
        markdown.contains("| --- | --- | ---: | ---: | ---: | ---: | ---: |"),
        "missing canonical pillar table separator in markdown:\n{markdown}"
    );

    // Score must render with two decimals. The complexity row composite from the fixture is
    // 86.50 (1 advisory + 1 warning + 1 error).
    assert!(
        markdown.contains(" 86.50 "),
        "expected complexity score 86.50 in markdown:\n{markdown}"
    );

    // Sort contract: findings DESC, then pillar ASC. Complexity (3 findings) before
    // Documentation (2) before Naming (1). Pillar names appear as the leading column cell
    // (` <name> | `).
    let complexity_pos = markdown
        .find("| complexity |")
        .expect("complexity row in markdown");
    let documentation_pos = markdown
        .find("| documentation |")
        .expect("documentation row in markdown");
    let naming_pos = markdown.find("| naming |").expect("naming row in markdown");
    assert!(
        complexity_pos < documentation_pos,
        "complexity (3 findings) must precede documentation (2)"
    );
    assert!(
        documentation_pos < naming_pos,
        "documentation (2 findings) must precede naming (1)"
    );

    // The Pillars block must appear AFTER the masthead score line and BEFORE the bulleted
    // findings list (consistent with the cross-port layout: header, pillars, findings).
    let score_pos = markdown.find("Score: **").expect("score header");
    let pillars_pos = markdown.find("## Pillars").expect("pillars heading");
    assert!(
        score_pos < pillars_pos,
        "Pillars section must follow the score header"
    );
    let first_finding_pos = markdown
        .find("\n- `")
        .expect("seeded fixture must produce a bulleted finding line");
    assert!(
        pillars_pos < first_finding_pos,
        "Pillars section must precede the findings list"
    );

    // Per-severity counts: complexity has advisory=1, warning=1, error=1.
    assert!(
        markdown.contains("| complexity | B | 86.50 | 3 | 1 | 1 | 1 |"),
        "complexity row should expose the 7 canonical cells exactly:\n{markdown}"
    );
    // Naming has advisory=1, warning=0, error=0 — zero cells must render literally as `0`.
    assert!(
        markdown.contains("| naming | A | 98.50 | 1 | 1 | 0 | 0 |"),
        "naming row should carry zero counts for warning and error:\n{markdown}"
    );
}