1use std::collections::{HashMap, HashSet, VecDeque};
21
22#[cfg(any(feature = "active-core", feature = "json"))]
23use crate::error::{Error, Result};
24use crate::ir::{AnnotationKind, InfinityModule, Inode, NodeId, ProvEntry, ProvValue, Provenance};
25#[cfg(feature = "active-core")]
26use crate::ir::{
27 BinOp, Constraint, InterpPart, PatchOp, SchemaFieldSpec, SemanticTag, TypeSpec, UnOp,
28};
29#[cfg(feature = "active-core")]
30use crate::unit::UnitTag;
31
32pub const SECRET_MARKER: &str = "***SECRET***";
34
35#[cfg(feature = "active-core")]
40mod bridge {
41 use super::*;
42 use active_core::ir::{
43 Annotation as CoreAnnotation, AnnotationKind as CoreAnnotationKind, Module as CoreModule,
44 Node as CoreNode, Value as CoreValue,
45 };
46 use active_core::types::{
47 ActiveType as CoreType, Constraint as CoreConstraint, Unit as CoreUnit,
48 };
49
50 pub fn from_active_core(module: &CoreModule) -> Result<InfinityModule> {
52 let mut out = InfinityModule::new();
53 let mut map: HashMap<u32, NodeId> = HashMap::new();
54 for index in 0..module.arena.len() {
57 let core_id = active_core::NodeId(index as u32);
58 if module.get(core_id).is_none() {
59 continue;
60 }
61 let node = lower_node(module, core_id, &mut out, &mut map)?;
62 map.insert(core_id.0, node);
63 }
64 for (name, core_id) in &module.bindings {
65 if let Some(id) = map.get(&core_id.0) {
66 out.bind(name.clone(), *id);
67 }
68 }
69 if let Some(root) = module.root {
70 out.set_root(map.get(&root.0).copied());
71 }
72 let mut annotations: Vec<(NodeId, AnnotationKind)> = Vec::new();
73 for (core_id, list) in &module.annotations {
74 let Some(node) = map.get(&core_id.0).copied() else {
75 continue;
76 };
77 for annotation in list {
78 let kind = match &annotation.kind {
79 CoreAnnotationKind::Intent(text) => AnnotationKind::Intent(text.clone()),
80 CoreAnnotationKind::Constraint { source, expr } => {
81 let expr = map
82 .get(&expr.0)
83 .copied()
84 .ok_or(Error::EncodeDangling(expr.0))?;
85 AnnotationKind::Constraint {
86 source: source.clone(),
87 expr,
88 }
89 }
90 CoreAnnotationKind::AiImmutable(flag) => AnnotationKind::AiImmutable(*flag),
91 };
92 annotations.push((node, kind));
93 }
94 }
95 for (node, kind) in annotations {
96 out.annotate(node, kind);
97 }
98 out.validate_structure(u32::MAX as u64, 0)?;
99 Ok(out)
100 }
101
102 fn lower_node(
103 module: &CoreModule,
104 id: active_core::NodeId,
105 out: &mut InfinityModule,
106 map: &mut HashMap<u32, NodeId>,
107 ) -> Result<NodeId> {
108 let node = module.get(id).ok_or(Error::EncodeDangling(id.0))?;
109 if let CoreNode::Literal(value) = node {
112 return push_value(value, out, map);
113 }
114 let lowered = match node {
115 CoreNode::Literal(_) => unreachable!("handled before the match"),
116 CoreNode::Env { name, default } => {
117 let default = match default {
118 Some(default) => Some(lower_ref(module, *default, out, map)?),
119 None => None,
120 };
121 Inode::Env {
122 name: name.clone(),
123 default,
124 }
125 }
126 CoreNode::Secret { locator } => Inode::SecretRef {
127 locator: lower_ref(module, *locator, out, map)?,
128 },
129 CoreNode::Ref(target) => Inode::Ref(lower_ref(module, *target, out, map)?),
130 CoreNode::Ident(name) => Inode::Ident(name.clone()),
131 CoreNode::Interpolate { parts } => Inode::Interpolate {
132 parts: parts
133 .iter()
134 .map(|part| match part {
135 active_core::InterpPart::Literal(text) => {
136 Ok(InterpPart::Literal(text.clone()))
137 }
138 active_core::InterpPart::Expr(id) => {
139 Ok(InterpPart::Expr(lower_ref(module, *id, out, map)?))
140 }
141 })
142 .collect::<Result<Vec<_>>>()?,
143 },
144 CoreNode::Binary { op, lhs, rhs } => Inode::Binary {
145 op: lower_binop(*op),
146 lhs: lower_ref(module, *lhs, out, map)?,
147 rhs: lower_ref(module, *rhs, out, map)?,
148 },
149 CoreNode::Unary { op, expr } => Inode::Unary {
150 op: lower_unop(*op),
151 expr: lower_ref(module, *expr, out, map)?,
152 },
153 CoreNode::Array { elements } => Inode::Array {
154 elements: elements
155 .iter()
156 .map(|id| lower_ref(module, *id, out, map))
157 .collect::<Result<Vec<_>>>()?,
158 },
159 CoreNode::Table { fields } => Inode::Table {
160 fields: fields
161 .iter()
162 .map(|(name, id)| Ok((name.clone(), lower_ref(module, *id, out, map)?)))
163 .collect::<Result<Vec<_>>>()?,
164 },
165 CoreNode::Quantity { value, unit } => Inode::Quantity {
166 value: lower_ref(module, *value, out, map)?,
167 unit: UnitTag::from(unit),
168 },
169 CoreNode::ApplyConstraint { target, constraint } => Inode::Constrained {
170 target: lower_ref(module, *target, out, map)?,
171 constraint: lower_constraint(constraint)?,
172 },
173 CoreNode::PatchDirective { op } => Inode::Patch(match op {
174 active_core::ir::PatchOp::Set { path, value } => PatchOp::Set {
175 path: path.clone(),
176 value: lower_ref(module, *value, out, map)?,
177 },
178 active_core::ir::PatchOp::Remove { path } => PatchOp::Remove { path: path.clone() },
179 active_core::ir::PatchOp::Rename { from, to } => PatchOp::Rename {
180 from: from.clone(),
181 to: to.clone(),
182 },
183 }),
184 CoreNode::SchemaField { field } => Inode::SchemaField(SchemaFieldSpec {
185 typ: lower_type(&field.typ)?,
186 min: lower_opt(module, field.min, out, map)?,
187 max: lower_opt(module, field.max, out, map)?,
188 min_len: lower_opt(module, field.min_len, out, map)?,
189 max_len: lower_opt(module, field.max_len, out, map)?,
190 pattern: lower_opt(module, field.pattern, out, map)?,
191 default: lower_opt(module, field.default, out, map)?,
192 deprecated: field.deprecated.clone(),
193 }),
194 CoreNode::SchemaTable { fields, closed } => Inode::SchemaTable {
195 fields: fields
196 .iter()
197 .map(|(name, id)| Ok((name.clone(), lower_ref(module, *id, out, map)?)))
198 .collect::<Result<Vec<_>>>()?,
199 closed: *closed,
200 },
201 CoreNode::SchemaArray { element } => Inode::SchemaArray {
202 element: lower_ref(module, *element, out, map)?,
203 },
204 CoreNode::Semantic { tag, inner } => Inode::Semantic {
205 tag: match tag {
206 active_core::ir::SemanticTag::Prompt => SemanticTag::Prompt,
207 active_core::ir::SemanticTag::Policy => SemanticTag::Policy,
208 active_core::ir::SemanticTag::Template => SemanticTag::Template,
209 },
210 inner: lower_ref(module, *inner, out, map)?,
211 },
212 };
213 let id = out.push(lowered);
214 Ok(id)
215 }
216
217 fn lower_value(value: &CoreValue) -> Inode {
220 match value {
221 CoreValue::Null => Inode::Null,
222 CoreValue::Bool(flag) => Inode::Bool(*flag),
223 CoreValue::Int(number) => Inode::Int(*number),
224 CoreValue::Float(number) => Inode::Float(*number),
225 CoreValue::String(text) => Inode::Str(text.clone()),
226 CoreValue::DurationMs(ms) => Inode::DurationMs(*ms),
227 CoreValue::Secret(text) => Inode::Secret(crate::ir::SecretBytes::from(text.clone())),
228 CoreValue::Quantity(..) | CoreValue::Array(_) | CoreValue::Table(_) => {
231 unreachable!("composed values are lowered by push_value")
232 }
233 }
234 }
235
236 fn push_value(
237 value: &CoreValue,
238 out: &mut InfinityModule,
239 map: &mut HashMap<u32, NodeId>,
240 ) -> Result<NodeId> {
241 let _ = map;
242 let node = match value {
243 CoreValue::Quantity(amount, unit) => {
244 let number = out.push(Inode::Float(*amount));
245 Inode::Quantity {
246 value: number,
247 unit: UnitTag::from(unit),
248 }
249 }
250 CoreValue::Array(items) => {
251 let mut elements = Vec::with_capacity(items.len());
252 for item in items {
253 elements.push(push_value(item, out, map)?);
254 }
255 Inode::Array { elements }
256 }
257 CoreValue::Table(entries) => {
258 let mut fields = Vec::with_capacity(entries.len());
259 for (name, item) in entries {
260 fields.push((name.clone(), push_value(item, out, map)?));
261 }
262 Inode::Table { fields }
263 }
264 other => lower_value(other),
265 };
266 Ok(out.push(node))
267 }
268
269 fn lower_ref(
270 module: &CoreModule,
271 id: active_core::NodeId,
272 out: &mut InfinityModule,
273 map: &mut HashMap<u32, NodeId>,
274 ) -> Result<NodeId> {
275 if let Some(mapped) = map.get(&id.0) {
276 return Ok(*mapped);
277 }
278 let lowered = lower_node(module, id, out, map)?;
279 map.insert(id.0, lowered);
280 Ok(lowered)
281 }
282
283 fn lower_opt(
284 module: &CoreModule,
285 id: Option<active_core::NodeId>,
286 out: &mut InfinityModule,
287 map: &mut HashMap<u32, NodeId>,
288 ) -> Result<Option<NodeId>> {
289 match id {
290 Some(id) => Ok(Some(lower_ref(module, id, out, map)?)),
291 None => Ok(None),
292 }
293 }
294
295 fn lower_binop(op: active_core::ir::BinOp) -> BinOp {
296 match op {
297 active_core::ir::BinOp::Add => BinOp::Add,
298 active_core::ir::BinOp::Sub => BinOp::Sub,
299 active_core::ir::BinOp::Mul => BinOp::Mul,
300 active_core::ir::BinOp::Div => BinOp::Div,
301 active_core::ir::BinOp::Eq => BinOp::Eq,
302 active_core::ir::BinOp::Ne => BinOp::Ne,
303 active_core::ir::BinOp::Lt => BinOp::Lt,
304 active_core::ir::BinOp::Le => BinOp::Le,
305 active_core::ir::BinOp::Gt => BinOp::Gt,
306 active_core::ir::BinOp::Ge => BinOp::Ge,
307 active_core::ir::BinOp::And => BinOp::And,
308 active_core::ir::BinOp::Or => BinOp::Or,
309 }
310 }
311
312 fn lower_unop(op: active_core::ir::UnOp) -> UnOp {
313 match op {
314 active_core::ir::UnOp::Neg => UnOp::Neg,
315 active_core::ir::UnOp::Not => UnOp::Not,
316 }
317 }
318
319 fn lower_constraint(constraint: &CoreConstraint) -> Result<Constraint> {
320 let converted = match constraint {
321 CoreConstraint::MinMax { min, max } => Constraint::MinMax {
322 min: *min,
323 max: *max,
324 },
325 CoreConstraint::Enum(items) => Constraint::Enum(items.clone()),
326 CoreConstraint::Pattern(pattern) => Constraint::Pattern(pattern.clone()),
327 CoreConstraint::Odd => Constraint::Odd,
328 CoreConstraint::Custom(text) => Constraint::Custom(text.clone()),
329 };
330 Ok(converted)
331 }
332
333 fn lower_type(typ: &CoreType) -> Result<TypeSpec> {
334 let spec = match typ {
335 CoreType::Null => TypeSpec::Null,
336 CoreType::Bool => TypeSpec::Bool,
337 CoreType::Int => TypeSpec::Int,
338 CoreType::Float => TypeSpec::Float,
339 CoreType::String => TypeSpec::String,
340 CoreType::Duration => TypeSpec::Duration,
341 CoreType::Bytes => TypeSpec::Bytes,
342 CoreType::Table { closed } => TypeSpec::Table { closed: *closed },
343 CoreType::Enum(items) => TypeSpec::Enum(items.clone()),
344 CoreType::Secret => TypeSpec::Secret,
345 CoreType::Any => TypeSpec::Any,
346 };
347 Ok(spec)
348 }
349
350 pub fn to_active_core(module: &InfinityModule) -> Result<CoreModule> {
356 let mut out = CoreModule::new("infinity");
357 for node in module.nodes() {
358 out.add_node(convert_node(node)?);
359 }
360 for (index, node) in module.nodes().iter().enumerate() {
362 let dependent = active_core::NodeId(index as u32);
363 for child in node.children() {
364 out.add_edge(active_core::NodeId(child.0), dependent);
365 }
366 }
367 for (name, id) in module.bindings() {
368 out.bindings.insert(name.clone(), active_core::NodeId(id.0));
369 }
370 out.root = module.root().map(|id| active_core::NodeId(id.0));
371 for annotation in module.annotations() {
372 let core = CoreAnnotation {
373 kind: convert_annotation(&annotation.kind)?,
374 path: format!("node{}", annotation.node.0),
375 span: active_core::diag::Span {
376 line: 0,
377 column: 0,
378 end_line: 0,
379 end_column: 0,
380 },
381 };
382 out.annotations
383 .entry(active_core::NodeId(annotation.node.0))
384 .or_default()
385 .push(core);
386 }
387 Ok(out)
388 }
389
390 fn convert_annotation(kind: &AnnotationKind) -> Result<CoreAnnotationKind> {
391 Ok(match kind {
392 AnnotationKind::Intent(text) => CoreAnnotationKind::Intent(text.clone()),
393 AnnotationKind::Constraint { source, expr } => CoreAnnotationKind::Constraint {
394 source: source.clone(),
395 expr: active_core::NodeId(expr.0),
396 },
397 AnnotationKind::AiImmutable(flag) => CoreAnnotationKind::AiImmutable(*flag),
398 })
399 }
400
401 fn convert_node(node: &Inode) -> Result<CoreNode> {
402 let converted = match node {
403 Inode::Null => CoreNode::Literal(CoreValue::Null),
404 Inode::Bool(flag) => CoreNode::Literal(CoreValue::Bool(*flag)),
405 Inode::Int(number) => CoreNode::Literal(CoreValue::Int(*number)),
406 Inode::Float(number) => CoreNode::Literal(CoreValue::Float(*number)),
407 Inode::Str(text) => CoreNode::Literal(CoreValue::String(text.clone())),
408 Inode::DurationMs(ms) => CoreNode::Literal(CoreValue::DurationMs(*ms)),
409 Inode::Quantity { value, unit } => CoreNode::Quantity {
410 value: active_core::NodeId(value.0),
411 unit: CoreUnit::from(unit),
412 },
413 Inode::Array { elements } => CoreNode::Array {
414 elements: elements
415 .iter()
416 .map(|id| active_core::NodeId(id.0))
417 .collect(),
418 },
419 Inode::Table { fields } => CoreNode::Table {
420 fields: fields
421 .iter()
422 .map(|(name, id)| (name.clone(), active_core::NodeId(id.0)))
423 .collect(),
424 },
425 Inode::Secret(secret) => CoreNode::Literal(CoreValue::Secret(
426 String::from_utf8_lossy(secret.expose()).into_owned(),
427 )),
428 Inode::SecretRef { locator } => CoreNode::Secret {
429 locator: active_core::NodeId(locator.0),
430 },
431 Inode::Ref(id) => CoreNode::Ref(active_core::NodeId(id.0)),
432 Inode::Env { name, default } => CoreNode::Env {
433 name: name.clone(),
434 default: default.map(|id| active_core::NodeId(id.0)),
435 },
436 Inode::Ident(name) => CoreNode::Ident(name.clone()),
437 Inode::Interpolate { parts } => CoreNode::Interpolate {
438 parts: parts
439 .iter()
440 .map(|part| match part {
441 InterpPart::Literal(text) => active_core::InterpPart::Literal(text.clone()),
442 InterpPart::Expr(id) => {
443 active_core::InterpPart::Expr(active_core::NodeId(id.0))
444 }
445 })
446 .collect(),
447 },
448 Inode::Binary { op, lhs, rhs } => CoreNode::Binary {
449 op: convert_binop(*op),
450 lhs: active_core::NodeId(lhs.0),
451 rhs: active_core::NodeId(rhs.0),
452 },
453 Inode::Unary { op, expr } => CoreNode::Unary {
454 op: match op {
455 UnOp::Neg => active_core::ir::UnOp::Neg,
456 UnOp::Not => active_core::ir::UnOp::Not,
457 },
458 expr: active_core::NodeId(expr.0),
459 },
460 Inode::Constrained { target, constraint } => CoreNode::ApplyConstraint {
461 target: active_core::NodeId(target.0),
462 constraint: convert_constraint(constraint),
463 },
464 Inode::Patch(op) => CoreNode::PatchDirective {
465 op: match op {
466 PatchOp::Set { path, value } => active_core::ir::PatchOp::Set {
467 path: path.clone(),
468 value: active_core::NodeId(value.0),
469 },
470 PatchOp::Remove { path } => {
471 active_core::ir::PatchOp::Remove { path: path.clone() }
472 }
473 PatchOp::Rename { from, to } => active_core::ir::PatchOp::Rename {
474 from: from.clone(),
475 to: to.clone(),
476 },
477 },
478 },
479 Inode::SchemaField(spec) => CoreNode::SchemaField {
480 field: active_core::SchemaField {
481 typ: convert_type(&spec.typ)?,
482 min: spec.min.map(|id| active_core::NodeId(id.0)),
483 max: spec.max.map(|id| active_core::NodeId(id.0)),
484 min_len: spec.min_len.map(|id| active_core::NodeId(id.0)),
485 max_len: spec.max_len.map(|id| active_core::NodeId(id.0)),
486 pattern: spec.pattern.map(|id| active_core::NodeId(id.0)),
487 default: spec.default.map(|id| active_core::NodeId(id.0)),
488 deprecated: spec.deprecated.clone(),
489 span: None,
490 },
491 },
492 Inode::SchemaTable { fields, closed } => CoreNode::SchemaTable {
493 fields: fields
494 .iter()
495 .map(|(name, id)| (name.clone(), active_core::NodeId(id.0)))
496 .collect(),
497 closed: *closed,
498 },
499 Inode::SchemaArray { element } => CoreNode::SchemaArray {
500 element: active_core::NodeId(element.0),
501 },
502 Inode::Semantic { tag, inner } => CoreNode::Semantic {
503 tag: match tag {
504 SemanticTag::Prompt => active_core::ir::SemanticTag::Prompt,
505 SemanticTag::Policy => active_core::ir::SemanticTag::Policy,
506 SemanticTag::Template => active_core::ir::SemanticTag::Template,
507 },
508 inner: active_core::NodeId(inner.0),
509 },
510 Inode::ContentAddressed { .. } => {
511 return Err(Error::Encode(
512 "active-core 1.4.0 has no ContentAddressed node kind",
513 ))
514 }
515 Inode::Blob { .. } => {
516 return Err(Error::Encode(
519 "active-core 1.4.0 has no blob value; keep the module in Infinity",
520 ));
521 }
522 };
523 Ok(converted)
524 }
525
526 fn convert_binop(op: BinOp) -> active_core::ir::BinOp {
527 match op {
528 BinOp::Add => active_core::ir::BinOp::Add,
529 BinOp::Sub => active_core::ir::BinOp::Sub,
530 BinOp::Mul => active_core::ir::BinOp::Mul,
531 BinOp::Div => active_core::ir::BinOp::Div,
532 BinOp::Eq => active_core::ir::BinOp::Eq,
533 BinOp::Ne => active_core::ir::BinOp::Ne,
534 BinOp::Lt => active_core::ir::BinOp::Lt,
535 BinOp::Le => active_core::ir::BinOp::Le,
536 BinOp::Gt => active_core::ir::BinOp::Gt,
537 BinOp::Ge => active_core::ir::BinOp::Ge,
538 BinOp::And => active_core::ir::BinOp::And,
539 BinOp::Or => active_core::ir::BinOp::Or,
540 }
541 }
542
543 fn convert_constraint(constraint: &Constraint) -> CoreConstraint {
544 match constraint {
545 Constraint::MinMax { min, max } => CoreConstraint::MinMax {
546 min: *min,
547 max: *max,
548 },
549 Constraint::Enum(items) => CoreConstraint::Enum(items.clone()),
550 Constraint::Pattern(pattern) => CoreConstraint::Pattern(pattern.clone()),
551 Constraint::Odd => CoreConstraint::Odd,
552 Constraint::Custom(text) => CoreConstraint::Custom(text.clone()),
553 }
554 }
555
556 fn convert_type(typ: &TypeSpec) -> Result<CoreType> {
557 let spec = match typ {
558 TypeSpec::Null => CoreType::Null,
559 TypeSpec::Bool => CoreType::Bool,
560 TypeSpec::Int => CoreType::Int,
561 TypeSpec::Float => CoreType::Float,
562 TypeSpec::String => CoreType::String,
563 TypeSpec::Duration => CoreType::Duration,
564 TypeSpec::Bytes => CoreType::Bytes,
565 TypeSpec::Table { closed } => CoreType::Table { closed: *closed },
566 TypeSpec::Enum(items) => CoreType::Enum(items.clone()),
567 TypeSpec::Secret => CoreType::Secret,
568 TypeSpec::Any => CoreType::Any,
569 TypeSpec::Blob => {
570 return Err(Error::Encode("active-core 1.4.0 has no Blob type"));
573 }
574 };
575 Ok(spec)
576 }
577
578 pub fn evaluate(
580 module: &InfinityModule,
581 budget: active_core::Budget,
582 ) -> Result<(active_core::Value, Vec<active_core::Diagnostic>)> {
583 let core = to_active_core(module)?;
584 let mut engine = active_core::Engine::new();
585 engine.budget = budget;
586 engine.evaluate(&core).map_err(|error| {
587 Error::Evaluation(active_error_code(error.code()))
590 })
591 }
592
593 fn active_error_code(code: &'static str) -> &'static str {
594 match code {
595 "S001" => "active/S001-cycle",
596 "S002" => "active/S002-missing-root",
597 "S003" => "active/S003-missing-node",
598 "T001" => "active/T001-type",
599 "X001" => "active/X001-node-budget",
600 "X002" => "active/X002-wall-clock",
601 "X003" => "active/X003-depth",
602 "X004" => "active/X004-array-length",
603 "X005" => "active/X005-keys",
604 "X006" => "active/X006-value-size",
605 _ => "active/eval-error",
606 }
607 }
608}
609
610#[cfg(feature = "active-core")]
611pub use bridge::{evaluate, from_active_core, to_active_core};
612
613#[cfg(feature = "json")]
618mod projection {
619 use super::*;
620
621 pub fn to_redacted_json(module: &InfinityModule) -> Result<String> {
629 let secrets = secret_literals(module);
630 let value = build_value(module, module.root(), &secrets);
631 serde_json::to_string(&value).map_err(|_| Error::Encode("json projection failed"))
632 }
633
634 pub fn to_redacted_json_pretty(module: &InfinityModule) -> Result<String> {
636 let secrets = secret_literals(module);
637 let value = build_value(module, module.root(), &secrets);
638 serde_json::to_string_pretty(&value).map_err(|_| Error::Encode("json projection failed"))
639 }
640
641 pub fn secret_literals(module: &InfinityModule) -> Vec<String> {
643 module
644 .nodes()
645 .iter()
646 .filter_map(|node| match node {
647 Inode::Secret(secret) => {
648 Some(String::from_utf8_lossy(secret.expose()).into_owned())
649 }
650 _ => None,
651 })
652 .collect()
653 }
654
655 fn redact_string(text: &str, secrets: &[String]) -> serde_json::Value {
656 if secrets
657 .iter()
658 .any(|secret| !secret.is_empty() && text.contains(secret.as_str()))
659 {
660 serde_json::Value::from(SECRET_MARKER)
661 } else {
662 serde_json::Value::from(text)
663 }
664 }
665
666 fn build_value(
667 module: &InfinityModule,
668 id: Option<NodeId>,
669 secrets: &[String],
670 ) -> serde_json::Value {
671 match id.and_then(|id| module.get(id)) {
672 None => serde_json::Value::Null,
673 Some(node) => match node {
674 Inode::Null => serde_json::Value::Null,
675 Inode::Bool(flag) => serde_json::Value::from(*flag),
676 Inode::Int(number) => serde_json::Value::from(*number),
677 Inode::Float(number) => serde_json::Value::from(*number),
678 Inode::Str(text) | Inode::Ident(text) => redact_string(text, secrets),
679 Inode::DurationMs(ms) => serde_json::json!({ "ms": ms }),
680 Inode::Quantity { value, unit } => serde_json::json!({
681 "value": build_value(module, Some(*value), secrets),
682 "unit": unit.name(),
683 }),
684 Inode::Array { elements } => serde_json::Value::Array(
685 elements
686 .iter()
687 .map(|id| build_value(module, Some(*id), secrets))
688 .collect(),
689 ),
690 Inode::Table { fields } => {
691 let mut map = serde_json::Map::new();
692 for (name, id) in fields {
693 map.insert(name.clone(), build_value(module, Some(*id), secrets));
694 }
695 serde_json::Value::Object(map)
696 }
697 Inode::Secret(_) | Inode::SecretRef { .. } => {
698 serde_json::Value::from(SECRET_MARKER)
699 }
700 Inode::Ref(id) => build_value(module, Some(*id), secrets),
701 Inode::Env { name, .. } => serde_json::json!({ "$env": name }),
702 Inode::Interpolate { parts } => {
703 let mut text = String::new();
704 let mut tainted = false;
705 for part in parts {
706 match part {
707 InterpPart::Literal(literal) => text.push_str(literal),
708 InterpPart::Expr(id) => match id.index() {
709 _ if is_secret(module, *id) => tainted = true,
710 _ => text.push_str("${}"),
711 },
712 }
713 }
714 if tainted {
715 serde_json::Value::from(SECRET_MARKER)
716 } else {
717 redact_string(&text, secrets)
718 }
719 }
720 Inode::Binary { .. }
721 | Inode::Unary { .. }
722 | Inode::Constrained { .. }
723 | Inode::Patch(_)
724 | Inode::SchemaField(_)
725 | Inode::SchemaTable { .. }
726 | Inode::SchemaArray { .. }
727 | Inode::ContentAddressed { .. } => {
728 serde_json::json!({ "$expr": crate::view::node_kind_name(node.kind_code()) })
729 }
730 Inode::Semantic { inner, .. } => build_value(module, Some(*inner), secrets),
731 Inode::Blob {
732 hash,
733 size,
734 tainted,
735 media_type,
736 ..
737 } => serde_json::json!({
738 "$active.blob": {
739 "hash": format!("blake3:{}", crate::ir::hash_hex(hash)),
740 "size": size,
741 "media_type": if *tainted { SECRET_MARKER } else { media_type.as_str() },
742 "tainted": tainted,
743 }
744 }),
745 },
746 }
747 }
748
749 fn is_secret(module: &InfinityModule, id: NodeId) -> bool {
750 module.get(id).map(|node| node.is_secret()).unwrap_or(false)
751 }
752
753 pub fn provenance_json(prov: &Provenance) -> serde_json::Value {
755 let mut map = serde_json::Map::new();
756 if let Some(producer) = &prov.producer {
757 map.insert("producer".into(), serde_json::Value::from(producer.clone()));
758 }
759 if prov.timestamp_ms != 0 {
760 map.insert(
761 "timestamp_ms".into(),
762 serde_json::Value::from(prov.timestamp_ms),
763 );
764 }
765 if let Some(model) = &prov.model {
766 map.insert("model".into(), serde_json::Value::from(model.clone()));
767 }
768 if let Some(tool) = &prov.tool {
769 map.insert("tool".into(), serde_json::Value::from(tool.clone()));
770 }
771 if let Some(parent) = prov.parent {
772 map.insert(
773 "parent".into(),
774 serde_json::Value::from(crate::ir::hash_hex(&parent)),
775 );
776 }
777 let mut entries = serde_json::Map::new();
778 for entry in &prov.entries {
779 let value = match &entry.value {
780 ProvValue::Text(text) => serde_json::Value::from(text.clone()),
781 ProvValue::Int(number) => serde_json::Value::from(*number),
782 ProvValue::Flag(flag) => serde_json::Value::from(*flag),
783 ProvValue::Hash(hash) => serde_json::Value::from(crate::ir::hash_hex(hash)),
784 };
785 entries.insert(entry.key.clone(), value);
786 }
787 map.insert("entries".into(), serde_json::Value::Object(entries));
788 serde_json::Value::Object(map)
789 }
790}
791
792#[cfg(feature = "json")]
793pub use projection::{provenance_json, secret_literals, to_redacted_json, to_redacted_json_pretty};
794
795pub fn value_paths(module: &InfinityModule) -> HashMap<NodeId, String> {
801 let mut paths: HashMap<NodeId, String> = HashMap::new();
802 for (name, id) in module.bindings() {
803 paths.insert(*id, name.clone());
804 }
805 if let Some(root) = module.root() {
806 let mut stack = vec![(root, String::new())];
807 while let Some((id, path)) = stack.pop() {
808 match module.get(id) {
809 Some(Inode::Table { fields }) => {
810 for (key, child) in fields {
811 let child_path = if path.is_empty() {
812 key.clone()
813 } else {
814 format!("{path}.{key}")
815 };
816 paths.entry(*child).or_insert_with(|| child_path.clone());
817 stack.push((*child, child_path));
818 }
819 }
820 Some(Inode::SchemaTable { fields, .. }) => {
821 for (key, child) in fields {
822 let child_path = if path.is_empty() {
823 key.clone()
824 } else {
825 format!("{path}.{key}")
826 };
827 paths.entry(*child).or_insert_with(|| child_path.clone());
828 stack.push((*child, child_path));
829 }
830 }
831 _ => {}
832 }
833 }
834 }
835 paths
836}
837
838pub fn node_label(module: &InfinityModule, id: NodeId) -> String {
845 if let Some(path) = value_paths(module).get(&id) {
846 return path.clone();
847 }
848 match module.get(id) {
849 Some(node) => format!("{}[{}]", node.kind().name(), id.0),
850 None => format!("node{}", id.0),
851 }
852}
853
854pub fn resolve_path(module: &InfinityModule, path: &str) -> Option<NodeId> {
857 if let Some(root) = module.root() {
858 let mut current = root;
859 let mut found = true;
860 for segment in path.split('.') {
861 match module.get(current) {
862 Some(Inode::Table { fields }) | Some(Inode::SchemaTable { fields, .. }) => {
863 match fields.iter().find(|(name, _)| name == segment) {
864 Some((_, child)) => current = *child,
865 None => {
866 found = false;
867 break;
868 }
869 }
870 }
871 _ => {
872 found = false;
873 break;
874 }
875 }
876 }
877 if found {
878 return Some(current);
879 }
880 }
881 if !path.contains('.') {
882 return module
883 .bindings()
884 .iter()
885 .find(|(name, _)| name == path)
886 .map(|(_, id)| *id);
887 }
888 None
889}
890
891fn adjacency(module: &InfinityModule) -> (Vec<Vec<NodeId>>, Vec<Vec<NodeId>>) {
892 let count = module.node_count();
893 let mut deps = vec![Vec::new(); count];
894 let mut dependents = vec![Vec::new(); count];
895 for (index, node) in module.nodes().iter().enumerate() {
896 for child in node.children() {
897 if child.index() < count {
898 deps[index].push(child);
899 dependents[child.index()].push(NodeId(index as u32));
900 }
901 }
902 }
903 (deps, dependents)
904}
905
906fn walk(adjacency: &[Vec<NodeId>], start: NodeId) -> Vec<NodeId> {
907 let mut seen = HashSet::new();
908 let mut out = Vec::new();
909 let mut stack = vec![start];
910 seen.insert(start);
911 while let Some(id) = stack.pop() {
912 for next in &adjacency[id.index()] {
913 if seen.insert(*next) {
914 out.push(*next);
915 stack.push(*next);
916 }
917 }
918 }
919 out
920}
921
922pub fn impact(module: &InfinityModule, id: NodeId) -> Vec<String> {
927 let (_, dependents) = adjacency(module);
928 let mut labels: Vec<String> = walk(&dependents, id)
929 .into_iter()
930 .map(|node| node_label(module, node))
931 .collect();
932 labels.sort();
933 labels.dedup();
934 labels
935}
936
937pub fn why(module: &InfinityModule, id: NodeId) -> Vec<String> {
939 let (deps, _) = adjacency(module);
940 let mut labels: Vec<String> = walk(&deps, id)
941 .into_iter()
942 .map(|node| node_label(module, node))
943 .collect();
944 labels.sort();
945 labels.dedup();
946 labels
947}
948
949pub fn dependents(module: &InfinityModule, id: NodeId) -> Vec<NodeId> {
951 let (_, dependents) = adjacency(module);
952 let mut out = dependents.get(id.index()).cloned().unwrap_or_default();
953 out.sort_unstable();
954 out.dedup();
955 out
956}
957
958pub fn dependencies(module: &InfinityModule, id: NodeId) -> Vec<NodeId> {
960 let mut out = module
961 .get(id)
962 .map(|node| node.children())
963 .unwrap_or_default();
964 out.sort_unstable();
965 out.dedup();
966 out
967}
968
969pub fn subgraph(module: &InfinityModule, roots: &[NodeId]) -> Vec<NodeId> {
971 let (deps, _) = adjacency(module);
972 let mut seen: HashSet<NodeId> = HashSet::new();
973 let mut order = Vec::new();
974 let mut queue: VecDeque<NodeId> = VecDeque::new();
975 for root in roots {
976 if module.get(*root).is_some() && seen.insert(*root) {
977 order.push(*root);
978 queue.push_back(*root);
979 }
980 }
981 while let Some(node) = queue.pop_front() {
982 for dep in &deps[node.index()] {
983 if seen.insert(*dep) {
984 order.push(*dep);
985 queue.push_back(*dep);
986 }
987 }
988 }
989 order
990}
991
992pub fn secret_tainted_paths(module: &InfinityModule) -> Vec<String> {
995 let mut tainted: Vec<bool> = vec![false; module.node_count()];
996 let order = subgraph(module, &root_ids(module));
999 let mut changed = true;
1000 while changed {
1001 changed = false;
1002 for id in &order {
1003 let Some(node) = module.get(*id) else {
1004 continue;
1005 };
1006 let value = node.is_secret()
1007 || node
1008 .children()
1009 .iter()
1010 .any(|child| tainted.get(child.index()).copied().unwrap_or(false));
1011 if value && !tainted[id.index()] {
1012 tainted[id.index()] = true;
1013 changed = true;
1014 }
1015 }
1016 }
1017 let paths = value_paths(module);
1018 let mut out: Vec<String> = module
1019 .nodes()
1020 .iter()
1021 .enumerate()
1022 .filter(|(index, _)| tainted[*index])
1023 .filter_map(|(index, _)| paths.get(&NodeId(index as u32)).cloned())
1024 .collect();
1025 out.sort();
1026 out.dedup();
1027 out
1028}
1029
1030fn root_ids(module: &InfinityModule) -> Vec<NodeId> {
1031 let mut roots = Vec::new();
1032 if let Some(root) = module.root() {
1033 roots.push(root);
1034 }
1035 roots.extend(module.bindings().iter().map(|(_, id)| *id));
1036 roots
1037}
1038
1039pub fn provenance_lines(module: &InfinityModule) -> Vec<String> {
1042 let Some(prov) = module.provenance() else {
1043 return Vec::new();
1044 };
1045 let secrets: Vec<String> = module
1046 .nodes()
1047 .iter()
1048 .filter_map(|node| match node {
1049 Inode::Secret(secret) => Some(String::from_utf8_lossy(secret.expose()).into_owned()),
1050 _ => None,
1051 })
1052 .collect();
1053 let redact = |text: &str| -> String {
1054 if secrets
1055 .iter()
1056 .any(|secret| !secret.is_empty() && text.contains(secret.as_str()))
1057 {
1058 SECRET_MARKER.to_string()
1059 } else {
1060 text.to_string()
1061 }
1062 };
1063 let mut lines = Vec::new();
1064 if let Some(producer) = &prov.producer {
1065 lines.push(format!("producer={}", redact(producer)));
1066 }
1067 if prov.timestamp_ms != 0 {
1068 lines.push(format!("timestamp_ms={}", prov.timestamp_ms));
1069 }
1070 if let Some(model) = &prov.model {
1071 lines.push(format!("model={}", redact(model)));
1072 }
1073 if let Some(tool) = &prov.tool {
1074 lines.push(format!("tool={}", redact(tool)));
1075 }
1076 if let Some(parent) = prov.parent {
1077 lines.push(format!("parent=blake3:{}", crate::ir::hash_hex(&parent)));
1078 }
1079 for entry in &prov.entries {
1080 let value = match &entry.value {
1081 ProvValue::Text(text) => redact(text),
1082 ProvValue::Int(number) => number.to_string(),
1083 ProvValue::Flag(flag) => flag.to_string(),
1084 ProvValue::Hash(hash) => format!("blake3:{}", crate::ir::hash_hex(hash)),
1085 };
1086 lines.push(format!("{}={value}", entry.key));
1087 }
1088 lines
1089}
1090
1091pub fn intents(module: &InfinityModule, id: NodeId) -> Vec<String> {
1093 module
1094 .annotations()
1095 .iter()
1096 .filter(|annotation| annotation.node == id)
1097 .filter_map(|annotation| match &annotation.kind {
1098 AnnotationKind::Intent(text) => Some(text.clone()),
1099 _ => None,
1100 })
1101 .collect()
1102}
1103
1104pub fn immutable_nodes(module: &InfinityModule) -> Vec<NodeId> {
1106 let mut out: Vec<NodeId> = module
1107 .annotations()
1108 .iter()
1109 .filter_map(|annotation| match &annotation.kind {
1110 AnnotationKind::AiImmutable(true) => Some(annotation.node),
1111 _ => None,
1112 })
1113 .collect();
1114 out.sort_unstable();
1115 out.dedup();
1116 out
1117}
1118
1119pub fn provenance_pairs(prov: &Provenance) -> Vec<&ProvEntry> {
1121 let mut entries: Vec<&ProvEntry> = prov.entries.iter().collect();
1122 entries.sort_by(|a, b| a.key.cmp(&b.key));
1123 entries
1124}
1125
1126#[cfg(feature = "active-core")]
1131pub fn from_active_source(source: &str, name: &str) -> Result<InfinityModule> {
1132 let core = active_core::parse(source, name)
1133 .map_err(|_| Error::Encode("Active source failed to parse"))?;
1134 from_active_core(&core)
1135}