Skip to main content

sva_cli/
lint.rs

1// Concern: what a composition can be told about itself without rendering a sample | Non-concern: rendering, or judging how it sounds | IO: (dir[, target]) -> Vec<Finding> or CliError
2
3use std::collections::BTreeSet;
4use std::path::Path;
5
6use sva_ast::{Binds, Expr, Graph, Skip, Source, children, ref_spans, resolve_ref_path};
7use sva_core::{
8    CliError, Diagnostic, LintCode, LintViolation, ROOT, Severity, lint_diagnostic, prepared,
9    refuse_unresolved_bars, settled,
10};
11use sva_engine::EngineError;
12
13/// These ride a `success` envelope; a refusal raises [`sva_core::CliError::LintRefused`].
14pub struct Finding {
15    pub code: LintCode,
16    pub severity: Severity,
17    pub subject: String,
18    pub message: String,
19    /// The 1-based line the check found it on, where it knows one.
20    pub line: Option<usize>,
21}
22
23impl Finding {
24    pub fn diagnostic(&self) -> Diagnostic {
25        lint_diagnostic(
26            self.code,
27            &self.subject,
28            &self.message,
29            self.severity,
30            self.line,
31        )
32    }
33}
34
35pub struct LintReport {
36    pub nodes: usize,
37    pub findings: Vec<Finding>,
38}
39
40/// With no target, lints the whole directory against `master`; with one, lints only what it
41/// reaches, against it instead.
42pub fn lint(dir: &Path, target: Option<&str>) -> Result<LintReport, CliError> {
43    let source = sva_ast::Dir::at(dir);
44    match target {
45        None => lint_whole(&source),
46        Some(target) => lint_reaching(&source, target),
47    }
48}
49
50/// A note or a rendering may sit beside a composition; the walk says which it passed over,
51/// less the documents. A dot directory never reaches here: `Dir::paths` skips one.
52fn not_nodes(graph: &Graph) -> Vec<Finding> {
53    graph
54        .skipped()
55        .iter()
56        .filter(|s| s.reason != Skip::Unnameable || !is_document(&s.path))
57        .map(|s| {
58            let path = &s.path;
59            let (code, message) = match s.reason {
60                Skip::Unnameable => (
61                    LintCode::NotANode,
62                    format!("no ref can name `{path}`, so it is not read as a node"),
63                ),
64                Skip::Special => (
65                    LintCode::NotAFile,
66                    format!("`{path}` is a socket, FIFO or device, so no text was read from it"),
67                ),
68            };
69            Finding {
70                code,
71                severity: Severity::Advice,
72                subject: path.clone(),
73                message,
74                line: None,
75            }
76        })
77        .collect()
78}
79
80/// Prose and a reference reading sit beside a composition's nodes, under any casing.
81fn is_document(path: &str) -> bool {
82    match path.rsplit_once('.') {
83        Some((_, suffix)) => matches!(suffix.to_ascii_lowercase().as_str(), "md" | "json"),
84        None => false,
85    }
86}
87
88fn lint_whole(source: &dyn Source) -> Result<LintReport, CliError> {
89    let graph = prepared(source)?;
90    refuse_unresolved_bars(&graph)?;
91    let violations = lint_violations(source, &graph);
92    // A lint violation refuses before a structural one.
93    if violations.is_empty() && graph.defines(ROOT) {
94        sva_engine::check_structure(&graph, ROOT).map_err(CliError::Engine)?;
95    }
96
97    let mut findings = entry_typing(&graph);
98    if !graph.defines(ROOT) {
99        findings.push(Finding {
100            code: LintCode::NoDefaultRoot,
101            severity: Severity::Advice,
102            subject: ROOT.to_string(),
103            message: format!(
104                "no `{ROOT}` file, so a render must name the node it wants — every node is \
105                 still a root"
106            ),
107            line: None,
108        });
109    }
110    findings.extend(not_nodes(&graph));
111    findings.extend(unreached(&graph));
112    findings.extend(grid_row_counts(&graph));
113    findings.extend(tag_findings(source, &graph));
114    findings.extend(crate::variables::key_findings(&graph));
115    findings.extend(crate::rates::rate_findings(&graph));
116    findings.extend(crate::windows::window_findings(
117        &graph,
118        &whole_roots(&graph),
119    ));
120    verdict(graph.paths().count(), violations, findings)
121}
122
123/// The status is the verdict; `data.diagnostics[]` carries everything the scan found.
124fn verdict(
125    nodes: usize,
126    violations: Vec<LintViolation>,
127    findings: Vec<Finding>,
128) -> Result<LintReport, CliError> {
129    if violations.is_empty() {
130        return Ok(LintReport { nodes, findings });
131    }
132    let mut every = violations;
133    every.extend(findings.into_iter().map(|f| LintViolation {
134        code: f.code,
135        severity: f.severity,
136        subject: f.subject,
137        message: f.message,
138        line: f.line,
139    }));
140    Err(CliError::LintRefused(every))
141}
142
143fn whole_roots(graph: &Graph) -> Vec<String> {
144    let mut out: Vec<String> = graph
145        .defines(ROOT)
146        .then(|| ROOT.to_string())
147        .into_iter()
148        .collect();
149    out.extend(entry_points(graph));
150    out
151}
152
153/// Every node nothing references is a root of its own, and a whole-directory lint says what
154/// typing each one found. One template nothing binds is not a broken directory, so this
155/// reports rather than stopping the pass; `master`, which a bare render targets, refuses.
156fn entry_typing(graph: &Graph) -> Vec<Finding> {
157    entry_points(graph)
158        .into_iter()
159        .filter(|p| p != ROOT)
160        .filter_map(|path| {
161            let refused = sva_engine::check_structure(graph, &path).err()?;
162            Some(Finding {
163                code: LintCode::EntryPointRefused,
164                severity: Severity::Warning,
165                subject: path,
166                message: refused.to_string(),
167                line: None,
168            })
169        })
170        .collect()
171}
172
173fn lint_reaching(source: &dyn Source, target: &str) -> Result<LintReport, CliError> {
174    let held = sva_core::roots_of(source, Some(target))?;
175    let roots: Vec<&str> = held.iter().map(String::as_str).collect();
176    let mut graph = settled(sva_ast::load_reaching(source, &roots))?;
177    refuse_unresolved_bars(&graph)?;
178    let violations = lint_violations(source, &graph);
179    // A target nothing defines is the same `probe` a render builds, so a typo refuses here.
180    let root = match graph.defines(target) {
181        true => target.to_string(),
182        false => {
183            sva_core::define_probe_for(&mut graph, target)?;
184            sva_core::PROBE.to_string()
185        }
186    };
187    if let Err(refused) = sva_engine::check_structure(&graph, &root)
188        && violations.is_empty()
189    {
190        // A closure loaded from the target alone never sees the call sites that bind it.
191        return match sva_core::instances_behind(source, target, &refused).as_deref() {
192            Some([only]) if only != target => lint_reaching(source, only),
193            Some(held) => Err(CliError::Engine(EngineError::AmbiguousNode(
194                target.to_string(),
195                held.to_vec(),
196            ))),
197            None => Err(CliError::Engine(refused)),
198        };
199    }
200    // No `unreached` here (every node is reached by construction); the other checks are per-node.
201    let mut findings = grid_row_counts(&graph);
202    findings.extend(tag_findings(source, &graph));
203    findings.extend(crate::variables::key_findings(&graph));
204    findings.extend(crate::rates::rate_findings(&graph));
205    findings.extend(crate::windows::window_findings(&graph, &[root]));
206    verdict(graph.paths().count(), violations, findings)
207}
208
209/// A row count tiles a span as a subdivision of a bar or as whole bars; both count.
210fn grid_row_counts(graph: &Graph) -> Vec<Finding> {
211    let mut findings = Vec::new();
212    for path in graph.paths() {
213        let Some(grid) = graph.grid(path) else {
214            continue;
215        };
216        let Some(bars) = grid.bar_span.filter(|b| *b > 0.0) else {
217            continue;
218        };
219        let rows_per_bar = grid.row_count as f64 / bars;
220        let bars_per_row = bars / grid.row_count as f64;
221        let whole = |v: f64| (v - v.round()).abs() <= 1e-9;
222        if !whole(rows_per_bar) && !whole(bars_per_row) {
223            findings.push(Finding {
224                code: LintCode::GridRowsPerBar,
225                severity: Severity::Warning,
226                subject: path.to_string(),
227                message: format!(
228                    "`{path}` has {} rows over {bars} bar(s), {rows_per_bar:.4} rows/bar and \
229                     {bars_per_row:.4} bars/row — neither a whole subdivision of a bar nor a \
230                     whole number of bars a row, likely a stray or missing row",
231                    grid.row_count
232                ),
233                line: None,
234            });
235        }
236    }
237    findings
238}
239
240/// Refuses with every violation found, not just the first: a tree-wide scan, unlike
241/// `check_arity`/`check_feedback`'s single graph evaluation.
242fn lint_violations(source: &dyn Source, graph: &Graph) -> Vec<LintViolation> {
243    let mut violations = Vec::new();
244    violations.extend(doc_comment_violations(source, graph));
245    violations.extend(long_comment_block_violations(source, graph));
246    violations.extend(long_expression_body_violations(source, graph));
247    violations
248}
249
250/// Even a justified multi-line block stays under this.
251const LONG_COMMENT_BLOCK_CHARS: usize = 1000;
252
253/// Above real compound tags (`sustained-pad`=13, `transient-response`=18).
254const MAX_TAG_CHARS: usize = 24;
255/// Few tags keep `Tags:` a quick triage aid, not a second `Neglects:` field.
256const MAX_TAGS: usize = 3;
257
258/// The line-1 doc-comment's own run is excluded from the count.
259fn long_comment_block_violations(source: &dyn Source, graph: &Graph) -> Vec<LintViolation> {
260    let mut violations = Vec::new();
261    for path in graph.paths() {
262        let Ok(Some(text)) = source.get(path) else {
263            continue;
264        };
265        // start line, running char total, first line's own char length.
266        let mut run: Option<(usize, usize, usize)> = None;
267        for (idx, line) in text.lines().enumerate() {
268            if line.trim_start().starts_with(';') {
269                let line_chars = line.chars().count();
270                run = Some(match run {
271                    Some((start, chars, first)) => (start, chars + line_chars, first),
272                    None => (idx + 1, line_chars, line_chars),
273                });
274            } else {
275                flush_comment_run(run.take(), path, &mut violations);
276            }
277        }
278        flush_comment_run(run, path, &mut violations);
279    }
280    violations
281}
282
283/// Drops the header line's own char count (not a flat `1`) when the run starts at line 1.
284fn flush_comment_run(
285    run: Option<(usize, usize, usize)>,
286    path: &str,
287    violations: &mut Vec<LintViolation>,
288) {
289    let Some((start, chars, first)) = run else {
290        return;
291    };
292    let chars = if start == 1 {
293        chars.saturating_sub(first)
294    } else {
295        chars
296    };
297    if chars > LONG_COMMENT_BLOCK_CHARS {
298        violations.push(LintViolation {
299            code: LintCode::LongCommentBlock,
300            severity: Severity::Error,
301            subject: path.to_string(),
302            message: format!(
303                "`{path}` has a {chars}-character comment block, over the \
304                 {LONG_COMMENT_BLOCK_CHARS}-character threshold — split it or trim it"
305            ),
306            line: Some(start),
307        });
308    }
309}
310
311/// Mirrors `annotated-tree`'s Concern/Non-concern/IO: exactly one
312/// `; Models: ... | Neglects: ... | IO: ... -> ... | Tags: ...` line at the top, or `lint`
313/// refuses it. Judges only the leading run — a grid's own inline `; lane` comments stay out
314/// of scope.
315fn doc_comment_violations(source: &dyn Source, graph: &Graph) -> Vec<LintViolation> {
316    let mut violations = Vec::new();
317    for path in graph.paths() {
318        let Ok(Some(text)) = source.get(path) else {
319            continue;
320        };
321        let header: Vec<&str> = text
322            .lines()
323            .take_while(|line| line.trim_start().starts_with(';'))
324            .collect();
325        match header.len() {
326            0 => violations.push(LintViolation {
327                code: LintCode::MissingComment,
328                severity: Severity::Error,
329                subject: path.to_string(),
330                message: format!(
331                    "`{path}` has no `;`-comment — every composition node must carry one `; \
332                     Models: ... | Neglects: ... | IO: ... -> ... | Tags: ...` line \
333                     documenting it"
334                ),
335                line: None,
336            }),
337            1 => {
338                if let Err(reason) = malformed_doc_comment(header[0]) {
339                    violations.push(LintViolation {
340                        code: LintCode::MalformedComment,
341                        severity: Severity::Error,
342                        subject: path.to_string(),
343                        message: format!(
344                            "`{path}`'s comment does not match `Models: ... | Neglects: ... \
345                             | IO: ... -> ... | Tags: ...` ({reason})"
346                        ),
347                        line: None,
348                    });
349                }
350            }
351            n => violations.push(LintViolation {
352                code: LintCode::MultilineComment,
353                severity: Severity::Error,
354                subject: path.to_string(),
355                message: format!(
356                    "`{path}` has {n} `;`-comment lines — exactly one is required, written \
357                     denser instead of split across lines"
358                ),
359                line: None,
360            }),
361        }
362    }
363    violations
364}
365
366/// Four ` | `-delimited, non-empty, exact-labeled fields; `IO:` needs an arrow too.
367fn malformed_doc_comment(line: &str) -> Result<(), String> {
368    let content = line.trim_start().trim_start_matches(';').trim();
369    let fields: Vec<&str> = content.split(" | ").collect();
370    let [models, neglects, io, tags] = fields.as_slice() else {
371        return Err("expected exactly four ` | `-delimited fields".to_string());
372    };
373
374    let models = models
375        .strip_prefix("Models:")
376        .ok_or("first field must start with `Models:`")?
377        .trim();
378    if models.is_empty() {
379        return Err("`Models:` field is empty".to_string());
380    }
381
382    let neglects = neglects
383        .strip_prefix("Neglects:")
384        .ok_or("second field must start with `Neglects:`")?
385        .trim();
386    if neglects.is_empty() {
387        return Err("`Neglects:` field is empty".to_string());
388    }
389
390    let io = io
391        .strip_prefix("IO:")
392        .ok_or("third field must start with `IO:`")?
393        .trim();
394    let Some((input, output)) = io.split_once("->") else {
395        return Err("`IO:` field must be `<input> -> <output>`".to_string());
396    };
397    if input.trim().is_empty() || output.trim().is_empty() {
398        return Err("`IO:` field's input and output must both be non-empty".to_string());
399    }
400
401    let tags = tags
402        .strip_prefix("Tags:")
403        .ok_or("fourth field must start with `Tags:`")?
404        .trim();
405    if tags.is_empty() {
406        return Err("`Tags:` field is empty".to_string());
407    }
408    for tag in tags.split(',').map(str::trim) {
409        if tag.is_empty() {
410            return Err("`Tags:` field has an empty tag between commas".to_string());
411        }
412    }
413
414    Ok(())
415}
416
417/// FORMAT 15.1 writes the shape as `Tags: <tag>[, <tag>]` and refuses a comment that does
418/// not parse into its four fields. How many tags and what a tag looks like is house style,
419/// so a numeral or a fourth tag is advised here and never refuses a composition.
420fn tag_advice(line: &str) -> Option<String> {
421    let content = line.trim_start().trim_start_matches(';').trim();
422    let tags = content.split(" | ").nth(3)?.strip_prefix("Tags:")?.trim();
423    let held: Vec<&str> = tags.split(',').map(str::trim).collect();
424    let plain = |tag: &str| {
425        !tag.starts_with('-')
426            && !tag.ends_with('-')
427            && !tag.contains("--")
428            && tag.chars().count() <= MAX_TAG_CHARS
429            && tag
430                .chars()
431                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
432    };
433    let odd: Vec<&str> = held.iter().copied().filter(|t| !plain(t)).collect();
434    let mut notes = Vec::new();
435    if held.len() > MAX_TAGS {
436        notes.push(format!(
437            "{} tags; {MAX_TAGS} keeps `Tags:` a triage aid rather than a second `Neglects:`",
438            held.len()
439        ));
440    }
441    if !odd.is_empty() {
442        notes.push(format!(
443            "`{}` reads as free text; a lowercase word of up to {MAX_TAG_CHARS} characters, \
444             hyphens between segments, sorts and greps with the rest",
445            odd.join("`, `")
446        ));
447    }
448    (!notes.is_empty()).then(|| notes.join("; "))
449}
450
451/// One advisory per node whose tags are not house style.
452fn tag_findings(source: &dyn Source, graph: &Graph) -> Vec<Finding> {
453    let mut findings = Vec::new();
454    for path in graph.paths() {
455        let Ok(Some(text)) = source.get(path) else {
456            continue;
457        };
458        let Some(line) = text.lines().find(|l| l.trim_start().starts_with(';')) else {
459            continue;
460        };
461        if let Some(message) = tag_advice(line) {
462            findings.push(Finding {
463                code: LintCode::TagShape,
464                severity: Severity::Advice,
465                subject: path.to_string(),
466                message: format!("`{path}`'s `Tags:` field: {message}"),
467                line: None,
468            });
469        }
470    }
471    findings
472}
473
474/// Generous on purpose: today's longest real body is ~5000 chars — a backstop against
475/// runaway generation, not a tight budget on complex physical models.
476const EXPRESSION_BODY_CHARS: usize = 10_000;
477
478/// A verbose local `@ref` name is a naming choice, not equation complexity.
479const REF_PLACEHOLDER: &str = "@x";
480
481/// Excludes the leading `;`-comment run. A node over budget on the raw count gets one more,
482/// cheap recount with ref names collapsed, so a verbose `@ref` name alone cannot trip the cap.
483fn long_expression_body_violations(source: &dyn Source, graph: &Graph) -> Vec<LintViolation> {
484    let mut violations = Vec::new();
485    for path in graph.paths() {
486        let Ok(Some(text)) = source.get(path) else {
487            continue;
488        };
489        let body: String = text
490            .lines()
491            .skip_while(|line| line.trim_start().starts_with(';'))
492            .collect::<Vec<_>>()
493            .join("\n");
494        let chars = body.chars().count();
495        if chars <= EXPRESSION_BODY_CHARS {
496            continue;
497        }
498        let (chars, has_refs) = ref_stripped_char_count(&body);
499        if chars > EXPRESSION_BODY_CHARS {
500            let message = if has_refs {
501                format!(
502                    "`{path}` has a {chars}-character expression body even with every `@ref` \
503                     name collapsed to `{REF_PLACEHOLDER}`, over the \
504                     {EXPRESSION_BODY_CHARS}-character threshold — decompose it into sub-nodes"
505                )
506            } else {
507                format!(
508                    "`{path}` has a {chars}-character expression body, over the \
509                     {EXPRESSION_BODY_CHARS}-character threshold — decompose it into sub-nodes"
510                )
511            };
512            violations.push(LintViolation {
513                code: LintCode::LongExpressionBody,
514                severity: Severity::Error,
515                subject: path.to_string(),
516                message,
517                line: None,
518            });
519        }
520    }
521    violations
522}
523
524/// Only reached once the raw count already exceeds the cap. Collapses each `@ref`'s `@path`
525/// head to a placeholder; a trailing `(...)` invocation stays, being real expression content.
526/// The `bool` says whether any ref was actually collapsed, so the error message can be worded
527/// accurately for a body already over budget on pure literal content.
528fn ref_stripped_char_count(body: &str) -> (usize, bool) {
529    let spans = ref_spans(body);
530    let has_refs = !spans.is_empty();
531    let mut reduced = String::with_capacity(body.len());
532    let mut cursor = 0;
533    for span in spans {
534        reduced.push_str(&body[cursor..span.start]);
535        reduced.push_str(REF_PLACEHOLDER);
536        cursor = span.end;
537    }
538    reduced.push_str(&body[cursor..]);
539    (reduced.chars().count(), has_refs)
540}
541
542pub fn referenced(graph: &Graph) -> BTreeSet<String> {
543    let mut reached: BTreeSet<String> = BTreeSet::new();
544    for path in graph.paths() {
545        if let Some(expr) = graph.expr(path) {
546            collect_refs(path, expr, &mut reached);
547        }
548    }
549    reached
550}
551
552/// Every node nothing references, `master` included — the roots a whole composition has.
553pub fn entry_points(graph: &Graph) -> Vec<String> {
554    let reached = referenced(graph);
555    graph
556        .paths()
557        .filter(|p| !reached.contains(*p))
558        .filter(|p| !sva_core::RESERVED_VARIABLES.contains(&p.rsplit('/').next().unwrap_or(p)))
559        .map(str::to_string)
560        .collect()
561}
562
563/// Not a waste warning — an unreached node costs no evaluation. It is what a typo looks like.
564fn unreached(graph: &Graph) -> Vec<Finding> {
565    entry_points(graph)
566        .into_iter()
567        .filter(|p| p != ROOT)
568        .map(|p| Finding {
569            code: LintCode::EntryPoint,
570            severity: Severity::Advice,
571            subject: p.to_string(),
572            message: format!("nothing refs `{p}`, so it is an entry point — or a typo"),
573            line: None,
574        })
575        .collect()
576}
577
578fn collect_refs(from: &str, expr: &Expr, out: &mut BTreeSet<String>) {
579    if let Expr::Ref { path, .. } = expr
580        && let Some(resolved) = resolve_ref_path(from, path)
581    {
582        out.insert(resolved);
583    }
584    for child in children(expr, Binds::Substitute) {
585        collect_refs(from, child, out);
586    }
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592    use std::fs;
593
594    fn dir_of(name: &str, files: &[(&str, &str)]) -> std::path::PathBuf {
595        let dir =
596            std::env::temp_dir().join(format!("sva-cli-lint-{name}-{:x}", std::process::id()));
597        let _ = fs::remove_dir_all(&dir);
598        fs::create_dir_all(&dir).unwrap();
599        for (rel, content) in files {
600            fs::write(dir.join(rel), content).unwrap();
601        }
602        dir
603    }
604
605    /// A well-formed doc comment, for a fixture node the test isn't about.
606    fn doc(models: &str) -> String {
607        format!(
608            "; Models: {models} | Neglects: nothing, it's a fixture | IO: t -> mix | Tags: \
609             fixture\n"
610        )
611    }
612
613    /// Both response paths are one external contract: a reader branches on `severity`, and
614    /// gets the same object shape whether `lint` succeeded or refused.
615    #[test]
616    fn both_lint_response_paths_answer_the_same_diagnostic_shape() {
617        let dir = dir_of(
618            "one-envelope",
619            &[
620                ("master", &(doc("the mix") + "@kick*0.5\n")),
621                ("kick", &(doc("a thump") + "sin(2*pi*50*t)\n")),
622                ("spare", &(doc("a spare") + "sin(2*pi*80*t)\n")),
623            ],
624        );
625        let report = lint(&dir, None).expect("a documented composition lints clean");
626        let found: Vec<Diagnostic> = report.findings.iter().map(Finding::diagnostic).collect();
627        let json = sva_core::success_envelope(
628            &crate::output::lint_data(&dir.display().to_string(), None, report.nodes),
629            &found,
630        );
631        assert!(json.contains("\"diagnostics\""), "{json}");
632        assert!(json.contains("\"code\": \"lint.entry_point\""), "{json}");
633        assert!(json.contains("\"severity\": \"advice\""), "{json}");
634        assert!(
635            json.contains("\"location\": { \"file\": \"spare\", \"span\": null, \"start\": null, \"end\": null }"),
636            "{json}"
637        );
638        assert!(json.contains("\"help\""), "{json}");
639
640        let undocumented = dir_of(
641            "one-envelope-refused",
642            &[("master", "@kick*0.5\n"), ("kick", "sin(t)\n")],
643        );
644        let Err(err) = lint(&undocumented, None) else {
645            panic!("an undocumented node must refuse");
646        };
647        let refused = sva_core::error_envelope(err.code(), &err.message(), &err.diagnostics());
648        assert!(refused.contains("\"diagnostics\""), "{refused}");
649        assert!(
650            refused.contains("\"code\": \"lint.missing_comment\""),
651            "{refused}"
652        );
653        assert!(refused.contains("\"severity\": \"error\""), "{refused}");
654        assert!(
655            refused
656                .contains("\"location\": { \"file\": \"kick\", \"span\": null, \"start\": null, \"end\": null }"),
657            "{refused}"
658        );
659        assert!(refused.contains("\"help\""), "{refused}");
660
661        let _ = fs::remove_dir_all(&dir);
662        let _ = fs::remove_dir_all(&undocumented);
663    }
664
665    /// The codes a clean-but-advised node reports, so a rule that only advises is asserted
666    /// by what it says rather than by what it refuses.
667    fn advised(result: Result<LintReport, CliError>, subject: &str) -> Vec<LintCode> {
668        let report = result.unwrap_or_else(|e| panic!("`{subject}` should lint: {}", e.message()));
669        report
670            .findings
671            .iter()
672            .filter(|f| f.subject == subject)
673            .map(|f| f.code)
674            .collect()
675    }
676
677    fn assert_refused(result: Result<LintReport, CliError>, code: LintCode, subject: &str) {
678        match result {
679            Ok(report) => panic!(
680                "expected a `{code:?}` refusal for `{subject}`, got a clean report: {:?}",
681                report.findings.iter().map(|f| &f.code).collect::<Vec<_>>()
682            ),
683            Err(CliError::LintRefused(violations)) => assert!(
684                violations
685                    .iter()
686                    .any(|v| v.code == code && v.subject == subject),
687                "expected a `{code:?}` refusal for `{subject}`, got: {:?}",
688                violations
689                    .iter()
690                    .map(|v| (v.code, &v.subject))
691                    .collect::<Vec<_>>()
692            ),
693            Err(other) => panic!("expected a `{code:?}` refusal, got a different error: {other:?}"),
694        }
695    }
696
697    #[test]
698    fn a_loop_the_engine_can_schedule_lints_clean() {
699        let dir = dir_of(
700            "long-loop",
701            &[
702                (
703                    "a",
704                    &(doc("a fixture signal") + "sin(t) + @b(t - 0.01s)*0.5\n"),
705                ),
706                ("b", &(doc("a fixture signal") + "@a(t - 0.01s)*0.5\n")),
707                ("master", &(doc("a fixture signal") + "@a\n")),
708            ],
709        );
710        assert!(
711            lint(&dir, None).is_ok(),
712            "a schedulable loop is not a finding"
713        );
714    }
715
716    /// A target lints only what it reaches, so an orphan elsewhere in the directory is never
717    /// seen — `unreached` does not run in this mode at all (see `lint`'s doc comment).
718    #[test]
719    fn a_target_skips_the_entry_point_check_a_whole_directory_lint_would_raise() {
720        let dir = dir_of(
721            "orphan",
722            &[
723                ("master", &(doc("a fixture signal") + "@drums\n")),
724                ("drums", &(doc("a fixture signal") + "sin(t)\n")),
725                ("orphan", &(doc("a fixture signal") + "sin(t)*0.5\n")),
726            ],
727        );
728        let whole = lint(&dir, None).unwrap();
729        assert!(
730            whole
731                .findings
732                .iter()
733                .any(|f| f.code == LintCode::EntryPoint && f.subject == "orphan"),
734            "a whole-directory lint must flag the unreferenced `orphan`"
735        );
736
737        let targeted = lint(&dir, Some("master")).unwrap();
738        assert!(
739            !targeted
740                .findings
741                .iter()
742                .any(|f| f.code == LintCode::EntryPoint),
743            "a targeted lint must not run the entry-point check at all"
744        );
745    }
746
747    /// The dogfooding bug: a trailing blank row makes 33, and 33/4 is not a whole subdivision —
748    /// yet every intended note is still there, which is why counting notes alone missed it.
749    #[test]
750    fn a_grid_with_a_trailing_blank_row_over_its_bar_span_is_flagged() {
751        let dir = dir_of(
752            "trailing-blank-row",
753            &[
754                ("kick", &(doc("a fixture kick") + "sin(2*pi*50*t)\n")),
755                (
756                    "pattern-4b",
757                    &(doc("a fixture pattern") + &("@kick\n".repeat(32) + "\n")),
758                ),
759                ("master", &(doc("a fixture signal") + "@pattern-4b\n")),
760            ],
761        );
762        let whole = lint(&dir, None).unwrap();
763        assert!(
764            whole
765                .findings
766                .iter()
767                .any(|f| f.code == LintCode::GridRowsPerBar && f.subject == "pattern-4b"),
768            "33 rows over 4 bars must be flagged: {:?}",
769            whole.findings.iter().map(|f| &f.code).collect::<Vec<_>>()
770        );
771
772        let targeted = lint(&dir, Some("master")).unwrap();
773        assert!(
774            targeted
775                .findings
776                .iter()
777                .any(|f| f.code == LintCode::GridRowsPerBar),
778            "the target-scoped path must run this check too"
779        );
780    }
781
782    #[test]
783    fn a_grid_whose_rows_tile_its_bar_span_evenly_is_clean() {
784        let dir = dir_of(
785            "clean-grid",
786            &[
787                ("kick", &(doc("a fixture kick") + "sin(2*pi*50*t)\n")),
788                (
789                    "pattern-4b",
790                    &(doc("a fixture pattern") + &"@kick\n".repeat(32)),
791                ),
792                ("master", &(doc("a fixture signal") + "@pattern-4b\n")),
793            ],
794        );
795        assert!(
796            !lint(&dir, None)
797                .unwrap()
798                .findings
799                .iter()
800                .any(|f| f.code == LintCode::GridRowsPerBar),
801            "32 rows over 4 bars is an exact subdivision"
802        );
803        assert!(
804            !lint(&dir, Some("master"))
805                .unwrap()
806                .findings
807                .iter()
808                .any(|f| f.code == LintCode::GridRowsPerBar)
809        );
810    }
811
812    #[test]
813    fn a_grid_spanned_in_seconds_has_no_bar_count_to_check() {
814        let dir = dir_of(
815            "seconds-spanned",
816            &[
817                ("kick", &(doc("a fixture kick") + "sin(2*pi*50*t)\n")),
818                (
819                    "pattern-2s",
820                    &(doc("a fixture pattern") + &("@kick\n".repeat(33) + "\n")),
821                ),
822                ("master", &(doc("a fixture signal") + "@pattern-2s\n")),
823            ],
824        );
825        assert!(
826            !lint(&dir, None)
827                .unwrap()
828                .findings
829                .iter()
830                .any(|f| f.code == LintCode::GridRowsPerBar),
831            "a `-Ns` grid declares no bars, so there is nothing to divide"
832        );
833    }
834
835    /// The run sits after `sin(t)`, not at line 1.
836    #[test]
837    fn a_comment_block_over_a_thousand_chars_is_flagged_in_both_modes() {
838        let trailing = format!(";{}", "x".repeat(1000)); // 1001 chars
839        let dir = dir_of(
840            "long-comment",
841            &[
842                (
843                    "long",
844                    &(doc("a fixture signal") + "sin(t)\n" + &trailing + "\n"),
845                ),
846                ("master", &(doc("a fixture signal") + "@long\n")),
847            ],
848        );
849        assert_refused(lint(&dir, None), LintCode::LongCommentBlock, "long");
850        assert_refused(
851            lint(&dir, Some("master")),
852            LintCode::LongCommentBlock,
853            "long",
854        );
855    }
856
857    #[test]
858    fn a_comment_block_of_exactly_a_thousand_chars_sits_at_the_threshold_not_over_it() {
859        let trailing = format!(";{}", "x".repeat(999)); // 1000 chars
860        let dir = dir_of(
861            "boundary-comment",
862            &[(
863                "master",
864                &(doc("a fixture signal") + "sin(t)\n" + &trailing + "\n"),
865            )],
866        );
867        assert!(
868            lint(&dir, None).is_ok(),
869            "exactly 1000 chars is the threshold itself, not over it"
870        );
871    }
872
873    /// Line 1's own run is exempt from this budget, however long its one required line is.
874    #[test]
875    fn a_long_but_well_formed_leading_doc_comment_is_not_a_long_comment_block() {
876        let filler = "x".repeat(2000);
877        let header = format!(
878            "; Models: {filler} | Neglects: nothing, it's a fixture | IO: t -> mix | Tags: \
879             fixture\n"
880        );
881        let dir = dir_of("long-header-exempt", &[("master", &(header + "sin(t)\n"))]);
882        assert!(
883            lint(&dir, None).is_ok(),
884            "a single well-formed doc-comment line is exempt from the long-comment-block \
885             budget regardless of its own length"
886        );
887    }
888
889    /// A 2-line run is also `multiline-comment`; `.any()` isolates the arithmetic under test.
890    #[test]
891    fn the_line_one_header_annotation_is_excluded_from_its_run() {
892        let header = ";header"; // 7 chars
893        let boundary_line = format!(";{}", "x".repeat(999)); // 1000 chars
894        let over_line = format!(";{}", "x".repeat(1000)); // 1001 chars
895
896        let under = dir_of(
897            "header-excluded-under",
898            &[("master", &format!("{header}\n{boundary_line}\nsin(t)\n"))],
899        );
900        let Err(CliError::LintRefused(violations)) = lint(&under, None) else {
901            panic!("a 2-line leading run is also multiline-comment, so this must still refuse")
902        };
903        assert!(
904            !violations
905                .iter()
906                .any(|v| v.code == LintCode::LongCommentBlock),
907            "1007 chars from line 1 is 1000 once the header is excluded: {:?}",
908            violations.iter().map(|v| &v.code).collect::<Vec<_>>()
909        );
910
911        let over = dir_of(
912            "header-excluded-over",
913            &[("master", &format!("{header}\n{over_line}\nsin(t)\n"))],
914        );
915        assert_refused(lint(&over, None), LintCode::LongCommentBlock, "master");
916    }
917
918    /// Well-formed, so it never adds noise to a scenario testing some other node's comment.
919    const WELL_FORMED_MASTER_COMMENT: &str = "; Models: a test harness's master node | Neglects: nothing, it's a fixture | IO: t -> mix | Tags: fixture\n";
920
921    #[test]
922    fn a_node_with_no_comment_at_all_is_missing_comment_in_both_modes() {
923        let dir = dir_of(
924            "missing-comment",
925            &[
926                ("plucked", "sin(t)\n"),
927                (
928                    "master",
929                    &(String::from(WELL_FORMED_MASTER_COMMENT) + "@plucked\n"),
930                ),
931            ],
932        );
933        assert_refused(lint(&dir, None), LintCode::MissingComment, "plucked");
934        assert_refused(
935            lint(&dir, Some("master")),
936            LintCode::MissingComment,
937            "plucked",
938        );
939    }
940
941    #[test]
942    fn two_contiguous_comment_lines_are_multiline_comment_in_both_modes() {
943        let dir = dir_of(
944            "multiline-comment",
945            &[
946                (
947                    "stacked",
948                    "; Models: a thing\n; Neglects: nothing | IO: t -> out\nsin(t)\n",
949                ),
950                (
951                    "master",
952                    &(String::from(WELL_FORMED_MASTER_COMMENT) + "@stacked\n"),
953                ),
954            ],
955        );
956        assert_refused(lint(&dir, None), LintCode::MultilineComment, "stacked");
957        assert_refused(
958            lint(&dir, Some("master")),
959            LintCode::MultilineComment,
960            "stacked",
961        );
962    }
963
964    #[test]
965    fn one_correctly_shaped_comment_line_under_the_length_threshold_is_clean() {
966        let dir = dir_of(
967            "well-formed-comment",
968            &[
969                (
970                    "plucked",
971                    "; Models: a plucked string's fundamental decay | Neglects: pick-position \
972                     comb, body coupling | IO: note -> supersaw base | Tags: pluck\nsin(t)\n",
973                ),
974                (
975                    "master",
976                    &(String::from(WELL_FORMED_MASTER_COMMENT) + "@plucked\n"),
977                ),
978            ],
979        );
980        assert!(
981            lint(&dir, None).is_ok(),
982            "a single well-formed, short comment must not refuse"
983        );
984        assert!(
985            lint(&dir, Some("master")).is_ok(),
986            "a single well-formed, short comment must not refuse"
987        );
988    }
989
990    #[test]
991    fn a_single_free_text_comment_line_is_malformed_comment_in_both_modes() {
992        let dir = dir_of(
993            "malformed-comment",
994            &[
995                (
996                    "plucked",
997                    "; a plucked string, decays over time, no body resonance modeled\nsin(t)\n",
998                ),
999                (
1000                    "master",
1001                    &(String::from(WELL_FORMED_MASTER_COMMENT) + "@plucked\n"),
1002                ),
1003            ],
1004        );
1005        assert_refused(lint(&dir, None), LintCode::MalformedComment, "plucked");
1006        assert_refused(
1007            lint(&dir, Some("master")),
1008            LintCode::MalformedComment,
1009            "plucked",
1010        );
1011    }
1012
1013    /// `; lane`-style inline comments inside a TSV grid, well below the header, are a
1014    /// separate, already-legitimate feature (`sva-ast`'s `code_rows`) — not a second doc
1015    /// comment competing with the header for "exactly one".
1016    #[test]
1017    fn a_grids_own_inline_comment_below_a_well_formed_header_is_not_multiline_comment() {
1018        let dir = dir_of(
1019            "grid-inline-comment",
1020            &[
1021                ("kick", &(doc("a fixture kick") + "sin(2*pi*50*t)\n")),
1022                (
1023                    "pattern-1b",
1024                    "; Models: a kick pattern | Neglects: dynamics, humanization | \
1025                     IO: (t) -> amplitude | Tags: kick\n@kick\n; lane\n@kick\n",
1026                ),
1027                (
1028                    "master",
1029                    &(String::from(WELL_FORMED_MASTER_COMMENT) + "@pattern-1b\n"),
1030                ),
1031            ],
1032        );
1033        assert!(
1034            lint(&dir, None).is_ok(),
1035            "a grid's own inline comment must not be mistaken for a second doc comment"
1036        );
1037    }
1038
1039    #[test]
1040    fn an_expression_body_over_ten_thousand_chars_is_flagged_in_both_modes() {
1041        let body = "0".repeat(10_001);
1042        let dir = dir_of(
1043            "long-expression-body",
1044            &[
1045                (
1046                    "drone",
1047                    &(String::from(
1048                        "; Models: a sustained drone | Neglects: envelope, detune | IO: t -> \
1049                         amplitude | Tags: drone\n",
1050                    ) + &body
1051                        + "\n"),
1052                ),
1053                (
1054                    "master",
1055                    &(String::from(WELL_FORMED_MASTER_COMMENT) + "@drone\n"),
1056                ),
1057            ],
1058        );
1059        assert_refused(lint(&dir, None), LintCode::LongExpressionBody, "drone");
1060        assert_refused(
1061            lint(&dir, Some("master")),
1062            LintCode::LongExpressionBody,
1063            "drone",
1064        );
1065    }
1066
1067    #[test]
1068    fn an_expression_body_of_exactly_ten_thousand_chars_sits_at_the_threshold_not_over_it() {
1069        let body = "0".repeat(10_000);
1070        let dir = dir_of(
1071            "boundary-expression-body",
1072            &[(
1073                "drone",
1074                &(String::from(
1075                    "; Models: a sustained drone | Neglects: envelope, detune | IO: t -> \
1076                     amplitude | Tags: drone\n",
1077                ) + &body
1078                    + "\n"),
1079            )],
1080        );
1081        assert!(
1082            lint(&dir, None).is_ok(),
1083            "exactly 10000 chars is the threshold itself, not over it"
1084        );
1085    }
1086
1087    /// A well-formed header plus a body just under the cap must not refuse, even though
1088    /// their combined length exceeds it.
1089    #[test]
1090    fn the_leading_doc_comment_is_excluded_from_the_expression_body_count() {
1091        let body = "0".repeat(9_999);
1092        let dir = dir_of(
1093            "doc-comment-excluded",
1094            &[(
1095                "drone",
1096                &(String::from(
1097                    "; Models: a sustained drone with a longer than usual doc comment header \
1098                     | Neglects: envelope, detune | IO: t -> amplitude | Tags: drone\n",
1099                ) + &body
1100                    + "\n"),
1101            )],
1102        );
1103        assert!(
1104            lint(&dir, None).is_ok(),
1105            "the doc comment header must not count toward the body length"
1106        );
1107    }
1108
1109    /// Raw count is over budget only because the author picked a verbose local `@ref` name;
1110    /// once every occurrence collapses to `@x` the equation itself is nowhere near the cap.
1111    #[test]
1112    fn a_body_over_budget_only_from_verbose_ref_names_is_clean_once_they_collapse() {
1113        let long_name = "a".repeat(50);
1114        let n = 200; // (1 + 50) chars per `@<name>` occurrence, joined by " + " (3 chars).
1115        let refs: Vec<String> = std::iter::repeat_n(format!("@{long_name}"), n).collect();
1116        let body = refs.join(" + ");
1117        let raw = body.chars().count();
1118        assert!(
1119            raw > EXPRESSION_BODY_CHARS,
1120            "raw count must be over budget: {raw}"
1121        );
1122
1123        let reduced: usize = n * REF_PLACEHOLDER.chars().count() + (n - 1) * 3;
1124        assert!(
1125            reduced <= EXPRESSION_BODY_CHARS,
1126            "reduced count must be under budget: {reduced}"
1127        );
1128
1129        let dir = dir_of(
1130            "ref-collapse-clean",
1131            &[
1132                (&long_name, &(doc("a fixture ref target") + "sin(t)\n")),
1133                (
1134                    "drone",
1135                    &(String::from(
1136                        "; Models: a sustained drone | Neglects: envelope, detune | IO: t -> \
1137                         amplitude | Tags: drone\n",
1138                    ) + &body
1139                        + "\n"),
1140                ),
1141                (
1142                    "master",
1143                    &(String::from(WELL_FORMED_MASTER_COMMENT) + "@drone\n"),
1144                ),
1145            ],
1146        );
1147        assert!(
1148            lint(&dir, None).is_ok(),
1149            "a body over budget only from a verbose ref name must clear once ref names collapse"
1150        );
1151    }
1152
1153    /// Even with ref names collapsed, enough literal filler keeps the reduced count over budget.
1154    #[test]
1155    fn a_body_still_over_budget_after_ref_collapse_is_still_flagged() {
1156        let long_name = "b".repeat(50);
1157        let filler = "0".repeat(10_500);
1158        let body = format!("@{long_name} + @{long_name} + {filler}");
1159        let raw = body.chars().count();
1160        assert!(
1161            raw > EXPRESSION_BODY_CHARS,
1162            "raw count must be over budget: {raw}"
1163        );
1164        let reduced = 2 * REF_PLACEHOLDER.chars().count() + 2 * 3 + filler.chars().count();
1165        assert!(
1166            reduced > EXPRESSION_BODY_CHARS,
1167            "reduced count must still be over budget: {reduced}"
1168        );
1169
1170        let dir = dir_of(
1171            "ref-collapse-still-over",
1172            &[
1173                (&long_name, &(doc("a fixture ref target") + "sin(t)\n")),
1174                (
1175                    "drone",
1176                    &(String::from(
1177                        "; Models: a sustained drone | Neglects: envelope, detune | IO: t -> \
1178                         amplitude | Tags: drone\n",
1179                    ) + &body
1180                        + "\n"),
1181                ),
1182                (
1183                    "master",
1184                    &(String::from(WELL_FORMED_MASTER_COMMENT) + "@drone\n"),
1185                ),
1186            ],
1187        );
1188        assert_refused(lint(&dir, None), LintCode::LongExpressionBody, "drone");
1189    }
1190
1191    /// A body with `@ref`s well under the raw cap is clean — refs or not, staying under
1192    /// budget never depends on collapsing them.
1193    #[test]
1194    fn a_body_with_refs_under_the_raw_cap_is_clean() {
1195        let dir = dir_of(
1196            "under-budget-with-refs",
1197            &[
1198                ("kick", &(doc("a fixture kick") + "sin(t)\n")),
1199                (
1200                    "drone",
1201                    &(String::from(
1202                        "; Models: a sustained drone | Neglects: envelope, detune | IO: t -> \
1203                         amplitude | Tags: drone\n",
1204                    ) + &"@kick + ".repeat(20)
1205                        + "0.5\n"),
1206                ),
1207                (
1208                    "master",
1209                    &(String::from(WELL_FORMED_MASTER_COMMENT) + "@drone\n"),
1210                ),
1211            ],
1212        );
1213        assert!(
1214            lint(&dir, None).is_ok(),
1215            "this body is well under the raw cap"
1216        );
1217    }
1218
1219    /// A well-formed doc comment with a caller-chosen `Tags:` value, for tests exercising
1220    /// `Tags:` validation specifically.
1221    fn doc_with_tags(tags: &str) -> String {
1222        format!(
1223            "; Models: a fixture signal | Neglects: nothing, it's a fixture | IO: t -> mix | \
1224             Tags: {tags}\n"
1225        )
1226    }
1227
1228    #[test]
1229    fn a_comment_with_the_old_three_fields_and_no_tags_is_refused_as_missing_a_fourth_field() {
1230        let dir = dir_of(
1231            "old-three-field-comment",
1232            &[(
1233                "master",
1234                "; Models: a fixture signal | Neglects: nothing, it's a fixture | IO: t -> \
1235                 mix\nsin(t)\n",
1236            )],
1237        );
1238        let Err(CliError::LintRefused(violations)) = lint(&dir, None) else {
1239            panic!("a comment with no `Tags:` field must refuse")
1240        };
1241        let violation = violations
1242            .iter()
1243            .find(|v| v.code == LintCode::MalformedComment && v.subject == "master")
1244            .unwrap_or_else(|| {
1245                panic!(
1246                    "expected a malformed-comment refusal, got: {:?}",
1247                    violations.iter().map(|v| v.code).collect::<Vec<_>>()
1248                )
1249            });
1250        assert!(
1251            violation.message.contains("four"),
1252            "expected the refusal to name the missing fourth field: {}",
1253            violation.message
1254        );
1255    }
1256
1257    #[test]
1258    fn a_tags_field_with_no_tags_is_refused() {
1259        let dir = dir_of(
1260            "empty-tags-field",
1261            &[("master", &(doc_with_tags("") + "sin(t)\n"))],
1262        );
1263        assert_refused(lint(&dir, None), LintCode::MalformedComment, "master");
1264    }
1265
1266    #[test]
1267    fn a_tags_field_with_four_tags_is_advised() {
1268        let dir = dir_of(
1269            "four-tags",
1270            &[(
1271                "master",
1272                &(doc_with_tags("piano, sustained-pad, mellow, extra") + "sin(t)\n"),
1273            )],
1274        );
1275        assert_eq!(
1276            advised(lint(&dir, None), "master"),
1277            vec![LintCode::TagShape]
1278        );
1279    }
1280
1281    #[test]
1282    fn a_tag_containing_a_space_is_advised() {
1283        let dir = dir_of(
1284            "tag-with-space",
1285            &[("master", &(doc_with_tags("sustained pad") + "sin(t)\n"))],
1286        );
1287        assert_eq!(
1288            advised(lint(&dir, None), "master"),
1289            vec![LintCode::TagShape]
1290        );
1291    }
1292
1293    #[test]
1294    fn a_tag_over_twenty_four_characters_is_advised() {
1295        let long_tag = "a".repeat(25);
1296        let dir = dir_of(
1297            "tag-too-long",
1298            &[("master", &(doc_with_tags(&long_tag) + "sin(t)\n"))],
1299        );
1300        assert_eq!(
1301            advised(lint(&dir, None), "master"),
1302            vec![LintCode::TagShape]
1303        );
1304    }
1305
1306    #[test]
1307    fn a_tag_of_exactly_twenty_four_characters_sits_at_the_threshold_not_over_it() {
1308        let boundary_tag = "a".repeat(24);
1309        let dir = dir_of(
1310            "tag-at-threshold",
1311            &[("master", &(doc_with_tags(&boundary_tag) + "sin(t)\n"))],
1312        );
1313        assert!(
1314            lint(&dir, None).is_ok(),
1315            "exactly 24 characters is the threshold itself, not over it"
1316        );
1317    }
1318
1319    #[test]
1320    fn an_empty_tag_between_commas_is_refused() {
1321        let dir = dir_of(
1322            "empty-tag-between-commas",
1323            &[("master", &(doc_with_tags("piano,,pad") + "sin(t)\n"))],
1324        );
1325        assert_refused(lint(&dir, None), LintCode::MalformedComment, "master");
1326    }
1327
1328    #[test]
1329    fn a_single_valid_tag_is_clean() {
1330        let dir = dir_of(
1331            "one-valid-tag",
1332            &[("master", &(doc_with_tags("piano") + "sin(t)\n"))],
1333        );
1334        assert!(
1335            lint(&dir, None).is_ok(),
1336            "a single valid tag is not a finding"
1337        );
1338    }
1339
1340    #[test]
1341    fn three_valid_comma_separated_tags_are_clean() {
1342        let dir = dir_of(
1343            "three-valid-tags",
1344            &[(
1345                "master",
1346                &(doc_with_tags("piano, sustained-pad, mellow") + "sin(t)\n"),
1347            )],
1348        );
1349        assert!(
1350            lint(&dir, None).is_ok(),
1351            "three valid comma-separated tags are not a finding"
1352        );
1353    }
1354
1355    /// A well-formed 4-field header (valid `Tags:` included) ahead of a separate, non-leading
1356    /// comment block must not shield that block from `LONG_COMMENT_BLOCK_CHARS` — the two
1357    /// checks stay independent even once the header carries a fourth field.
1358    #[test]
1359    fn a_well_formed_tagged_header_does_not_exempt_a_later_long_comment_block() {
1360        let trailing = format!(";{}", "x".repeat(1000)); // 1001 chars
1361        let dir = dir_of(
1362            "tagged-header-long-block",
1363            &[
1364                (
1365                    "long",
1366                    &(doc_with_tags("fixture") + "sin(t)\n" + &trailing + "\n"),
1367                ),
1368                ("master", &(doc_with_tags("fixture") + "@long\n")),
1369            ],
1370        );
1371        assert_refused(lint(&dir, None), LintCode::LongCommentBlock, "long");
1372    }
1373}