Skip to main content

devscout_rs/graph/compiler_facts/
artifact.rs

1// The admitted-artifact header: the fixed identity/negotiation fields
2// `admit` checks, parsed from a `serde_json::Value` view exactly as
3// `graph::imports::parse_imported_edges` reads `format`/`schemaVersion`/
4// `provenance` off a `Value` before typing sub-blocks. The bulk payload --
5// diagnostics, symbol facts, and the embedded compilation-context
6// envelope's own body -- is NEVER deserialized into an exhaustive Rust
7// struct here and is not modeled by this type at all; `admit` keeps the
8// original candidate byte slice unchanged and that is the one thing
9// publication ever writes. A producer adding a new payload field can never
10// be silently dropped by a re-serialization this module never performs,
11// and reading a published artifact back always reproduces the exact bytes
12// a valid run wrote.
13//
14// Wire shape (the one this module's `parse_header` accepts):
15//
16//   {
17//     "format": "compiler-facts",
18//     "contractVersion": 1,
19//     "artifactSchemaVersion": 2,
20//     "producer": {"name": "...", "engineRevision": "..."},
21//     "profile": {"target": "...", "configuration": "...", "platform": "..."},
22//     "dependencyFingerprint": "<hex>",
23//     "context": {
24//       "schemaVersion": 1,
25//       "contextFingerprint": "<hex>",
26//       "envelope": { "compilations": [ {"identity": {...}, "fingerprint": "<hex>"|null, ...opaque...}, ... ] }
27//     },
28//     "sourceSnapshot": {"headSha": "<hex>", "dirty": bool, "dirtyDigest": "<hex>"},
29//     "capabilities": {"requested": [...], "provided": [...]},
30//     "completion": {"terminal": true},
31//     "units": {"processed": [...], "missing": [...]},
32//     "coverage": {"state": "complete"} |
33//                 {"state": "incomplete", "incompleteUnits": [{"unit":"...","reason":"..."}]},
34//     "diagnostics": [ ...opaque... ],
35//     "symbols": [ ...opaque... ],
36//     "occurrences": {"spanEncoding": "...", "identityEncoding": "...", "sites": [ {"compilation": {"identity": {...}, "fingerprint": "<hex>"|null}, ...opaque...}, ... ]}
37//   }
38//
39// `context.contextFingerprint` is a header-level summary this admission path
40// **recomputes** (`super::context_summary::recompute`) from the embedded
41// envelope's own ordered `context.envelope.compilations[i].identity`/
42// `fingerprint` pairs, rather than comparing two producer-written copies of
43// one value -- see `RefusalReason::ContextFingerprintMismatch`'s doc comment
44// for why this differs from `DependencyFingerprintMismatch`. `identity` is
45// read as an opaque `Value` and never decomposed into named sub-fields; the
46// rest of each compilation entry, and the rest of `occurrences.sites[]`
47// beyond its own `compilation` sub-object, are read never, touched never,
48// preserved only because publication writes the candidate bytes verbatim.
49// `sourceSnapshot.dirty`/`.dirtyDigest` are the second freshness leg (an
50// uncommitted edit `headSha` alone cannot see); this admission path reads
51// only `headSha` for `RefusalReason::SourceSnapshotMismatch` and leaves the
52// other two fields opaque, same as everything else in this header it does
53// not name.
54
55use serde_json::{Map, Value};
56
57use super::reasons::RefusalReason;
58
59/// The export format `compiler-facts` admission accepts.
60pub const COMPILER_FACTS_FORMAT: &str = "compiler-facts";
61
62/// The wire-contract version this admission path was built for. Also the
63/// literal that names the artifact file (`compiler-facts-v<N>.json`).
64pub const COMPILER_FACTS_CONTRACT_VERSION: u64 = 1;
65
66/// This artifact's own schema version (distinct from `contractVersion`, the
67/// producer's own wire-shape version, the same split
68/// `graph::imports::IMPORTED_EDGES_SCHEMA_VERSION` /
69/// `IMPORTED_EDGES_ARTIFACT_SCHEMA_VERSION` already draws). The shipped
70/// engine now writes `2` (per-reference occurrence facts, an additive top-
71/// level `occurrences` key). Nothing in this admission path branches on the
72/// literal for equality -- `parse_header` reads `artifactSchemaVersion` for
73/// presence/shape only -- but the constant's name promises the artifact's
74/// schema version, so it is kept equal to what the shipped engine actually
75/// writes rather than left to name a version nothing emits.
76pub const COMPILER_FACTS_ARTIFACT_SCHEMA_VERSION: u64 = 2;
77
78/// The requested compilation profile: target, configuration and platform.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct Profile {
81    /// The requested target framework moniker, e.g. `net9.0`.
82    pub target: String,
83    /// The requested build configuration, e.g. `Debug`.
84    pub configuration: String,
85    /// The requested platform, e.g. `AnyCPU`.
86    pub platform: String,
87}
88
89/// One unit a structurally valid but declared-incomplete artifact could not
90/// fully process, with the compiler's own per-unit reason.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct IncompleteUnit {
93    /// The unit identifier, matching an entry in `units.processed`.
94    pub unit: String,
95    /// The compiler's own diagnostic reason this unit stayed incomplete.
96    pub reason: String,
97}
98
99/// Whether an admitted artifact reports clean, complete coverage or a
100/// qualified partial one. Never conflated with a [`RefusalReason`]: a
101/// structurally valid, declared-incomplete artifact is admitted, not
102/// refused.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum Coverage {
105    /// Every unit the candidate declared processed reports no diagnostic
106    /// incompleteness.
107    Complete,
108    /// At least one processed unit carries a diagnostic and is reported
109    /// incomplete; sibling units unrelated to it are still admitted.
110    Incomplete {
111        /// Every incomplete unit's id and reason, in declared order.
112        units: Vec<IncompleteUnit>,
113    },
114}
115
116impl Coverage {
117    /// Whether this coverage state is [`Coverage::Complete`].
118    pub fn is_complete(&self) -> bool {
119        matches!(self, Coverage::Complete)
120    }
121}
122
123/// One embedded envelope compilation's `identity`+`fingerprint` pair, read
124/// as this admission path's only window into the envelope body: `identity`
125/// stays an opaque JSON value (never decomposed into named sub-fields, so
126/// this narrows envelope opacity in exactly one stated place), `fingerprint`
127/// is `None` for a JSON `null` (the envelope's own "unsupported compilation"
128/// case) and for anything else non-string. The exact same shape is used for
129/// `context.envelope.compilations[i]` and for an occurrence's own
130/// `compilation` sub-object -- deliberately one type, since both name a
131/// compilation the same way.
132#[derive(Debug, Clone, PartialEq)]
133pub struct CompilationRef {
134    /// The compilation's own identity, exactly as the producer wrote it.
135    pub identity: Value,
136    /// The compilation's own fingerprint, or `None` for an unsupported
137    /// compilation (a JSON `null`) or a non-string value.
138    pub fingerprint: Option<String>,
139}
140
141/// The admitted-artifact header: every fixed identity/negotiation field
142/// `admit` checks, parsed from the candidate's own JSON. See this module's
143/// header comment for the full wire shape and for why the bulk payload
144/// (diagnostics, symbols, the embedded context-envelope body) is not
145/// modeled here at all.
146#[derive(Debug, Clone)]
147pub struct CandidateHeader {
148    /// The candidate's own declared wire-contract version.
149    pub contract_version: u64,
150    /// The producer name, e.g. `scout-semantic`.
151    pub producer_name: String,
152    /// The producer's own engine-protocol revision.
153    pub engine_revision: String,
154    /// The requested compilation profile.
155    pub profile: Profile,
156    /// The engine build's own dependency-lock digest.
157    pub dependency_fingerprint: String,
158    /// The embedded compilation-context envelope's own version literal.
159    pub context_schema_version: u64,
160    /// The header-level context-fingerprint summary, when the producer
161    /// wrote one.
162    pub context_fingerprint_header: Option<String>,
163    /// `context.envelope.compilations[i].identity`/`fingerprint`, in
164    /// declared order -- the only two envelope fields this admission path
165    /// ever reads. Best-effort, not structural: a missing `compilations`
166    /// key, a non-array value, or an entry missing `identity` yields `None`
167    /// here rather than a parse failure; the identity/fingerprint checks
168    /// this feeds refuse a candidate that needs it but does not have it.
169    pub context_compilations: Option<Vec<CompilationRef>>,
170    /// Every occurrence site's own `compilation` reference, in declared
171    /// order. `None` when the top-level `occurrences` key is absent;
172    /// `Some(vec![])` when present with an empty `sites` array. Every other
173    /// occurrence field is not modeled here at all -- read never, touched
174    /// never, preserved only because publication writes the candidate bytes
175    /// verbatim.
176    pub occurrence_sites: Option<Vec<CompilationRef>>,
177    /// Whether `capabilities.provided` names `"occurrences"`.
178    pub occurrences_capability_provided: bool,
179    /// The declared source-snapshot identity (`sourceSnapshot.headSha`),
180    /// when the producer wrote one.
181    pub source_head_sha: Option<String>,
182    /// Whether the candidate carries an explicit terminal completion
183    /// record. A killed or truncated run never reaches `true`.
184    pub completion_terminal: bool,
185    /// Every unit id the candidate declares processed.
186    pub units_processed: Vec<String>,
187    /// Every unit id the candidate declares missing.
188    pub units_missing: Vec<String>,
189    /// The candidate's own declared coverage state.
190    pub coverage: Coverage,
191}
192
193fn obj_str<'a>(obj: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
194    obj.get(key).and_then(Value::as_str)
195}
196
197fn obj_u64(obj: &Map<String, Value>, key: &str) -> Option<u64> {
198    obj.get(key).and_then(Value::as_u64)
199}
200
201fn obj_bool(obj: &Map<String, Value>, key: &str) -> Option<bool> {
202    obj.get(key).and_then(Value::as_bool)
203}
204
205fn obj_obj<'a>(obj: &'a Map<String, Value>, key: &str) -> Option<&'a Map<String, Value>> {
206    obj.get(key).and_then(Value::as_object)
207}
208
209fn str_array(obj: &Map<String, Value>, key: &str) -> Option<Vec<String>> {
210    obj.get(key)?
211        .as_array()?
212        .iter()
213        .map(|v| v.as_str().map(str::to_string))
214        .collect()
215}
216
217fn parse_coverage(obj: &Map<String, Value>) -> Result<Coverage, RefusalReason> {
218    let coverage_obj = obj_obj(obj, "coverage").ok_or(RefusalReason::MalformedEncoding)?;
219    match obj_str(coverage_obj, "state").ok_or(RefusalReason::MalformedEncoding)? {
220        "complete" => Ok(Coverage::Complete),
221        "incomplete" => {
222            let raw = coverage_obj
223                .get("incompleteUnits")
224                .and_then(Value::as_array)
225                .ok_or(RefusalReason::MalformedEncoding)?;
226            let mut units = Vec::with_capacity(raw.len());
227            for item in raw {
228                let item_obj = item.as_object().ok_or(RefusalReason::MalformedEncoding)?;
229                units.push(IncompleteUnit {
230                    unit: obj_str(item_obj, "unit")
231                        .ok_or(RefusalReason::MalformedEncoding)?
232                        .to_string(),
233                    reason: obj_str(item_obj, "reason")
234                        .ok_or(RefusalReason::MalformedEncoding)?
235                        .to_string(),
236                });
237            }
238            if units.is_empty() {
239                return Err(RefusalReason::MalformedEncoding);
240            }
241            Ok(Coverage::Incomplete { units })
242        }
243        _ => Err(RefusalReason::MalformedEncoding),
244    }
245}
246
247/// Parses and structurally validates a candidate artifact's header.
248///
249/// `Err(RefusalReason::MalformedEncoding)` is the only failure mode: invalid
250/// JSON, a non-object root, an unrecognised `format`, or any required
251/// header field missing or of the wrong type. Every other refusal class is
252/// decided by `super::admit::admit` once this header is in hand -- this
253/// function performs no identity comparison of its own.
254pub fn parse_header(bytes: &[u8]) -> Result<CandidateHeader, RefusalReason> {
255    let value: Value =
256        serde_json::from_slice(bytes).map_err(|_| RefusalReason::MalformedEncoding)?;
257    let obj = value.as_object().ok_or(RefusalReason::MalformedEncoding)?;
258
259    if obj_str(obj, "format") != Some(COMPILER_FACTS_FORMAT) {
260        return Err(RefusalReason::MalformedEncoding);
261    }
262    let contract_version =
263        obj_u64(obj, "contractVersion").ok_or(RefusalReason::MalformedEncoding)?;
264    // `artifactSchemaVersion` is read for presence/shape only; nothing in
265    // this admission path branches on it yet (there is only one artifact
266    // schema version so far).
267    obj_u64(obj, "artifactSchemaVersion").ok_or(RefusalReason::MalformedEncoding)?;
268
269    let producer = obj_obj(obj, "producer").ok_or(RefusalReason::MalformedEncoding)?;
270    let producer_name = obj_str(producer, "name")
271        .ok_or(RefusalReason::MalformedEncoding)?
272        .to_string();
273    let engine_revision = obj_str(producer, "engineRevision")
274        .ok_or(RefusalReason::MalformedEncoding)?
275        .to_string();
276
277    let profile_obj = obj_obj(obj, "profile").ok_or(RefusalReason::MalformedEncoding)?;
278    let profile = Profile {
279        target: obj_str(profile_obj, "target")
280            .ok_or(RefusalReason::MalformedEncoding)?
281            .to_string(),
282        configuration: obj_str(profile_obj, "configuration")
283            .ok_or(RefusalReason::MalformedEncoding)?
284            .to_string(),
285        platform: obj_str(profile_obj, "platform")
286            .ok_or(RefusalReason::MalformedEncoding)?
287            .to_string(),
288    };
289
290    let dependency_fingerprint = obj_str(obj, "dependencyFingerprint")
291        .ok_or(RefusalReason::MalformedEncoding)?
292        .to_string();
293
294    let context_obj = obj_obj(obj, "context").ok_or(RefusalReason::MalformedEncoding)?;
295    let context_schema_version =
296        obj_u64(context_obj, "schemaVersion").ok_or(RefusalReason::MalformedEncoding)?;
297    let context_fingerprint_header = obj_str(context_obj, "contextFingerprint").map(str::to_string);
298    let context_compilations = parse_compilation_refs(context_obj);
299
300    let occurrences_capability_provided = obj_obj(obj, "capabilities")
301        .and_then(|c| c.get("provided"))
302        .and_then(Value::as_array)
303        .is_some_and(|provided| provided.iter().any(|v| v.as_str() == Some("occurrences")));
304    let occurrence_sites = parse_occurrence_sites(obj);
305
306    let source_head_sha = obj_obj(obj, "sourceSnapshot")
307        .and_then(|s| obj_str(s, "headSha"))
308        .map(str::to_string);
309
310    let completion_obj = obj_obj(obj, "completion").ok_or(RefusalReason::MalformedEncoding)?;
311    let completion_terminal =
312        obj_bool(completion_obj, "terminal").ok_or(RefusalReason::MalformedEncoding)?;
313
314    let units_obj = obj_obj(obj, "units").ok_or(RefusalReason::MalformedEncoding)?;
315    let units_processed =
316        str_array(units_obj, "processed").ok_or(RefusalReason::MalformedEncoding)?;
317    let units_missing = str_array(units_obj, "missing").ok_or(RefusalReason::MalformedEncoding)?;
318
319    let coverage = parse_coverage(obj)?;
320
321    Ok(CandidateHeader {
322        contract_version,
323        producer_name,
324        engine_revision,
325        profile,
326        dependency_fingerprint,
327        context_schema_version,
328        context_fingerprint_header,
329        context_compilations,
330        occurrence_sites,
331        occurrences_capability_provided,
332        source_head_sha,
333        completion_terminal,
334        units_processed,
335        units_missing,
336        coverage,
337    })
338}
339
340/// Best-effort: `None` for a missing `compilations` key, a non-array value,
341/// or an entry missing `identity`; a `fingerprint` that is not a string
342/// (including JSON `null`) maps to `None` for that entry only, never
343/// invalidating the whole list.
344fn parse_compilation_refs(context_obj: &Map<String, Value>) -> Option<Vec<CompilationRef>> {
345    let entries = obj_obj(context_obj, "envelope")?
346        .get("compilations")?
347        .as_array()?;
348    let mut result = Vec::with_capacity(entries.len());
349    for entry in entries {
350        let entry_obj = entry.as_object()?;
351        let identity = entry_obj.get("identity")?.clone();
352        let fingerprint = entry_obj
353            .get("fingerprint")
354            .and_then(Value::as_str)
355            .map(str::to_string);
356        result.push(CompilationRef {
357            identity,
358            fingerprint,
359        });
360    }
361    Some(result)
362}
363
364/// Best-effort, the same way [`parse_compilation_refs`] is: `None` when the
365/// top-level `occurrences` key is absent or malformed; `Some(vec![])` when
366/// present with an empty `sites` array. Only each site's own `compilation`
367/// sub-object is read -- every other occurrence field is not this module's
368/// concern.
369fn parse_occurrence_sites(obj: &Map<String, Value>) -> Option<Vec<CompilationRef>> {
370    let sites = obj_obj(obj, "occurrences")?.get("sites")?.as_array()?;
371    let mut result = Vec::with_capacity(sites.len());
372    for site in sites {
373        let compilation_obj = obj_obj(site.as_object()?, "compilation")?;
374        let identity = compilation_obj.get("identity")?.clone();
375        let fingerprint = compilation_obj
376            .get("fingerprint")
377            .and_then(Value::as_str)
378            .map(str::to_string);
379        result.push(CompilationRef {
380            identity,
381            fingerprint,
382        });
383    }
384    Some(result)
385}