Skip to main content

plsql_core/
lib.rs

1#![forbid(unsafe_code)]
2
3use std::collections::{BTreeMap, BTreeSet, HashMap};
4
5use miette::SourceSpan;
6use serde::de::DeserializeOwned;
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8use serde_json::Value;
9use tracing::instrument;
10
11macro_rules! numeric_id {
12    ($name:ident) => {
13        #[derive(
14            Clone,
15            Copy,
16            Debug,
17            Default,
18            Eq,
19            PartialEq,
20            Ord,
21            PartialOrd,
22            Hash,
23            Serialize,
24            Deserialize,
25        )]
26        #[serde(transparent)]
27        pub struct $name(u64);
28
29        impl $name {
30            #[must_use]
31            #[instrument(level = "trace")]
32            pub fn new(raw: u64) -> Self {
33                Self(raw)
34            }
35
36            #[must_use]
37            #[instrument(level = "trace", skip(self))]
38            pub fn get(self) -> u64 {
39                self.0
40            }
41        }
42    };
43}
44
45macro_rules! interned_name {
46    ($name:ident) => {
47        #[derive(
48            Clone,
49            Copy,
50            Debug,
51            Default,
52            Eq,
53            PartialEq,
54            Ord,
55            PartialOrd,
56            Hash,
57            Serialize,
58            Deserialize,
59        )]
60        #[serde(transparent)]
61        pub struct $name(SymbolId);
62
63        impl $name {
64            #[must_use]
65            #[instrument(level = "trace")]
66            pub fn new(symbol: SymbolId) -> Self {
67                Self(symbol)
68            }
69
70            #[must_use]
71            #[instrument(level = "trace", skip(self))]
72            pub fn symbol(self) -> SymbolId {
73                self.0
74            }
75        }
76
77        impl From<SymbolId> for $name {
78            fn from(value: SymbolId) -> Self {
79                Self::new(value)
80            }
81        }
82    };
83}
84
85#[derive(
86    Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
87)]
88#[serde(transparent)]
89pub struct FileId(u32);
90
91impl FileId {
92    #[must_use]
93    #[instrument(level = "trace")]
94    pub fn new(raw: u32) -> Self {
95        Self(raw)
96    }
97
98    #[must_use]
99    #[instrument(level = "trace", skip(self))]
100    pub fn get(self) -> u32 {
101        self.0
102    }
103}
104
105numeric_id!(AnalysisRunId);
106numeric_id!(SymbolId);
107numeric_id!(ObjectId);
108numeric_id!(ColumnId);
109numeric_id!(MemberId);
110
111interned_name!(SchemaName);
112interned_name!(UserName);
113interned_name!(EditionName);
114interned_name!(RoleName);
115interned_name!(ObjectName);
116interned_name!(ColumnName);
117interned_name!(MemberName);
118
119#[derive(
120    Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
121)]
122pub struct Position {
123    pub line: u32,
124    pub column: u32,
125    pub offset: u32,
126}
127
128impl Position {
129    #[must_use]
130    #[instrument(level = "trace")]
131    pub fn new(line: u32, column: u32, offset: u32) -> Self {
132        Self {
133            line,
134            column,
135            offset,
136        }
137    }
138}
139
140#[derive(
141    Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
142)]
143pub struct Span {
144    pub file_id: FileId,
145    pub start: Position,
146    pub end: Position,
147}
148
149impl Span {
150    #[must_use]
151    #[instrument(level = "trace")]
152    pub fn new(file_id: FileId, start: Position, end: Position) -> Self {
153        Self {
154            file_id,
155            start,
156            end,
157        }
158    }
159
160    #[must_use]
161    #[instrument(level = "trace", skip(self))]
162    pub fn len(self) -> u32 {
163        self.end.offset.saturating_sub(self.start.offset)
164    }
165
166    #[must_use]
167    #[instrument(level = "trace", skip(self))]
168    pub fn is_empty(self) -> bool {
169        self.start.offset >= self.end.offset
170    }
171
172    #[must_use]
173    #[instrument(level = "trace", skip(self))]
174    pub fn source_span(self) -> SourceSpan {
175        SourceSpan::from((
176            usize::try_from(self.start.offset).unwrap_or(usize::MAX),
177            usize::try_from(self.len()).unwrap_or(usize::MAX),
178        ))
179    }
180}
181
182#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
183pub struct SpanLabel {
184    pub label: String,
185    pub span: Span,
186}
187
188impl SpanLabel {
189    #[must_use]
190    #[instrument(level = "trace", skip(label))]
191    pub fn new(label: impl Into<String>, span: Span) -> Self {
192        Self {
193            label: label.into(),
194            span,
195        }
196    }
197}
198
199#[derive(
200    Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
201)]
202pub enum Severity {
203    #[default]
204    Info,
205    Warn,
206    Error,
207    Fatal,
208}
209
210#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
211pub enum UnknownReason {
212    DynamicSqlOpaque,
213    DbLinkRemoteObject,
214    WrappedSource,
215    MissingCatalogObject,
216    MissingPackageBody,
217    ConditionalCompilationBranch,
218    EditionedObject,
219    InvokerRightsRuntimeResolution,
220    RuntimeGrantOrRole,
221    UnsupportedDialectFeature,
222    ParserRecoveryRegion,
223    /// A bounded-depth analysis walk (call-site / table-access
224    /// extraction over re-lowered control-flow bodies) hit its
225    /// recursion-depth cap before the body provably shrank — almost
226    /// always a malformed / parser-recovered unit whose `IF`/`LOOP`
227    /// text slice fails to strictly shrink across re-lowering passes.
228    /// The remainder of that nested body is degraded honestly rather
229    /// than walked unbounded (which would stack-overflow / abort).
230    AnalysisRecursionLimit,
231    /// Response had MCP / tool-call markers scrubbed before being returned
232    /// to the agent. Tracks 's K18 prompt-injection
233    /// sanitization step so downstream consumers know the row text was
234    /// rewritten.
235    ResponseSanitized,
236}
237
238#[derive(
239    Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
240)]
241pub enum ConfidenceLevel {
242    High,
243    Medium,
244    Low,
245    #[default]
246    Opaque,
247}
248
249#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
250pub struct Confidence {
251    pub level: ConfidenceLevel,
252    pub explanation: Option<String>,
253}
254
255impl Confidence {
256    #[must_use]
257    #[instrument(level = "trace", skip(explanation))]
258    pub fn new(level: ConfidenceLevel, explanation: impl Into<Option<String>>) -> Self {
259        Self {
260            level,
261            explanation: explanation.into(),
262        }
263    }
264
265    #[must_use]
266    #[instrument(level = "trace")]
267    pub fn opaque() -> Self {
268        Self {
269            level: ConfidenceLevel::Opaque,
270            explanation: None,
271        }
272    }
273}
274
275#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
276pub struct Evidence {
277    pub code: String,
278    pub summary: String,
279    pub spans: Vec<SpanLabel>,
280    pub notes: Vec<String>,
281    pub attributes: BTreeMap<String, Value>,
282    pub confidence: Option<Confidence>,
283}
284
285impl Evidence {
286    #[must_use]
287    #[instrument(level = "trace", skip(code, summary))]
288    pub fn new(code: impl Into<String>, summary: impl Into<String>) -> Self {
289        Self {
290            code: code.into(),
291            summary: summary.into(),
292            ..Self::default()
293        }
294    }
295
296    #[must_use]
297    #[instrument(level = "trace", skip(self))]
298    pub fn with_span(mut self, span: SpanLabel) -> Self {
299        self.spans.push(span);
300        self
301    }
302
303    #[must_use]
304    #[instrument(level = "trace", skip(self, note))]
305    pub fn with_note(mut self, note: impl Into<String>) -> Self {
306        self.notes.push(note.into());
307        self
308    }
309
310    #[must_use]
311    #[instrument(level = "trace", skip(self, key, value))]
312    pub fn with_attribute(mut self, key: impl Into<String>, value: Value) -> Self {
313        self.attributes.insert(key.into(), value);
314        self
315    }
316
317    #[must_use]
318    #[instrument(level = "trace", skip(self))]
319    pub fn with_confidence(mut self, confidence: Confidence) -> Self {
320        self.confidence = Some(confidence);
321        self
322    }
323}
324
325#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
326pub struct Diagnostic {
327    pub code: String,
328    pub severity: Severity,
329    pub message: String,
330    pub primary_span: Option<Span>,
331    pub related_spans: Vec<SpanLabel>,
332    pub help: Option<String>,
333    pub unknown_reasons: Vec<UnknownReason>,
334    pub evidence: Vec<Evidence>,
335}
336
337impl Diagnostic {
338    #[must_use]
339    #[instrument(level = "trace", skip(code, message))]
340    pub fn new(code: impl Into<String>, severity: Severity, message: impl Into<String>) -> Self {
341        Self {
342            code: code.into(),
343            severity,
344            message: message.into(),
345            ..Self::default()
346        }
347    }
348
349    #[must_use]
350    #[instrument(level = "trace", skip(self))]
351    pub fn with_primary_span(mut self, span: Span) -> Self {
352        self.primary_span = Some(span);
353        self
354    }
355
356    #[must_use]
357    #[instrument(level = "trace", skip(self))]
358    pub fn with_related_span(mut self, span: SpanLabel) -> Self {
359        self.related_spans.push(span);
360        self
361    }
362
363    #[must_use]
364    #[instrument(level = "trace", skip(self, help))]
365    pub fn with_help(mut self, help: impl Into<String>) -> Self {
366        self.help = Some(help.into());
367        self
368    }
369
370    #[must_use]
371    #[instrument(level = "trace", skip(self))]
372    pub fn with_unknown_reason(mut self, reason: UnknownReason) -> Self {
373        self.unknown_reasons.push(reason);
374        self
375    }
376
377    #[must_use]
378    #[instrument(level = "trace", skip(self))]
379    pub fn with_evidence(mut self, evidence: Evidence) -> Self {
380        self.evidence.push(evidence);
381        self
382    }
383}
384
385pub trait JsonExportable: Serialize + DeserializeOwned {
386    #[instrument(level = "trace", skip(self))]
387    fn to_json_value(&self) -> serde_json::Result<Value> {
388        serde_json::to_value(self)
389    }
390
391    fn from_json_value(value: Value) -> serde_json::Result<Self>
392    where
393        Self: Sized,
394    {
395        serde_json::from_value(value)
396    }
397}
398
399impl<T> JsonExportable for T where T: Serialize + DeserializeOwned {}
400
401#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
402pub struct RobotJson<T> {
403    pub payload: T,
404}
405
406impl<T> RobotJson<T> {
407    #[must_use]
408    #[instrument(level = "trace", skip(payload))]
409    pub fn new(payload: T) -> Self {
410        Self { payload }
411    }
412
413    #[must_use]
414    #[instrument(level = "trace", skip(self))]
415    pub fn into_payload(self) -> T {
416        self.payload
417    }
418}
419
420#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
421pub enum LiteralValue {
422    String(String),
423    Integer(i64),
424    Decimal(String),
425    Boolean(bool),
426    Null,
427}
428
429#[derive(
430    Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
431)]
432pub enum NlsLengthSemantics {
433    #[default]
434    Byte,
435    Char,
436}
437
438#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
439pub struct NlsSettings {
440    pub language: Option<String>,
441    pub territory: Option<String>,
442    pub date_format: Option<String>,
443    pub timestamp_format: Option<String>,
444    pub timestamp_tz_format: Option<String>,
445    pub length_semantics: NlsLengthSemantics,
446}
447
448#[derive(
449    Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
450)]
451pub enum DbLinkPolicy {
452    AllowRemoteMetadata,
453    #[default]
454    RecordOpaqueRemoteObjects,
455    RejectRemoteObjects,
456}
457
458#[derive(
459    Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
460)]
461pub enum UnknownFeatureBehavior {
462    #[default]
463    RecordUnknown,
464    TreatAsUnsupported,
465    FailAnalysis,
466}
467
468#[derive(
469    Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
470)]
471pub enum OracleVersion {
472    Oracle11g,
473    Oracle12c,
474    #[default]
475    Oracle19c,
476    Oracle21c,
477    Oracle23ai,
478    Oracle26ai,
479}
480
481#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
482pub enum OracleFeature {
483    SqlBoolean23ai,
484    PlsqlVector23ai,
485    BinaryVector26ai,
486    SparseVector26ai,
487    VectorArithmetic26ai,
488    PackageResettable26ai,
489    JsonRelationalDuality23ai,
490    SqlMacros,
491    PolymorphicTableFunctions,
492    MultilingualEngineCallSpecs,
493}
494
495impl OracleVersion {
496    #[must_use]
497    #[instrument(level = "trace", skip(self))]
498    pub fn default_features(self) -> BTreeSet<OracleFeature> {
499        let features = match self {
500            Self::Oracle11g | Self::Oracle12c | Self::Oracle19c => Vec::new(),
501            Self::Oracle21c => vec![
502                OracleFeature::SqlMacros,
503                OracleFeature::PolymorphicTableFunctions,
504            ],
505            Self::Oracle23ai => vec![
506                OracleFeature::SqlBoolean23ai,
507                OracleFeature::PlsqlVector23ai,
508                OracleFeature::JsonRelationalDuality23ai,
509                OracleFeature::SqlMacros,
510                OracleFeature::PolymorphicTableFunctions,
511            ],
512            Self::Oracle26ai => vec![
513                OracleFeature::SqlBoolean23ai,
514                OracleFeature::PlsqlVector23ai,
515                OracleFeature::BinaryVector26ai,
516                OracleFeature::SparseVector26ai,
517                OracleFeature::VectorArithmetic26ai,
518                OracleFeature::PackageResettable26ai,
519                OracleFeature::JsonRelationalDuality23ai,
520                OracleFeature::SqlMacros,
521                OracleFeature::PolymorphicTableFunctions,
522                OracleFeature::MultilingualEngineCallSpecs,
523            ],
524        };
525
526        features.into_iter().collect()
527    }
528}
529
530#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
531pub struct FeaturePolicy {
532    pub enabled: BTreeSet<OracleFeature>,
533    pub disabled: BTreeSet<OracleFeature>,
534    pub unknown_feature_behavior: UnknownFeatureBehavior,
535}
536
537impl FeaturePolicy {
538    #[must_use]
539    #[instrument(level = "trace")]
540    pub fn from_version(version: OracleVersion) -> Self {
541        Self {
542            enabled: version.default_features(),
543            disabled: BTreeSet::new(),
544            unknown_feature_behavior: UnknownFeatureBehavior::RecordUnknown,
545        }
546    }
547
548    #[must_use]
549    #[instrument(level = "trace", skip(self))]
550    pub fn is_enabled(&self, feature: OracleFeature) -> bool {
551        !self.disabled.contains(&feature) && self.enabled.contains(&feature)
552    }
553
554    #[must_use]
555    #[instrument(level = "trace", skip(self))]
556    pub fn with_enabled(mut self, feature: OracleFeature) -> Self {
557        self.disabled.remove(&feature);
558        self.enabled.insert(feature);
559        self
560    }
561
562    #[must_use]
563    #[instrument(level = "trace", skip(self))]
564    pub fn with_disabled(mut self, feature: OracleFeature) -> Self {
565        self.enabled.remove(&feature);
566        self.disabled.insert(feature);
567        self
568    }
569}
570
571#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
572pub struct AnalysisProfile {
573    pub oracle_version: OracleVersion,
574    pub compatibility: Option<OracleVersion>,
575    pub feature_policy: FeaturePolicy,
576    pub current_schema: Option<SchemaName>,
577    pub current_user: Option<UserName>,
578    pub current_edition: Option<EditionName>,
579    pub plsql_ccflags: HashMap<String, LiteralValue>,
580    pub nls: NlsSettings,
581    pub enabled_roles: Vec<RoleName>,
582    pub db_link_policy: DbLinkPolicy,
583}
584
585impl Default for AnalysisProfile {
586    fn default() -> Self {
587        Self::for_version(OracleVersion::Oracle19c)
588    }
589}
590
591impl AnalysisProfile {
592    #[must_use]
593    #[instrument(level = "trace")]
594    pub fn for_version(oracle_version: OracleVersion) -> Self {
595        Self {
596            oracle_version,
597            compatibility: None,
598            feature_policy: FeaturePolicy::from_version(oracle_version),
599            current_schema: None,
600            current_user: None,
601            current_edition: None,
602            plsql_ccflags: HashMap::new(),
603            nls: NlsSettings::default(),
604            enabled_roles: Vec::new(),
605            db_link_policy: DbLinkPolicy::default(),
606        }
607    }
608
609    #[must_use]
610    #[instrument(level = "trace", skip(self))]
611    pub fn supports_feature(&self, feature: OracleFeature) -> bool {
612        self.feature_policy.is_enabled(feature)
613    }
614}
615
616/// An honest count that distinguishes "measured and found zero" from
617/// "never measured" (§1.5 Evidence-UX honesty).
618///
619/// A bare `0` on a metric whose pipeline stage is not yet wired is a
620/// false-clean lie: a reader cannot tell "we looked and found none"
621/// from "we never looked". Every gap metric that is structurally
622/// not-yet-computed serialises as `{ "unmeasured": true }` instead of
623/// `0`, so a consumer can never mistake an un-run analysis for a
624/// clean one.
625#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
626pub enum Measured<T> {
627    /// The producing analysis stage ran and established this value.
628    Measured(T),
629    /// The producing analysis stage is not yet wired; the true value
630    /// is unknown. NEVER treat this as zero.
631    #[default]
632    Unmeasured,
633}
634
635impl<T> Measured<T> {
636    /// `Some` only when the value was actually measured.
637    pub fn measured(self) -> Option<T> {
638        match self {
639            Self::Measured(v) => Some(v),
640            Self::Unmeasured => None,
641        }
642    }
643
644    #[must_use]
645    pub fn is_measured(&self) -> bool {
646        matches!(self, Self::Measured(_))
647    }
648}
649
650// `untagged` needs `Unmeasured` to round-trip; model it as the JSON
651// object `{ "unmeasured": true }` rather than `null` (which would be
652// ambiguous with a measured `Option`-shaped value).
653mod measured_serde {
654    use super::Measured;
655    use serde::{Deserialize, Serialize};
656
657    #[derive(Serialize, Deserialize)]
658    struct UnmeasuredMarker {
659        unmeasured: bool,
660    }
661
662    impl<T: Serialize> Serialize for Measured<T> {
663        fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
664            match self {
665                Measured::Measured(v) => v.serialize(s),
666                Measured::Unmeasured => UnmeasuredMarker { unmeasured: true }.serialize(s),
667            }
668        }
669    }
670
671    impl<'de, T: serde::de::DeserializeOwned> Deserialize<'de> for Measured<T> {
672        fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
673            let value = serde_json::Value::deserialize(d)?;
674            if let Ok(m) = serde_json::from_value::<UnmeasuredMarker>(value.clone()) {
675                if m.unmeasured {
676                    return Ok(Measured::Unmeasured);
677                }
678            }
679            let v = serde_json::from_value::<T>(value).map_err(serde::de::Error::custom)?;
680            Ok(Measured::Measured(v))
681        }
682    }
683}
684
685/// The overall epistemic posture of an analysis run (§1.5, §22).
686///
687/// This is the headline a consumer reads first. It is derived from
688/// the honest signals — it is NEVER `Clean` when the run understood
689/// little (high unrecognised-object ratio or large diagnostic
690/// volume). Honesty cuts both ways: a genuinely clean run over clean
691/// input still reads `Clean`.
692#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
693pub enum CompletenessPosture {
694    /// Parsed and semantically lowered with no material gaps.
695    Clean,
696    /// Some recovery / catalog gaps, but the bulk was understood.
697    Partial,
698    /// A large share of objects were not understood, or the
699    /// diagnostic volume is high. Downstream results are NOT
700    /// trustworthy as a complete picture.
701    LowConfidence,
702    /// The run could not establish a meaningful model at all.
703    #[default]
704    Degraded,
705}
706
707impl std::fmt::Display for CompletenessPosture {
708    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
709        f.write_str(match self {
710            Self::Clean => "Clean",
711            Self::Partial => "Partial",
712            Self::LowConfidence => "LowConfidence",
713            Self::Degraded => "Degraded",
714        })
715    }
716}
717
718#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
719pub struct CompletenessReport {
720    pub files_total: usize,
721    pub files_parsed_cleanly: usize,
722    pub files_recovered: usize,
723    pub skipped_token_ratio: f32,
724    pub objects_total: usize,
725    pub objects_with_source: usize,
726    pub objects_catalog_only: usize,
727    // --- structurally not-yet-wired gap metrics (honest Unmeasured) ---
728    // These read as `{ "unmeasured": true }` until their analysis
729    // stage is wired, so a reader can never mistake "not computed"
730    // for "none found".
731    pub wrapped_units: Measured<usize>,
732    pub missing_package_bodies: Measured<usize>,
733    pub dynamic_sql_sites: Measured<usize>,
734    pub opaque_dynamic_sql_sites: Measured<usize>,
735    pub db_link_edges: Measured<usize>,
736    pub unresolved_references: Measured<usize>,
737    // --- honest extraction signals (always populated) ---
738    /// Total diagnostics emitted across the whole run. A large
739    /// number here means the run is NOT clean even if the file
740    /// counts look healthy.
741    pub diagnostics_total: usize,
742    /// Top-level objects the AST classifier could not lower
743    /// (`IR_UNCLASSIFIED_DECL`). These contributed NOTHING to the
744    /// semantic model — they are unknown, not clean.
745    pub objects_unrecognized: usize,
746    /// Objects for which real semantics were extracted (lowered).
747    pub objects_with_extracted_semantics: usize,
748    /// `objects_with_extracted_semantics / (lowered + unrecognized)`,
749    /// in `[0.0, 1.0]`. Low ratio ⇒ the run understood little.
750    pub extracted_semantics_ratio: f32,
751    /// Derived headline. NEVER `Clean` on a low-extraction run.
752    pub posture: CompletenessPosture,
753    pub catalog_available: bool,
754    pub plscope_available: bool,
755}
756
757impl CompletenessReport {
758    /// Populate the derived honest signals (`extracted_semantics_ratio`
759    /// and `posture`) from the raw counts. Call this after setting
760    /// `objects_with_extracted_semantics`, `objects_unrecognized` and
761    /// `diagnostics_total`.
762    ///
763    /// Posture rules (anti-spin — a low-extraction run MUST look
764    /// exactly as uncertain as it truly is):
765    /// * `Degraded`   — nothing meaningful established (no objects, or
766    ///   ratio ≈ 0 with work attempted).
767    /// * `LowConfidence` — a material share of objects unrecognised
768    ///   (ratio < 0.85) OR the diagnostic volume rivals the object
769    ///   count (a "we barely understood this" signal).
770    /// * `Partial`    — mostly understood but with recovery/gap noise.
771    /// * `Clean`      — fully lowered, no unrecognised objects, low
772    ///   diagnostic noise, every file parsed cleanly.
773    pub fn finalize_posture(&mut self) {
774        let denom = self
775            .objects_with_extracted_semantics
776            .saturating_add(self.objects_unrecognized);
777        self.extracted_semantics_ratio = if denom == 0 {
778            // No top-level objects at all: a genuinely empty tree is
779            // not "clean extraction"; treat ratio as 1.0 only when
780            // there were truly no objects AND no diagnostics.
781            if self.diagnostics_total == 0 {
782                1.0
783            } else {
784                0.0
785            }
786        } else {
787            self.objects_with_extracted_semantics as f32 / denom as f32
788        };
789
790        // Diagnostic pressure relative to attempted objects: when the
791        // run emitted roughly as many (or more) diagnostics as it has
792        // objects, it did not "cleanly parse" anything in a meaningful
793        // sense regardless of the file tallies.
794        let attempted = denom.max(self.objects_total);
795        let high_diag_pressure = attempted > 0 && self.diagnostics_total * 2 >= attempted;
796
797        self.posture = if denom == 0 && self.objects_total == 0 {
798            // No top-level objects at all. An empty tree, or a tree
799            // with files but zero diagnostics, is genuinely Clean;
800            // files present *with* diagnostics is Degraded (nothing
801            // understood, noise emitted).
802            if self.files_total == 0 || self.diagnostics_total == 0 {
803                CompletenessPosture::Clean
804            } else {
805                CompletenessPosture::Degraded
806            }
807        } else if self.extracted_semantics_ratio < 0.10 {
808            CompletenessPosture::Degraded
809        } else if self.objects_unrecognized > 0
810            || self.extracted_semantics_ratio < 0.85
811            || high_diag_pressure
812        {
813            CompletenessPosture::LowConfidence
814        } else if self.files_recovered > 0
815            || self.diagnostics_total > 0
816            || self.files_parsed_cleanly < self.files_total
817        {
818            CompletenessPosture::Partial
819        } else {
820            CompletenessPosture::Clean
821        };
822    }
823}
824
825#[derive(Clone, Debug, Default, Eq, PartialEq)]
826pub struct SymbolInterner {
827    symbols: Vec<String>,
828    index: HashMap<String, SymbolId>,
829}
830
831impl SymbolInterner {
832    #[must_use]
833    #[instrument(level = "trace")]
834    pub fn new() -> Self {
835        Self::default()
836    }
837
838    #[must_use]
839    #[instrument(level = "trace", skip(self, text))]
840    pub fn intern(&mut self, text: impl Into<String>) -> Option<SymbolId> {
841        let text = text.into();
842        if let Some(&symbol_id) = self.index.get(text.as_str()) {
843            return Some(symbol_id);
844        }
845
846        let next_index = u64::try_from(self.symbols.len()).ok()?;
847        let symbol_id = SymbolId::new(next_index);
848        self.symbols.push(text.clone());
849        self.index.insert(text, symbol_id);
850        Some(symbol_id)
851    }
852
853    #[must_use]
854    #[instrument(level = "trace", skip(self))]
855    pub fn resolve(&self, symbol_id: SymbolId) -> Option<&str> {
856        let index = usize::try_from(symbol_id.get()).ok()?;
857        self.symbols.get(index).map(String::as_str)
858    }
859
860    #[must_use]
861    #[instrument(level = "trace", skip(self, text))]
862    pub fn contains(&self, text: impl AsRef<str>) -> bool {
863        self.index.contains_key(text.as_ref())
864    }
865
866    #[must_use]
867    #[instrument(level = "trace", skip(self))]
868    pub fn len(&self) -> usize {
869        self.symbols.len()
870    }
871
872    #[must_use]
873    #[instrument(level = "trace", skip(self))]
874    pub fn is_empty(&self) -> bool {
875        self.symbols.is_empty()
876    }
877
878    #[must_use]
879    #[instrument(level = "trace", skip(self, text))]
880    pub fn intern_schema_name(&mut self, text: impl Into<String>) -> Option<SchemaName> {
881        self.intern(text).map(SchemaName::from)
882    }
883
884    #[must_use]
885    #[instrument(level = "trace", skip(self, text))]
886    pub fn intern_user_name(&mut self, text: impl Into<String>) -> Option<UserName> {
887        self.intern(text).map(UserName::from)
888    }
889
890    #[must_use]
891    #[instrument(level = "trace", skip(self, text))]
892    pub fn intern_role_name(&mut self, text: impl Into<String>) -> Option<RoleName> {
893        self.intern(text).map(RoleName::from)
894    }
895}
896
897impl Serialize for SymbolInterner {
898    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
899    where
900        S: Serializer,
901    {
902        self.symbols.serialize(serializer)
903    }
904}
905
906impl<'de> Deserialize<'de> for SymbolInterner {
907    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
908    where
909        D: Deserializer<'de>,
910    {
911        let symbols = Vec::<String>::deserialize(deserializer)?;
912        let mut interner = SymbolInterner::default();
913        for symbol in symbols {
914            interner
915                .intern(symbol)
916                .ok_or_else(|| serde::de::Error::custom("symbol table overflow"))?;
917        }
918        Ok(interner)
919    }
920}
921
922impl std::fmt::Display for NlsLengthSemantics {
923    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
924        match self {
925            Self::Byte => f.write_str("Byte"),
926            Self::Char => f.write_str("Char"),
927        }
928    }
929}
930
931#[cfg(test)]
932mod tests {
933    use super::{
934        AnalysisProfile, ColumnName, CompletenessPosture, CompletenessReport, Confidence,
935        ConfidenceLevel, DbLinkPolicy, Diagnostic, EditionName, Evidence, FeaturePolicy, FileId,
936        JsonExportable, LiteralValue, Measured, NlsSettings, ObjectName, OracleFeature,
937        OracleVersion, Position, RobotJson, RoleName, SchemaName, Severity, SourceSpan, Span,
938        SymbolId, SymbolInterner, UnknownFeatureBehavior, UnknownReason, UserName,
939    };
940    use serde_json::Value;
941
942    #[test]
943    fn span_len_uses_offsets() {
944        let span = Span::new(
945            FileId::new(7),
946            Position::new(2, 4, 10),
947            Position::new(2, 9, 21),
948        );
949
950        assert_eq!(span.len(), 11);
951        assert!(!span.is_empty());
952        assert_eq!(span.source_span(), SourceSpan::from((10usize, 11usize)));
953    }
954
955    #[test]
956    fn evidence_builder_retains_attributes() {
957        let evidence = Evidence::new("SYM001", "resolved via same-schema lookup")
958            .with_note("package body available")
959            .with_attribute("strategy", Value::String(String::from("same-schema")))
960            .with_confidence(Confidence::new(
961                ConfidenceLevel::High,
962                Some(String::from("catalog snapshot and source agree")),
963            ));
964
965        assert_eq!(evidence.code, "SYM001");
966        assert_eq!(evidence.notes, [String::from("package body available")]);
967        assert_eq!(
968            evidence.attributes.get("strategy"),
969            Some(&Value::String(String::from("same-schema")))
970        );
971        assert_eq!(
972            evidence.confidence,
973            Some(Confidence::new(
974                ConfidenceLevel::High,
975                Some(String::from("catalog snapshot and source agree")),
976            ))
977        );
978    }
979
980    #[test]
981    fn diagnostic_builder_captures_unknowns() {
982        let span = Span::new(
983            FileId::new(1),
984            Position::new(4, 1, 20),
985            Position::new(4, 14, 33),
986        );
987        let diagnostic = Diagnostic::new(
988            "PARSE001",
989            Severity::Warn,
990            "parser recovered after unsupported token",
991        )
992        .with_primary_span(span)
993        .with_unknown_reason(UnknownReason::ParserRecoveryRegion)
994        .with_help("review the recovered region before trusting downstream analysis");
995
996        assert_eq!(diagnostic.primary_span, Some(span));
997        assert_eq!(
998            diagnostic.unknown_reasons,
999            vec![UnknownReason::ParserRecoveryRegion]
1000        );
1001        assert_eq!(
1002            diagnostic.help,
1003            Some(String::from(
1004                "review the recovered region before trusting downstream analysis"
1005            ))
1006        );
1007    }
1008
1009    #[test]
1010    fn symbol_interner_deduplicates_and_resolves_names() {
1011        let mut interner = SymbolInterner::new();
1012        let first = interner.intern("claims_pkg");
1013        let second = interner.intern("claims_pkg");
1014        let schema = interner.intern_schema_name("billing");
1015        let role = interner.intern_role_name("app_reader");
1016
1017        assert_eq!(first, second);
1018        assert_eq!(interner.len(), 3);
1019        assert_eq!(
1020            first.and_then(|symbol_id| interner.resolve(symbol_id)),
1021            Some("claims_pkg")
1022        );
1023        assert_eq!(schema.map(SchemaName::symbol), interner.intern("billing"));
1024        assert_eq!(role.map(RoleName::symbol), interner.intern("app_reader"));
1025    }
1026
1027    #[test]
1028    fn analysis_profile_uses_version_feature_defaults() {
1029        let base = AnalysisProfile::default();
1030        let modern = AnalysisProfile::for_version(OracleVersion::Oracle26ai);
1031
1032        assert_eq!(base.oracle_version, OracleVersion::Oracle19c);
1033        assert!(!base.supports_feature(OracleFeature::SqlBoolean23ai));
1034        assert!(modern.supports_feature(OracleFeature::PackageResettable26ai));
1035        assert!(modern.supports_feature(OracleFeature::MultilingualEngineCallSpecs));
1036    }
1037
1038    #[test]
1039    fn feature_policy_supports_explicit_overrides() {
1040        let policy = FeaturePolicy::from_version(OracleVersion::Oracle19c)
1041            .with_enabled(OracleFeature::SqlBoolean23ai)
1042            .with_disabled(OracleFeature::SqlBoolean23ai);
1043
1044        assert!(!policy.is_enabled(OracleFeature::SqlBoolean23ai));
1045        assert_eq!(
1046            policy.unknown_feature_behavior,
1047            UnknownFeatureBehavior::RecordUnknown
1048        );
1049    }
1050
1051    #[test]
1052    fn robot_json_round_trips_json_exportable_payloads() {
1053        let report = CompletenessReport {
1054            files_total: 8,
1055            files_parsed_cleanly: 7,
1056            files_recovered: 1,
1057            ..CompletenessReport::default()
1058        };
1059        let wrapped = RobotJson::new(report);
1060        let value = wrapped.to_json_value().expect("wrapper should serialize");
1061        let parsed = RobotJson::<CompletenessReport>::from_json_value(value)
1062            .expect("wrapper should deserialize");
1063
1064        assert_eq!(parsed.payload.files_total, 8);
1065        assert_eq!(parsed.payload.files_recovered, 1);
1066    }
1067
1068    #[test]
1069    fn names_and_policy_types_have_stable_defaults() {
1070        let schema = SchemaName::from(SymbolId::new(3));
1071        let user = UserName::from(SymbolId::new(4));
1072        let edition = EditionName::from(SymbolId::new(5));
1073        let object = ObjectName::from(SymbolId::new(6));
1074        let column = ColumnName::from(SymbolId::new(7));
1075
1076        assert_eq!(schema.symbol().get(), 3);
1077        assert_eq!(user.symbol().get(), 4);
1078        assert_eq!(edition.symbol().get(), 5);
1079        assert_eq!(object.symbol().get(), 6);
1080        assert_eq!(column.symbol().get(), 7);
1081        assert_eq!(
1082            DbLinkPolicy::default(),
1083            DbLinkPolicy::RecordOpaqueRemoteObjects
1084        );
1085        assert_eq!(NlsSettings::default().length_semantics.to_string(), "Byte");
1086        assert_eq!(LiteralValue::Boolean(true), LiteralValue::Boolean(true));
1087    }
1088
1089    #[test]
1090    fn default_features_are_monotonic_across_versions() {
1091        // Oracle does not remove PL/SQL language features in newer
1092        // releases: every feature available at version N must remain
1093        // available at all later versions. The per-version lists in
1094        // `default_features` are hand-maintained duplicated vecs, so
1095        // a "added to 23ai, forgot 26ai" edit would silently DISABLE
1096        // a feature for newer Oracle (wrong dialect gating → false
1097        // parse errors / missed SAST). This locks the invariant for
1098        // every current and future feature addition.
1099        let ordered = [
1100            OracleVersion::Oracle11g,
1101            OracleVersion::Oracle12c,
1102            OracleVersion::Oracle19c,
1103            OracleVersion::Oracle21c,
1104            OracleVersion::Oracle23ai,
1105            OracleVersion::Oracle26ai,
1106        ];
1107        for pair in ordered.windows(2) {
1108            let [older, newer] = [pair[0], pair[1]];
1109            let older_f = older.default_features();
1110            let newer_f = newer.default_features();
1111            assert!(
1112                older_f.is_subset(&newer_f),
1113                "{older:?} features must remain available in {newer:?}; missing: {:?}",
1114                older_f.difference(&newer_f).collect::<Vec<_>>()
1115            );
1116        }
1117    }
1118
1119    // --- oracle-bh4p / Phase 2: honest CompletenessReport ----------
1120
1121    #[test]
1122    fn low_extraction_run_is_never_clean() {
1123        // Mirrors a real private-estate shape: file tallies look
1124        // pristine, but thousands of objects were never lowered and
1125        // the diagnostic volume is huge. This MUST NOT read as clean.
1126        let mut r = CompletenessReport {
1127            files_total: 4251,
1128            files_parsed_cleanly: 4224,
1129            files_recovered: 27,
1130            objects_total: 4123,
1131            objects_with_source: 4123,
1132            objects_with_extracted_semantics: 4123,
1133            objects_unrecognized: 6609,
1134            diagnostics_total: 6784,
1135            ..CompletenessReport::default()
1136        };
1137        r.finalize_posture();
1138
1139        assert_ne!(
1140            r.posture,
1141            CompletenessPosture::Clean,
1142            "a run that failed to recognise 6609 objects must not present as Clean"
1143        );
1144        assert_eq!(r.objects_unrecognized, 6609);
1145        assert_eq!(r.diagnostics_total, 6784);
1146        assert!(
1147            r.extracted_semantics_ratio < 0.85,
1148            "ratio {} should reflect heavy non-extraction",
1149            r.extracted_semantics_ratio
1150        );
1151        // The structurally-unwired gap metrics must NOT read as 0.
1152        assert_eq!(r.dynamic_sql_sites, Measured::Unmeasured);
1153        assert_eq!(r.unresolved_references, Measured::Unmeasured);
1154        assert!(!r.dynamic_sql_sites.is_measured());
1155    }
1156
1157    #[test]
1158    fn clean_input_still_reads_clean() {
1159        // Honesty cuts both ways: a genuinely clean run over clean
1160        // input still reads healthy.
1161        let mut r = CompletenessReport {
1162            files_total: 12,
1163            files_parsed_cleanly: 12,
1164            files_recovered: 0,
1165            objects_total: 30,
1166            objects_with_source: 30,
1167            objects_with_extracted_semantics: 30,
1168            objects_unrecognized: 0,
1169            diagnostics_total: 0,
1170            ..CompletenessReport::default()
1171        };
1172        r.finalize_posture();
1173        assert_eq!(r.posture, CompletenessPosture::Clean);
1174        assert!((r.extracted_semantics_ratio - 1.0).abs() < f32::EPSILON);
1175    }
1176
1177    #[test]
1178    fn unmeasured_gap_metric_serializes_honestly_not_as_zero() {
1179        let r = CompletenessReport::default();
1180        let v = serde_json::to_value(&r).expect("serializes");
1181        // A not-yet-wired metric must NOT serialize as a misleading 0.
1182        assert_eq!(
1183            v["dynamic_sql_sites"],
1184            serde_json::json!({"unmeasured": true})
1185        );
1186        assert_ne!(v["dynamic_sql_sites"], serde_json::json!(0));
1187        // Round-trips back to Unmeasured.
1188        let back: CompletenessReport = serde_json::from_value(v).expect("round-trips");
1189        assert_eq!(back.dynamic_sql_sites, Measured::Unmeasured);
1190
1191        // A measured value serializes as the bare number.
1192        let r2 = CompletenessReport {
1193            dynamic_sql_sites: Measured::Measured(7),
1194            ..CompletenessReport::default()
1195        };
1196        let v2 = serde_json::to_value(&r2).expect("serializes");
1197        assert_eq!(v2["dynamic_sql_sites"], serde_json::json!(7));
1198        let back2: CompletenessReport = serde_json::from_value(v2).expect("round-trips");
1199        assert_eq!(back2.dynamic_sql_sites, Measured::Measured(7));
1200    }
1201
1202    #[test]
1203    fn degraded_when_nothing_understood() {
1204        let mut r = CompletenessReport {
1205            files_total: 100,
1206            files_parsed_cleanly: 0,
1207            objects_total: 500,
1208            objects_unrecognized: 500,
1209            objects_with_extracted_semantics: 0,
1210            diagnostics_total: 500,
1211            ..CompletenessReport::default()
1212        };
1213        r.finalize_posture();
1214        assert_eq!(r.posture, CompletenessPosture::Degraded);
1215    }
1216}