1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
//! RPM406 `include-not-expanded` — `%include path` directive present
//! in the spec.
//!
//! The parser keeps `%include` lines verbatim and does **not** load
//! the referenced file. Consequently, all other rules that reason
//! about declared `BuildRequires`, declared bconds, available
//! sections, or anything else "global" run with an incomplete view —
//! whatever the include contributes is invisible. Rules will
//! optimistically not fire (false negatives) for content present in
//! the included file.
//!
//! This rule emits a single per-include notice so the user knows the
//! lint output may be incomplete. It is **opt-in informational**:
//! [`Severity::Allow`] is the default, meaning the lint is *skipped
//! entirely* by [`crate::session::LintSession::run`] (the session
//! treats `Allow` as silenced and never calls `take_diagnostics`).
//! Users who want the notice enable it via config or
//! `--warn=include-not-expanded` / `--warn=RPM406`. A future
//! repository-level analyzer (Phase 25.9) will resolve the include
//! and lift the limitation. The diagnostic is anchored at the include
//! line so editor-jump lands on it.
use rpm_spec::ast::{
ConditionalMacro, IncludeDirective, Span, SpecFile, SpecItem, Text, TextSegment,
};
use crate::diagnostic::{Diagnostic, LintCategory, Severity};
use crate::lint::{Lint, LintMetadata};
use crate::visit::Visit;
pub static METADATA: LintMetadata = LintMetadata {
id: "RPM406",
name: "include-not-expanded",
description: "`%include path` directive — the analyzer does not follow includes, so other \
rules see only the visible spec. Findings may be incomplete for symbols \
defined inside the included file.",
default_severity: Severity::Allow,
category: LintCategory::Style,
};
/// Lint state for RPM406.
#[derive(Debug, Default)]
pub struct IncludeNotExpanded {
diagnostics: Vec<Diagnostic>,
}
impl IncludeNotExpanded {
/// Construct an empty lint instance with no diagnostics buffered.
pub fn new() -> Self {
Self::default()
}
}
impl<'ast> Visit<'ast> for IncludeNotExpanded {
fn visit_spec(&mut self, spec: &'ast SpecFile<Span>) {
walk_items(&spec.items, &mut self.diagnostics);
}
}
fn walk_items(items: &[SpecItem<Span>], out: &mut Vec<Diagnostic>) {
for item in items {
match item {
SpecItem::Include(inc) => emit(inc, out),
SpecItem::Conditional(c) => {
for branch in &c.branches {
walk_items(&branch.body, out);
}
if let Some(els) = &c.otherwise {
walk_items(els, out);
}
}
_ => {}
}
}
}
fn emit(inc: &IncludeDirective<Span>, out: &mut Vec<Diagnostic>) {
let path_label = render_path_verbatim(&inc.path);
out.push(Diagnostic::new(
&METADATA,
Severity::Allow,
format!(
"`%include {path_label}` is not followed by the analyzer; rules checking declared \
symbols, sections, or bconds may miss anything defined in the included file"
),
inc.data,
));
}
/// Best-effort verbatim render of a `Text` path: literal segments are
/// emitted as-is, macro segments as `%{name}` (or `%{?name}` /
/// `%{!?name}` for conditional references). Mirrors
/// [`crate::shell::tokens::ShellToken::render_verbatim`] so diagnostic
/// messages never silently drop macros from the include path. Macro
/// arguments and `:default` bodies are not reconstructed — for an
/// `%include` path the bare `%{name}` form is unambiguous enough and
/// avoids re-implementing the full `Text` pretty-printer here.
fn render_path_verbatim(path: &Text) -> String {
let mut out = String::new();
for seg in &path.segments {
match seg {
TextSegment::Literal(s) => out.push_str(s),
TextSegment::Macro(m) => {
let prefix = match m.conditional {
ConditionalMacro::None => "",
ConditionalMacro::IfDefined => "?",
ConditionalMacro::IfNotDefined => "!?",
// `ConditionalMacro` is `#[non_exhaustive]`. Any
// variant added upstream falls back to the bare
// `%{name}` form — better than failing to compile
// and acceptable for a diagnostic-only message.
_ => "",
};
out.push_str("%{");
out.push_str(prefix);
out.push_str(&m.name);
out.push('}');
}
_ => {}
}
}
if out.is_empty() {
// Defensive: the parser should not produce an empty include
// path, but if it does, fall back to a recognisable marker so
// the diagnostic message is still readable.
out.push_str("<empty path>");
}
out
}
impl Lint for IncludeNotExpanded {
fn metadata(&self) -> &'static LintMetadata {
&METADATA
}
fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
std::mem::take(&mut self.diagnostics)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::parse;
fn run(src: &str) -> Vec<Diagnostic> {
let outcome = parse(src);
let mut lint = IncludeNotExpanded::new();
lint.visit_spec(&outcome.spec);
lint.take_diagnostics()
}
#[test]
fn flags_each_include() {
let src = "Name: x\n%include common.spec\n%include macros.inc\n";
let diags = run(src);
assert_eq!(diags.len(), 2, "{diags:?}");
assert_eq!(diags[0].lint_id, "RPM406");
}
#[test]
fn silent_on_clean_spec() {
let src = "Name: x\nVersion: 1\n";
assert!(run(src).is_empty());
}
#[test]
fn flags_include_inside_conditional() {
let src = "Name: x\n%if 0%{?fedora}\n%include fedora.inc\n%endif\n";
assert_eq!(run(src).len(), 1);
}
#[test]
fn diagnostic_carries_path() {
let src = "Name: x\n%include path/to/foo.spec\n";
let diags = run(src);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("path/to/foo.spec"));
}
#[test]
fn flags_include_with_macro_path() {
let src = "Name: x\n%include %{_sourcedir}/common.inc\n";
let diags = run(src);
assert_eq!(diags.len(), 1);
// The diagnostic should mention the include's path verbatim
// (including the unresolved macro), not the generic placeholder.
assert!(
diags[0].message.contains("_sourcedir") || diags[0].message.contains("common.inc"),
"message should surface the include path: {}",
diags[0].message
);
}
}