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