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