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 module.validate_structure(u32::MAX as u64, 4096)?;
632 let secrets = secret_literals(module);
633 let value = build_value(module, module.root(), &secrets, 0)?;
634 serde_json::to_string(&value).map_err(|_| Error::Encode("json projection failed"))
635 }
636
637 pub fn to_redacted_json_pretty(module: &InfinityModule) -> Result<String> {
639 module.validate_structure(u32::MAX as u64, 4096)?;
640 let secrets = secret_literals(module);
641 let value = build_value(module, module.root(), &secrets, 0)?;
642 serde_json::to_string_pretty(&value).map_err(|_| Error::Encode("json projection failed"))
643 }
644
645 pub fn secret_literals(module: &InfinityModule) -> Vec<String> {
647 module
648 .nodes()
649 .iter()
650 .filter_map(|node| match node {
651 Inode::Secret(secret) => {
652 Some(String::from_utf8_lossy(secret.expose()).into_owned())
653 }
654 _ => None,
655 })
656 .collect()
657 }
658
659 fn redact_string(text: &str, secrets: &[String]) -> serde_json::Value {
660 if secrets
661 .iter()
662 .any(|secret| !secret.is_empty() && text.contains(secret.as_str()))
663 {
664 serde_json::Value::from(SECRET_MARKER)
665 } else {
666 serde_json::Value::from(text)
667 }
668 }
669
670 const PROJECTION_DEPTH: usize = 1024;
674
675 fn build_value(
676 module: &InfinityModule,
677 id: Option<NodeId>,
678 secrets: &[String],
679 depth: usize,
680 ) -> Result<serde_json::Value> {
681 if depth > PROJECTION_DEPTH {
682 return Err(Error::Encode("projection nesting exceeds the limit"));
683 }
684 let value = match id.and_then(|id| module.get(id)) {
685 None => serde_json::Value::Null,
686 Some(node) => match node {
687 Inode::Null => serde_json::Value::Null,
688 Inode::Bool(flag) => serde_json::Value::from(*flag),
689 Inode::Int(number) => serde_json::Value::from(*number),
690 Inode::Float(number) => serde_json::Value::from(*number),
691 Inode::Str(text) | Inode::Ident(text) => redact_string(text, secrets),
692 Inode::DurationMs(ms) => serde_json::json!({ "ms": ms }),
693 Inode::Quantity { value, unit } => serde_json::json!({
694 "value": build_value(module, Some(*value), secrets, depth + 1)?,
695 "unit": unit.name(),
696 }),
697 Inode::Array { elements } => serde_json::Value::Array(
698 elements
699 .iter()
700 .map(|id| build_value(module, Some(*id), secrets, depth + 1))
701 .collect::<Result<Vec<_>>>()?,
702 ),
703 Inode::Table { fields } => {
704 let mut map = serde_json::Map::new();
705 for (name, id) in fields {
706 map.insert(
707 name.clone(),
708 build_value(module, Some(*id), secrets, depth + 1)?,
709 );
710 }
711 serde_json::Value::Object(map)
712 }
713 Inode::Secret(_) | Inode::SecretRef { .. } => {
714 serde_json::Value::from(SECRET_MARKER)
715 }
716 Inode::Ref(id) => build_value(module, Some(*id), secrets, depth + 1)?,
717 Inode::Env { name, .. } => serde_json::json!({ "$env": name }),
718 Inode::Interpolate { parts } => {
719 let mut text = String::new();
720 let mut tainted = false;
721 for part in parts {
722 match part {
723 InterpPart::Literal(literal) => text.push_str(literal),
724 InterpPart::Expr(id) => match id.index() {
725 _ if is_secret(module, *id) => tainted = true,
726 _ => text.push_str("${}"),
727 },
728 }
729 }
730 if tainted {
731 serde_json::Value::from(SECRET_MARKER)
732 } else {
733 redact_string(&text, secrets)
734 }
735 }
736 Inode::Binary { .. }
737 | Inode::Unary { .. }
738 | Inode::Constrained { .. }
739 | Inode::Patch(_)
740 | Inode::SchemaField(_)
741 | Inode::SchemaTable { .. }
742 | Inode::SchemaArray { .. }
743 | Inode::ContentAddressed { .. } => {
744 serde_json::json!({ "$expr": crate::view::node_kind_name(node.kind_code()) })
745 }
746 Inode::Semantic { inner, .. } => {
747 build_value(module, Some(*inner), secrets, depth + 1)?
748 }
749 Inode::Blob {
750 hash,
751 size,
752 tainted,
753 media_type,
754 ..
755 } => serde_json::json!({
756 "$active.blob": {
757 "hash": format!("blake3:{}", crate::ir::hash_hex(hash)),
758 "size": size,
759 "media_type": if *tainted { SECRET_MARKER } else { media_type.as_str() },
760 "tainted": tainted,
761 }
762 }),
763 },
764 };
765 Ok(value)
766 }
767
768 fn is_secret(module: &InfinityModule, id: NodeId) -> bool {
769 module.get(id).map(|node| node.is_secret()).unwrap_or(false)
770 }
771
772 pub fn provenance_json(prov: &Provenance) -> serde_json::Value {
774 let mut map = serde_json::Map::new();
775 if let Some(producer) = &prov.producer {
776 map.insert("producer".into(), serde_json::Value::from(producer.clone()));
777 }
778 if prov.timestamp_ms != 0 {
779 map.insert(
780 "timestamp_ms".into(),
781 serde_json::Value::from(prov.timestamp_ms),
782 );
783 }
784 if let Some(model) = &prov.model {
785 map.insert("model".into(), serde_json::Value::from(model.clone()));
786 }
787 if let Some(tool) = &prov.tool {
788 map.insert("tool".into(), serde_json::Value::from(tool.clone()));
789 }
790 if let Some(parent) = prov.parent {
791 map.insert(
792 "parent".into(),
793 serde_json::Value::from(crate::ir::hash_hex(&parent)),
794 );
795 }
796 let mut entries = serde_json::Map::new();
797 for entry in &prov.entries {
798 let value = match &entry.value {
799 ProvValue::Text(text) => serde_json::Value::from(text.clone()),
800 ProvValue::Int(number) => serde_json::Value::from(*number),
801 ProvValue::Flag(flag) => serde_json::Value::from(*flag),
802 ProvValue::Hash(hash) => serde_json::Value::from(crate::ir::hash_hex(hash)),
803 };
804 entries.insert(entry.key.clone(), value);
805 }
806 map.insert("entries".into(), serde_json::Value::Object(entries));
807 serde_json::Value::Object(map)
808 }
809}
810
811#[cfg(feature = "json")]
812pub use projection::{provenance_json, secret_literals, to_redacted_json, to_redacted_json_pretty};
813
814pub fn value_paths(module: &InfinityModule) -> HashMap<NodeId, String> {
820 let mut paths: HashMap<NodeId, String> = HashMap::new();
821 for (name, id) in module.bindings() {
822 paths.insert(*id, name.clone());
823 }
824 if let Some(root) = module.root() {
825 let mut stack = vec![(root, String::new())];
826 let mut expanded = std::collections::HashSet::new();
827 while let Some((id, path)) = stack.pop() {
828 if !expanded.insert(id) {
830 continue;
831 }
832 match module.get(id) {
833 Some(Inode::Table { fields }) => {
834 for (key, child) in fields {
835 let child_path = if path.is_empty() {
836 key.clone()
837 } else {
838 format!("{path}.{key}")
839 };
840 paths.entry(*child).or_insert_with(|| child_path.clone());
841 stack.push((*child, child_path));
842 }
843 }
844 Some(Inode::SchemaTable { fields, .. }) => {
845 for (key, child) in fields {
846 let child_path = if path.is_empty() {
847 key.clone()
848 } else {
849 format!("{path}.{key}")
850 };
851 paths.entry(*child).or_insert_with(|| child_path.clone());
852 stack.push((*child, child_path));
853 }
854 }
855 _ => {}
856 }
857 }
858 }
859 paths
860}
861
862pub fn node_label(module: &InfinityModule, id: NodeId) -> String {
869 if let Some(path) = value_paths(module).get(&id) {
870 return path.clone();
871 }
872 match module.get(id) {
873 Some(node) => format!("{}[{}]", node.kind().name(), id.0),
874 None => format!("node{}", id.0),
875 }
876}
877
878pub fn resolve_path(module: &InfinityModule, path: &str) -> Option<NodeId> {
881 if let Some(root) = module.root() {
882 let mut current = root;
883 let mut found = true;
884 for segment in path.split('.') {
885 match module.get(current) {
886 Some(Inode::Table { fields }) | Some(Inode::SchemaTable { fields, .. }) => {
887 match fields.iter().find(|(name, _)| name == segment) {
888 Some((_, child)) => current = *child,
889 None => {
890 found = false;
891 break;
892 }
893 }
894 }
895 _ => {
896 found = false;
897 break;
898 }
899 }
900 }
901 if found {
902 return Some(current);
903 }
904 }
905 if !path.contains('.') {
906 return module
907 .bindings()
908 .iter()
909 .find(|(name, _)| name == path)
910 .map(|(_, id)| *id);
911 }
912 None
913}
914
915fn adjacency(module: &InfinityModule) -> (Vec<Vec<NodeId>>, Vec<Vec<NodeId>>) {
916 let count = module.node_count();
917 let mut deps = vec![Vec::new(); count];
918 let mut dependents = vec![Vec::new(); count];
919 for (index, node) in module.nodes().iter().enumerate() {
920 for child in node.children() {
921 if child.index() < count {
922 deps[index].push(child);
923 dependents[child.index()].push(NodeId(index as u32));
924 }
925 }
926 }
927 (deps, dependents)
928}
929
930fn walk(adjacency: &[Vec<NodeId>], start: NodeId) -> Vec<NodeId> {
931 let mut seen = HashSet::new();
932 let mut out = Vec::new();
933 let mut stack = vec![start];
934 seen.insert(start);
935 while let Some(id) = stack.pop() {
936 for next in &adjacency[id.index()] {
937 if seen.insert(*next) {
938 out.push(*next);
939 stack.push(*next);
940 }
941 }
942 }
943 out
944}
945
946pub fn impact(module: &InfinityModule, id: NodeId) -> Vec<String> {
951 let (_, dependents) = adjacency(module);
952 let mut labels: Vec<String> = walk(&dependents, id)
953 .into_iter()
954 .map(|node| node_label(module, node))
955 .collect();
956 labels.sort();
957 labels.dedup();
958 labels
959}
960
961pub fn why(module: &InfinityModule, id: NodeId) -> Vec<String> {
963 let (deps, _) = adjacency(module);
964 let mut labels: Vec<String> = walk(&deps, id)
965 .into_iter()
966 .map(|node| node_label(module, node))
967 .collect();
968 labels.sort();
969 labels.dedup();
970 labels
971}
972
973pub fn dependents(module: &InfinityModule, id: NodeId) -> Vec<NodeId> {
975 let (_, dependents) = adjacency(module);
976 let mut out = dependents.get(id.index()).cloned().unwrap_or_default();
977 out.sort_unstable();
978 out.dedup();
979 out
980}
981
982pub fn dependencies(module: &InfinityModule, id: NodeId) -> Vec<NodeId> {
984 let mut out = module
985 .get(id)
986 .map(|node| node.children())
987 .unwrap_or_default();
988 out.sort_unstable();
989 out.dedup();
990 out
991}
992
993pub fn subgraph(module: &InfinityModule, roots: &[NodeId]) -> Vec<NodeId> {
995 let (deps, _) = adjacency(module);
996 let mut seen: HashSet<NodeId> = HashSet::new();
997 let mut order = Vec::new();
998 let mut queue: VecDeque<NodeId> = VecDeque::new();
999 for root in roots {
1000 if module.get(*root).is_some() && seen.insert(*root) {
1001 order.push(*root);
1002 queue.push_back(*root);
1003 }
1004 }
1005 while let Some(node) = queue.pop_front() {
1006 for dep in &deps[node.index()] {
1007 if seen.insert(*dep) {
1008 order.push(*dep);
1009 queue.push_back(*dep);
1010 }
1011 }
1012 }
1013 order
1014}
1015
1016pub fn secret_tainted_paths(module: &InfinityModule) -> Vec<String> {
1019 let mut tainted: Vec<bool> = vec![false; module.node_count()];
1020 let order = subgraph(module, &root_ids(module));
1023 let mut changed = true;
1024 while changed {
1025 changed = false;
1026 for id in &order {
1027 let Some(node) = module.get(*id) else {
1028 continue;
1029 };
1030 let value = node.is_secret()
1031 || node
1032 .children()
1033 .iter()
1034 .any(|child| tainted.get(child.index()).copied().unwrap_or(false));
1035 if value && !tainted[id.index()] {
1036 tainted[id.index()] = true;
1037 changed = true;
1038 }
1039 }
1040 }
1041 let paths = value_paths(module);
1042 let mut out: Vec<String> = module
1043 .nodes()
1044 .iter()
1045 .enumerate()
1046 .filter(|(index, _)| tainted[*index])
1047 .filter_map(|(index, _)| paths.get(&NodeId(index as u32)).cloned())
1048 .collect();
1049 out.sort();
1050 out.dedup();
1051 out
1052}
1053
1054fn root_ids(module: &InfinityModule) -> Vec<NodeId> {
1055 let mut roots = Vec::new();
1056 if let Some(root) = module.root() {
1057 roots.push(root);
1058 }
1059 roots.extend(module.bindings().iter().map(|(_, id)| *id));
1060 roots
1061}
1062
1063pub fn provenance_lines(module: &InfinityModule) -> Vec<String> {
1066 let Some(prov) = module.provenance() else {
1067 return Vec::new();
1068 };
1069 let secrets: Vec<String> = module
1070 .nodes()
1071 .iter()
1072 .filter_map(|node| match node {
1073 Inode::Secret(secret) => Some(String::from_utf8_lossy(secret.expose()).into_owned()),
1074 _ => None,
1075 })
1076 .collect();
1077 let redact = |text: &str| -> String {
1078 if secrets
1079 .iter()
1080 .any(|secret| !secret.is_empty() && text.contains(secret.as_str()))
1081 {
1082 SECRET_MARKER.to_string()
1083 } else {
1084 text.to_string()
1085 }
1086 };
1087 let mut lines = Vec::new();
1088 if let Some(producer) = &prov.producer {
1089 lines.push(format!("producer={}", redact(producer)));
1090 }
1091 if prov.timestamp_ms != 0 {
1092 lines.push(format!("timestamp_ms={}", prov.timestamp_ms));
1093 }
1094 if let Some(model) = &prov.model {
1095 lines.push(format!("model={}", redact(model)));
1096 }
1097 if let Some(tool) = &prov.tool {
1098 lines.push(format!("tool={}", redact(tool)));
1099 }
1100 if let Some(parent) = prov.parent {
1101 lines.push(format!("parent=blake3:{}", crate::ir::hash_hex(&parent)));
1102 }
1103 for entry in &prov.entries {
1104 let value = match &entry.value {
1105 ProvValue::Text(text) => redact(text),
1106 ProvValue::Int(number) => number.to_string(),
1107 ProvValue::Flag(flag) => flag.to_string(),
1108 ProvValue::Hash(hash) => format!("blake3:{}", crate::ir::hash_hex(hash)),
1109 };
1110 lines.push(format!("{}={value}", entry.key));
1111 }
1112 lines
1113}
1114
1115pub fn intents(module: &InfinityModule, id: NodeId) -> Vec<String> {
1117 module
1118 .annotations()
1119 .iter()
1120 .filter(|annotation| annotation.node == id)
1121 .filter_map(|annotation| match &annotation.kind {
1122 AnnotationKind::Intent(text) => Some(text.clone()),
1123 _ => None,
1124 })
1125 .collect()
1126}
1127
1128pub fn immutable_nodes(module: &InfinityModule) -> Vec<NodeId> {
1130 let mut out: Vec<NodeId> = module
1131 .annotations()
1132 .iter()
1133 .filter_map(|annotation| match &annotation.kind {
1134 AnnotationKind::AiImmutable(true) => Some(annotation.node),
1135 _ => None,
1136 })
1137 .collect();
1138 out.sort_unstable();
1139 out.dedup();
1140 out
1141}
1142
1143pub fn provenance_pairs(prov: &Provenance) -> Vec<&ProvEntry> {
1145 let mut entries: Vec<&ProvEntry> = prov.entries.iter().collect();
1146 entries.sort_by(|a, b| a.key.cmp(&b.key));
1147 entries
1148}
1149
1150#[cfg(feature = "active-core")]
1155pub fn from_active_source(source: &str, name: &str) -> Result<InfinityModule> {
1156 let core = active_core::parse(source, name)
1157 .map_err(|_| Error::Encode("Active source failed to parse"))?;
1158 from_active_core(&core)
1159}