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}