Skip to main content

kotlin_codegen/
validate.rs

1//! Checking a [`KtFile`] before it is written.
2//!
3//! A *program* builds the declaration model, so the mistakes are program
4//! mistakes: a name derived from a Rust field that happens to be `object`, two
5//! generated classes that mangle to the same name. Without a check here those
6//! surface much later, when the Kotlin compiler runs — often in a different
7//! build, against a generated file that gives no hint about what produced it.
8//!
9//! Two rules bound what belongs here:
10//!
11//! 1. **Only what the model proves.** No type resolution, no parsing of the raw
12//!    text inside [`KtCode`](super::KtCode) bodies. Whether `io.zenoh.ZSession`
13//!    exists is the compiler's question, not ours.
14//! 2. **No false positives.** A check that occasionally fires on correct output
15//!    is worse than no check, because the first false alarm teaches people to
16//!    switch the checks off.
17//!
18//! Validation is a **separate read-only pass**: the model stays a plain data
19//! structure, the builders stay infallible, and
20//! [`render`](super::KtFile::render) stays infallible too — rendering a model
21//! you already know is broken is exactly what you want while debugging a
22//! generator. The checks run on the *write* path instead
23//! ([`merge_files`](super::merge_files), [`write_files`](super::write_files)).
24
25use std::collections::{BTreeMap, BTreeSet};
26
27use super::{
28    ident::{is_valid_kotlin_package, is_writable_kotlin_ident},
29    model::{
30        KtBody, KtClass, KtClassKind, KtClassModifier, KtCompanion, KtCtorParam, KtDecl, KtFile,
31        KtFun, KtParam,
32    },
33    slot::KtPropertyValue,
34};
35
36/// Which check produced a [`Diagnostic`].
37///
38/// Consumers match on this to override a severity, so it is the stable name of
39/// a check rather than its message.
40#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
41#[non_exhaustive]
42pub enum Check {
43    /// A declared name is not something Kotlin can write.
44    InvalidIdentifier,
45    /// A file's package is not a legal Kotlin package path.
46    InvalidPackage,
47    /// Two type declarations in one scope share a name — classes, `fun
48    /// interface`s and type aliases all live in Kotlin's *classifier*
49    /// namespace.
50    DuplicateType,
51    /// Two value declarations in one scope share a name — properties and
52    /// `val`/`var` constructor parameters.
53    DuplicateValue,
54    /// Two functions in one scope share a name *and* a parameter type list.
55    /// Same-named functions with different parameters are overloads and pass.
56    DuplicateFunction,
57    /// Two `Raw` blocks in one scope share a name. Identical ones are merged
58    /// rather than reported (see [`merge_files`](super::merge_files)), so this
59    /// means two different blocks are claiming one identity.
60    DuplicateRaw,
61    /// A property has no type, no value and no accessors — `val x` alone,
62    /// which is never legal.
63    PropertyWithoutTypeOrValue,
64    /// An `enum class` declares primary-constructor parameters, but an entry
65    /// passes no arguments.
66    EnumEntryMissingArguments,
67    /// A function has no body somewhere a bodiless function is not allowed.
68    FunctionWithoutBody,
69    /// Two distinct FQNs referenced from raw text share a simple name, so the
70    /// text cannot be made to refer to both.
71    ImportCollision,
72}
73
74impl Check {
75    /// Every check, so a policy can be built over all of them. Adding a check
76    /// extends this list, which is what keeps
77    /// [`ValidationPolicy::warn_all`] correct as the set grows.
78    pub const ALL: &'static [Check] = &[
79        Check::InvalidIdentifier,
80        Check::InvalidPackage,
81        Check::DuplicateType,
82        Check::DuplicateValue,
83        Check::DuplicateFunction,
84        Check::DuplicateRaw,
85        Check::PropertyWithoutTypeOrValue,
86        Check::EnumEntryMissingArguments,
87        Check::FunctionWithoutBody,
88        Check::ImportCollision,
89    ];
90
91    /// The stable, greppable name of this check.
92    pub fn name(self) -> &'static str {
93        match self {
94            Check::InvalidIdentifier => "invalid-identifier",
95            Check::InvalidPackage => "invalid-package",
96            Check::DuplicateType => "duplicate-type",
97            Check::DuplicateValue => "duplicate-value",
98            Check::DuplicateFunction => "duplicate-function",
99            Check::DuplicateRaw => "duplicate-raw",
100            Check::PropertyWithoutTypeOrValue => "property-without-type-or-value",
101            Check::EnumEntryMissingArguments => "enum-entry-missing-arguments",
102            Check::FunctionWithoutBody => "function-without-body",
103            Check::ImportCollision => "import-collision",
104        }
105    }
106}
107
108impl std::fmt::Display for Check {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        f.write_str(self.name())
111    }
112}
113
114/// How much a diagnostic matters. A [`Check`] set to neither is off.
115#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
116pub enum Severity {
117    /// Reported, but does not stop generation.
118    Warning,
119    /// Stops generation.
120    Error,
121}
122
123impl std::fmt::Display for Severity {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        f.write_str(match self {
126            Severity::Warning => "warning",
127            Severity::Error => "error",
128        })
129    }
130}
131
132/// One problem found in a [`KtFile`].
133#[derive(Clone, Debug, PartialEq, Eq)]
134pub struct Diagnostic {
135    pub check: Check,
136    pub severity: Severity,
137    /// Where the problem is, as a path through the declaration tree —
138    /// `io.zenoh.jni.session/ZSession/Companion`. The model has no source
139    /// positions, so this is how a diagnostic is located.
140    pub scope: String,
141    pub message: String,
142}
143
144impl std::fmt::Display for Diagnostic {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        write!(
147            f,
148            "{} [{}] in `{}`: {}",
149            self.severity, self.check, self.scope, self.message
150        )
151    }
152}
153
154/// Per-check severity, so a consumer can downgrade or disable a check instead
155/// of pinning an old version of this crate when one misfires.
156///
157/// Every check is an error by default.
158///
159/// ```
160/// use kotlin_codegen::{Check, Severity, ValidationPolicy};
161/// let policy = ValidationPolicy::new().warn(Check::DuplicateType);
162/// assert_eq!(
163///     policy.severity_of(Check::DuplicateType),
164///     Some(Severity::Warning)
165/// );
166/// ```
167#[derive(Clone, Debug, Default)]
168pub struct ValidationPolicy {
169    /// Only the overrides; anything absent is [`Severity::Error`].
170    overrides: BTreeMap<Check, Option<Severity>>,
171}
172
173impl ValidationPolicy {
174    /// Every check an error.
175    pub fn new() -> Self {
176        Self::default()
177    }
178
179    /// Report `check` but keep generating.
180    pub fn warn(mut self, check: Check) -> Self {
181        self.overrides.insert(check, Some(Severity::Warning));
182        self
183    }
184
185    /// Stop generating on `check` (the default).
186    pub fn deny(mut self, check: Check) -> Self {
187        self.overrides.insert(check, Some(Severity::Error));
188        self
189    }
190
191    /// Downgrade *every* check to a warning.
192    ///
193    /// The way to adopt validation in a generator that already produces
194    /// output: run it in warning mode first, look at what comes back, and
195    /// switch to the default once it is quiet. Reaching a clean run and then
196    /// dropping this call is much less disruptive than having a build start
197    /// failing on output that was fine yesterday.
198    ///
199    /// ```
200    /// use kotlin_codegen::{Check, Severity, ValidationPolicy};
201    /// let policy = ValidationPolicy::warn_all();
202    /// for check in Check::ALL {
203    ///     assert_eq!(policy.severity_of(*check), Some(Severity::Warning));
204    /// }
205    /// ```
206    pub fn warn_all() -> Self {
207        let mut policy = Self::new();
208        for check in Check::ALL {
209            policy = policy.warn(*check);
210        }
211        policy
212    }
213
214    /// Turn `check` off entirely.
215    pub fn allow(mut self, check: Check) -> Self {
216        self.overrides.insert(check, None);
217        self
218    }
219
220    /// The effective severity of `check`, or `None` when it is off.
221    pub fn severity_of(&self, check: Check) -> Option<Severity> {
222        self.overrides
223            .get(&check)
224            .copied()
225            .unwrap_or(Some(Severity::Error))
226    }
227}
228
229/// A child scope path. The root package is the empty string, so joining
230/// naively would yield a leading `/` and make diagnostics inconsistent between
231/// the root package and every other one.
232fn scope_join(scope: &str, child: &str) -> String {
233    if scope.is_empty() {
234        child.to_string()
235    } else {
236        format!("{scope}/{child}")
237    }
238}
239
240/// Collects diagnostics under the active policy, dropping the ones that are
241/// switched off so no check pays for a report nobody wants.
242pub(crate) struct Diagnostics<'a> {
243    policy: &'a ValidationPolicy,
244    out: Vec<Diagnostic>,
245}
246
247impl<'a> Diagnostics<'a> {
248    pub(crate) fn new(policy: &'a ValidationPolicy) -> Self {
249        Self {
250            policy,
251            out: Vec::new(),
252        }
253    }
254
255    pub(crate) fn push(&mut self, check: Check, scope: &str, message: String) {
256        if let Some(severity) = self.policy.severity_of(check) {
257            self.out.push(Diagnostic {
258                check,
259                severity,
260                scope: scope.to_string(),
261                message,
262            });
263        }
264    }
265
266    pub(crate) fn finish(self) -> Vec<Diagnostic> {
267        self.out
268    }
269}
270
271impl KtFile {
272    /// Every problem in this file, with each check at its default severity
273    /// (error).
274    ///
275    /// An empty result means the file is as good as the *model* can prove:
276    /// names, redeclarations and declaration shapes are checked, and [`Check`]
277    /// enumerates exactly which. Anything needing type resolution, or reading
278    /// the raw text inside [`KtCode`](super::KtCode) bodies, stays the Kotlin
279    /// compiler's job.
280    pub fn validate(&self) -> Vec<Diagnostic> {
281        self.validate_with(&ValidationPolicy::new())
282    }
283
284    /// [`KtFile::validate`] with per-check severities.
285    pub fn validate_with(&self, policy: &ValidationPolicy) -> Vec<Diagnostic> {
286        let mut d = Diagnostics::new(policy);
287        check_identifiers(self, &mut d);
288        check_scope(&self.decls, &[], None, &self.package, &mut d);
289        check_shapes(&self.decls, Container::File, &self.package, &mut d);
290        check_extra_imports(self, &mut d);
291        d.finish()
292    }
293}
294
295/// The names declared in one scope must not collide — but Kotlin keeps three
296/// *separate* namespaces, so what counts as a collision depends on the kind.
297///
298/// A scope is one declaration container: the file, a class body, a companion
299/// body. Nested classes and companions are walked recursively, which is where
300/// most generated declarations actually live.
301///
302/// | Namespace | Holds | Rule |
303/// |---|---|---|
304/// | types | classes, `fun interface`s, type aliases | unique by name |
305/// | values | properties, `val`/`var` constructor parameters | unique by name |
306/// | functions | functions | unique by name **and** parameter types |
307///
308/// Keeping types and values apart is what allows `class Foo` and `val Foo` to
309/// coexist, which Kotlin permits and this check used to reject.
310///
311/// **Limitation.** There is no type resolver here, so a function's parameter
312/// types — and its extension receiver — are compared *as written*: `io.p.Foo`
313/// and `Foo` are different keys even when they name the same type, and
314/// `fun <T> f(x: T)` does not collide with `fun <R> f(x: R)`. The check
315/// therefore misses some real duplicates. It is a net, not a proof — and a net
316/// that never catches a fish it shouldn't.
317fn check_scope<'a>(
318    decls: &'a [KtDecl],
319    ctor_params: &'a [KtCtorParam],
320    companion: Option<&'a KtCompanion>,
321    scope: &str,
322    d: &mut Diagnostics<'_>,
323) {
324    let mut types: BTreeSet<&str> = BTreeSet::new();
325    let mut values: BTreeSet<&str> = BTreeSet::new();
326    let mut funs: BTreeSet<String> = BTreeSet::new();
327    let mut raws: BTreeSet<&str> = BTreeSet::new();
328
329    // `val`/`var` constructor parameters are properties of the class, so they
330    // share the value namespace with its members. A plain (non-property)
331    // parameter is constructor-local and declares nothing.
332    for p in ctor_params.iter().filter(|p| p.prop.is_some()) {
333        if !p.name.is_empty() && !values.insert(&p.name) {
334            d.push(
335                Check::DuplicateValue,
336                scope,
337                format!("duplicate value `{}` (constructor property)", p.name),
338            );
339        }
340    }
341
342    for decl in decls {
343        match decl {
344            KtDecl::Class(c) => {
345                declare_name(&mut types, &c.name, Check::DuplicateType, "type", scope, d);
346                check_class_scope(c, scope, d);
347            }
348            KtDecl::FunInterface(i) => {
349                declare_name(&mut types, &i.name, Check::DuplicateType, "type", scope, d);
350            }
351            KtDecl::TypeAlias { name, .. } => {
352                declare_name(&mut types, name, Check::DuplicateType, "type", scope, d);
353            }
354            KtDecl::Property(p) => {
355                declare_name(
356                    &mut values,
357                    &p.name,
358                    Check::DuplicateValue,
359                    "value",
360                    scope,
361                    d,
362                );
363            }
364            KtDecl::Fun(f) => {
365                if f.name.is_empty() {
366                    continue;
367                }
368                let sig = fun_signature(f);
369                if !funs.insert(sig.clone()) {
370                    d.push(
371                        Check::DuplicateFunction,
372                        scope,
373                        format!("duplicate function `{sig}`"),
374                    );
375                }
376            }
377            KtDecl::Raw { name, .. } => {
378                declare_name(&mut raws, name, Check::DuplicateRaw, "raw block", scope, d);
379            }
380        }
381    }
382
383    // A *named* companion object is a classifier nested in this class, so its
384    // name shares the type namespace with the class's other type members —
385    // `class Factory` alongside `companion object Factory` is a redeclaration.
386    //
387    // The implicit `Companion` is deliberately left out: that name is one this
388    // crate supplies rather than one the model declares, so treating it as a
389    // declaration could fire on a generator that manages the collision itself
390    // by renaming the companion.
391    if let Some(name) = companion.and_then(|c| c.name.as_deref()) {
392        if !name.is_empty() && !types.insert(name) {
393            d.push(
394                Check::DuplicateType,
395                scope,
396                format!("companion object `{name}` collides with another type of that name"),
397            );
398        }
399    }
400}
401
402/// A function's overload identity, as it reads in a diagnostic:
403/// `f(Int)`, or `Foo.f(Int)` for an extension function.
404///
405/// The receiver is part of the identity because Kotlin dispatches an extension
406/// on it — `Foo.f()` and `Bar.f()` are two declarations in one package, not a
407/// redeclaration. It is parenthesized on the same rule the renderer uses, so
408/// the diagnostic reads as the syntax it is describing.
409fn fun_signature(f: &KtFun) -> String {
410    let params = param_signature(&f.params);
411    match &f.receiver {
412        Some(r) if r.needs_receiver_parens() => format!("({r}).{}({params})", f.name),
413        Some(r) => format!("{r}.{}({params})", f.name),
414        None => format!("{}({params})", f.name),
415    }
416}
417
418/// A function's parameter types as written, which is the only signature the
419/// model can offer — see the note on [`check_scope`].
420fn param_signature(params: &[KtParam]) -> String {
421    params
422        .iter()
423        .map(|p| p.ty.to_string())
424        .collect::<Vec<_>>()
425        .join(", ")
426}
427
428fn declare_name<'a>(
429    set: &mut BTreeSet<&'a str>,
430    name: &'a str,
431    check: Check,
432    what: &str,
433    scope: &str,
434    d: &mut Diagnostics<'_>,
435) {
436    if name.is_empty() {
437        return;
438    }
439    if !set.insert(name) {
440        d.push(check, scope, format!("duplicate {what} `{name}`"));
441    }
442}
443
444/// A class body is its own scope, and so is its companion's.
445fn check_class_scope(c: &KtClass, scope: &str, d: &mut Diagnostics<'_>) {
446    let inner = scope_join(scope, &c.name);
447    // The companion is part of this class's scope, not a member of it — its
448    // name is declared here, while its body is a scope of its own.
449    check_scope(
450        &c.members,
451        c.ctor_params(),
452        c.companion.as_deref(),
453        &inner,
454        d,
455    );
456    if let Some(comp) = &c.companion {
457        let cscope = scope_join(&inner, comp.name.as_deref().unwrap_or("Companion"));
458        check_scope(&comp.members, &[], None, &cscope, d);
459    }
460}
461
462/// Extra imports carry pre-shortened raw-text references, so a simple-name
463/// collision between two distinct FQNs cannot be repaired by qualifying a use
464/// site the way a modelled type's can — the raw text already says `Foo`.
465///
466/// Lowercase simple names are exempt: those are top-level *function* imports,
467/// which Kotlin allows to overload across packages.
468fn check_extra_imports(file: &KtFile, d: &mut Diagnostics<'_>) {
469    let mut by_simple: BTreeMap<&str, &str> = BTreeMap::new();
470    for imp in &file.extra_imports {
471        let simple = imp.rsplit_once('.').map(|(_, s)| s).unwrap_or(imp.as_str());
472        if simple.chars().next().is_some_and(|c| c.is_lowercase()) {
473            continue;
474        }
475        // First registration owns the simple name — the same rule `ImportSet`
476        // uses — so with three or more colliding FQNs every diagnostic points
477        // at the one owner rather than at whichever was seen most recently.
478        let owner = *by_simple.entry(simple).or_insert(imp.as_str());
479        if owner != imp.as_str() {
480            d.push(
481                Check::ImportCollision,
482                &file.package,
483                format!("import simple-name collision: `{owner}` and `{imp}`"),
484            );
485        }
486    }
487}
488
489/// Every name the model will write out is a legal Kotlin identifier.
490///
491/// Deliberately *not* checked, because they are free-form fragments rather than
492/// plain identifiers and checking them would produce false positives: generic
493/// parameter lists (`out R`, `T : Comparable<T>`), annotations, modifier
494/// keywords, and a `Raw` block's name — that last one is a merge identity, not
495/// something rendered.
496fn check_identifiers(file: &KtFile, d: &mut Diagnostics<'_>) {
497    if !is_valid_kotlin_package(&file.package) {
498        d.push(
499            Check::InvalidPackage,
500            &file.package,
501            format!("`{}` is not a valid Kotlin package path", file.package),
502        );
503    }
504    for decl in &file.decls {
505        check_decl_identifiers(decl, &file.package, d);
506    }
507}
508
509fn check_ident(name: &str, what: &str, scope: &str, d: &mut Diagnostics<'_>) {
510    if !is_writable_kotlin_ident(name) {
511        d.push(
512            Check::InvalidIdentifier,
513            scope,
514            format!("{what} name `{name}` is not a valid Kotlin identifier"),
515        );
516    }
517}
518
519fn check_decl_identifiers(decl: &KtDecl, scope: &str, d: &mut Diagnostics<'_>) {
520    match decl {
521        KtDecl::Class(c) => check_class_identifiers(c, scope, d),
522        KtDecl::Fun(f) => {
523            check_ident(&f.name, "function", scope, d);
524            let inner = scope_join(scope, &f.name);
525            for p in &f.params {
526                check_ident(&p.name, "parameter", &inner, d);
527            }
528        }
529        KtDecl::FunInterface(i) => {
530            check_ident(&i.name, "fun interface", scope, d);
531            let inner = scope_join(scope, &i.name);
532            check_ident(&i.method.name, "function", &inner, d);
533            let method = scope_join(&inner, &i.method.name);
534            for p in &i.method.params {
535                check_ident(&p.name, "parameter", &method, d);
536            }
537        }
538        KtDecl::Property(p) => check_ident(&p.name, "property", scope, d),
539        KtDecl::TypeAlias { name, .. } => check_ident(name, "type alias", scope, d),
540        // A `Raw` block's name is its merge identity, never rendered.
541        KtDecl::Raw { .. } => {}
542    }
543}
544
545fn check_class_identifiers(c: &KtClass, scope: &str, d: &mut Diagnostics<'_>) {
546    check_ident(&c.name, "class", scope, d);
547    let inner = scope_join(scope, &c.name);
548    for p in c.ctor_params() {
549        check_ident(&p.name, "constructor parameter", &inner, d);
550    }
551    for e in c.kind.entries() {
552        check_ident(&e.name, "enum entry", &inner, d);
553    }
554    for m in &c.members {
555        check_decl_identifiers(m, &inner, d);
556    }
557    if let Some(comp) = &c.companion {
558        if let Some(name) = &comp.name {
559            check_ident(name, "companion object", &inner, d);
560        }
561        let cscope = scope_join(&inner, comp.name.as_deref().unwrap_or("Companion"));
562        for m in &comp.members {
563            check_decl_identifiers(m, &cscope, d);
564        }
565    }
566}
567
568/// What encloses a declaration. A bodiless function is legal or not depending
569/// on where it sits, which no type in the model can capture — so this is the
570/// context the shape checks need.
571#[derive(Clone, Copy, PartialEq, Eq)]
572enum Container {
573    /// Top level of a file.
574    File,
575    /// An `interface` or `sealed interface`: members without a body are
576    /// abstract by position, no keyword required.
577    Interface,
578    /// An `abstract` or `sealed` class: a member may be abstract, but must say
579    /// so.
580    Abstract,
581    /// Anything else — every function needs a body.
582    Concrete,
583}
584
585impl Container {
586    fn of(c: &KtClass) -> Self {
587        match &c.kind {
588            KtClassKind::Interface | KtClassKind::SealedInterface => Container::Interface,
589            KtClassKind::Class {
590                modifier: Some(KtClassModifier::Abstract) | Some(KtClassModifier::Sealed),
591                ..
592            } => Container::Abstract,
593            _ => Container::Concrete,
594        }
595    }
596}
597
598/// The shapes that [Part A](https://github.com/milyin/kotlin-codegen/pull/6)
599/// could not make unrepresentable, because each depends on a relation between
600/// fields or on where a declaration sits rather than on one field's type.
601fn check_shapes(decls: &[KtDecl], container: Container, scope: &str, d: &mut Diagnostics<'_>) {
602    for decl in decls {
603        match decl {
604            KtDecl::Property(p) => {
605                // Only the all-absent case is unambiguously wrong. A type alone
606                // is an abstract property; a value alone infers its type.
607                if p.ty.is_none()
608                    && matches!(p.value, KtPropertyValue::None)
609                    && p.accessors.is_none()
610                {
611                    d.push(
612                        Check::PropertyWithoutTypeOrValue,
613                        scope,
614                        format!(
615                            "property `{}` has no type, no value and no accessors",
616                            p.name
617                        ),
618                    );
619                }
620            }
621            KtDecl::Fun(f) => {
622                if !matches!(f.body, KtBody::None) {
623                    continue;
624                }
625                let allowed = match container {
626                    Container::Interface => true,
627                    Container::Abstract => f
628                        .modifiers
629                        .iter()
630                        .any(|m| m.split(' ').any(|w| w == "abstract")),
631                    Container::File | Container::Concrete => false,
632                };
633                if !allowed {
634                    d.push(
635                        Check::FunctionWithoutBody,
636                        scope,
637                        format!(
638                            "function `{}` has no body; only an interface member, or an \
639                             `abstract` member of an abstract class, may omit one",
640                            f.name
641                        ),
642                    );
643                }
644            }
645            KtDecl::Class(c) => check_class_shapes(c, scope, d),
646            KtDecl::FunInterface(_) | KtDecl::TypeAlias { .. } | KtDecl::Raw { .. } => {}
647        }
648    }
649}
650
651fn check_class_shapes(c: &KtClass, scope: &str, d: &mut Diagnostics<'_>) {
652    let inner = scope_join(scope, &c.name);
653    // Every entry of an enum with a primary constructor must call it.
654    if let KtClassKind::Enum { ctor, entries } = &c.kind {
655        if !ctor.is_empty() {
656            for e in entries.iter().filter(|e| e.args.is_none()) {
657                d.push(
658                    Check::EnumEntryMissingArguments,
659                    &inner,
660                    format!(
661                        "entry `{}` passes no arguments, but the enum declares {} \
662                         constructor parameter(s)",
663                        e.name,
664                        ctor.len()
665                    ),
666                );
667            }
668        }
669    }
670    check_shapes(&c.members, Container::of(c), &inner, d);
671    if let Some(comp) = &c.companion {
672        let cscope = scope_join(&inner, comp.name.as_deref().unwrap_or("Companion"));
673        // A companion object is concrete: its members need bodies.
674        check_shapes(&comp.members, Container::Concrete, &cscope, d);
675    }
676}