Skip to main content

active_core/
lint.rs

1//! Linter rules (C-042, spec/linter.md).
2//!
3//! Static checks beyond validation: hygiene, probable mistakes, and
4//! style risks. Every rule is independently disableable, emits stable
5//! `L0xx` Warning diagnostics the language server surfaces as-is, and
6//! never affects evaluation — linting reads the module only.
7
8use std::collections::{HashMap, HashSet};
9
10use crate::diag::{Diagnostic, Repair, RepairKind, Severity};
11use crate::ir::{Module, Node, NodeId, Value};
12
13/// Rule IDs, shared by CLI flags, options, and tests.
14pub const RULE_UNUSED_LETS: &str = "unused-lets";
15pub const RULE_DEPENDS_ON: &str = "depends-on";
16pub const RULE_COMPLEXITY: &str = "complexity";
17pub const RULE_HARDCODED_SECRETS: &str = "hardcoded-secrets";
18pub const RULE_DEPRECATED: &str = "deprecated";
19
20/// All rule IDs in stable order (also the CLI's unknown-name error).
21pub const ALL_RULES: &[&str] = &[
22    RULE_UNUSED_LETS,
23    RULE_DEPENDS_ON,
24    RULE_COMPLEXITY,
25    RULE_HARDCODED_SECRETS,
26    RULE_DEPRECATED,
27];
28
29/// Expression operator nodes that trip the complexity budget.
30const COMPLEXITY_LIMIT: u64 = 100;
31
32/// Shannon entropy floor (bits/char) and minimum length for the
33/// content-based hard-coded-secret signal. Tuned for precision: with
34/// no per-file suppression yet, false positives hurt more than misses
35/// (spec/linter.md §1).
36const ENTROPY_BITS: f64 = 4.5;
37const ENTROPY_MIN_LEN: usize = 20;
38
39/// Linter options: rules in `disabled` do not run.
40#[derive(Debug, Clone, Default)]
41pub struct LintOptions {
42    pub disabled: HashSet<String>,
43}
44
45impl LintOptions {
46    /// Fails on the first unknown rule name, listing the valid ones.
47    pub fn with_disabled(names: &[String]) -> Result<Self, String> {
48        let mut disabled = HashSet::new();
49        for name in names {
50            if !ALL_RULES.contains(&name.as_str()) {
51                return Err(format!(
52                    "unknown lint rule `{name}` (expected one of: {})",
53                    ALL_RULES.join(", ")
54                ));
55            }
56            disabled.insert(name.clone());
57        }
58        Ok(Self { disabled })
59    }
60
61    fn enabled(&self, rule: &str) -> bool {
62        !self.disabled.contains(rule)
63    }
64}
65
66/// Run all enabled rules in stable rule order. Deterministic for a
67/// fixed input: every rule walks source-ordered structures (table
68/// fields are `IndexMap`; bindings sort by name).
69pub fn lint(module: &Module) -> Vec<Diagnostic> {
70    lint_with(module, &LintOptions::default())
71}
72
73/// Run the enabled subset of rules.
74pub fn lint_with(module: &Module, options: &LintOptions) -> Vec<Diagnostic> {
75    let mut out = Vec::new();
76    if options.enabled(RULE_UNUSED_LETS) {
77        out.extend(unused_lets(module));
78    }
79    if options.enabled(RULE_DEPENDS_ON) {
80        out.extend(depends_on(module));
81    }
82    if options.enabled(RULE_COMPLEXITY) {
83        out.extend(complexity(module));
84    }
85    if options.enabled(RULE_HARDCODED_SECRETS) {
86        out.extend(hardcoded_secrets(module));
87    }
88    if options.enabled(RULE_DEPRECATED) {
89        out.extend(deprecated(module));
90    }
91    out
92}
93
94fn warning(
95    module: &Module,
96    code: &str,
97    message: String,
98    path: Option<String>,
99    node: Option<NodeId>,
100) -> Diagnostic {
101    Diagnostic {
102        severity: Severity::Warning,
103        code: code.into(),
104        message,
105        path,
106        span: node.and_then(|id| module.node_spans.get(&id).cloned()),
107        repair: None,
108        confidence: 1.0,
109        file: node.and_then(|id| module.node_files.get(&id).cloned()),
110    }
111}
112
113/// Every `Ref` target reachable from `id`, including through nested
114/// tables and arrays. Iterative: reference chains cannot recurse the
115/// stack.
116fn ref_targets(module: &Module, id: NodeId) -> HashSet<NodeId> {
117    let mut targets = HashSet::new();
118    let mut stack = vec![id];
119    let mut seen = HashSet::new();
120    while let Some(nid) = stack.pop() {
121        if !seen.insert(nid) {
122            continue;
123        }
124        match module.get(nid) {
125            Some(Node::Ref(target)) => {
126                targets.insert(*target);
127            }
128            Some(Node::Table { fields }) => {
129                stack.extend(fields.values().copied());
130            }
131            Some(Node::Array { elements }) => {
132                stack.extend(elements.iter().copied());
133            }
134            Some(Node::Binary { lhs, rhs, .. }) => {
135                stack.push(*lhs);
136                stack.push(*rhs);
137            }
138            Some(Node::Unary { expr, .. }) => {
139                stack.push(*expr);
140            }
141            Some(Node::Interpolate { parts }) => {
142                for part in parts {
143                    if let crate::ir::InterpPart::Expr(e) = part {
144                        stack.push(*e);
145                    }
146                }
147            }
148            Some(Node::Semantic { inner, .. }) => {
149                stack.push(*inner);
150            }
151            Some(Node::Env {
152                default: Some(def), ..
153            }) => {
154                stack.push(*def);
155            }
156            Some(Node::Secret { locator }) => {
157                stack.push(*locator);
158            }
159            _ => {}
160        }
161    }
162    targets
163}
164
165/// L001: `let` bindings never referenced by any `Ref`.
166fn unused_lets(module: &Module) -> Vec<Diagnostic> {
167    // Single flat scan: every Ref node in the arena contributes its
168    // target. The old code unioned `ref_targets(id)` over every node —
169    // O(V·E) re-walks of the same subgraphs. The sets are identical:
170    // each arena node is reachable from itself, so the union over all
171    // nodes is exactly the set of all Ref targets (C-055 perf audit).
172    let mut used = HashSet::new();
173    for node in module.arena.iter() {
174        if let Node::Ref(target) = node {
175            used.insert(*target);
176        }
177    }
178    // Bindings sort by name: HashMap iteration is random, diagnostics
179    // must not be.
180    let mut names: Vec<&String> = module.bindings.keys().collect();
181    names.sort();
182    let mut out = Vec::new();
183    for name in names {
184        let id = module.bindings[name];
185        if !used.contains(&id) {
186            out.push(warning(
187                module,
188                "L001",
189                format!("unused let binding '{name}'"),
190                Some(name.clone()),
191                Some(id),
192            ));
193        }
194    }
195    out
196}
197
198/// L002: incomplete, dangling, or self-scoped `depends_on` sets.
199fn depends_on(module: &Module) -> Vec<Diagnostic> {
200    let paths = module.value_paths();
201    // Scope path per depends_on table, by table node id.
202    let mut scopes: HashMap<NodeId, String> = HashMap::new();
203    if let Some(root) = module.root {
204        let mut stack = vec![(root, String::new())];
205        while let Some((id, path)) = stack.pop() {
206            if module.depends_on.contains_key(&id) {
207                scopes.insert(id, path.clone());
208            }
209            if let Some(Node::Table { fields }) = module.get(id) {
210                for (key, child) in fields {
211                    let child_path = if path.is_empty() {
212                        key.clone()
213                    } else {
214                        format!("{path}.{key}")
215                    };
216                    stack.push((*child, child_path));
217                }
218            }
219        }
220    }
221    // Deterministic: sort scopes by path (HashMap order is random).
222    let mut entries: Vec<(NodeId, String)> = scopes.into_iter().collect();
223    entries.sort_by(|a, b| a.1.cmp(&b.1));
224    let mut out = Vec::new();
225    for (table_id, scope_path) in entries {
226        let dep = &module.depends_on[&table_id];
227        let declared: HashSet<&str> = dep.paths.iter().map(String::as_str).collect();
228        // Referenced names/paths inside the scope, mapped through the
229        // reverse index; unmapped nodes are skipped, never guessed.
230        let mut referenced: HashSet<String> = HashSet::new();
231        if let Some(Node::Table { fields }) = module.get(table_id) {
232            for child in fields.values() {
233                for target in ref_targets(module, *child) {
234                    if let Some(name) = paths.get(&target) {
235                        referenced.insert(name.clone());
236                    }
237                }
238            }
239        }
240        let span_node = Some(table_id);
241        if dep.paths.is_empty() {
242            if !referenced.is_empty() {
243                let mut missing: Vec<&String> = referenced.iter().collect();
244                missing.sort();
245                for name in missing {
246                    out.push(missing_entry(module, &scope_path, span_node, name));
247                }
248            }
249            continue;
250        }
251        for path in &dep.paths {
252            if path == &scope_path
253                || (!scope_path.is_empty() && path.starts_with(&format!("{scope_path}.")))
254            {
255                out.push(warning(
256                    module,
257                    "L002",
258                    format!("unnecessary depends_on entry '{path}' (own scope)"),
259                    Some(scope_path.clone()),
260                    span_node,
261                ));
262            } else if module.resolve_value_path(path).is_none() {
263                out.push(warning(
264                    module,
265                    "L002",
266                    format!("dangling depends_on path '{path}'"),
267                    Some(scope_path.clone()),
268                    span_node,
269                ));
270            }
271        }
272        let mut missing: Vec<&String> = referenced
273            .iter()
274            .filter(|name| !declared.contains(name.as_str()))
275            .collect();
276        missing.sort();
277        for name in missing {
278            out.push(missing_entry(module, &scope_path, span_node, name));
279        }
280    }
281    out
282}
283
284/// An L002 missing-entry diagnostic: human prose plus a machine
285/// repair carrying the missing path (empty original = insertion),
286/// so tooling never parses the message.
287fn missing_entry(
288    module: &Module,
289    scope_path: &str,
290    span_node: Option<NodeId>,
291    name: &str,
292) -> Diagnostic {
293    let mut diag = warning(
294        module,
295        "L002",
296        format!("missing depends_on entry for '{name}'"),
297        Some(scope_path.to_string()),
298        span_node,
299    );
300    diag.repair = Some(Repair {
301        original: String::new(),
302        suggested: name.to_string(),
303        kind: RepairKind::Other("add_depends_on".to_string()),
304        safe: true,
305    });
306    diag
307}
308
309/// Count operator nodes in a value subtree (spec/linter.md §1).
310/// Data nodes are free but descended: a big inline table under a key
311/// is that key's complexity.
312fn operator_count(module: &Module, id: NodeId) -> u64 {
313    let mut count = 0u64;
314    let mut stack = vec![id];
315    let mut seen = HashSet::new();
316    while let Some(nid) = stack.pop() {
317        if !seen.insert(nid) {
318            continue;
319        }
320        match module.get(nid) {
321            Some(Node::Binary { lhs, rhs, .. }) => {
322                count += 1;
323                stack.push(*lhs);
324                stack.push(*rhs);
325            }
326            Some(Node::Unary { expr, .. }) => {
327                count += 1;
328                stack.push(*expr);
329            }
330            Some(Node::Interpolate { parts }) => {
331                for part in parts {
332                    if let crate::ir::InterpPart::Expr(e) = part {
333                        count += 1;
334                        stack.push(*e);
335                    }
336                }
337            }
338            Some(Node::Ref(_)) | Some(Node::Ident(_)) => {
339                count += 1;
340            }
341            Some(Node::Env { default, .. }) => {
342                // Environment defaults are expressions too: a large
343                // default must not bypass the budget.
344                count += 1;
345                if let Some(def) = default {
346                    stack.push(*def);
347                }
348            }
349            Some(Node::Secret { locator }) => {
350                // Secret locators are expressions with the same rule.
351                count += 1;
352                stack.push(*locator);
353            }
354            Some(Node::Table { fields }) => {
355                stack.extend(fields.values().copied());
356            }
357            Some(Node::Array { elements }) => {
358                stack.extend(elements.iter().copied());
359            }
360            Some(Node::Semantic { inner, .. }) => {
361                stack.push(*inner);
362            }
363            _ => {}
364        }
365    }
366    count
367}
368
369/// L003: per-statement operator budgets. Roots are key values and
370/// `let` bindings (bindings sort by name; table fields keep source
371/// order — both deterministic).
372fn complexity(module: &Module) -> Vec<Diagnostic> {
373    let mut out = Vec::new();
374    let check = |path: String, id: NodeId, out: &mut Vec<Diagnostic>| {
375        let count = operator_count(module, id);
376        if count > COMPLEXITY_LIMIT {
377            out.push(warning(
378                module,
379                "L003",
380                format!("expression complexity {count} exceeds limit {COMPLEXITY_LIMIT}"),
381                Some(path),
382                Some(id),
383            ));
384        }
385    };
386    let mut names: Vec<&String> = module.bindings.keys().collect();
387    names.sort();
388    for name in names {
389        check(name.clone(), module.bindings[name], &mut out);
390    }
391    if let Some(root) = module.root {
392        if let Some(Node::Table { fields }) = module.get(root).cloned() {
393            for (key, id) in &fields {
394                check(key.clone(), *id, &mut out);
395            }
396        }
397    }
398    out
399}
400
401/// Normalized credential-name signals (lowercase, separators out).
402fn is_credential_name(key: &str) -> bool {
403    const SIGNALS: &[&str] = &[
404        "password",
405        "passwd",
406        "secret",
407        "token",
408        "apikey",
409        "privatekey",
410        "auth",
411        "credential",
412        "accesskey",
413        "secretkey",
414    ];
415    let normalized: String = key
416        .to_lowercase()
417        .chars()
418        .filter(|c| *c != '_' && *c != '-')
419        .collect();
420    SIGNALS.iter().any(|signal| {
421        normalized == *signal || normalized.starts_with(signal) || normalized.ends_with(signal)
422    })
423}
424
425/// Values the name-based signal never flags: placeholders,
426/// markers, empties, and URL locators (a `scheme://` value under a
427/// credential-named key is an address, not a secret). The entropy
428/// signal still applies to URLs — a token-laden query string stays
429/// flaggable. Credentials embedded in low-entropy URLs are a
430/// documented blind spot.
431fn is_placeholder(value: &str) -> bool {
432    if has_url_scheme(value) {
433        return true;
434    }
435    matches!(
436        value.to_lowercase().as_str(),
437        "changeme"
438            | "password"
439            | "xxx"
440            | "test"
441            | "example"
442            | "placeholder"
443            | "redacted"
444            | "***"
445            | "***secret***"
446            | ""
447    )
448}
449
450/// True for `scheme://` locators (`https://…`, `postgres://…`).
451fn has_url_scheme(value: &str) -> bool {
452    let bytes = value.as_bytes();
453    let mut i = 0;
454    if bytes.first().is_some_and(|c| c.is_ascii_alphabetic()) {
455        i += 1;
456        while i < bytes.len()
457            && (bytes[i].is_ascii_alphanumeric() || matches!(bytes[i], b'+' | b'-' | b'.'))
458        {
459            i += 1;
460        }
461    }
462    value.len() > i + 2 && &value[i..i + 3] == "://"
463}
464
465/// Shannon entropy in bits per character (unigram distribution).
466/// Counts sort by character for determinism: HashMap iteration order
467/// could otherwise flip boundary cases through float rounding.
468fn shannon_entropy(value: &str) -> f64 {
469    use std::collections::BTreeMap;
470    let mut counts = BTreeMap::new();
471    let mut total = 0usize;
472    for c in value.chars() {
473        *counts.entry(c).or_insert(0usize) += 1;
474        total += 1;
475    }
476    if total == 0 {
477        return 0.0;
478    }
479    counts
480        .values()
481        .map(|count| {
482            let p = *count as f64 / total as f64;
483            -p * p.log2()
484        })
485        .sum()
486}
487
488/// L004: probable hard-coded secrets. Messages name the key path
489/// only — quoting the value would leak it into logs (C-036).
490fn hardcoded_secrets(module: &Module) -> Vec<Diagnostic> {
491    let mut out = Vec::new();
492    // (path, node, text) for every pure string literal in value
493    // position: root tables, nested tables, arrays (with indexed
494    // paths), and top-level lets.
495    let mut literals: Vec<(String, NodeId, String)> = Vec::new();
496    // Iterative walk from the root; bindings handled separately below.
497    if let Some(root) = module.root {
498        let mut stack = vec![(root, String::new())];
499        while let Some((id, path)) = stack.pop() {
500            match module.get(id) {
501                Some(Node::Table { fields }) => {
502                    // Reverse push keeps source order on pop (stack).
503                    let mut entries: Vec<_> = fields.iter().collect();
504                    entries.reverse();
505                    for (key, child) in entries {
506                        let child_path = if path.is_empty() {
507                            key.clone()
508                        } else {
509                            format!("{path}.{key}")
510                        };
511                        match module.get(*child) {
512                            Some(Node::Literal(Value::String(text))) => {
513                                literals.push((child_path, *child, text.clone()));
514                            }
515                            _ => stack.push((*child, child_path)),
516                        }
517                    }
518                }
519                Some(Node::Array { elements }) => {
520                    let mut indexed: Vec<_> = elements.iter().enumerate().collect();
521                    indexed.reverse();
522                    for (i, child) in indexed {
523                        let child_path = format!("{path}[{i}]");
524                        match module.get(*child) {
525                            Some(Node::Literal(Value::String(text))) => {
526                                literals.push((child_path, *child, text.clone()));
527                            }
528                            _ => stack.push((*child, child_path)),
529                        }
530                    }
531                }
532                _ => {}
533            }
534        }
535    }
536    let mut names: Vec<&String> = module.bindings.keys().collect();
537    names.sort();
538    for name in names {
539        if let Some(Node::Literal(Value::String(text))) = module.get(module.bindings[name]) {
540            literals.push((name.clone(), module.bindings[name], text.clone()));
541        }
542    }
543    for (path, id, text) in literals {
544        // Array indices belong to the owning key (`password[0]` checks
545        // against `password`).
546        let key = path.rsplit('.').next().unwrap_or(&path);
547        let key = key.split('[').next().unwrap_or(key);
548        let named = is_credential_name(key) && !is_placeholder(&text);
549        let entropic =
550            text.chars().count() >= ENTROPY_MIN_LEN && shannon_entropy(&text) >= ENTROPY_BITS;
551        if named || entropic {
552            out.push(warning(
553                module,
554                "L004",
555                format!(
556                    "possible hard-coded secret in key '{path}' (use secret() or secret.env())"
557                ),
558                Some(path),
559                Some(id),
560            ));
561        }
562    }
563    out
564}
565
566/// L005: values present under schema-deprecated fields. Walks the
567/// schema and value trees together (spec/validation.md §2 pairing);
568/// the migration message is schema-authored and safe to quote.
569fn deprecated(module: &Module) -> Vec<Diagnostic> {
570    let Some(schema_id) = module.schema else {
571        return Vec::new();
572    };
573    let Some(root) = module.root else {
574        return Vec::new();
575    };
576    let mut out = Vec::new();
577    // (schema node, value node, path) worklist.
578    let mut stack = vec![(schema_id, root, String::new())];
579    while let Some((schema, value, path)) = stack.pop() {
580        let (
581            Some(Node::SchemaTable {
582                fields: schema_fields,
583                ..
584            }),
585            Some(Node::Table {
586                fields: value_fields,
587            }),
588        ) = (module.get(schema), module.get(value))
589        else {
590            continue;
591        };
592        // Deterministic: schema fields keep source order; reverse for
593        // stack discipline.
594        let mut entries: Vec<_> = schema_fields.iter().collect();
595        entries.reverse();
596        for (name, field_id) in entries {
597            let child_path = if path.is_empty() {
598                name.clone()
599            } else {
600                format!("{path}.{name}")
601            };
602            let Some(value_id) = value_fields.get(name).copied() else {
603                continue;
604            };
605            match module.get(*field_id) {
606                Some(Node::SchemaField { field }) => {
607                    if let Some(reason) = &field.deprecated {
608                        let message = if reason.is_empty() {
609                            format!("deprecated field '{child_path}' is still used")
610                        } else {
611                            format!("deprecated field '{child_path}' is still used: {reason}")
612                        };
613                        out.push(warning(
614                            module,
615                            "L005",
616                            message,
617                            Some(child_path),
618                            Some(value_id),
619                        ));
620                    }
621                }
622                Some(Node::SchemaTable { .. }) => {
623                    stack.push((*field_id, value_id, child_path));
624                }
625                Some(Node::SchemaArray { element }) => {
626                    if let Some(Node::Array { elements }) = module.get(value_id) {
627                        for (i, element_id) in elements.iter().enumerate() {
628                            stack.push((*element, *element_id, format!("{child_path}[{i}]")));
629                        }
630                    }
631                }
632                _ => {}
633            }
634        }
635    }
636    // The stack reverses source order; restore it for determinism.
637    out.reverse();
638    out
639}