Skip to main content

perl_semantic_facts/
lib.rs

1//! Neutral semantic fact vocabulary for Perl analysis layers.
2//!
3//! This crate defines strongly-typed IDs and serializable fact records that can be shared
4//! between parser-derived semantics, semantic analyzer synthesis, and workspace indexing.
5//!
6//! It intentionally does **not** parse Perl, implement LSP providers, or own workspace
7//! storage backends.
8
9use serde::{Deserialize, Serialize};
10
11macro_rules! id_newtype {
12    ($name:ident) => {
13        #[derive(
14            Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
15        )]
16        pub struct $name(pub u64);
17    };
18}
19
20id_newtype!(FileId);
21id_newtype!(ScopeId);
22id_newtype!(EntityId);
23id_newtype!(AnchorId);
24id_newtype!(OccurrenceId);
25id_newtype!(EdgeId);
26id_newtype!(DiagnosticId);
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
29pub enum EntityKind {
30    Package,
31    Class,
32    Role,
33    Subroutine,
34    Method,
35    Variable,
36    Constant,
37    Field,
38    Label,
39    Format,
40    Module,
41    GeneratedMember,
42    ExternalSymbol,
43    Unknown,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
47pub enum OccurrenceKind {
48    Definition,
49    Reference,
50    Read,
51    Write,
52    Call,
53    MethodCall,
54    StaticMethodCall,
55    CoderefReference,
56    TypeglobReference,
57    Import,
58    Export,
59    Inheritance,
60    RoleComposition,
61    GeneratedUse,
62    DynamicBoundary,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
66pub enum EdgeKind {
67    Defines,
68    References,
69    Reads,
70    Writes,
71    Calls,
72    ImportsModule,
73    ImportsSymbol,
74    ExportsSymbol,
75    ExportsGroup,
76    Inherits,
77    ComposesRole,
78    MemberOf,
79    GeneratedFrom,
80    AliasOf,
81    DependsOn,
82    DynamicBoundary,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
86pub enum Provenance {
87    ExactAst,
88    DesugaredAst,
89    SemanticAnalyzer,
90    FrameworkSynthesis,
91    ImportExportInference,
92    PragmaInference,
93    NameHeuristic,
94    SearchFallback,
95    DynamicBoundary,
96    /// Exact `require Module; Module->import(literal list)` pattern where all
97    /// import arguments are literal strings or `qw(...)` words — no variables,
98    /// no computed expressions.  More precise than `ExactAst` because it
99    /// guarantees the symbol list is fully statically known.
100    LiteralRequireImport,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
104pub enum Confidence {
105    High,
106    Medium,
107    Low,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct AnchorFact {
112    pub id: AnchorId,
113    pub file_id: FileId,
114    pub span_start_byte: u32,
115    pub span_end_byte: u32,
116    pub scope_id: Option<ScopeId>,
117    pub provenance: Provenance,
118    pub confidence: Confidence,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct EntityFact {
123    pub id: EntityId,
124    pub kind: EntityKind,
125    pub canonical_name: String,
126    pub anchor_id: Option<AnchorId>,
127    pub scope_id: Option<ScopeId>,
128    pub provenance: Provenance,
129    pub confidence: Confidence,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct OccurrenceFact {
134    pub id: OccurrenceId,
135    pub kind: OccurrenceKind,
136    pub entity_id: Option<EntityId>,
137    pub anchor_id: AnchorId,
138    pub scope_id: Option<ScopeId>,
139    pub provenance: Provenance,
140    pub confidence: Confidence,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct EdgeFact {
145    pub id: EdgeId,
146    pub kind: EdgeKind,
147    pub from_entity_id: EntityId,
148    pub to_entity_id: EntityId,
149    pub via_occurrence_id: Option<OccurrenceId>,
150    pub provenance: Provenance,
151    pub confidence: Confidence,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155pub struct DiagnosticFact {
156    pub id: DiagnosticId,
157    pub code: Option<String>,
158    pub message: String,
159    pub primary_anchor_id: AnchorId,
160    pub related_anchor_ids: Vec<AnchorId>,
161    pub scope_id: Option<ScopeId>,
162    pub provenance: Provenance,
163    pub confidence: Confidence,
164}
165
166/// Canonical export facts inferred for a Perl package.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct ExportSet {
169    /// Package symbols exported by default (`@EXPORT`).
170    pub default_exports: Vec<String>,
171    /// Package symbols exported on request (`@EXPORT_OK`).
172    pub optional_exports: Vec<String>,
173    /// Named export groups (`%EXPORT_TAGS`).
174    pub tags: Vec<ExportTag>,
175    /// How this export set was inferred.
176    pub provenance: Provenance,
177    /// Confidence for the inferred export set.
178    pub confidence: Confidence,
179    /// Exporting module or package name, when known.
180    pub module_name: Option<String>,
181    /// Source anchor of the export declaration, when available.
182    pub anchor_id: Option<AnchorId>,
183}
184
185/// Named `%EXPORT_TAGS` entry and its members.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct ExportTag {
188    /// Tag name (for example `all` from `:all`).
189    pub name: String,
190    /// Symbols in this tag.
191    pub members: Vec<String>,
192}
193
194/// Canonical import specification inferred for a single import site.
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196pub struct ImportSpec {
197    /// Imported module or package name.
198    pub module: String,
199    /// Syntactic import shape used at the site.
200    pub kind: ImportKind,
201    /// Symbol selection policy represented at the site.
202    pub symbols: ImportSymbols,
203    /// Import site provenance.
204    pub provenance: Provenance,
205    /// Confidence for inferred import semantics.
206    pub confidence: Confidence,
207    /// File containing this import site, when known.
208    pub file_id: Option<FileId>,
209    /// Source anchor for this import site, when available.
210    pub anchor_id: Option<AnchorId>,
211    /// Scope enclosing this import site, when known.
212    pub scope_id: Option<ScopeId>,
213    /// Byte offset of the start of this import statement in the source file.
214    ///
215    /// Used for order-aware suppression: a dynamic import only suppresses
216    /// barewords that appear **after** the import statement in the file.
217    /// `None` means the position is unknown; callers should be conservative
218    /// (no suppression) when this is absent.
219    pub span_start_byte: Option<u32>,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223pub enum ImportKind {
224    Use,
225    UseEmpty,
226    UseExplicitList,
227    UseTag,
228    Require,
229    RequireThenImport,
230    UseConstant,
231    DynamicRequire,
232    /// A `Class->import(...)` method call — not a `use` statement.
233    ///
234    /// Used when a class's `import` method is called directly, typically with
235    /// a dynamic argument list (`Foo->import(@names)`).  This is distinct from
236    /// `Use` (which is a `use Foo` statement) and `Require` (which is a bare
237    /// `require Foo` statement).
238    ManualImport,
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242pub enum ImportSymbols {
243    Default,
244    None,
245    Explicit(Vec<String>),
246    Tags(Vec<String>),
247    Mixed { tags: Vec<String>, names: Vec<String> },
248    Dynamic,
249}
250
251/// One symbol visible at a query point with source attribution.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct VisibleSymbol {
254    /// Symbol display name visible at the query point.
255    pub name: String,
256    /// Optional backing entity when known.
257    pub entity_id: Option<EntityId>,
258    /// Visibility source classification.
259    pub source: VisibleSymbolSource,
260    /// Visibility confidence.
261    pub confidence: Confidence,
262    /// Optional origin metadata for hover explanations and rename safety.
263    pub context: Option<VisibleSymbolContext>,
264}
265
266/// Origin metadata for a [`VisibleSymbol`], enabling hover explanations
267/// and rename safety analysis.
268///
269/// Tracks the source module and the import/export anchor IDs that made
270/// the symbol visible at the query point.
271#[non_exhaustive]
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273pub struct VisibleSymbolContext {
274    /// Module that originally defined or exported the symbol.
275    pub source_module: Option<String>,
276    /// Anchor of the `use`/`require` statement that imported the symbol.
277    pub source_import_anchor_id: Option<AnchorId>,
278    /// Anchor of the `@EXPORT`/`@EXPORT_OK` declaration that exported the symbol.
279    pub source_export_anchor_id: Option<AnchorId>,
280}
281
282impl VisibleSymbolContext {
283    /// Create a new `VisibleSymbolContext`.
284    ///
285    /// Required because `#[non_exhaustive]` prevents struct-literal
286    /// construction outside this crate.
287    pub fn new(
288        source_module: Option<String>,
289        source_import_anchor_id: Option<AnchorId>,
290        source_export_anchor_id: Option<AnchorId>,
291    ) -> Self {
292        Self { source_module, source_import_anchor_id, source_export_anchor_id }
293    }
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
297pub enum VisibleSymbolSource {
298    LocalLexical,
299    LocalPackage,
300    ExplicitImport,
301    DefaultExport,
302    ExportTag,
303    Constant,
304    Generated,
305    External,
306    DynamicUnknown,
307}
308
309// ── Definition Ranking ──
310
311/// Coarse ranking tier for a definition candidate.
312///
313/// Variants are ordered from most specific (best) to least specific (worst).
314/// The `Ord` derive reflects this ordering so that sorting a candidate list
315/// places `ExactQualified` first and `Heuristic` last.
316#[non_exhaustive]
317#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
318pub enum DefinitionRank {
319    ExactQualified,
320    SamePackage,
321    ExplicitImport,
322    DefaultExport,
323    WorkspaceCandidate,
324    Heuristic,
325}
326
327/// Structured reason explaining why a [`DefinitionCandidate`] received its rank.
328///
329/// Variants that carry a `module` field identify the specific module that
330/// contributed the import or export relationship.
331#[non_exhaustive]
332#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
333pub enum DefinitionRankReason {
334    ExactQualifiedName,
335    SamePackage,
336    ExplicitImport { module: String },
337    DefaultExport { module: String },
338    WorkspaceSymbol,
339    HeuristicNameMatch,
340}
341
342/// A ranked entry in a definition candidate list.
343///
344/// Produced by the workspace definition index and consumed by
345/// `SemanticQueries::definitions` to present ordered results to providers.
346#[non_exhaustive]
347#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
348pub struct DefinitionCandidate {
349    /// The entity this candidate refers to.
350    pub entity_id: EntityId,
351    /// Source anchor for the definition site.
352    pub anchor_id: AnchorId,
353    /// Fully qualified canonical name of the entity.
354    pub canonical_name: String,
355    /// Human-readable display name.
356    pub display_name: String,
357    /// Owning package, if known.
358    pub package: Option<String>,
359    /// Entity kind (Subroutine, Method, Variable, etc.).
360    pub kind: EntityKind,
361    /// How this candidate was discovered.
362    pub provenance: Provenance,
363    /// Confidence in the candidate.
364    pub confidence: Confidence,
365    /// Coarse ranking tier.
366    pub rank: DefinitionRank,
367    /// Structured reason for the assigned rank.
368    pub rank_reason: DefinitionRankReason,
369}
370
371impl DefinitionCandidate {
372    /// Construct a new `DefinitionCandidate`.
373    ///
374    /// Required because the struct is `#[non_exhaustive]` and cannot be
375    /// constructed with struct-literal syntax outside this crate.
376    #[allow(clippy::too_many_arguments)] // mirrors the struct fields 1-to-1
377    pub fn new(
378        entity_id: EntityId,
379        anchor_id: AnchorId,
380        canonical_name: String,
381        display_name: String,
382        package: Option<String>,
383        kind: EntityKind,
384        provenance: Provenance,
385        confidence: Confidence,
386        rank: DefinitionRank,
387        rank_reason: DefinitionRankReason,
388    ) -> Self {
389        Self {
390            entity_id,
391            anchor_id,
392            canonical_name,
393            display_name,
394            package,
395            kind,
396            provenance,
397            confidence,
398            rank,
399            rank_reason,
400        }
401    }
402}
403
404/// Occurrence-based reference edge linking a reference site to zero, one, or many
405/// target entity candidates.
406///
407/// Unlike [`EdgeFact`] which connects two known entities, a `ReferenceEdge` is
408/// anchored on an [`OccurrenceFact`] and carries a candidate list that may be empty
409/// (unresolved), singular (exact), or plural (ambiguous).
410#[non_exhaustive]
411#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
412pub struct ReferenceEdge {
413    /// The occurrence that produced this reference.
414    pub occurrence_id: OccurrenceId,
415    /// Source anchor for the reference site.
416    pub anchor_id: AnchorId,
417    /// File containing the reference.
418    pub file_id: FileId,
419    /// Bare or qualified symbol key used at the reference site.
420    pub symbol_key: String,
421    /// Zero, one, or many candidate target entities.
422    pub target_candidates: Vec<EntityId>,
423    /// Occurrence classification (Read, Call, MethodCall, etc.).
424    pub kind: OccurrenceKind,
425    /// How this reference was inferred.
426    pub provenance: Provenance,
427    /// Confidence in the resolution.
428    pub confidence: Confidence,
429}
430
431// ── Package Graph ──
432
433/// A node in the package/class/role graph.
434///
435/// Represents a known package, class, or role in the workspace.
436#[non_exhaustive]
437#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
438pub struct PackageNode {
439    /// Entity backing this package node.
440    pub entity_id: EntityId,
441    /// Fully qualified package name.
442    pub name: String,
443    /// Classification of this package node.
444    pub kind: PackageKind,
445    /// Source anchor for the package declaration, when available.
446    pub anchor_id: Option<AnchorId>,
447    /// File containing this package declaration, when known.
448    pub file_id: Option<FileId>,
449}
450
451impl PackageNode {
452    /// Construct a new `PackageNode`.
453    ///
454    /// Required because the struct is `#[non_exhaustive]` and cannot be
455    /// constructed with struct-literal syntax outside this crate.
456    pub fn new(
457        entity_id: EntityId,
458        name: String,
459        kind: PackageKind,
460        anchor_id: Option<AnchorId>,
461        file_id: Option<FileId>,
462    ) -> Self {
463        Self { entity_id, name, kind, anchor_id, file_id }
464    }
465}
466
467/// Classification of a package graph node.
468#[non_exhaustive]
469#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
470pub enum PackageKind {
471    /// A plain Perl package.
472    Package,
473    /// A class (Moo/Moose/native).
474    Class,
475    /// A role (Moo::Role/Moose::Role/Role::Tiny).
476    Role,
477    /// An external package not found in the workspace.
478    External,
479}
480
481/// A directed edge in the package graph.
482///
483/// Connects a source package to a target package with a relationship kind
484/// (inheritance, role composition, or dependency).
485#[non_exhaustive]
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
487pub struct PackageEdge {
488    /// Source package name (the inheriting/consuming package).
489    pub from_package: String,
490    /// Target package name (the inherited/composed package).
491    pub to_package: String,
492    /// Relationship kind.
493    pub kind: PackageEdgeKind,
494    /// Source anchor for the statement that established this edge.
495    pub anchor_id: Option<AnchorId>,
496    /// How this edge was inferred.
497    pub provenance: Provenance,
498    /// Confidence in this edge.
499    pub confidence: Confidence,
500}
501
502impl PackageEdge {
503    /// Construct a new `PackageEdge`.
504    ///
505    /// Required because the struct is `#[non_exhaustive]` and cannot be
506    /// constructed with struct-literal syntax outside this crate.
507    pub fn new(
508        from_package: String,
509        to_package: String,
510        kind: PackageEdgeKind,
511        anchor_id: Option<AnchorId>,
512        provenance: Provenance,
513        confidence: Confidence,
514    ) -> Self {
515        Self { from_package, to_package, kind, anchor_id, provenance, confidence }
516    }
517}
518
519/// Kind of relationship in the package graph.
520#[non_exhaustive]
521#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
522pub enum PackageEdgeKind {
523    /// Inheritance: `use parent`, `use base`, `@ISA`, `extends`.
524    Inherits,
525    /// Role composition: `with 'Role'`.
526    ComposesRole,
527    /// General dependency (e.g. `use Module`).
528    DependsOn,
529}
530
531// ── Generated Members ──────────────────────────────────────────────────
532
533/// A framework-synthesized member (e.g. Moo/Moose accessor from `has`).
534#[non_exhaustive]
535#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
536pub struct GeneratedMember {
537    /// Deterministic entity ID for this generated member.
538    pub entity_id: EntityId,
539    /// Name of the generated method (e.g. `x` for `has 'x'`).
540    pub name: String,
541    /// What kind of generated member this is.
542    pub kind: GeneratedMemberKind,
543    /// Anchor of the `has` declaration that generated this member.
544    pub source_anchor_id: AnchorId,
545    /// Package that owns this generated member.
546    pub package: String,
547    /// Always `FrameworkSynthesis` for generated members.
548    pub provenance: Provenance,
549    /// Always `Medium` for generated members.
550    pub confidence: Confidence,
551}
552
553impl GeneratedMember {
554    /// Construct a new `GeneratedMember`.
555    ///
556    /// Required because the struct is `#[non_exhaustive]` and cannot be
557    /// constructed with struct-literal syntax outside this crate.
558    #[allow(clippy::too_many_arguments)] // mirrors the struct fields 1-to-1
559    pub fn new(
560        entity_id: EntityId,
561        name: String,
562        kind: GeneratedMemberKind,
563        source_anchor_id: AnchorId,
564        package: String,
565        provenance: Provenance,
566        confidence: Confidence,
567    ) -> Self {
568        Self { entity_id, name, kind, source_anchor_id, package, provenance, confidence }
569    }
570}
571
572/// Classification of a framework-generated member.
573#[non_exhaustive]
574#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
575pub enum GeneratedMemberKind {
576    /// Read-only accessor (getter).
577    Getter,
578    /// Read-write accessor (setter).
579    Setter,
580    /// Combined read-write accessor (single method for both get and set).
581    Accessor,
582    /// Predicate method (`has_<attr>`).
583    Predicate,
584    /// Clearer method (`clear_<attr>`).
585    Clearer,
586    /// Builder method (`_build_<attr>`).
587    Builder,
588    /// Constant value.
589    Constant,
590}
591
592// ── Value Shape ─────────────────────────────────────────────────────
593
594/// Lightweight type approximation for a variable or expression.
595///
596/// Used for method candidate filtering — not a full type system.
597/// `bless` is not a separate top-level shape; it produces
598/// [`ValueShape::Object`] with low confidence.
599#[non_exhaustive]
600#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
601pub enum ValueShape {
602    /// Shape could not be determined.
603    Unknown,
604    /// Plain scalar value.
605    Scalar,
606    /// Array reference (`[...]` or `\@arr`).
607    ArrayRef,
608    /// Hash reference (`{...}` or `\%hash`).
609    HashRef,
610    /// Code reference (`sub { ... }` or `\&sub`).
611    CodeRef,
612    /// A package name used as a class (e.g. `Foo` in `Foo->method`).
613    PackageName {
614        /// Fully qualified package name.
615        package: String,
616    },
617    /// An object instance (blessed reference).
618    Object {
619        /// Package the object was blessed into.
620        package: String,
621        /// Confidence in the inferred package.
622        confidence: Confidence,
623    },
624}
625
626// ── Provider Fact-Source Tracing ─────────────────────────────────────
627
628/// LSP provider surface that consumed or considered a semantic fact.
629#[non_exhaustive]
630#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
631pub enum ProviderSurface {
632    Diagnostics,
633    Completion,
634    Hover,
635    Definition,
636    References,
637    Rename,
638    SafeDelete,
639    WorkspaceSymbols,
640    DocumentSymbols,
641    SemanticTokens,
642}
643
644/// Coarse source class for a provider answer.
645#[non_exhaustive]
646#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
647pub enum ProviderFactSourceKind {
648    /// Parser tokens or AST shape.
649    ParserSyntax,
650    /// Legacy workspace index or provider-local data.
651    LegacyWorkspace,
652    /// Canonical semantic fact graph.
653    SemanticFact,
654    /// Rust compiler-substrate fact.
655    CompilerFact,
656    /// Framework adapter projection.
657    FrameworkAdapter,
658    /// Dynamic-boundary fact used to avoid false precision.
659    DynamicBoundary,
660    /// Fallback behavior because no stronger fact was available.
661    Fallback,
662    /// Source is intentionally unknown or unavailable.
663    Unknown,
664}
665
666/// Freshness state for a provider fact source.
667#[non_exhaustive]
668#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
669pub enum ProviderFactFreshness {
670    Fresh,
671    Stale,
672    Unknown,
673    NotApplicable,
674}
675
676/// How a provider used a traced fact source.
677#[non_exhaustive]
678#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
679pub enum ProviderFallbackState {
680    /// Primary answer path used this source.
681    Primary,
682    /// Source was measured but not used for live behavior.
683    Shadow,
684    /// Provider fell back from a stronger unavailable source.
685    Fallback,
686    /// Source existed but could not answer this request.
687    Unavailable,
688    /// Source blocked an unsafe provider action.
689    Blocked,
690}
691
692/// Source/provenance trace for a provider answer.
693///
694/// This is an additive contract for provider cutover proof. It lets providers
695/// report where an answer came from before any broad provider behavior change.
696#[non_exhaustive]
697#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
698pub struct ProviderFactTrace {
699    /// Provider surface that produced the answer.
700    pub surface: ProviderSurface,
701    /// Coarse source class used by the provider.
702    pub source: ProviderFactSourceKind,
703    /// Semantic provenance for the underlying fact, when known.
704    pub provenance: Provenance,
705    /// Confidence in the underlying fact or fallback.
706    pub confidence: Confidence,
707    /// Freshness of the underlying fact relative to the request.
708    pub freshness: ProviderFactFreshness,
709    /// Whether this source drove live behavior, shadow proof, fallback, or a blocker.
710    pub fallback_state: ProviderFallbackState,
711    /// Optional stable source hash used by the producer.
712    pub source_hash: Option<String>,
713    /// Optional semantic anchor used by the producer.
714    pub anchor_id: Option<AnchorId>,
715    /// Optional fact/model version used by the producer.
716    pub model_version: Option<u32>,
717}
718
719impl ProviderFactTrace {
720    /// Construct a new provider fact trace.
721    #[allow(clippy::too_many_arguments)] // mirrors the public trace fields 1-to-1
722    pub fn new(
723        surface: ProviderSurface,
724        source: ProviderFactSourceKind,
725        provenance: Provenance,
726        confidence: Confidence,
727        freshness: ProviderFactFreshness,
728        fallback_state: ProviderFallbackState,
729        source_hash: Option<String>,
730        anchor_id: Option<AnchorId>,
731        model_version: Option<u32>,
732    ) -> Self {
733        Self {
734            surface,
735            source,
736            provenance,
737            confidence,
738            freshness,
739            fallback_state,
740            source_hash,
741            anchor_id,
742            model_version,
743        }
744    }
745}
746
747// ── Rename and Safe Delete Plans ────────────────────────────────────
748
749/// A conservative rename plan enumerating affected occurrences and blockers.
750///
751/// Produced by `SemanticQueries::rename_plan` and consumed by the rename
752/// provider to decide whether the rename is safe to apply.
753#[non_exhaustive]
754#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
755pub struct RenamePlan {
756    /// The entity being renamed.
757    pub entity_id: EntityId,
758    /// Current name of the entity.
759    pub old_name: String,
760    /// Proposed new name.
761    pub new_name: String,
762    /// Planned text edits for the rename.
763    pub edits: Vec<PlannedEdit>,
764    /// Conditions that block the rename.
765    pub blockers: Vec<PlanBlocker>,
766    /// Non-blocking warnings for the rename.
767    pub warnings: Vec<PlanWarning>,
768}
769
770impl RenamePlan {
771    /// Construct a new `RenamePlan`.
772    ///
773    /// Required because the struct is `#[non_exhaustive]` and cannot be
774    /// constructed with struct-literal syntax outside this crate.
775    pub fn new(
776        entity_id: EntityId,
777        old_name: String,
778        new_name: String,
779        edits: Vec<PlannedEdit>,
780        blockers: Vec<PlanBlocker>,
781        warnings: Vec<PlanWarning>,
782    ) -> Self {
783        Self { entity_id, old_name, new_name, edits, blockers, warnings }
784    }
785}
786
787/// A conservative safe-delete plan enumerating blockers that prevent deletion.
788///
789/// Produced by `SemanticQueries::safe_delete_plan` and consumed by the
790/// safe-delete provider to decide whether deletion is safe.
791#[non_exhaustive]
792#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
793pub struct SafeDeletePlan {
794    /// The entity being considered for deletion.
795    pub entity_id: EntityId,
796    /// Name of the entity being considered for deletion.
797    pub name: String,
798    /// Conditions that block the deletion.
799    pub blockers: Vec<PlanBlocker>,
800    /// Non-blocking warnings for the deletion.
801    pub warnings: Vec<PlanWarning>,
802}
803
804impl SafeDeletePlan {
805    /// Construct a new `SafeDeletePlan`.
806    ///
807    /// Required because the struct is `#[non_exhaustive]` and cannot be
808    /// constructed with struct-literal syntax outside this crate.
809    pub fn new(
810        entity_id: EntityId,
811        name: String,
812        blockers: Vec<PlanBlocker>,
813        warnings: Vec<PlanWarning>,
814    ) -> Self {
815        Self { entity_id, name, blockers, warnings }
816    }
817}
818
819/// A condition that blocks a rename or safe-delete operation.
820#[non_exhaustive]
821#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
822pub struct PlanBlocker {
823    /// Why the operation is blocked.
824    pub reason: PlanBlockerReason,
825    /// Source anchor for the blocking reference, when available.
826    pub anchor_id: Option<AnchorId>,
827    /// Human-readable description of the blocker.
828    pub description: String,
829}
830
831impl PlanBlocker {
832    /// Construct a new `PlanBlocker`.
833    ///
834    /// Required because the struct is `#[non_exhaustive]` and cannot be
835    /// constructed with struct-literal syntax outside this crate.
836    pub fn new(
837        reason: PlanBlockerReason,
838        anchor_id: Option<AnchorId>,
839        description: String,
840    ) -> Self {
841        Self { reason, anchor_id, description }
842    }
843}
844
845/// Reason a rename or safe-delete operation is blocked.
846#[non_exhaustive]
847#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
848pub enum PlanBlockerReason {
849    /// Reference crosses a dynamic boundary (string eval, symbolic deref, AUTOLOAD).
850    DynamicBoundary,
851    /// Reference is ambiguous (multiple candidate targets).
852    AmbiguousReference,
853    /// Symbol is exported and referenced from other modules.
854    CrossModuleExport,
855    /// Symbol is imported by another file.
856    ImportedSymbol,
857    /// Symbol is listed in an ExportSet.
858    ExportedSymbol,
859    /// Symbol has remaining references in the workspace.
860    ReferencesExist,
861    /// Symbol is a generated member without a generator-specific edit plan.
862    GeneratedMember,
863    /// Occurrence could not be classified into a known category.
864    UnclassifiedOccurrence,
865}
866
867/// A non-blocking warning attached to a rename or safe-delete plan.
868#[non_exhaustive]
869#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
870pub struct PlanWarning {
871    /// Human-readable warning message.
872    pub message: String,
873    /// Source anchor for the warning site, when available.
874    pub anchor_id: Option<AnchorId>,
875}
876
877impl PlanWarning {
878    /// Construct a new `PlanWarning`.
879    ///
880    /// Required because the struct is `#[non_exhaustive]` and cannot be
881    /// constructed with struct-literal syntax outside this crate.
882    pub fn new(message: String, anchor_id: Option<AnchorId>) -> Self {
883        Self { message, anchor_id }
884    }
885}
886
887/// A planned text edit within a rename operation.
888#[non_exhaustive]
889#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
890pub struct PlannedEdit {
891    /// Source anchor for the edit site.
892    pub anchor_id: AnchorId,
893    /// File containing the edit site.
894    pub file_id: FileId,
895    /// Classification of this edit (definition, reference, import, export).
896    pub category: PlannedEditCategory,
897    /// Text being replaced.
898    pub old_text: String,
899    /// Replacement text.
900    pub new_text: String,
901}
902
903impl PlannedEdit {
904    /// Construct a new `PlannedEdit`.
905    ///
906    /// Required because the struct is `#[non_exhaustive]` and cannot be
907    /// constructed with struct-literal syntax outside this crate.
908    pub fn new(
909        anchor_id: AnchorId,
910        file_id: FileId,
911        category: PlannedEditCategory,
912        old_text: String,
913        new_text: String,
914    ) -> Self {
915        Self { anchor_id, file_id, category, old_text, new_text }
916    }
917}
918
919/// Classification of a planned edit within a rename operation.
920///
921/// Distinguishes definition edits from reference edits, import list edits,
922/// and export list edits so that the rename provider can handle each
923/// category appropriately.
924#[non_exhaustive]
925#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
926pub enum PlannedEditCategory {
927    /// Edit to the symbol's definition site.
928    Definition,
929    /// Edit to a reference (call site, read, write).
930    Reference,
931    /// Edit to an import list (`use Foo qw(...)` argument).
932    ImportList,
933    /// Edit to an export list (`@EXPORT`, `@EXPORT_OK`, `%EXPORT_TAGS` entry).
934    ExportList,
935}
936
937impl ReferenceEdge {
938    /// Construct a new `ReferenceEdge`.
939    ///
940    /// Required because the struct is `#[non_exhaustive]` and cannot be
941    /// constructed with struct-literal syntax outside this crate.
942    #[allow(clippy::too_many_arguments)] // mirrors the struct fields 1-to-1
943    pub fn new(
944        occurrence_id: OccurrenceId,
945        anchor_id: AnchorId,
946        file_id: FileId,
947        symbol_key: String,
948        target_candidates: Vec<EntityId>,
949        kind: OccurrenceKind,
950        provenance: Provenance,
951        confidence: Confidence,
952    ) -> Self {
953        Self {
954            occurrence_id,
955            anchor_id,
956            file_id,
957            symbol_key,
958            target_candidates,
959            kind,
960            provenance,
961            confidence,
962        }
963    }
964}
965
966#[cfg(test)]
967mod tests {
968    use super::*;
969
970    #[test]
971    fn entity_fact_roundtrips_through_json() -> Result<(), serde_json::Error> {
972        let fact = EntityFact {
973            id: EntityId(100),
974            kind: EntityKind::Method,
975            canonical_name: "Foo::bar".to_string(),
976            anchor_id: Some(AnchorId(12)),
977            scope_id: Some(ScopeId(3)),
978            provenance: Provenance::SemanticAnalyzer,
979            confidence: Confidence::High,
980        };
981
982        let serialized = serde_json::to_string(&fact)?;
983        let decoded: EntityFact = serde_json::from_str(&serialized)?;
984        assert_eq!(decoded, fact);
985        Ok(())
986    }
987
988    #[test]
989    fn deterministic_debug_for_edge_fact() {
990        let fact = EdgeFact {
991            id: EdgeId(7),
992            kind: EdgeKind::Calls,
993            from_entity_id: EntityId(11),
994            to_entity_id: EntityId(22),
995            via_occurrence_id: Some(OccurrenceId(33)),
996            provenance: Provenance::ExactAst,
997            confidence: Confidence::Medium,
998        };
999
1000        assert_eq!(
1001            format!("{fact:?}"),
1002            "EdgeFact { id: EdgeId(7), kind: Calls, from_entity_id: EntityId(11), to_entity_id: EntityId(22), via_occurrence_id: Some(OccurrenceId(33)), provenance: ExactAst, confidence: Medium }"
1003        );
1004    }
1005
1006    #[test]
1007    fn pretty_json_for_anchor_fact_is_stable() -> Result<(), serde_json::Error> {
1008        let fact = AnchorFact {
1009            id: AnchorId(5),
1010            file_id: FileId(1),
1011            span_start_byte: 10,
1012            span_end_byte: 15,
1013            scope_id: None,
1014            provenance: Provenance::DesugaredAst,
1015            confidence: Confidence::Low,
1016        };
1017
1018        let json = serde_json::to_string_pretty(&fact)?;
1019        assert_eq!(
1020            json,
1021            "{\n  \"id\": 5,\n  \"file_id\": 1,\n  \"span_start_byte\": 10,\n  \"span_end_byte\": 15,\n  \"scope_id\": null,\n  \"provenance\": \"DesugaredAst\",\n  \"confidence\": \"Low\"\n}"
1022        );
1023        Ok(())
1024    }
1025
1026    /// Verify OccurrenceFact with a None entity_id round-trips correctly — this
1027    /// exercises the optional foreign-key path that EntityFact's test does not cover.
1028    #[test]
1029    fn occurrence_fact_with_null_entity_id_roundtrips() -> Result<(), serde_json::Error> {
1030        let fact = OccurrenceFact {
1031            id: OccurrenceId(42),
1032            kind: OccurrenceKind::Call,
1033            entity_id: None,
1034            anchor_id: AnchorId(10),
1035            scope_id: Some(ScopeId(2)),
1036            provenance: Provenance::NameHeuristic,
1037            confidence: Confidence::Low,
1038        };
1039        let serialized = serde_json::to_string(&fact)?;
1040        let decoded: OccurrenceFact = serde_json::from_str(&serialized)?;
1041        assert_eq!(decoded, fact);
1042        // entity_id: None must serialize as JSON null, not be omitted.
1043        assert!(
1044            serialized.contains("\"entity_id\":null"),
1045            "entity_id null must be explicit in JSON"
1046        );
1047        Ok(())
1048    }
1049
1050    /// Verify that u64::MAX is preserved through JSON serialization without
1051    /// truncation — serde_json serializes u64 as a JSON number, which can
1052    /// exceed JS safe-integer range but round-trips correctly in Rust.
1053    #[test]
1054    fn id_u64_max_roundtrips() -> Result<(), serde_json::Error> {
1055        let id = EntityId(u64::MAX);
1056        let serialized = serde_json::to_string(&id)?;
1057        let decoded: EntityId = serde_json::from_str(&serialized)?;
1058        assert_eq!(decoded, id);
1059        Ok(())
1060    }
1061
1062    #[test]
1063    fn import_spec_roundtrips_through_json() -> Result<(), serde_json::Error> {
1064        let spec = ImportSpec {
1065            module: "Foo::Bar".to_string(),
1066            kind: ImportKind::RequireThenImport,
1067            symbols: ImportSymbols::Mixed {
1068                tags: vec!["all".to_string()],
1069                names: vec!["$X".to_string(), "@Y".to_string()],
1070            },
1071            provenance: Provenance::ImportExportInference,
1072            confidence: Confidence::Medium,
1073            file_id: None,
1074            anchor_id: None,
1075            scope_id: None,
1076            span_start_byte: None,
1077        };
1078
1079        let serialized = serde_json::to_string(&spec)?;
1080        let decoded: ImportSpec = serde_json::from_str(&serialized)?;
1081        assert_eq!(decoded, spec);
1082        Ok(())
1083    }
1084
1085    #[test]
1086    fn import_symbols_debug_is_deterministic() {
1087        let symbols = ImportSymbols::Mixed {
1088            tags: vec!["io".to_string(), "all".to_string()],
1089            names: vec!["open".to_string(), "close".to_string()],
1090        };
1091        assert_eq!(
1092            format!("{symbols:?}"),
1093            "Mixed { tags: [\"io\", \"all\"], names: [\"open\", \"close\"] }"
1094        );
1095    }
1096
1097    #[test]
1098    fn visible_symbol_pretty_json_is_stable() -> Result<(), serde_json::Error> {
1099        let visible = VisibleSymbol {
1100            name: "slurp".to_string(),
1101            entity_id: Some(EntityId(17)),
1102            source: VisibleSymbolSource::ExplicitImport,
1103            confidence: Confidence::High,
1104            context: None,
1105        };
1106
1107        let json = serde_json::to_string_pretty(&visible)?;
1108        assert_eq!(
1109            json,
1110            "{\n  \"name\": \"slurp\",\n  \"entity_id\": 17,\n  \"source\": \"ExplicitImport\",\n  \"confidence\": \"High\",\n  \"context\": null\n}"
1111        );
1112        Ok(())
1113    }
1114
1115    /// ReferenceEdge with multiple target candidates round-trips through JSON.
1116    #[test]
1117    fn reference_edge_roundtrips_through_json() -> Result<(), serde_json::Error> {
1118        let edge = ReferenceEdge {
1119            occurrence_id: OccurrenceId(50),
1120            anchor_id: AnchorId(20),
1121            file_id: FileId(3),
1122            symbol_key: "Foo::bar".to_string(),
1123            target_candidates: vec![EntityId(100), EntityId(200)],
1124            kind: OccurrenceKind::Call,
1125            provenance: Provenance::ExactAst,
1126            confidence: Confidence::High,
1127        };
1128
1129        let serialized = serde_json::to_string(&edge)?;
1130        let decoded: ReferenceEdge = serde_json::from_str(&serialized)?;
1131        assert_eq!(decoded, edge);
1132        Ok(())
1133    }
1134
1135    /// ReferenceEdge with empty target_candidates (unresolved) round-trips correctly.
1136    #[test]
1137    fn reference_edge_empty_candidates_roundtrips() -> Result<(), serde_json::Error> {
1138        let edge = ReferenceEdge {
1139            occurrence_id: OccurrenceId(51),
1140            anchor_id: AnchorId(21),
1141            file_id: FileId(4),
1142            symbol_key: "unknown_sub".to_string(),
1143            target_candidates: vec![],
1144            kind: OccurrenceKind::Reference,
1145            provenance: Provenance::NameHeuristic,
1146            confidence: Confidence::Low,
1147        };
1148
1149        let serialized = serde_json::to_string(&edge)?;
1150        let decoded: ReferenceEdge = serde_json::from_str(&serialized)?;
1151        assert_eq!(decoded, edge);
1152        // Empty candidates must serialize as an empty array, not null.
1153        assert!(
1154            serialized.contains("\"target_candidates\":[]"),
1155            "empty target_candidates must be an empty JSON array"
1156        );
1157        Ok(())
1158    }
1159
1160    /// DefinitionRank round-trips through JSON for every variant.
1161    #[test]
1162    fn definition_rank_roundtrips_through_json() -> Result<(), serde_json::Error> {
1163        let variants = [
1164            DefinitionRank::ExactQualified,
1165            DefinitionRank::SamePackage,
1166            DefinitionRank::ExplicitImport,
1167            DefinitionRank::DefaultExport,
1168            DefinitionRank::WorkspaceCandidate,
1169            DefinitionRank::Heuristic,
1170        ];
1171        for variant in &variants {
1172            let serialized = serde_json::to_string(variant)?;
1173            let decoded: DefinitionRank = serde_json::from_str(&serialized)?;
1174            assert_eq!(&decoded, variant);
1175        }
1176        Ok(())
1177    }
1178
1179    /// DefinitionRank ordering: ExactQualified < SamePackage < … < Heuristic.
1180    #[test]
1181    fn definition_rank_ordering_matches_design() {
1182        assert!(DefinitionRank::ExactQualified < DefinitionRank::SamePackage);
1183        assert!(DefinitionRank::SamePackage < DefinitionRank::ExplicitImport);
1184        assert!(DefinitionRank::ExplicitImport < DefinitionRank::DefaultExport);
1185        assert!(DefinitionRank::DefaultExport < DefinitionRank::WorkspaceCandidate);
1186        assert!(DefinitionRank::WorkspaceCandidate < DefinitionRank::Heuristic);
1187    }
1188
1189    /// DefinitionRankReason round-trips through JSON for all variants,
1190    /// including those carrying a module field.
1191    #[test]
1192    fn definition_rank_reason_roundtrips_through_json() -> Result<(), serde_json::Error> {
1193        let reasons = [
1194            DefinitionRankReason::ExactQualifiedName,
1195            DefinitionRankReason::SamePackage,
1196            DefinitionRankReason::ExplicitImport { module: "Foo::Bar".to_string() },
1197            DefinitionRankReason::DefaultExport { module: "Baz::Qux".to_string() },
1198            DefinitionRankReason::WorkspaceSymbol,
1199            DefinitionRankReason::HeuristicNameMatch,
1200        ];
1201        for reason in &reasons {
1202            let serialized = serde_json::to_string(reason)?;
1203            let decoded: DefinitionRankReason = serde_json::from_str(&serialized)?;
1204            assert_eq!(&decoded, reason);
1205        }
1206        Ok(())
1207    }
1208
1209    /// DefinitionCandidate with all fields populated round-trips through JSON.
1210    #[test]
1211    fn definition_candidate_roundtrips_through_json() -> Result<(), serde_json::Error> {
1212        let candidate = DefinitionCandidate {
1213            entity_id: EntityId(300),
1214            anchor_id: AnchorId(40),
1215            canonical_name: "Foo::Bar::baz".to_string(),
1216            display_name: "baz".to_string(),
1217            package: Some("Foo::Bar".to_string()),
1218            kind: EntityKind::Subroutine,
1219            provenance: Provenance::ExactAst,
1220            confidence: Confidence::High,
1221            rank: DefinitionRank::ExactQualified,
1222            rank_reason: DefinitionRankReason::ExactQualifiedName,
1223        };
1224
1225        let serialized = serde_json::to_string(&candidate)?;
1226        let decoded: DefinitionCandidate = serde_json::from_str(&serialized)?;
1227        assert_eq!(decoded, candidate);
1228        Ok(())
1229    }
1230
1231    /// DefinitionCandidate with None package round-trips correctly.
1232    #[test]
1233    fn definition_candidate_none_package_roundtrips() -> Result<(), serde_json::Error> {
1234        let candidate = DefinitionCandidate {
1235            entity_id: EntityId(301),
1236            anchor_id: AnchorId(41),
1237            canonical_name: "main::helper".to_string(),
1238            display_name: "helper".to_string(),
1239            package: None,
1240            kind: EntityKind::Subroutine,
1241            provenance: Provenance::NameHeuristic,
1242            confidence: Confidence::Low,
1243            rank: DefinitionRank::Heuristic,
1244            rank_reason: DefinitionRankReason::HeuristicNameMatch,
1245        };
1246
1247        let serialized = serde_json::to_string(&candidate)?;
1248        let decoded: DefinitionCandidate = serde_json::from_str(&serialized)?;
1249        assert_eq!(decoded, candidate);
1250        // package: None must serialize as JSON null, not be omitted.
1251        assert!(serialized.contains("\"package\":null"), "package null must be explicit in JSON");
1252        Ok(())
1253    }
1254
1255    /// DefinitionCandidate with import-based rank reason round-trips the module field.
1256    #[test]
1257    fn definition_candidate_import_reason_roundtrips() -> Result<(), serde_json::Error> {
1258        let candidate = DefinitionCandidate {
1259            entity_id: EntityId(302),
1260            anchor_id: AnchorId(42),
1261            canonical_name: "List::Util::first".to_string(),
1262            display_name: "first".to_string(),
1263            package: Some("List::Util".to_string()),
1264            kind: EntityKind::Subroutine,
1265            provenance: Provenance::ImportExportInference,
1266            confidence: Confidence::Medium,
1267            rank: DefinitionRank::ExplicitImport,
1268            rank_reason: DefinitionRankReason::ExplicitImport { module: "List::Util".to_string() },
1269        };
1270
1271        let serialized = serde_json::to_string(&candidate)?;
1272        let decoded: DefinitionCandidate = serde_json::from_str(&serialized)?;
1273        assert_eq!(decoded, candidate);
1274        Ok(())
1275    }
1276
1277    /// ProviderFactTrace round-trips through JSON with source hash and model version.
1278    #[test]
1279    fn provider_fact_trace_roundtrips_through_json() -> Result<(), serde_json::Error> {
1280        let trace = ProviderFactTrace::new(
1281            ProviderSurface::Completion,
1282            ProviderFactSourceKind::CompilerFact,
1283            Provenance::ImportExportInference,
1284            Confidence::High,
1285            ProviderFactFreshness::Fresh,
1286            ProviderFallbackState::Shadow,
1287            Some("fixture-source-sha".to_string()),
1288            Some(AnchorId(10)),
1289            Some(1),
1290        );
1291
1292        let serialized = serde_json::to_string(&trace)?;
1293        let decoded: ProviderFactTrace = serde_json::from_str(&serialized)?;
1294        assert_eq!(decoded, trace);
1295        Ok(())
1296    }
1297
1298    /// ProviderFactTrace keeps null freshness metadata explicit when unavailable.
1299    #[test]
1300    fn provider_fact_trace_optional_metadata_roundtrips() -> Result<(), serde_json::Error> {
1301        let trace = ProviderFactTrace::new(
1302            ProviderSurface::Diagnostics,
1303            ProviderFactSourceKind::Fallback,
1304            Provenance::SearchFallback,
1305            Confidence::Low,
1306            ProviderFactFreshness::NotApplicable,
1307            ProviderFallbackState::Fallback,
1308            None,
1309            None,
1310            None,
1311        );
1312
1313        let serialized = serde_json::to_string(&trace)?;
1314        let decoded: ProviderFactTrace = serde_json::from_str(&serialized)?;
1315        assert_eq!(decoded, trace);
1316        assert!(
1317            serialized.contains("\"source_hash\":null")
1318                && serialized.contains("\"anchor_id\":null")
1319                && serialized.contains("\"model_version\":null"),
1320            "optional trace metadata should remain explicit for downstream consumers"
1321        );
1322        Ok(())
1323    }
1324
1325    /// Provider trace enums round-trip through JSON for every current variant.
1326    #[test]
1327    fn provider_fact_trace_enums_roundtrip_through_json() -> Result<(), serde_json::Error> {
1328        for surface in [
1329            ProviderSurface::Diagnostics,
1330            ProviderSurface::Completion,
1331            ProviderSurface::Hover,
1332            ProviderSurface::Definition,
1333            ProviderSurface::References,
1334            ProviderSurface::Rename,
1335            ProviderSurface::SafeDelete,
1336            ProviderSurface::WorkspaceSymbols,
1337            ProviderSurface::DocumentSymbols,
1338            ProviderSurface::SemanticTokens,
1339        ] {
1340            let serialized = serde_json::to_string(&surface)?;
1341            let decoded: ProviderSurface = serde_json::from_str(&serialized)?;
1342            assert_eq!(decoded, surface);
1343        }
1344
1345        for source in [
1346            ProviderFactSourceKind::ParserSyntax,
1347            ProviderFactSourceKind::LegacyWorkspace,
1348            ProviderFactSourceKind::SemanticFact,
1349            ProviderFactSourceKind::CompilerFact,
1350            ProviderFactSourceKind::FrameworkAdapter,
1351            ProviderFactSourceKind::DynamicBoundary,
1352            ProviderFactSourceKind::Fallback,
1353            ProviderFactSourceKind::Unknown,
1354        ] {
1355            let serialized = serde_json::to_string(&source)?;
1356            let decoded: ProviderFactSourceKind = serde_json::from_str(&serialized)?;
1357            assert_eq!(decoded, source);
1358        }
1359
1360        for freshness in [
1361            ProviderFactFreshness::Fresh,
1362            ProviderFactFreshness::Stale,
1363            ProviderFactFreshness::Unknown,
1364            ProviderFactFreshness::NotApplicable,
1365        ] {
1366            let serialized = serde_json::to_string(&freshness)?;
1367            let decoded: ProviderFactFreshness = serde_json::from_str(&serialized)?;
1368            assert_eq!(decoded, freshness);
1369        }
1370
1371        for fallback_state in [
1372            ProviderFallbackState::Primary,
1373            ProviderFallbackState::Shadow,
1374            ProviderFallbackState::Fallback,
1375            ProviderFallbackState::Unavailable,
1376            ProviderFallbackState::Blocked,
1377        ] {
1378            let serialized = serde_json::to_string(&fallback_state)?;
1379            let decoded: ProviderFallbackState = serde_json::from_str(&serialized)?;
1380            assert_eq!(decoded, fallback_state);
1381        }
1382
1383        Ok(())
1384    }
1385
1386    // ── Package Graph round-trip tests ──
1387
1388    /// PackageEdge round-trips through JSON.
1389    #[test]
1390    fn package_edge_roundtrips_through_json() -> Result<(), serde_json::Error> {
1391        let edge = PackageEdge {
1392            from_package: "Child".to_string(),
1393            to_package: "Parent".to_string(),
1394            kind: PackageEdgeKind::Inherits,
1395            anchor_id: Some(AnchorId(99)),
1396            provenance: Provenance::ExactAst,
1397            confidence: Confidence::High,
1398        };
1399
1400        let serialized = serde_json::to_string(&edge)?;
1401        let decoded: PackageEdge = serde_json::from_str(&serialized)?;
1402        assert_eq!(decoded, edge);
1403        Ok(())
1404    }
1405
1406    /// PackageEdgeKind round-trips through JSON for every variant.
1407    #[test]
1408    fn package_edge_kind_roundtrips_through_json() -> Result<(), serde_json::Error> {
1409        let variants =
1410            [PackageEdgeKind::Inherits, PackageEdgeKind::ComposesRole, PackageEdgeKind::DependsOn];
1411        for variant in &variants {
1412            let serialized = serde_json::to_string(variant)?;
1413            let decoded: PackageEdgeKind = serde_json::from_str(&serialized)?;
1414            assert_eq!(&decoded, variant);
1415        }
1416        Ok(())
1417    }
1418
1419    /// PackageNode round-trips through JSON.
1420    #[test]
1421    fn package_node_roundtrips_through_json() -> Result<(), serde_json::Error> {
1422        let node = PackageNode {
1423            entity_id: EntityId(500),
1424            name: "My::Package".to_string(),
1425            kind: PackageKind::Class,
1426            anchor_id: Some(AnchorId(10)),
1427            file_id: Some(FileId(2)),
1428        };
1429
1430        let serialized = serde_json::to_string(&node)?;
1431        let decoded: PackageNode = serde_json::from_str(&serialized)?;
1432        assert_eq!(decoded, node);
1433        Ok(())
1434    }
1435
1436    /// PackageKind round-trips through JSON for every variant.
1437    #[test]
1438    fn package_kind_roundtrips_through_json() -> Result<(), serde_json::Error> {
1439        let variants =
1440            [PackageKind::Package, PackageKind::Class, PackageKind::Role, PackageKind::External];
1441        for variant in &variants {
1442            let serialized = serde_json::to_string(variant)?;
1443            let decoded: PackageKind = serde_json::from_str(&serialized)?;
1444            assert_eq!(&decoded, variant);
1445        }
1446        Ok(())
1447    }
1448
1449    /// PackageEdge with None anchor_id round-trips correctly.
1450    #[test]
1451    fn package_edge_none_anchor_roundtrips() -> Result<(), serde_json::Error> {
1452        let edge = PackageEdge {
1453            from_package: "App::Worker".to_string(),
1454            to_package: "Unknown::External".to_string(),
1455            kind: PackageEdgeKind::DependsOn,
1456            anchor_id: None,
1457            provenance: Provenance::NameHeuristic,
1458            confidence: Confidence::Low,
1459        };
1460
1461        let serialized = serde_json::to_string(&edge)?;
1462        let decoded: PackageEdge = serde_json::from_str(&serialized)?;
1463        assert_eq!(decoded, edge);
1464        assert!(
1465            serialized.contains("\"anchor_id\":null"),
1466            "anchor_id null must be explicit in JSON"
1467        );
1468        Ok(())
1469    }
1470
1471    // ── GeneratedMember tests ───────────────────────────────────────────
1472
1473    /// GeneratedMember round-trips through JSON.
1474    #[test]
1475    fn generated_member_roundtrips_through_json() -> Result<(), serde_json::Error> {
1476        let member = GeneratedMember {
1477            entity_id: EntityId(600),
1478            name: "username".to_string(),
1479            kind: GeneratedMemberKind::Getter,
1480            source_anchor_id: AnchorId(50),
1481            package: "MyApp::User".to_string(),
1482            provenance: Provenance::FrameworkSynthesis,
1483            confidence: Confidence::Medium,
1484        };
1485
1486        let serialized = serde_json::to_string(&member)?;
1487        let decoded: GeneratedMember = serde_json::from_str(&serialized)?;
1488        assert_eq!(decoded, member);
1489        Ok(())
1490    }
1491
1492    /// GeneratedMemberKind round-trips through JSON for every variant.
1493    #[test]
1494    fn generated_member_kind_roundtrips_through_json() -> Result<(), serde_json::Error> {
1495        let variants = [
1496            GeneratedMemberKind::Getter,
1497            GeneratedMemberKind::Setter,
1498            GeneratedMemberKind::Accessor,
1499            GeneratedMemberKind::Predicate,
1500            GeneratedMemberKind::Clearer,
1501            GeneratedMemberKind::Builder,
1502            GeneratedMemberKind::Constant,
1503        ];
1504        for variant in &variants {
1505            let serialized = serde_json::to_string(variant)?;
1506            let decoded: GeneratedMemberKind = serde_json::from_str(&serialized)?;
1507            assert_eq!(&decoded, variant);
1508        }
1509        Ok(())
1510    }
1511
1512    /// GeneratedMember constructed via `new()` matches struct literal.
1513    #[test]
1514    fn generated_member_new_constructor() -> Result<(), serde_json::Error> {
1515        let via_new = GeneratedMember::new(
1516            EntityId(700),
1517            "email".to_string(),
1518            GeneratedMemberKind::Accessor,
1519            AnchorId(60),
1520            "MyApp::User".to_string(),
1521            Provenance::FrameworkSynthesis,
1522            Confidence::Medium,
1523        );
1524        let via_literal = GeneratedMember {
1525            entity_id: EntityId(700),
1526            name: "email".to_string(),
1527            kind: GeneratedMemberKind::Accessor,
1528            source_anchor_id: AnchorId(60),
1529            package: "MyApp::User".to_string(),
1530            provenance: Provenance::FrameworkSynthesis,
1531            confidence: Confidence::Medium,
1532        };
1533        assert_eq!(via_new, via_literal);
1534        Ok(())
1535    }
1536
1537    // ── ValueShape tests ───────────────────────────────────────────────
1538
1539    /// ValueShape::Unknown round-trips through JSON.
1540    #[test]
1541    fn value_shape_unknown_roundtrips() -> Result<(), serde_json::Error> {
1542        let shape = ValueShape::Unknown;
1543        let serialized = serde_json::to_string(&shape)?;
1544        let decoded: ValueShape = serde_json::from_str(&serialized)?;
1545        assert_eq!(decoded, shape);
1546        Ok(())
1547    }
1548
1549    /// ValueShape::Object round-trips through JSON preserving package and confidence.
1550    #[test]
1551    fn value_shape_object_roundtrips() -> Result<(), serde_json::Error> {
1552        let shape =
1553            ValueShape::Object { package: "My::Class".to_string(), confidence: Confidence::High };
1554        let serialized = serde_json::to_string(&shape)?;
1555        let decoded: ValueShape = serde_json::from_str(&serialized)?;
1556        assert_eq!(decoded, shape);
1557        Ok(())
1558    }
1559
1560    /// ValueShape::PackageName round-trips through JSON.
1561    #[test]
1562    fn value_shape_package_name_roundtrips() -> Result<(), serde_json::Error> {
1563        let shape = ValueShape::PackageName { package: "Foo::Bar".to_string() };
1564        let serialized = serde_json::to_string(&shape)?;
1565        let decoded: ValueShape = serde_json::from_str(&serialized)?;
1566        assert_eq!(decoded, shape);
1567        Ok(())
1568    }
1569
1570    /// All ValueShape variants round-trip through JSON.
1571    #[test]
1572    fn value_shape_all_variants_roundtrip() -> Result<(), serde_json::Error> {
1573        let variants: Vec<ValueShape> = vec![
1574            ValueShape::Unknown,
1575            ValueShape::Scalar,
1576            ValueShape::ArrayRef,
1577            ValueShape::HashRef,
1578            ValueShape::CodeRef,
1579            ValueShape::PackageName { package: "Foo".to_string() },
1580            ValueShape::Object { package: "Bar::Baz".to_string(), confidence: Confidence::Low },
1581        ];
1582        for shape in &variants {
1583            let serialized = serde_json::to_string(shape)?;
1584            let decoded: ValueShape = serde_json::from_str(&serialized)?;
1585            assert_eq!(&decoded, shape);
1586        }
1587        Ok(())
1588    }
1589
1590    // ── RenamePlan / SafeDeletePlan round-trip tests ─────────────────
1591
1592    /// RenamePlan with edits, blockers, and warnings round-trips through JSON.
1593    #[test]
1594    fn rename_plan_roundtrips_through_json() -> Result<(), serde_json::Error> {
1595        let plan = RenamePlan {
1596            entity_id: EntityId(400),
1597            old_name: "foo".to_string(),
1598            new_name: "bar".to_string(),
1599            edits: vec![
1600                PlannedEdit {
1601                    anchor_id: AnchorId(80),
1602                    file_id: FileId(1),
1603                    category: PlannedEditCategory::Definition,
1604                    old_text: "foo".to_string(),
1605                    new_text: "bar".to_string(),
1606                },
1607                PlannedEdit {
1608                    anchor_id: AnchorId(81),
1609                    file_id: FileId(2),
1610                    category: PlannedEditCategory::Reference,
1611                    old_text: "foo".to_string(),
1612                    new_text: "bar".to_string(),
1613                },
1614            ],
1615            blockers: vec![PlanBlocker {
1616                reason: PlanBlockerReason::DynamicBoundary,
1617                anchor_id: Some(AnchorId(90)),
1618                description: "reference crosses eval boundary".to_string(),
1619            }],
1620            warnings: vec![PlanWarning {
1621                message: "symbol also appears in comments".to_string(),
1622                anchor_id: None,
1623            }],
1624        };
1625
1626        let serialized = serde_json::to_string(&plan)?;
1627        let decoded: RenamePlan = serde_json::from_str(&serialized)?;
1628        assert_eq!(decoded, plan);
1629        Ok(())
1630    }
1631
1632    /// RenamePlan with empty edits, blockers, and warnings round-trips correctly.
1633    #[test]
1634    fn rename_plan_empty_collections_roundtrip() -> Result<(), serde_json::Error> {
1635        let plan = RenamePlan {
1636            entity_id: EntityId(401),
1637            old_name: "x".to_string(),
1638            new_name: "y".to_string(),
1639            edits: vec![],
1640            blockers: vec![],
1641            warnings: vec![],
1642        };
1643
1644        let serialized = serde_json::to_string(&plan)?;
1645        let decoded: RenamePlan = serde_json::from_str(&serialized)?;
1646        assert_eq!(decoded, plan);
1647        assert!(serialized.contains("\"edits\":[]"), "empty edits must be an empty JSON array");
1648        assert!(
1649            serialized.contains("\"blockers\":[]"),
1650            "empty blockers must be an empty JSON array"
1651        );
1652        assert!(
1653            serialized.contains("\"warnings\":[]"),
1654            "empty warnings must be an empty JSON array"
1655        );
1656        Ok(())
1657    }
1658
1659    /// RenamePlan constructed via `new()` matches struct literal.
1660    #[test]
1661    fn rename_plan_new_constructor() {
1662        let via_new = RenamePlan::new(
1663            EntityId(402),
1664            "old".to_string(),
1665            "new".to_string(),
1666            vec![],
1667            vec![],
1668            vec![],
1669        );
1670        let via_literal = RenamePlan {
1671            entity_id: EntityId(402),
1672            old_name: "old".to_string(),
1673            new_name: "new".to_string(),
1674            edits: vec![],
1675            blockers: vec![],
1676            warnings: vec![],
1677        };
1678        assert_eq!(via_new, via_literal);
1679    }
1680
1681    /// SafeDeletePlan with blockers and warnings round-trips through JSON.
1682    #[test]
1683    fn safe_delete_plan_roundtrips_through_json() -> Result<(), serde_json::Error> {
1684        let plan = SafeDeletePlan {
1685            entity_id: EntityId(500),
1686            name: "unused_sub".to_string(),
1687            blockers: vec![
1688                PlanBlocker {
1689                    reason: PlanBlockerReason::ReferencesExist,
1690                    anchor_id: Some(AnchorId(70)),
1691                    description: "3 references remain".to_string(),
1692                },
1693                PlanBlocker {
1694                    reason: PlanBlockerReason::ExportedSymbol,
1695                    anchor_id: Some(AnchorId(71)),
1696                    description: "symbol in @EXPORT_OK".to_string(),
1697                },
1698            ],
1699            warnings: vec![],
1700        };
1701
1702        let serialized = serde_json::to_string(&plan)?;
1703        let decoded: SafeDeletePlan = serde_json::from_str(&serialized)?;
1704        assert_eq!(decoded, plan);
1705        Ok(())
1706    }
1707
1708    /// SafeDeletePlan with no blockers round-trips correctly.
1709    #[test]
1710    fn safe_delete_plan_no_blockers_roundtrips() -> Result<(), serde_json::Error> {
1711        let plan = SafeDeletePlan {
1712            entity_id: EntityId(501),
1713            name: "dead_code".to_string(),
1714            blockers: vec![],
1715            warnings: vec![PlanWarning {
1716                message: "symbol appears in pod documentation".to_string(),
1717                anchor_id: Some(AnchorId(72)),
1718            }],
1719        };
1720
1721        let serialized = serde_json::to_string(&plan)?;
1722        let decoded: SafeDeletePlan = serde_json::from_str(&serialized)?;
1723        assert_eq!(decoded, plan);
1724        Ok(())
1725    }
1726
1727    /// SafeDeletePlan constructed via `new()` matches struct literal.
1728    #[test]
1729    fn safe_delete_plan_new_constructor() {
1730        let via_new = SafeDeletePlan::new(EntityId(502), "helper".to_string(), vec![], vec![]);
1731        let via_literal = SafeDeletePlan {
1732            entity_id: EntityId(502),
1733            name: "helper".to_string(),
1734            blockers: vec![],
1735            warnings: vec![],
1736        };
1737        assert_eq!(via_new, via_literal);
1738    }
1739
1740    /// PlanBlockerReason round-trips through JSON for every variant.
1741    #[test]
1742    fn plan_blocker_reason_roundtrips_through_json() -> Result<(), serde_json::Error> {
1743        let variants = [
1744            PlanBlockerReason::DynamicBoundary,
1745            PlanBlockerReason::AmbiguousReference,
1746            PlanBlockerReason::CrossModuleExport,
1747            PlanBlockerReason::ImportedSymbol,
1748            PlanBlockerReason::ExportedSymbol,
1749            PlanBlockerReason::ReferencesExist,
1750            PlanBlockerReason::GeneratedMember,
1751            PlanBlockerReason::UnclassifiedOccurrence,
1752        ];
1753        for variant in &variants {
1754            let serialized = serde_json::to_string(variant)?;
1755            let decoded: PlanBlockerReason = serde_json::from_str(&serialized)?;
1756            assert_eq!(&decoded, variant);
1757        }
1758        Ok(())
1759    }
1760
1761    /// PlanBlocker with None anchor_id round-trips correctly.
1762    #[test]
1763    fn plan_blocker_none_anchor_roundtrips() -> Result<(), serde_json::Error> {
1764        let blocker = PlanBlocker {
1765            reason: PlanBlockerReason::GeneratedMember,
1766            anchor_id: None,
1767            description: "generated accessor without edit plan".to_string(),
1768        };
1769
1770        let serialized = serde_json::to_string(&blocker)?;
1771        let decoded: PlanBlocker = serde_json::from_str(&serialized)?;
1772        assert_eq!(decoded, blocker);
1773        assert!(
1774            serialized.contains("\"anchor_id\":null"),
1775            "anchor_id null must be explicit in JSON"
1776        );
1777        Ok(())
1778    }
1779
1780    /// PlanBlocker constructed via `new()` matches struct literal.
1781    #[test]
1782    fn plan_blocker_new_constructor() {
1783        let via_new = PlanBlocker::new(
1784            PlanBlockerReason::ImportedSymbol,
1785            Some(AnchorId(99)),
1786            "imported by other file".to_string(),
1787        );
1788        let via_literal = PlanBlocker {
1789            reason: PlanBlockerReason::ImportedSymbol,
1790            anchor_id: Some(AnchorId(99)),
1791            description: "imported by other file".to_string(),
1792        };
1793        assert_eq!(via_new, via_literal);
1794    }
1795
1796    /// PlanWarning round-trips through JSON.
1797    #[test]
1798    fn plan_warning_roundtrips_through_json() -> Result<(), serde_json::Error> {
1799        let warning = PlanWarning {
1800            message: "symbol also used in string interpolation".to_string(),
1801            anchor_id: Some(AnchorId(85)),
1802        };
1803
1804        let serialized = serde_json::to_string(&warning)?;
1805        let decoded: PlanWarning = serde_json::from_str(&serialized)?;
1806        assert_eq!(decoded, warning);
1807        Ok(())
1808    }
1809
1810    /// PlanWarning constructed via `new()` matches struct literal.
1811    #[test]
1812    fn plan_warning_new_constructor() {
1813        let via_new = PlanWarning::new("check pod docs".to_string(), None);
1814        let via_literal = PlanWarning { message: "check pod docs".to_string(), anchor_id: None };
1815        assert_eq!(via_new, via_literal);
1816    }
1817
1818    /// PlannedEdit round-trips through JSON.
1819    #[test]
1820    fn planned_edit_roundtrips_through_json() -> Result<(), serde_json::Error> {
1821        let edit = PlannedEdit {
1822            anchor_id: AnchorId(60),
1823            file_id: FileId(5),
1824            category: PlannedEditCategory::ImportList,
1825            old_text: "foo".to_string(),
1826            new_text: "bar".to_string(),
1827        };
1828
1829        let serialized = serde_json::to_string(&edit)?;
1830        let decoded: PlannedEdit = serde_json::from_str(&serialized)?;
1831        assert_eq!(decoded, edit);
1832        Ok(())
1833    }
1834
1835    /// PlannedEdit constructed via `new()` matches struct literal.
1836    #[test]
1837    fn planned_edit_new_constructor() {
1838        let via_new = PlannedEdit::new(
1839            AnchorId(61),
1840            FileId(6),
1841            PlannedEditCategory::ExportList,
1842            "old_sym".to_string(),
1843            "new_sym".to_string(),
1844        );
1845        let via_literal = PlannedEdit {
1846            anchor_id: AnchorId(61),
1847            file_id: FileId(6),
1848            category: PlannedEditCategory::ExportList,
1849            old_text: "old_sym".to_string(),
1850            new_text: "new_sym".to_string(),
1851        };
1852        assert_eq!(via_new, via_literal);
1853    }
1854
1855    /// PlannedEditCategory round-trips through JSON for every variant.
1856    #[test]
1857    fn planned_edit_category_roundtrips_through_json() -> Result<(), serde_json::Error> {
1858        let variants = [
1859            PlannedEditCategory::Definition,
1860            PlannedEditCategory::Reference,
1861            PlannedEditCategory::ImportList,
1862            PlannedEditCategory::ExportList,
1863        ];
1864        for variant in &variants {
1865            let serialized = serde_json::to_string(variant)?;
1866            let decoded: PlannedEditCategory = serde_json::from_str(&serialized)?;
1867            assert_eq!(&decoded, variant);
1868        }
1869        Ok(())
1870    }
1871}