From 36d80a79a40087e51fd65fd4609cf988e8a1ca6f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Feb 2026 05:15:50 +0000 Subject: [PATCH 1/7] Add SecretName to core type authority Co-authored-by: Brian Searls --- core/ir/src/port_type.rs | 3 ++- core/ir/src/type_registry.rs | 1 + core/ir/src/types.rs | 10 +++++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/core/ir/src/port_type.rs b/core/ir/src/port_type.rs index be0f5c19d40..01245291a87 100644 --- a/core/ir/src/port_type.rs +++ b/core/ir/src/port_type.rs @@ -181,7 +181,7 @@ fn try_parse_port_type(s: &str) -> Option { // Domain types — string-backed "FilePath" | "Path" | "TextFilePath" - | "Url" | "Email" | "NonEmptyString" + | "Url" | "Email" | "NonEmptyString" | "SecretName" | "Platform" | "ContentEncoding" | "OidcAudience" | "WifAudience" | "GcpProjectId" | "GcpSecretId" | "GcpSecretVersion" @@ -290,6 +290,7 @@ mod tests { assert_eq!(PortType::from("Platform"), PortType::String); assert_eq!(PortType::from("Url"), PortType::String); assert_eq!(PortType::from("GcpProjectId"), PortType::String); + assert_eq!(PortType::from("SecretName"), PortType::String); // Bytes-backed domain types assert_eq!(PortType::from("BinaryFilePath"), PortType::Bytes); diff --git a/core/ir/src/type_registry.rs b/core/ir/src/type_registry.rs index 4cd2407a0ab..52e9661f214 100644 --- a/core/ir/src/type_registry.rs +++ b/core/ir/src/type_registry.rs @@ -298,6 +298,7 @@ impl TypeRegistry { /// These are structural refinements over primitives (e.g., Url is a refined String). pub fn register_core_types(&mut self) { self.register("NonEmptyString", type_lib::non_empty_string()); + self.register("SecretName", type_lib::non_empty_string()); self.register("Url", type_lib::url()); self.register("FilePath", type_lib::file_path()); self.register("Path", type_lib::file_path()); diff --git a/core/ir/src/types.rs b/core/ir/src/types.rs index d4d5fa727a9..c8c48e92f49 100644 --- a/core/ir/src/types.rs +++ b/core/ir/src/types.rs @@ -751,7 +751,7 @@ pub fn semantic_carrier_kind_for_type_id(type_id: &str) -> SemanticCarrierKind { "String" | "Bool" | "Int" | "Float" | "Bytes" | "Unit" | "Json" | "Void" | "Any" | "Error" // Refined primitives. - | "NonEmptyString" | "Url" | "FilePath" | "Path" | "Email" + | "NonEmptyString" | "SecretName" | "Url" | "FilePath" | "Path" | "Email" | "PositiveInt" | "NonNegativeInt" // Refined GCP identity/resource aliases. | "OidcAudience" | "WifAudience" @@ -1188,6 +1188,10 @@ mod tests { semantic_carrier_kind_for_type_id("GcpServiceAccountEmail"), SemanticCarrierKind::Structural ); + assert_eq!( + semantic_carrier_kind_for_type_id("SecretName"), + SemanticCarrierKind::Structural + ); assert_eq!( semantic_carrier_kind_for_type_id("TransportRequest"), SemanticCarrierKind::TransportRequest @@ -1262,6 +1266,10 @@ mod tests { value_backing_for_type_id("GcpSubjectToken"), ValueBacking::String ); + assert_eq!( + value_backing_for_type_id("SecretName"), + ValueBacking::String + ); } #[test] From 367018036e439a5a0b38d5193f347c47eda22beb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Feb 2026 05:22:06 +0000 Subject: [PATCH 2/7] Propagate lane1 graph port domain types and typed mocks Co-authored-by: Brian Searls --- core/daglang/daglang-emit/src/test_gen.rs | 42 +++++++++- gunbc-dag/src/testgen_dag/graph.rs | 2 +- lib/aws-ops/src/graph.rs | 6 +- lib/azure-ops/src/graph.rs | 6 +- lib/cloud-ops/src/github_credential_graph.rs | 4 +- lib/cloud-ops/src/graph.rs | 20 ++--- lib/cloud-ops/src/infra_bootstrap.rs | 20 ++--- lib/cloud-ops/src/infra_plan_apply.rs | 10 +-- lib/gcp-ops/src/graph.rs | 68 ++++++++--------- lib/llm-ops/src/graph.rs | 16 ++-- lib/review/src/graph.rs | 80 ++++++++++---------- lib/tools/gist/src/graph.rs | 6 +- 12 files changed, 158 insertions(+), 122 deletions(-) diff --git a/core/daglang/daglang-emit/src/test_gen.rs b/core/daglang/daglang-emit/src/test_gen.rs index 755e7efd795..404682cb67f 100644 --- a/core/daglang/daglang-emit/src/test_gen.rs +++ b/core/daglang/daglang-emit/src/test_gen.rs @@ -11,7 +11,7 @@ use std::fmt::Write; use daglang_derive::TestObligations; use daglang_lower::LoweredOp; -use gunbc_ir::Dag; +use gunbc_ir::{value_backing_for_type_id, Dag, ValueBacking}; use crate::computation::{classify_computation, Computation}; use crate::EmittedFile; @@ -152,7 +152,24 @@ fn typed_mock_for_response(response_type: &str) -> &'static str { "FileResponse" => r#"{"path":"/tmp/test.txt","success":true,"operation":"read"}"#, "RestResponse" => r#"{"status":200,"body":{"ok":true}}"#, "ShellResponse" => r#"{"exit_code":0,"stdout":"output"}"#, - _ => "mock-response", + "StringList" | "NonEmptyStringList" | "List" => r#"["mock-item"]"#, + "IntList" | "List" => r#"[1]"#, + "BoolList" | "List" => r#"[true]"#, + "JsonList" | "List" => r#"[{"mock":true}]"#, + _ => mock_for_backing_type(response_type), + } +} + +fn mock_for_backing_type(type_id: &str) -> &'static str { + match value_backing_for_type_id(type_id) { + ValueBacking::String => "mock-response", + ValueBacking::Bool => "true", + ValueBacking::Int | ValueBacking::Float => "1", + ValueBacking::Json => r#"{"ok":true}"#, + ValueBacking::Map => r#"{"mock":"value"}"#, + ValueBacking::List | ValueBacking::Set => r#"["mock-item"]"#, + ValueBacking::Unit => "null", + ValueBacking::Bytes => "[0]", } } @@ -178,7 +195,20 @@ pub fn witness_mock_responses(response_type: &str) -> Vec { r#"{"exit_code":0,"stdout":"ok"}"#.to_string(), r#"{"exit_code":1,"stderr":"error"}"#.to_string(), ], - _ => vec!["mock-response".to_string()], + _ => { + let one = typed_mock_for_response(response_type).to_string(); + let alternate = match value_backing_for_type_id(response_type) { + ValueBacking::String => "alt-mock-response".to_string(), + ValueBacking::Bool => "false".to_string(), + ValueBacking::Int | ValueBacking::Float => "2".to_string(), + ValueBacking::Json => r#"{"ok":false}"#.to_string(), + ValueBacking::Map => r#"{"mock":"alternate"}"#.to_string(), + ValueBacking::List | ValueBacking::Set => r#"["mock-item","alt-item"]"#.to_string(), + ValueBacking::Unit => "null".to_string(), + ValueBacking::Bytes => "[1]".to_string(), + }; + vec![one, alternate] + } } } @@ -631,6 +661,12 @@ mod tests { assert!(emit_transport_mock_tests("c", &dag_without_transport_nodes()).is_none()); } + #[test] + fn typed_mock_for_response_emits_list_shape_for_string_list() { + assert_eq!(typed_mock_for_response("StringList"), r#"["mock-item"]"#); + assert_eq!(typed_mock_for_response("List"), r#"["mock-item"]"#); + } + // ===== E3.4-E3.6: TestSpec-based generation tests ===== fn sample_spec() -> TestSpec { diff --git a/gunbc-dag/src/testgen_dag/graph.rs b/gunbc-dag/src/testgen_dag/graph.rs index 7c9ab100fae..1f7cd751a9c 100644 --- a/gunbc-dag/src/testgen_dag/graph.rs +++ b/gunbc-dag/src/testgen_dag/graph.rs @@ -108,7 +108,7 @@ fn add_upsert_chain( let generate = builder.add_root_node(Node::opaque( gen_id.as_str(), vec![], - vec![port("content", "String")], + vec![port("content", "NonEmptyString")], generate_op, ))?; diff --git a/lib/aws-ops/src/graph.rs b/lib/aws-ops/src/graph.rs index a891b85a39b..66ba14ea103 100644 --- a/lib/aws-ops/src/graph.rs +++ b/lib/aws-ops/src/graph.rs @@ -19,9 +19,9 @@ pub fn build_aws_secrets_manager_credential_graph( "aws_secrets_manager_stub", vec![ port("config", "CloudSecretConfig"), - port("scheme", "String"), + port("scheme", "NonEmptyString"), optional("header_name", "OptionalString"), - port("source_id", "String"), + port("source_id", "NonEmptyString"), list("required_scopes", "String"), optional("lifetime_seconds", "OptionalInt"), optional("request_url", "OptionalString"), @@ -50,7 +50,7 @@ pub fn build_aws_secrets_manager_upsert_graph() -> Result Result Result, port("config", "CloudSecretConfig"), port("secret_value", "Secret"), ], - vec![port("version", "String")], + vec![port("version", "NonEmptyString")], DynOp::new(AzureOps::Unsupported), ))?; diff --git a/lib/cloud-ops/src/github_credential_graph.rs b/lib/cloud-ops/src/github_credential_graph.rs index f622e8fcd3a..7292e89486f 100644 --- a/lib/cloud-ops/src/github_credential_graph.rs +++ b/lib/cloud-ops/src/github_credential_graph.rs @@ -181,7 +181,7 @@ pub fn build_github_credential_graph() -> Result, B vec![], vec![ port("service", "String"), - port("secret_name", "String"), + port("secret_name", "SecretName"), port("scheme", "String"), port("header_name", "String"), list("required_scopes", "String"), @@ -197,7 +197,7 @@ pub fn build_github_credential_graph() -> Result, B vec![ port("config", "CloudSecretConfig"), port("service", "String"), - port("secret_name", "String"), + port("secret_name", "SecretName"), ], vec![port("config", "CloudSecretConfig")], GitHubCredentialGraphOp::Cloud(DynOp::new(CloudOps::BindSecretName)), diff --git a/lib/cloud-ops/src/graph.rs b/lib/cloud-ops/src/graph.rs index 6f10ce2b053..ee282e614c7 100644 --- a/lib/cloud-ops/src/graph.rs +++ b/lib/cloud-ops/src/graph.rs @@ -183,7 +183,7 @@ fn build_cloud_secret_manager_credential_graph_gcp( vec![ port("provider", "Platform"), port("runtime", "String"), - port("audience", "String"), + port("audience", "NonEmptyString"), port("project_or_account", "String"), port("secret", "String"), optional("version", "OptionalString"), @@ -194,10 +194,10 @@ fn build_cloud_secret_manager_credential_graph_gcp( ))?; let mut map_outputs = vec![ - port("project", "String"), + port("project", "GcpProjectId"), port("secret", "String"), optional("version", "OptionalString"), - port("service_account", "String"), + port("service_account", "GcpServiceAccountEmail"), port("scheme", "String"), optional("header_name", "OptionalString"), port("source_id", "String"), @@ -211,7 +211,7 @@ fn build_cloud_secret_manager_credential_graph_gcp( // propagate up through nested sub-DAGs. if !matches!(runtime, CloudRuntimeKind::LocalDev) { map_outputs.push(optional("interactive_allowed", "OptionalBool")); - map_outputs.push(port("audience", "String")); + map_outputs.push(port("audience", "NonEmptyString")); } if matches!(runtime, CloudRuntimeKind::GitHubActions) { map_outputs.push(optional("request_url", "OptionalString")); @@ -224,7 +224,7 @@ fn build_cloud_secret_manager_credential_graph_gcp( vec![ port("provider", "Platform"), port("runtime", "String"), - port("audience", "String"), + port("audience", "NonEmptyString"), port("project_or_account", "String"), port("secret", "String"), optional("version", "OptionalString"), @@ -343,7 +343,7 @@ fn build_cloud_secret_manager_upsert_graph_gcp( vec![ port("provider", "Platform"), port("runtime", "String"), - port("audience", "String"), + port("audience", "NonEmptyString"), port("project_or_account", "String"), port("secret", "String"), optional("version", "OptionalString"), @@ -354,9 +354,9 @@ fn build_cloud_secret_manager_upsert_graph_gcp( ))?; let mut map_outputs = vec![ - port("project", "String"), + port("project", "GcpProjectId"), port("secret", "String"), - port("service_account", "String"), + port("service_account", "GcpServiceAccountEmail"), optional("version", "OptionalString"), optional("allow_impersonation", "OptionalBool"), optional("lifetime_seconds", "OptionalInt"), @@ -364,7 +364,7 @@ fn build_cloud_secret_manager_upsert_graph_gcp( // interactive_allowed only needed for non-local runtimes (see credential graph). if !matches!(runtime, CloudRuntimeKind::LocalDev) { map_outputs.push(optional("interactive_allowed", "OptionalBool")); - map_outputs.push(port("audience", "String")); + map_outputs.push(port("audience", "NonEmptyString")); } if matches!(runtime, CloudRuntimeKind::GitHubActions) { map_outputs.push(optional("request_url", "OptionalString")); @@ -377,7 +377,7 @@ fn build_cloud_secret_manager_upsert_graph_gcp( vec![ port("provider", "Platform"), port("runtime", "String"), - port("audience", "String"), + port("audience", "NonEmptyString"), port("project_or_account", "String"), port("secret", "String"), optional("version", "OptionalString"), diff --git a/lib/cloud-ops/src/infra_bootstrap.rs b/lib/cloud-ops/src/infra_bootstrap.rs index 418de50372a..8479cc54d79 100644 --- a/lib/cloud-ops/src/infra_bootstrap.rs +++ b/lib/cloud-ops/src/infra_bootstrap.rs @@ -751,7 +751,7 @@ fn add_idempotent_stage( let prepare = builder.add_node_after( Node::opaque( format!("prepare_{}", stage_name).as_str(), - vec![port("prev_ok", "Bool"), port("access_token", "String")], + vec![port("prev_ok", "Bool"), port("access_token", "Secret")], vec![port("request", "TransportRequest"), port("skip", "Bool")], InfraBootstrapGraphOp::Bootstrap(prepare_op), ), @@ -777,12 +777,12 @@ fn add_idempotent_stage( format!("check_{}", stage_name).as_str(), vec![ port("response", "TransportResponse"), - port("access_token", "String"), + port("access_token", "Secret"), ], vec![ port("request", "TransportRequest"), port("skip", "Bool"), - port("action", "String"), + port("action", "NonEmptyString"), ], InfraBootstrapGraphOp::Bootstrap(check_op), ), @@ -808,9 +808,9 @@ fn add_idempotent_stage( format!("parse_{}", stage_name).as_str(), vec![ port("response", "TransportResponse"), - port("action", "String"), + port("action", "NonEmptyString"), ], - vec![port("ok", "Bool"), port("action", "String")], + vec![port("ok", "Bool"), port("action", "NonEmptyString")], InfraBootstrapGraphOp::Bootstrap(parse_op), ), &execute_apply, @@ -847,8 +847,8 @@ pub fn build_wif_bootstrap_dag( let context = builder .add_root_node(Node::opaque( "context", - vec![port("access_token", "String")], - vec![port("access_token", "String")], + vec![port("access_token", "Secret")], + vec![port("access_token", "Secret")], InfraBootstrapGraphOp::Bootstrap(InfraBootstrapOps::PassAccessToken), )) .map_err(|err| format!("failed to add context node: {err}"))?; @@ -857,8 +857,8 @@ pub fn build_wif_bootstrap_dag( .add_node_after( Node::opaque( "enable_apis", - vec![port("access_token", "String")], - vec![port("ok", "Bool"), port("note", "String")], + vec![port("access_token", "Secret")], + vec![port("ok", "Bool"), port("note", "NonEmptyString")], InfraBootstrapGraphOp::Bootstrap(InfraBootstrapOps::EnableApis { project: infra_spec.config.secrets_project.to_string(), services: vec![ @@ -1008,7 +1008,7 @@ pub fn build_wif_bootstrap_dag( Node::opaque( "bootstrap_summary", vec![port("prev_ok", "Bool")], - vec![port("ok", "Bool"), port("report", "String")], + vec![port("ok", "Bool"), port("report", "NonEmptyString")], InfraBootstrapGraphOp::Bootstrap(InfraBootstrapOps::SummarizeBootstrap { environment: infra_spec.environment.to_string(), project: project.clone(), diff --git a/lib/cloud-ops/src/infra_plan_apply.rs b/lib/cloud-ops/src/infra_plan_apply.rs index 0a5fc419b14..718c1f1dd49 100644 --- a/lib/cloud-ops/src/infra_plan_apply.rs +++ b/lib/cloud-ops/src/infra_plan_apply.rs @@ -113,7 +113,7 @@ pub fn build_infra_plan_dag( "plan", vec![], vec![ - port("environment", "String"), + port("environment", "NonEmptyString"), list("planned_targets", "String"), port("target_count", "Int"), ], @@ -160,7 +160,7 @@ pub fn build_infra_apply_dag( "plan", vec![], vec![ - port("environment", "String"), + port("environment", "NonEmptyString"), list("planned_targets", "String"), port("target_count", "Int"), ], @@ -177,7 +177,7 @@ pub fn build_infra_apply_dag( "runtime_reconcile", vec![], vec![ - port("environment", "String"), + port("environment", "NonEmptyString"), list("reconciled_targets", "String"), port("reconciled_count", "Int"), ], @@ -204,9 +204,9 @@ pub fn build_infra_apply_dag( "apply_summary", vec![port("target_count", "Int"), port("reconciled_count", "Int")], vec![ - port("environment", "String"), + port("environment", "NonEmptyString"), port("applied_count", "Int"), - port("report", "String"), + port("report", "NonEmptyString"), ], InfraPlanApplyGraphOp::Infra(InfraPlanApplyOps::SummarizeApply { environment: infra_spec.environment.to_string(), diff --git a/lib/gcp-ops/src/graph.rs b/lib/gcp-ops/src/graph.rs index c5eaa381090..f3d93516a2e 100644 --- a/lib/gcp-ops/src/graph.rs +++ b/lib/gcp-ops/src/graph.rs @@ -52,7 +52,7 @@ pub fn build_gcp_secret_manager_credential_graph( let prepare = builder.add_root_node(Node::opaque( "prepare_github_oidc", vec![ - port("audience", "String"), + port("audience", "NonEmptyString"), optional("request_url", "OptionalString"), optional("request_token", "OptionalString"), ], @@ -97,7 +97,7 @@ pub fn build_gcp_secret_manager_credential_graph( GcpRuntimeKind::GcpMetadata => { let prepare = builder.add_root_node(Node::opaque( "prepare_metadata_oidc", - vec![port("audience", "String")], + vec![port("audience", "NonEmptyString")], vec![port("request", "TransportRequest"), port("skip", "Bool")], DynOp::new(GcpOps::PrepareMetadataOidcRequest), ))?; @@ -143,7 +143,7 @@ pub fn build_gcp_secret_manager_credential_graph( let prepare_sts = builder.add_node_after( Node::opaque( "prepare_sts", - vec![port("audience", "String"), port("subject_token", "String")], + vec![port("audience", "NonEmptyString"), port("subject_token", "String")], vec![port("request", "TransportRequest"), port("skip", "Bool")], DynOp::new(GcpOps::PrepareStsExchange), ), @@ -168,7 +168,7 @@ pub fn build_gcp_secret_manager_credential_graph( Node::opaque( "parse_sts", vec![port("response", "TransportResponse")], - vec![port("access_token", "String"), port("expires_in", "Int")], + vec![port("access_token", "Secret"), port("expires_in", "Int")], DynOp::new(GcpOps::ParseStsExchange), ), &execute_sts, @@ -210,7 +210,7 @@ pub fn build_gcp_secret_manager_credential_graph( Node::opaque( "should_impersonate", vec![ - port("service_account", "String"), + port("service_account", "GcpServiceAccountEmail"), optional("allow_impersonation", "OptionalBool"), ], vec![port("should", "Bool")], @@ -223,8 +223,8 @@ pub fn build_gcp_secret_manager_credential_graph( Node::opaque( "prepare_impersonate", vec![ - port("access_token", "String"), - port("service_account", "String"), + port("access_token", "Secret"), + port("service_account", "GcpServiceAccountEmail"), optional("lifetime_seconds", "OptionalInt"), optional("should_impersonate", "OptionalBool"), ], @@ -255,7 +255,7 @@ pub fn build_gcp_secret_manager_credential_graph( port("response", "TransportResponse"), optional("base_access_token", "OptionalString"), ], - vec![port("access_token", "String"), port("expires_at", "String")], + vec![port("access_token", "Secret"), port("expires_at", "String")], DynOp::new(GcpOps::ParseImpersonate), ), &execute_impersonate, @@ -298,8 +298,8 @@ pub fn build_gcp_secret_manager_credential_graph( Node::opaque( "prepare_secret_access", vec![ - port("access_token", "String"), - port("project", "String"), + port("access_token", "Secret"), + port("project", "GcpProjectId"), port("secret", "String"), optional("version", "OptionalString"), ], @@ -443,7 +443,7 @@ pub fn build_gcp_secret_manager_upsert_graph( let prepare = builder.add_root_node(Node::opaque( "prepare_github_oidc", vec![ - port("audience", "String"), + port("audience", "NonEmptyString"), optional("request_url", "OptionalString"), optional("request_token", "OptionalString"), ], @@ -488,7 +488,7 @@ pub fn build_gcp_secret_manager_upsert_graph( GcpRuntimeKind::GcpMetadata => { let prepare = builder.add_root_node(Node::opaque( "prepare_metadata_oidc", - vec![port("audience", "String")], + vec![port("audience", "NonEmptyString")], vec![port("request", "TransportRequest"), port("skip", "Bool")], DynOp::new(GcpOps::PrepareMetadataOidcRequest), ))?; @@ -534,7 +534,7 @@ pub fn build_gcp_secret_manager_upsert_graph( let prepare_sts = builder.add_node_after( Node::opaque( "prepare_sts", - vec![port("audience", "String"), port("subject_token", "String")], + vec![port("audience", "NonEmptyString"), port("subject_token", "String")], vec![port("request", "TransportRequest"), port("skip", "Bool")], DynOp::new(GcpOps::PrepareStsExchange), ), @@ -559,7 +559,7 @@ pub fn build_gcp_secret_manager_upsert_graph( Node::opaque( "parse_sts", vec![port("response", "TransportResponse")], - vec![port("access_token", "String"), port("expires_in", "Int")], + vec![port("access_token", "Secret"), port("expires_in", "Int")], DynOp::new(GcpOps::ParseStsExchange), ), &execute_sts, @@ -601,7 +601,7 @@ pub fn build_gcp_secret_manager_upsert_graph( Node::opaque( "should_impersonate", vec![ - port("service_account", "String"), + port("service_account", "GcpServiceAccountEmail"), optional("allow_impersonation", "OptionalBool"), ], vec![port("should", "Bool")], @@ -614,8 +614,8 @@ pub fn build_gcp_secret_manager_upsert_graph( Node::opaque( "prepare_impersonate", vec![ - port("access_token", "String"), - port("service_account", "String"), + port("access_token", "Secret"), + port("service_account", "GcpServiceAccountEmail"), optional("lifetime_seconds", "OptionalInt"), optional("should_impersonate", "OptionalBool"), ], @@ -646,7 +646,7 @@ pub fn build_gcp_secret_manager_upsert_graph( port("response", "TransportResponse"), optional("base_access_token", "OptionalString"), ], - vec![port("access_token", "String"), port("expires_at", "String")], + vec![port("access_token", "Secret"), port("expires_at", "String")], DynOp::new(GcpOps::ParseImpersonate), ), &execute_impersonate, @@ -689,8 +689,8 @@ pub fn build_gcp_secret_manager_upsert_graph( Node::opaque( "prepare_secret_get", vec![ - port("access_token", "String"), - port("project", "String"), + port("access_token", "Secret"), + port("project", "GcpProjectId"), port("secret", "String"), ], vec![port("request", "TransportRequest"), port("skip", "Bool")], @@ -739,8 +739,8 @@ pub fn build_gcp_secret_manager_upsert_graph( Node::opaque( "prepare_secret_create", vec![ - port("access_token", "String"), - port("project", "String"), + port("access_token", "Secret"), + port("project", "GcpProjectId"), port("secret", "String"), port("exists", "Bool"), ], @@ -783,8 +783,8 @@ pub fn build_gcp_secret_manager_upsert_graph( Node::opaque( "prepare_secret_add_version", vec![ - port("access_token", "String"), - port("project", "String"), + port("access_token", "Secret"), + port("project", "GcpProjectId"), port("secret", "String"), port("secret_value", "Secret"), optional("create_done", "OptionalBool"), @@ -968,21 +968,21 @@ fn add_ensure_iam_nodes_with_mode( }; let mut prepare_inputs = vec![ - port("access_token", "String"), - port("project", "String"), - port("service_account", "String"), + port("access_token", "Secret"), + port("project", "GcpProjectId"), + port("service_account", "GcpServiceAccountEmail"), ]; let mut prepare_outputs = vec![ port("request", "TransportRequest"), port("skip", "Bool"), - port("service_account", "String"), - port("project", "String"), + port("service_account", "GcpServiceAccountEmail"), + port("project", "GcpProjectId"), ]; let mut check_inputs = vec![ port("response", "TransportResponse"), - port("access_token", "String"), - port("project", "String"), - port("service_account", "String"), + port("access_token", "Secret"), + port("project", "GcpProjectId"), + port("service_account", "GcpServiceAccountEmail"), ]; if include_member_port { prepare_inputs.push(port("member", "String")); @@ -1549,7 +1549,7 @@ fn build_local_auth_upsert_dag() -> Dag { optional("retry_access_token", "OptionalString"), optional("retry_expires_in", "OptionalInt"), ], - vec![port("access_token", "String"), port("expires_in", "Int")], + vec![port("access_token", "Secret"), port("expires_in", "Int")], DynOp::new(GcpOps::MergeAuthResult), )); @@ -1605,7 +1605,7 @@ mod tests { .add_root_node(Node::opaque( "access_token_source", vec![], - vec![port("access_token", "String")], + vec![port("access_token", "Secret")], DynOp::new(GcpOps::ResolveRuntime), )) .expect("access_token_source"); diff --git a/lib/llm-ops/src/graph.rs b/lib/llm-ops/src/graph.rs index 2b8c337b3af..ac29ede637c 100644 --- a/lib/llm-ops/src/graph.rs +++ b/lib/llm-ops/src/graph.rs @@ -75,10 +75,10 @@ pub fn build_chat_completion_graph_with_config( // Node 1: Resolve auth requirements (pure) let resolve_auth = builder.add_root_node(Node::opaque( "resolve_auth", - vec![port("provider", "String")], + vec![port("provider", "NonEmptyString")], vec![ port("service", "String"), - port("secret_name", "String"), + port("secret_name", "SecretName"), optional("allow_impersonation", "OptionalBool"), port("scheme", "String"), port("header_name", "String"), @@ -95,7 +95,7 @@ pub fn build_chat_completion_graph_with_config( vec![ port("config", "CloudSecretConfig"), port("service", "String"), - port("secret_name", "String"), + port("secret_name", "SecretName"), ], vec![port("config", "CloudSecretConfig")], DynOp::new(CloudOps::BindSecretName), @@ -129,8 +129,8 @@ pub fn build_chat_completion_graph_with_config( "execute", "parse", vec![ - port("provider", "String"), - port("model", "String"), + port("provider", "NonEmptyString"), + port("model", "NonEmptyString"), port("messages", "Json"), optional("system_prompt", "OptionalString"), optional("temperature", "OptionalJson"), @@ -140,10 +140,10 @@ pub fn build_chat_completion_graph_with_config( optional("scope_verified", "OptionalBool"), resource("credential", "Credential", AccessMode::Read), ], - vec![port("provider", "String")], + vec![port("provider", "NonEmptyString")], vec![ - port("content", "String"), - port("model", "String"), + port("content", "NonEmptyString"), + port("model", "NonEmptyString"), port("finish_reason", "String"), port("input_tokens", "Int"), port("output_tokens", "Int"), diff --git a/lib/review/src/graph.rs b/lib/review/src/graph.rs index e3bff8950dd..e88786292ac 100644 --- a/lib/review/src/graph.rs +++ b/lib/review/src/graph.rs @@ -90,7 +90,7 @@ fn add_cloud_credential_chain( vec![ port("config", "CloudSecretConfig"), port("service", "String"), - port("secret_name", "String"), + port("secret_name", "SecretName"), ], vec![port("config", "CloudSecretConfig")], DynOp::new(CloudOps::BindSecretName), @@ -323,14 +323,14 @@ pub fn build_review_phase_graph_with_config( let resolve_auth = builder.add_node_after( Node::opaque( "resolve_auth", - vec![port("provider", "String")], + vec![port("provider", "NonEmptyString")], vec![ port("service", "String"), port("scheme", "String"), port("header_name", "String"), list("required_scopes", "String"), port("interactive_allowed", "Bool"), - port("secret_name", "String"), + port("secret_name", "SecretName"), ], DynOp::new(ReviewOps::ResolveAuthContract), ), @@ -350,17 +350,17 @@ pub fn build_review_phase_graph_with_config( "execute_llm", "parse_llm", vec![ - port("content", "String"), + port("content", "NonEmptyString"), port("question", "String"), - port("provider", "String"), - port("model", "String"), + port("provider", "NonEmptyString"), + port("model", "NonEmptyString"), optional("system_prompt", "OptionalString"), ], vec![ optional("scope_verified", "OptionalBool"), resource("credential", "Credential", AccessMode::Read), ], - vec![port("provider", "String")], + vec![port("provider", "NonEmptyString")], vec![port("answer", "String")], DynOp::new(LlmOps::PrepareSimpleRequest), DynOp::new(LlmOps::ParseSimpleResponse), @@ -466,14 +466,14 @@ pub fn build_inline_review_graph_with_config( let resolve_auth = builder.add_node_after( Node::opaque( "resolve_auth", - vec![port("provider", "String")], + vec![port("provider", "NonEmptyString")], vec![ port("service", "String"), port("scheme", "String"), port("header_name", "String"), list("required_scopes", "String"), port("interactive_allowed", "Bool"), - port("secret_name", "String"), + port("secret_name", "SecretName"), ], DynOp::new(ReviewOps::ResolveAuthContract), ), @@ -493,17 +493,17 @@ pub fn build_inline_review_graph_with_config( "execute_llm", "parse_llm", vec![ - port("content", "String"), + port("content", "NonEmptyString"), port("question", "String"), - port("provider", "String"), - port("model", "String"), + port("provider", "NonEmptyString"), + port("model", "NonEmptyString"), optional("system_prompt", "OptionalString"), ], vec![ optional("scope_verified", "OptionalBool"), resource("credential", "Credential", AccessMode::Read), ], - vec![port("provider", "String")], + vec![port("provider", "NonEmptyString")], vec![port("answer", "String")], DynOp::new(LlmOps::PrepareSimpleRequest), DynOp::new(LlmOps::ParseSimpleResponse), @@ -623,8 +623,8 @@ pub fn build_diff_review_graph_with_cloud_config( "config", vec![], vec![ - port("provider", "String"), - port("model", "String"), + port("provider", "NonEmptyString"), + port("model", "NonEmptyString"), port("criteria", "Json"), ], DynOp::new(ReviewOps::LoadPipelineConfig(config)), @@ -698,14 +698,14 @@ pub fn build_diff_review_graph_with_cloud_config( let resolve_auth = builder.add_node_after( Node::opaque( "resolve_auth", - vec![port("provider", "String")], + vec![port("provider", "NonEmptyString")], vec![ port("service", "String"), port("scheme", "String"), port("header_name", "String"), list("required_scopes", "String"), port("interactive_allowed", "Bool"), - port("secret_name", "String"), + port("secret_name", "SecretName"), ], DynOp::new(ReviewOps::ResolveAuthContract), ), @@ -724,17 +724,17 @@ pub fn build_diff_review_graph_with_cloud_config( "execute_llm", "parse_llm", vec![ - port("content", "String"), + port("content", "NonEmptyString"), port("question", "String"), - port("provider", "String"), - port("model", "String"), + port("provider", "NonEmptyString"), + port("model", "NonEmptyString"), optional("system_prompt", "OptionalString"), ], vec![ optional("scope_verified", "OptionalBool"), resource("credential", "Credential", AccessMode::Read), ], - vec![port("provider", "String")], + vec![port("provider", "NonEmptyString")], vec![port("answer", "String")], DynOp::new(LlmOps::PrepareSimpleRequest), DynOp::new(LlmOps::ParseSimpleResponse), @@ -885,8 +885,8 @@ pub fn build_multi_source_review_graph_with_cloud_config( "config", vec![], vec![ - port("provider", "String"), - port("model", "String"), + port("provider", "NonEmptyString"), + port("model", "NonEmptyString"), port("criteria", "Json"), ], DynOp::new(ReviewOps::LoadPipelineConfig(config)), @@ -913,14 +913,14 @@ pub fn build_multi_source_review_graph_with_cloud_config( let resolve_auth = builder.add_node_after( Node::opaque( "resolve_auth", - vec![port("provider", "String")], + vec![port("provider", "NonEmptyString")], vec![ port("service", "String"), port("scheme", "String"), port("header_name", "String"), list("required_scopes", "String"), port("interactive_allowed", "Bool"), - port("secret_name", "String"), + port("secret_name", "SecretName"), ], DynOp::new(ReviewOps::ResolveAuthContract), ), @@ -939,17 +939,17 @@ pub fn build_multi_source_review_graph_with_cloud_config( "execute_llm", "parse_llm", vec![ - port("content", "String"), + port("content", "NonEmptyString"), port("question", "String"), - port("provider", "String"), - port("model", "String"), + port("provider", "NonEmptyString"), + port("model", "NonEmptyString"), optional("system_prompt", "OptionalString"), ], vec![ optional("scope_verified", "OptionalBool"), resource("credential", "Credential", AccessMode::Read), ], - vec![port("provider", "String")], + vec![port("provider", "NonEmptyString")], vec![port("answer", "String")], DynOp::new(LlmOps::PrepareSimpleRequest), DynOp::new(LlmOps::ParseSimpleResponse), @@ -1116,7 +1116,7 @@ pub fn build_dimension_diff_review_graph_with_cloud_config( let llm_config = builder.add_root_node(Node::opaque( "llm_config", vec![], - vec![port("provider", "String"), port("model", "String")], + vec![port("provider", "NonEmptyString"), port("model", "NonEmptyString")], DynOp::new(DimensionGraphConfigOps::LoadLlmConfig { provider: config.provider, model: config.model, @@ -1126,14 +1126,14 @@ pub fn build_dimension_diff_review_graph_with_cloud_config( let resolve_auth = builder.add_node_after( Node::opaque( "resolve_auth", - vec![port("provider", "String")], + vec![port("provider", "NonEmptyString")], vec![ port("service", "String"), port("scheme", "String"), port("header_name", "String"), list("required_scopes", "String"), port("interactive_allowed", "Bool"), - port("secret_name", "String"), + port("secret_name", "SecretName"), ], DynOp::new(ReviewOps::ResolveAuthContract), ), @@ -1194,17 +1194,17 @@ pub fn build_dimension_diff_review_graph_with_cloud_config( format!("execute_{prefix}_llm").as_str(), format!("parse_{prefix}_llm").as_str(), vec![ - port("content", "String"), + port("content", "NonEmptyString"), port("question", "String"), - port("provider", "String"), - port("model", "String"), + port("provider", "NonEmptyString"), + port("model", "NonEmptyString"), optional("system_prompt", "OptionalString"), ], vec![ optional("scope_verified", "OptionalBool"), resource("credential", "Credential", AccessMode::Read), ], - vec![port("provider", "String")], + vec![port("provider", "NonEmptyString")], vec![port("answer", "String")], DynOp::new(LlmOps::PrepareSimpleRequest), DynOp::new(LlmOps::ParseSimpleResponse), @@ -1351,17 +1351,17 @@ pub fn build_dimension_diff_review_graph_with_cloud_config( "execute_aspirational_llm", "parse_aspirational_llm", vec![ - port("content", "String"), + port("content", "NonEmptyString"), port("question", "String"), - port("provider", "String"), - port("model", "String"), + port("provider", "NonEmptyString"), + port("model", "NonEmptyString"), optional("system_prompt", "OptionalString"), ], vec![ optional("scope_verified", "OptionalBool"), resource("credential", "Credential", AccessMode::Read), ], - vec![port("provider", "String")], + vec![port("provider", "NonEmptyString")], vec![port("answer", "String")], DynOp::new(LlmOps::PrepareSimpleRequest), DynOp::new(LlmOps::ParseSimpleResponse), diff --git a/lib/tools/gist/src/graph.rs b/lib/tools/gist/src/graph.rs index 7250705d00e..2f354a89a92 100644 --- a/lib/tools/gist/src/graph.rs +++ b/lib/tools/gist/src/graph.rs @@ -240,10 +240,10 @@ pub fn build_read_file_body_dag() -> Dag { // PrepareReadFile node — needs both filename (element) and repo_path (extra input) dag.add_node(Node::opaque( "prepare", - vec![port("filename", "String"), port("repo_path", "FilePath")], + vec![port("filename", "FilePath"), port("repo_path", "FilePath")], vec![ port("request", "TransportRequest"), - port("filename", "String"), + port("filename", "FilePath"), port("skip", "Bool"), ], DynOp::new(PrepareReadFileOp), @@ -266,7 +266,7 @@ pub fn build_read_file_body_dag() -> Dag { "parse", vec![ port("response", "TransportResponse"), - port("filename", "String"), + port("filename", "FilePath"), ], vec![port("result", "String")], DynOp::new(ParseReadFileOp), From 546e3136c2a292ba8e1d6acc3f8dafb2eb8b4443 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Feb 2026 05:23:03 +0000 Subject: [PATCH 3/7] Add M7-D and M15-D modeling design docs Co-authored-by: Brian Searls --- .../m15-typed-package-manager-modeling.md | 63 ++++++++++++++++++ .../m7-secret-redaction-by-default.md | 65 +++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 docs/design/modeling/m15-typed-package-manager-modeling.md create mode 100644 docs/design/modeling/m7-secret-redaction-by-default.md diff --git a/docs/design/modeling/m15-typed-package-manager-modeling.md b/docs/design/modeling/m15-typed-package-manager-modeling.md new file mode 100644 index 00000000000..460e5594b1b --- /dev/null +++ b/docs/design/modeling/m15-typed-package-manager-modeling.md @@ -0,0 +1,63 @@ +# M15-D: Typed Package Manager Modeling + +## Status + +- Decision: **Approved for implementation** +- Scope: `lib/tools/deps` typed install planning and adapter boundaries + +## Problem + +Legacy install flows can drift when package manager identity remains stringly or when install-option choice is implicit. We need strict typed identity, deterministic policy, and fail-closed unknown handling. + +## Required contract + +1. Package manager identity is typed (`PackageManagerId`), not free-form string. +2. Unknown IDs fail closed in strict paths. +3. Install-option selection policy is explicit/deterministic. +4. Adapter boundaries preserve required fields (`script`, `url`, `packages`) without silent loss. + +## Type model + +- `PackageManagerId`: closed enum (Apt, Apk, Brew, Cargo, Script, GithubRelease). +- `InstallPlan`: typed install intent: + - `package_manager: PackageManagerId` + - `packages: Vec` + - `script: Option` + - `url: Option` + +## Selection policy model + +- `InstallSelectionPolicy` defines total, deterministic ranking. +- Ties are broken deterministically (manager rank, stable enum ordering, declaration index). +- No declaration-order-only implicit fallback. + +## Strict vs compatibility boundaries + +### Strict path + +- `parse_strict` must reject unknown manager IDs. +- Invalid/underspecified plans fail fast (e.g. script manager without script, github_release without url). + +### Compatibility path + +- Allowed only at explicit legacy boundaries. +- May accept older surface shapes but must still validate required fields before execution. + +## DAG/resource/admission implications + +- Install operations remain explicit effectful steps; typed manager IDs do not bypass admission/resource controls. +- Manager identity is data, not execution escape hatch. + +## Migration strategy + +1. Keep compatibility parse helper only at legacy ingress points. +2. Keep strict parse as authoritative internal path. +3. Consolidate all option-selection through explicit policy. +4. Add/keep exhaustive tests over manager parsing and selection determinism. + +## Acceptance criteria (M15) + +- Unknown manager IDs fail closed on strict path. +- Deterministic policy is used for option selection. +- Required fields are preserved/validated across adapter conversion. +- Tests cover all supported manager IDs and policy ordering behavior. diff --git a/docs/design/modeling/m7-secret-redaction-by-default.md b/docs/design/modeling/m7-secret-redaction-by-default.md new file mode 100644 index 00000000000..8b45242881c --- /dev/null +++ b/docs/design/modeling/m7-secret-redaction-by-default.md @@ -0,0 +1,65 @@ +# M7-D: Secret Redaction by Default + +## Status + +- Decision: **Approved for implementation** +- Scope: `core/ir`, `core/exec`, render/codegen surfaces, lint guardrails + +## Problem + +Secret-bearing values are redacted in many display paths, but plaintext extraction still exists broadly enough to risk accidental leakage when new code paths are added. The contract must be capability-split and fail-closed. + +## Required contract + +1. `Display` / `Debug` / `.to_string()` for secret-bearing values are **always redacted**. +2. Plaintext extraction is explicit and boundary-scoped (`*_for_transport` API naming). +3. Non-boundary usage of plaintext extraction is lint-audited and denied by default. +4. Renderers and execution/status output never emit plaintext secrets. + +## Capability split model + +### Runtime secret carriers + +- `Value::Secret(SecretString)` +- transport credential `Secret` + +These may hold plaintext internally, but have no implicit formatting path that reveals content. + +### Redacted render surface + +- Any formatter/logging path sees only redacted forms (`***`). +- Secret-aware renderers must preserve redaction at code/text output boundaries. + +### Plaintext boundary capability + +- `expose_plaintext_for_transport()` remains the only plaintext extraction API. +- Semantics: allowed only at outbound transport adaptation boundaries and cryptographic serialization boundaries that must materialize bytes. + +## DAG/resource/admission implications + +- Secret flow remains typed (`Secret`, `Credential`) and explicit through ports/resources. +- No scheduler/admission bypass: secret extraction cannot be used as an alternate data path around transport/resource modeling. + +## Invalidation and migration strategy + +1. Keep existing `expose_plaintext_for_transport` API stable. +2. Continue deprecating/removing legacy aliases (`expose`). +3. Tighten lint policy for plaintext extraction usage scope. +4. Audit callsites: + - keep boundary callsites, + - replace non-boundary callsites with redacted-safe alternatives. +5. Add regressions proving no plaintext appears in display/render outputs. + +## Enforcement plan + +- Lint-level policy: + - deny legacy alias use, + - add targeted disallowed-method enforcement for plaintext extractors outside approved modules where feasible. +- Test-level policy: + - secret formatting tests for `Value`, transport credentials, and renderer outputs. + +## Acceptance criteria (M7) + +- Redaction invariant holds for all standard formatting paths. +- Plaintext extraction is explicit and grep-auditable. +- New regression tests fail if plaintext appears in renderer/display outputs. From 2372d49e3780d6028df1b25613f45ea3c70e0af0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Feb 2026 05:25:38 +0000 Subject: [PATCH 4/7] Process supported type annotations in daglang-typecheck Co-authored-by: Brian Searls --- core/daglang/daglang-typecheck/src/lib.rs | 295 +++++++++++++++++++--- 1 file changed, 256 insertions(+), 39 deletions(-) diff --git a/core/daglang/daglang-typecheck/src/lib.rs b/core/daglang/daglang-typecheck/src/lib.rs index 2e21275b3ba..3a33a784c15 100644 --- a/core/daglang/daglang-typecheck/src/lib.rs +++ b/core/daglang/daglang-typecheck/src/lib.rs @@ -20,12 +20,13 @@ //! - `@contract` annotation validation (behavioral specs are well-typed) //! - Subtyping via the bounded lattice (§4.1.4 of dsl-design.md) -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::PathBuf; use daglang_resolve::{ModuleGraph, ResolvedModule}; use daglang_syntax::ast::{ - Expr, Field, Item, Param, ProvidesClause, SourceFile, Stmt, TypeBody, TypeExpr, UsesClause, + Annotation, Expr, Field, Item, Literal, Param, ProvidesClause, SourceFile, Stmt, TypeBody, + TypeExpr, UsesClause, }; use daglang_syntax::ast_utils::{ canonical_type_name, resource_type_name, service_call_lookup_keys, @@ -3042,47 +3043,36 @@ fn validate_type_expr( } } } - "content" => { - // @content(encoding) — validates encoding is a known ContentEncoding variant - if let Some(encoding_name) = annotation.args.first().and_then(expr_as_string) { - let valid = matches!( - encoding_name.as_str(), - "Text" | "UTF8" | "ASCII" | "Latin1" | "Binary" | "Unknown" - ); - if !valid { + "content" | "brand" | "non_empty" | "pattern" | "file_types" => { + match process_supported_annotation(annotation) { + Ok(processed) => match processed { + ProcessedAnnotation::PredicateContent(encoding) => { + debug_assert!(!encoding.is_empty()); + } + ProcessedAnnotation::TypeOpBrand(brand) => { + debug_assert!(!brand.is_empty()); + } + ProcessedAnnotation::PredicateNonEmpty => {} + ProcessedAnnotation::PredicateMatches(regex) => { + debug_assert!(!regex.is_empty()); + } + ProcessedAnnotation::FileTypes(mapping) => { + if mapping.is_empty() { + errors.push(TypeError::UnsatisfiableRefinement { + ty: type_expr_to_string(inner), + constraint: "@file_types must define at least one extension or default mapping".to_string(), + }); + } + } + }, + Err(constraint) => { errors.push(TypeError::UnsatisfiableRefinement { ty: type_expr_to_string(inner), - constraint: format!( - "unknown content encoding `{encoding_name}` — expected one of: Text, UTF8, ASCII, Latin1, Binary, Unknown" - ), + constraint, }); } } } - "brand" => { - // @brand(name) — validates the brand name is non-empty - if annotation.args.is_empty() { - errors.push(TypeError::UnsatisfiableRefinement { - ty: type_expr_to_string(inner), - constraint: "@brand requires a name argument".to_string(), - }); - } - } - "non_empty" => { - // @non_empty — no arguments needed, just recognized - } - "pattern" => { - // @pattern(regex) — validates a pattern argument is present - if annotation.args.is_empty() { - errors.push(TypeError::UnsatisfiableRefinement { - ty: type_expr_to_string(inner), - constraint: "@pattern requires a regex argument".to_string(), - }); - } - } - "file_types" => { - // @file_types { text: [...], binary: [...] } — recognized, validated at lower level - } _ => { // Unknown annotations are silently accepted for forward compatibility } @@ -3124,12 +3114,160 @@ fn resolve_generic_arity( /// Extract a string value from an expression (string literal or identifier). fn expr_as_string(expr: &Expr) -> Option { match expr { - Expr::Literal(daglang_syntax::ast::Literal::String(s)) => Some(s.clone()), + Expr::Literal(Literal::String(s)) => Some(s.clone()), Expr::Ident(name) => Some(name.clone()), _ => None, } } +#[derive(Debug, Clone, PartialEq, Eq)] +enum ProcessedAnnotation { + PredicateContent(String), + TypeOpBrand(String), + PredicateNonEmpty, + PredicateMatches(String), + FileTypes(BTreeMap), +} + +fn process_supported_annotation(annotation: &Annotation) -> Result { + match annotation.name.as_str() { + "content" => { + if annotation.args.len() != 1 { + return Err("@content requires exactly one encoding argument".to_string()); + } + let encoding_name = annotation + .args + .first() + .and_then(expr_as_string) + .ok_or_else(|| "@content encoding argument must be a string/identifier".to_string())?; + let canonical = canonical_content_encoding(&encoding_name).ok_or_else(|| { + format!( + "unknown content encoding `{encoding_name}` — expected one of: Text, UTF8, ASCII, Latin1, Binary, Unknown" + ) + })?; + Ok(ProcessedAnnotation::PredicateContent(canonical)) + } + "brand" => { + if annotation.args.len() != 1 { + return Err("@brand requires exactly one name argument".to_string()); + } + let brand = annotation + .args + .first() + .and_then(expr_as_string) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| "@brand requires a non-empty name argument".to_string())?; + Ok(ProcessedAnnotation::TypeOpBrand(brand)) + } + "non_empty" => { + if !annotation.args.is_empty() { + return Err("@non_empty does not accept arguments".to_string()); + } + Ok(ProcessedAnnotation::PredicateNonEmpty) + } + "pattern" => { + if annotation.args.len() != 1 { + return Err("@pattern requires exactly one regex argument".to_string()); + } + let regex = annotation + .args + .first() + .and_then(expr_as_string) + .ok_or_else(|| "@pattern regex argument must be a string/identifier".to_string())?; + if regex.trim().is_empty() { + return Err("@pattern requires a non-empty regex".to_string()); + } + Ok(ProcessedAnnotation::PredicateMatches(regex)) + } + "file_types" => process_file_types_annotation(annotation), + _ => Err(format!( + "unsupported annotation processor `{}`", + annotation.name + )), + } +} + +fn process_file_types_annotation(annotation: &Annotation) -> Result { + if annotation.args.len() != 1 { + return Err("@file_types requires exactly one record argument".to_string()); + } + let record = annotation + .args + .first() + .ok_or_else(|| "@file_types requires one record argument".to_string())?; + let Expr::Record(_, fields) = record else { + return Err("@file_types argument must be a record".to_string()); + }; + + let mut mapping: BTreeMap = BTreeMap::new(); + for (field_name, field_expr) in fields { + match field_name.as_str() { + "text" => { + for ext in expr_as_string_list(field_expr)? { + validate_file_extension(&ext)?; + mapping.insert(ext, "Text".to_string()); + } + } + "binary" => { + for ext in expr_as_string_list(field_expr)? { + validate_file_extension(&ext)?; + mapping.insert(ext, "Binary".to_string()); + } + } + "default" => { + let default_encoding = expr_as_string(field_expr).ok_or_else(|| { + "@file_types.default must be a string/identifier encoding".to_string() + })?; + let canonical = canonical_content_encoding(&default_encoding).ok_or_else(|| { + format!( + "unknown @file_types.default encoding `{default_encoding}` — expected one of: Text, UTF8, ASCII, Latin1, Binary, Unknown" + ) + })?; + mapping.insert("*".to_string(), canonical); + } + other => { + return Err(format!( + "@file_types field `{other}` is unsupported (expected text|binary|default)" + )); + } + } + } + + Ok(ProcessedAnnotation::FileTypes(mapping)) +} + +fn expr_as_string_list(expr: &Expr) -> Result, String> { + let Expr::List(items) = expr else { + return Err("expected list of string extensions".to_string()); + }; + let mut out = Vec::with_capacity(items.len()); + for item in items { + let value = expr_as_string(item) + .ok_or_else(|| "file extension list items must be strings/identifiers".to_string())?; + out.push(value); + } + Ok(out) +} + +fn validate_file_extension(ext: &str) -> Result<(), String> { + if !ext.starts_with('.') || ext.trim().len() < 2 { + return Err(format!( + "invalid file extension `{ext}` — expected dot-prefixed suffix like `.rs`" + )); + } + Ok(()) +} + +fn canonical_content_encoding(raw: &str) -> Option { + match raw { + "Text" | "UTF8" | "ASCII" | "Latin1" | "Binary" | "Unknown" => { + Some(raw.to_string()) + } + _ => None, + } +} + fn extract_range_bounds(args: &[Expr]) -> (Option, Option) { let mut min = None; let mut max = None; @@ -3158,7 +3296,7 @@ fn extract_range_bounds(args: &[Expr]) -> (Option, Option) { fn extract_int_literal(expr: &Expr) -> Option { match expr { - Expr::Literal(daglang_syntax::ast::Literal::Int(value)) => Some(*value), + Expr::Literal(Literal::Int(value)) => Some(*value), _ => None, } } @@ -3195,6 +3333,85 @@ mod tests { ModuleGraph { modules } } + fn ann(name: &str, args: Vec) -> Annotation { + Annotation { + name: name.to_string(), + args, + } + } + + #[test] + fn process_supported_annotation_content_maps_to_predicate_content() { + let processed = process_supported_annotation(&ann("content", vec![Expr::Ident("UTF8".into())])) + .expect("content annotation should process"); + assert_eq!( + processed, + ProcessedAnnotation::PredicateContent("UTF8".to_string()) + ); + } + + #[test] + fn process_supported_annotation_brand_requires_name() { + let err = process_supported_annotation(&ann("brand", vec![])) + .expect_err("brand without name should fail"); + assert!(err.contains("@brand requires exactly one name argument")); + } + + #[test] + fn process_supported_annotation_non_empty_rejects_args() { + let err = process_supported_annotation(&ann( + "non_empty", + vec![Expr::Literal(Literal::String("oops".into()))], + )) + .expect_err("non_empty should reject arguments"); + assert!(err.contains("@non_empty does not accept arguments")); + } + + #[test] + fn process_supported_annotation_file_types_maps_extensions() { + let processed = process_supported_annotation(&ann( + "file_types", + vec![Expr::Record( + None, + vec![ + ( + "text".to_string(), + Expr::List(vec![Expr::Literal(Literal::String(".rs".into()))]), + ), + ( + "binary".to_string(), + Expr::List(vec![Expr::Literal(Literal::String(".png".into()))]), + ), + ("default".to_string(), Expr::Ident("Binary".into())), + ], + )], + )) + .expect("file_types should process"); + + let ProcessedAnnotation::FileTypes(mapping) = processed else { + panic!("expected file_types annotation mapping"); + }; + assert_eq!(mapping.get(".rs").map(String::as_str), Some("Text")); + assert_eq!(mapping.get(".png").map(String::as_str), Some("Binary")); + assert_eq!(mapping.get("*").map(String::as_str), Some("Binary")); + } + + #[test] + fn process_supported_annotation_file_types_rejects_non_dot_extensions() { + let err = process_supported_annotation(&ann( + "file_types", + vec![Expr::Record( + None, + vec![( + "text".to_string(), + Expr::List(vec![Expr::Literal(Literal::String("rs".into()))]), + )], + )], + )) + .expect_err("invalid extension should fail"); + assert!(err.contains("invalid file extension")); + } + // Test infrastructure: filesystem access for test fixtures #[allow(clippy::disallowed_methods)] #[test] From a4289ecf071daf79dd5210629b8650df85f3da5a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Feb 2026 05:27:10 +0000 Subject: [PATCH 5/7] Harden secret rendering and remove expose aliases Co-authored-by: Brian Searls --- core/ir/src/transport/credential.rs | 6 ------ core/ir/src/value.rs | 6 ------ core/ir/src/value_expr.rs | 9 ++++++++- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/core/ir/src/transport/credential.rs b/core/ir/src/transport/credential.rs index f3de52cee72..9b84f66ed22 100644 --- a/core/ir/src/transport/credential.rs +++ b/core/ir/src/transport/credential.rs @@ -128,12 +128,6 @@ impl Secret { &self.value } - /// Backward-compatible alias; prefer `expose_plaintext_for_transport`. - #[deprecated(note = "use expose_plaintext_for_transport instead")] - pub fn expose(&self) -> &str { - self.expose_plaintext_for_transport() - } - /// When this secret expires, if ever. pub fn expires_at(&self) -> Option { self.expires_at diff --git a/core/ir/src/value.rs b/core/ir/src/value.rs index daecdc68569..336d542ab15 100644 --- a/core/ir/src/value.rs +++ b/core/ir/src/value.rs @@ -77,12 +77,6 @@ impl SecretString { &self.inner } - /// Backward-compatible alias; prefer `expose_plaintext_for_transport`. - #[deprecated(note = "use expose_plaintext_for_transport instead")] - pub fn expose(&self) -> &str { - self.expose_plaintext_for_transport() - } - /// Length of the secret (safe to expose for diagnostics). pub fn len(&self) -> usize { self.inner.len() diff --git a/core/ir/src/value_expr.rs b/core/ir/src/value_expr.rs index 2fa5b4a13ea..4b55774bb52 100644 --- a/core/ir/src/value_expr.rs +++ b/core/ir/src/value_expr.rs @@ -64,7 +64,7 @@ impl From<&Value> for ValueExpr { Value::Response(r) => response_to_value_expr(r), Value::Float(f) => ValueExpr::Json(serde_json::json!(*f)), Value::Bytes(b) => ValueExpr::Json(serde_json::json!({"__bytes": b.len()})), - Value::Secret(s) => ValueExpr::Secret(s.expose_plaintext_for_transport().to_string()), + Value::Secret(_) => ValueExpr::Secret("***".to_string()), Value::Skipped => ValueExpr::Skipped, } } @@ -300,4 +300,11 @@ mod tests { ]) ); } + + #[test] + fn secret_conversion_is_redacted_for_rendering() { + let v = Value::Secret(crate::value::SecretString::new("top-secret-token")); + let expr = ValueExpr::from(&v); + assert_eq!(expr, ValueExpr::Secret("***".to_string())); + } } From 4744be0fb85762b1b72d603b40f6085018119cd9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Feb 2026 05:39:13 +0000 Subject: [PATCH 6/7] Align credential and non-empty content compatibility Co-authored-by: Brian Searls --- core/ir/src/types.rs | 13 +++++++++++++ lib/gcp-ops/src/graph.rs | 4 ++-- lib/gcp-ops/src/ops.rs | 19 +++++++++++++++++-- lib/review/src/graph.rs | 16 ++++++++-------- 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/core/ir/src/types.rs b/core/ir/src/types.rs index c8c48e92f49..665db1230cf 100644 --- a/core/ir/src/types.rs +++ b/core/ir/src/types.rs @@ -846,6 +846,11 @@ impl ValueBacking { pub fn value_backing_for_type_id(type_id: &str) -> ValueBacking { use crate::port_type::PortType; + // Credential remains a structured map payload at runtime. + if type_id == "Credential" { + return ValueBacking::Map; + } + // Check for parametric Map first if parse_map_type_id(type_id).is_some() { return ValueBacking::Map; @@ -1270,6 +1275,7 @@ mod tests { value_backing_for_type_id("SecretName"), ValueBacking::String ); + assert_eq!(value_backing_for_type_id("Credential"), ValueBacking::Map); } #[test] @@ -1302,6 +1308,13 @@ mod tests { "Platform", &Value::Str("linux".into()) )); + assert!(value_compatible_with_type_id( + "Credential", + &Value::Map(BTreeMap::from([( + "token".to_string(), + Value::Secret(crate::SecretString::new("secret-token")), + )])) + )); assert!(value_compatible_with_type_id("Any", &Value::Skipped)); assert_eq!(value_kind_name(&Value::Int(7)), "Int"); diff --git a/lib/gcp-ops/src/graph.rs b/lib/gcp-ops/src/graph.rs index f3d93516a2e..f4110a93356 100644 --- a/lib/gcp-ops/src/graph.rs +++ b/lib/gcp-ops/src/graph.rs @@ -253,7 +253,7 @@ pub fn build_gcp_secret_manager_credential_graph( "parse_impersonate", vec![ port("response", "TransportResponse"), - optional("base_access_token", "OptionalString"), + optional("base_access_token", "Optional"), ], vec![port("access_token", "Secret"), port("expires_at", "String")], DynOp::new(GcpOps::ParseImpersonate), @@ -644,7 +644,7 @@ pub fn build_gcp_secret_manager_upsert_graph( "parse_impersonate", vec![ port("response", "TransportResponse"), - optional("base_access_token", "OptionalString"), + optional("base_access_token", "Optional"), ], vec![port("access_token", "Secret"), port("expires_at", "String")], DynOp::new(GcpOps::ParseImpersonate), diff --git a/lib/gcp-ops/src/ops.rs b/lib/gcp-ops/src/ops.rs index c12cea1e319..3a516909f87 100644 --- a/lib/gcp-ops/src/ops.rs +++ b/lib/gcp-ops/src/ops.rs @@ -1,7 +1,7 @@ //! Pure GCP ops for WIF + Secret Manager. use gunbc_exec::{ - optional_bool_strict, optional_str_list_strict, optional_str_strict, require_bool, require_str, + optional_bool_strict, optional_str_list_strict, require_bool, require_str, ExecError, Executable, OutputMap, }; use gunbc_ir::transport::file::FileRequest; @@ -528,7 +528,7 @@ impl Executable for GcpOps { } GcpOps::ParseImpersonate => { let base_access_token = - optional_str_strict(&inputs, "base_access_token")?.unwrap_or(""); + optional_secret_or_str(&inputs, "base_access_token")?.unwrap_or_default(); let response = match inputs.get("response") { Some(Value::Skipped) => { return OutputMap::new() @@ -1328,6 +1328,21 @@ fn is_unreserved_url_byte(b: u8) -> bool { ) } +fn optional_secret_or_str( + inputs: &HashMap, + key: &str, +) -> Result, ExecError> { + match inputs.get(key) { + None | Some(Value::Skipped) => Ok(None), + Some(Value::Str(value)) => Ok(Some(value.clone())), + Some(Value::Secret(value)) => Ok(Some(value.expose_plaintext_for_transport().to_string())), + Some(_) => Err(ExecError::new(format!( + "invalid '{}' input: expected String or Secret", + key + ))), + } +} + fn base64_encode(input: &str) -> String { const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let bytes = input.as_bytes(); diff --git a/lib/review/src/graph.rs b/lib/review/src/graph.rs index e88786292ac..c5e75844387 100644 --- a/lib/review/src/graph.rs +++ b/lib/review/src/graph.rs @@ -307,7 +307,7 @@ pub fn build_review_phase_graph_with_config( let prepare_prompt = builder.add_root_node(Node::opaque( "prepare_prompt", vec![ - port("artifact", "String"), + port("artifact", "NonEmptyString"), port("criteria", "Json"), optional("context", "OptionalString"), ], @@ -454,7 +454,7 @@ pub fn build_inline_review_graph_with_config( let prepare_prompt = builder.add_root_node(Node::opaque( "prepare_prompt", vec![ - port("artifact", "String"), + port("artifact", "NonEmptyString"), port("criteria", "Json"), optional("context", "OptionalString"), ], @@ -667,7 +667,7 @@ pub fn build_diff_review_graph_with_cloud_config( Node::opaque( "format_artifact", vec![port("diff_files", "Map")], - vec![port("artifact", "String")], + vec![port("artifact", "NonEmptyString")], DynOp::new(ReviewOps::FormatDiffArtifact), ), &diff_triplet, @@ -681,7 +681,7 @@ pub fn build_diff_review_graph_with_cloud_config( Node::opaque( "prepare_prompt", vec![ - port("artifact", "String"), + port("artifact", "NonEmptyString"), port("criteria", "Json"), optional("context", "OptionalString"), ], @@ -900,7 +900,7 @@ pub fn build_multi_source_review_graph_with_cloud_config( Node::opaque( "prepare_prompt", vec![ - port("artifact", "String"), + port("artifact", "NonEmptyString"), port("criteria", "Json"), optional("context", "OptionalString"), ], @@ -1107,7 +1107,7 @@ pub fn build_dimension_diff_review_graph_with_cloud_config( Node::opaque( "format_artifact", vec![port("diff_files", "Map")], - vec![port("artifact", "String")], + vec![port("artifact", "NonEmptyString")], DynOp::new(ReviewOps::FormatDiffArtifact), ), &diff_triplet, @@ -1174,7 +1174,7 @@ pub fn build_dimension_diff_review_graph_with_cloud_config( Node::opaque( format!("{prefix}_prepare_prompt"), vec![ - port("artifact", "String"), + port("artifact", "NonEmptyString"), port("criteria", "Json"), port("dimension", "String"), port("depth", "String"), @@ -1331,7 +1331,7 @@ pub fn build_dimension_diff_review_graph_with_cloud_config( Node::opaque( "aspirational_prepare_prompt", vec![ - port("artifact", "String"), + port("artifact", "NonEmptyString"), port("criteria", "Json"), port("dimension", "String"), port("depth", "String"), From 72c8e07f11aea05bb4394b7867bde65c3f2a0dcf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Feb 2026 14:46:53 +0000 Subject: [PATCH 7/7] Complete hard cutover for type compatibility and port parsing Co-authored-by: Brian Searls --- core/codegen/src/testgen/codegen.rs | 33 ++--- core/daglang/daglang-lower/src/lib.rs | 31 ++-- core/daglang/daglang-syntax/src/ast_utils.rs | 6 +- core/daglang/daglang-typecheck/src/lib.rs | 106 ++++++++++---- core/ir/src/port_type.rs | 140 ++++++++++--------- core/ir/src/system_model.rs | 16 ++- core/ir/src/type_registry.rs | 6 +- core/ir/src/types.rs | 50 +++---- 8 files changed, 231 insertions(+), 157 deletions(-) diff --git a/core/codegen/src/testgen/codegen.rs b/core/codegen/src/testgen/codegen.rs index 986cde07348..10e1a7a6115 100644 --- a/core/codegen/src/testgen/codegen.rs +++ b/core/codegen/src/testgen/codegen.rs @@ -231,8 +231,8 @@ pub struct TestGenerator<'a, T> { signature_fn: Option, /// CLI entrypoints for contract test generation: (tool_name, entrypoints). cli_entrypoints: Option<(String, Vec)>, - /// Optional type registry for contract-derived witness values. - type_registry: Option, + /// Type registry for contract-derived witness values. + type_registry: TypeRegistry, } struct ProbeObserverBundle { @@ -257,7 +257,7 @@ impl<'a, T: Clone> TestGenerator<'a, T> { mock_spec_fn: None, signature_fn: None, cli_entrypoints: None, - type_registry: Some(TypeRegistry::with_core_types()), + type_registry: TypeRegistry::with_core_types(), } } @@ -303,7 +303,7 @@ impl<'a, T: Clone> TestGenerator<'a, T> { /// Set a type registry for contract-derived witness values. pub fn with_type_registry(mut self, registry: TypeRegistry) -> Self { - self.type_registry = Some(registry); + self.type_registry = registry; self } @@ -2000,7 +2000,7 @@ impl<'a, T: Clone> TestGenerator<'a, T> { type_id, *cardinality, count, - self.type_registry.as_ref(), + &self.type_registry, ); let mocks_expr = self.dryrun_mocks_expr(analysis, "cardinality coverage tests"); @@ -3468,9 +3468,9 @@ impl<'a, T: Clone> TestGenerator<'a, T> { } let value = if port.has_guard() { - select_guard_value(port, self.type_registry.as_ref()) + select_guard_value(port, &self.type_registry) } else { - required_value_for_port(port, self.type_registry.as_ref()) + required_value_for_port(port, &self.type_registry) }; match value { @@ -5330,9 +5330,8 @@ fn witness_value_for_count( type_id: &str, cardinality: Cardinality, count: u32, - registry: Option<&TypeRegistry>, + registry: &TypeRegistry, ) -> Option { - let registry = registry?; let type_dag = registry.get_by_name(type_id)?; let witnesses = contract::witnesses(type_dag); @@ -5365,7 +5364,7 @@ fn try_mock_value_for_count( type_id: &str, cardinality: Cardinality, count: u32, - registry: Option<&TypeRegistry>, + registry: &TypeRegistry, ) -> Option { if let Some(value) = witness_value_for_count(type_id, cardinality, count, registry) { return Some(value); @@ -5403,7 +5402,7 @@ fn required_count_for_port(port: &gunbc_ir::Port) -> Option { fn candidate_values_for_guard( port: &gunbc_ir::Port, - registry: Option<&TypeRegistry>, + registry: &TypeRegistry, ) -> Vec { let Some(count) = required_count_for_port(port) else { return Vec::new(); @@ -5435,7 +5434,7 @@ fn candidate_values_for_guard( values } -fn select_guard_value(port: &gunbc_ir::Port, registry: Option<&TypeRegistry>) -> Option { +fn select_guard_value(port: &gunbc_ir::Port, registry: &TypeRegistry) -> Option { candidate_values_for_guard(port, registry) .into_iter() .find(|candidate| port.check_guard(candidate)) @@ -5443,7 +5442,7 @@ fn select_guard_value(port: &gunbc_ir::Port, registry: Option<&TypeRegistry>) -> fn required_value_for_port( port: &gunbc_ir::Port, - registry: Option<&TypeRegistry>, + registry: &TypeRegistry, ) -> Option { let count = required_count_for_port(port)?; try_mock_value_for_count(port.type_id.0.as_str(), port.cardinality, count, registry) @@ -5461,7 +5460,7 @@ fn mock_value_expr_for_count( type_id: &str, cardinality: Cardinality, count: u32, - registry: Option<&TypeRegistry>, + registry: &TypeRegistry, ) -> ValueExpr { if let Some(value) = witness_value_for_count(type_id, cardinality, count, registry) { return ValueExpr::from(&value); @@ -6146,15 +6145,13 @@ mod tests { #[test] fn test_mock_value_respects_cardinality() { let registry = TypeRegistry::with_core_types(); - let list_expr = - mock_value_expr_for_count("String", Cardinality::ZERO_OR_MORE, 1, Some(®istry)); + let list_expr = mock_value_expr_for_count("String", Cardinality::ZERO_OR_MORE, 1, ®istry); assert_eq!( list_expr, ValueExpr::List(vec![ValueExpr::Str("example".to_string())]) ); - let opt_zero = - mock_value_expr_for_count("String", Cardinality::ZERO_OR_ONE, 0, Some(®istry)); + let opt_zero = mock_value_expr_for_count("String", Cardinality::ZERO_OR_ONE, 0, ®istry); assert_eq!(opt_zero, ValueExpr::Unit); } diff --git a/core/daglang/daglang-lower/src/lib.rs b/core/daglang/daglang-lower/src/lib.rs index 409ba72bbaf..d179f9aa885 100644 --- a/core/daglang/daglang-lower/src/lib.rs +++ b/core/daglang/daglang-lower/src/lib.rs @@ -20,9 +20,8 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use daglang_syntax::ast::{Annotation, Expr, Item, Literal, OperationDef, ServiceDef, Stmt}; use daglang_syntax::ast_utils::{ - canonical_resource_type_name as canonical_type_name, resource_type_name, - service_call_lookup_keys, should_track_call_name as should_track_call, type_expr_to_string, - walk_stmts, + resource_type_name, service_call_lookup_keys, + should_track_call_name as should_track_call, type_expr_to_string, walk_stmts, }; use daglang_typecheck::{TypedCallableSignature, TypedItemSignature, TypedProject}; use gunbc_ir::patterns::branch::IfBuilder; @@ -615,8 +614,18 @@ fn provider_hint_from_resource_properties(properties: &[(String, Expr)]) -> Opti None } +fn normalize_type_id_key(name: &str) -> String { + let trimmed = name.trim(); + let base_without_config = trimmed.split('(').next().unwrap_or(trimmed).trim(); + let base_without_annotations = base_without_config + .split_whitespace() + .next() + .unwrap_or(base_without_config); + gunbc_ir::TypeId::from(base_without_annotations).0 +} + fn insert_canonical_names(set: &mut HashSet, name: &str) { - let canonical = canonical_type_name(name); + let canonical = normalize_type_id_key(name); let short = canonical .rsplit('.') .next() @@ -627,7 +636,7 @@ fn insert_canonical_names(set: &mut HashSet, name: &str) { } fn is_known_uses_type(set: &HashSet, name: &str) -> bool { - let canonical = canonical_type_name(name); + let canonical = normalize_type_id_key(name); set.contains(&canonical) || set.contains(canonical.rsplit('.').next().unwrap_or(canonical.as_str())) } @@ -4608,7 +4617,7 @@ fn resolve_interface_resource_endpoint( project: &TypedProject, registry: &ResourceLifecycleRegistry, ) -> ResourceEndpointResolution { - let target_canonical = canonical_type_name(resource_type); + let target_canonical = normalize_type_id_key(resource_type); let target_short = target_canonical .rsplit('.') .next() @@ -4624,7 +4633,7 @@ fn resolve_interface_resource_endpoint( let Some(implemented) = &resource.implements else { continue; }; - let implemented_canonical = canonical_type_name(implemented); + let implemented_canonical = normalize_type_id_key(implemented); let implemented_short = implemented_canonical .rsplit('.') .next() @@ -4817,7 +4826,7 @@ fn add_interface_contract_verification_nodes( sanitize_identifier(&format!( "{module_name}_{}_{}_{}", resource.name, - canonical_type_name(interface_name), + normalize_type_id_key(interface_name), index )) ); @@ -4831,7 +4840,7 @@ fn add_interface_contract_verification_nodes( name: format!( "interface_contract::{}::{}::{}", resource.name, - canonical_type_name(interface_name), + normalize_type_id_key(interface_name), index ), obligation: ObligationCategory::InterfaceContractVerification, @@ -4857,7 +4866,7 @@ fn add_interface_contract_verification_nodes( } fn resolve_interface_contract_count(project: &TypedProject, interface_name: &str) -> usize { - let target = canonical_type_name(interface_name); + let target = normalize_type_id_key(interface_name); let target_short = target.rsplit('.').next().unwrap_or(target.as_str()); let mut counts = Vec::new(); for module in &project.modules { @@ -4867,7 +4876,7 @@ fn resolve_interface_contract_count(project: &TypedProject, interface_name: &str continue; }; let qualified = format!("{module_name}.{}", interface.name); - let qualified_canonical = canonical_type_name(&qualified); + let qualified_canonical = normalize_type_id_key(&qualified); let interface_short = interface .name .rsplit('.') diff --git a/core/daglang/daglang-syntax/src/ast_utils.rs b/core/daglang/daglang-syntax/src/ast_utils.rs index 6da86643d7e..29c57130977 100644 --- a/core/daglang/daglang-syntax/src/ast_utils.rs +++ b/core/daglang/daglang-syntax/src/ast_utils.rs @@ -24,17 +24,13 @@ pub fn type_expr_to_string(expr: &TypeExpr) -> String { } } -pub fn canonical_type_name(name: &str) -> String { - name.split('<').next().unwrap_or(name).trim().to_string() -} - pub fn canonical_resource_type_name(name: &str) -> String { let base_without_config = name.split('(').next().unwrap_or(name).trim(); let base_without_annotations = base_without_config .split_whitespace() .next() .unwrap_or(base_without_config); - canonical_type_name(base_without_annotations) + base_without_annotations.to_string() } pub fn resource_type_name(resource_type: &TypeExpr) -> String { diff --git a/core/daglang/daglang-typecheck/src/lib.rs b/core/daglang/daglang-typecheck/src/lib.rs index 3a33a784c15..0f4b82c3a77 100644 --- a/core/daglang/daglang-typecheck/src/lib.rs +++ b/core/daglang/daglang-typecheck/src/lib.rs @@ -29,8 +29,8 @@ use daglang_syntax::ast::{ TypeExpr, UsesClause, }; use daglang_syntax::ast_utils::{ - canonical_type_name, resource_type_name, service_call_lookup_keys, - should_track_call_name as should_validate_call_name, type_expr_to_string, walk_stmts, + resource_type_name, service_call_lookup_keys, should_track_call_name as should_validate_call_name, + type_expr_to_string, walk_stmts, }; /// A typechecked project snapshot. @@ -2225,7 +2225,7 @@ fn infer_expr_type_for_expected_named_record( compatible = false; continue; }; - if !types_match(expected_field_ty, &inferred_name) { + if !type_ids_compatible(expected_field_ty, &inferred_name) { eprintln!("[DEBUG field_mismatch] expected_type={expected_type:?} field={name:?} expected_field_ty={expected_field_ty:?} inferred={inferred_name:?}"); errors.push(TypeError::TypeMismatch { expected: expected_field_ty.clone(), @@ -2364,7 +2364,7 @@ fn infer_expr_type( daglang_syntax::ast::BinOp::NullCoalesce => lhs_ty, _ => match (lhs_ty, rhs_ty) { (ValueType::Named(lhs), ValueType::Named(rhs)) - if canonical_type_name(&lhs) == canonical_type_name(&rhs) => + if type_ids_compatible(&lhs, &rhs) && type_ids_compatible(&rhs, &lhs) => { ValueType::Named(lhs) } @@ -2534,7 +2534,7 @@ fn push_type_mismatch_if_needed(expected: &str, inferred: &ValueType) -> Vec Vec bool { - if expected == got { - return true; +fn normalize_type_id_text(name: &str) -> String { + let trimmed = name.trim(); + let base_without_config = trimmed.split('(').next().unwrap_or(trimmed).trim(); + let base_without_annotations = base_without_config + .split_whitespace() + .next() + .unwrap_or(base_without_config); + if let Some(inner) = base_without_annotations.strip_suffix('?') { + return format!("Optional<{}>", normalize_type_id_text(inner)); } - // Canonicalize and check structural equality - let expected_canonical = canonical_type_name(expected); - let got_canonical = canonical_type_name(got); - if expected_canonical == got_canonical - || expected_canonical.rsplit('.').next() == got_canonical.rsplit('.').next() - { - return true; + base_without_annotations.to_string() +} + +fn erase_generic_args(type_id: &str) -> String { + let mut depth = 0usize; + let mut out = String::with_capacity(type_id.len()); + for ch in type_id.chars() { + match ch { + '<' => depth += 1, + '>' => depth = depth.saturating_sub(1), + _ if depth == 0 => out.push(ch), + _ => {} + } + } + out.trim().to_string() +} + +fn type_id_candidates(name: &str) -> Vec { + let normalized = normalize_type_id_text(name); + let mut candidates = Vec::::new(); + + let mut add_candidate = |candidate: String| { + if !candidate.is_empty() { + let candidate_id = gunbc_ir::TypeId::from(candidate.as_str()); + if !candidates.contains(&candidate_id) { + candidates.push(candidate_id); + } + } + }; + + let erased = erase_generic_args(&normalized); + for raw in [normalized.clone(), erased.clone()] { + add_candidate(raw.clone()); + let short = raw.rsplit('.').next().unwrap_or(raw.as_str()).to_string(); + add_candidate(short); + } + + candidates +} + +/// Check whether `got` is compatible with `expected` using TypeId-based lookups +/// and TypeRegistry coercion/compatibility rules. +fn type_ids_compatible(expected: &str, got: &str) -> bool { + let registry = gunbc_ir::type_registry::TypeRegistry::with_core_types(); + let expected_ids = type_id_candidates(expected); + let got_ids = type_id_candidates(got); + for expected_id in &expected_ids { + for got_id in &got_ids { + if got_id == expected_id || registry.is_compatible(got_id, expected_id) { + return true; + } + } } - // Check TypeRegistry coercion paths (e.g. TextFilePath → FilePath → String) - use gunbc_ir::type_registry::TypeRegistry; - let registry = TypeRegistry::with_core_types(); - let got_id = gunbc_ir::TypeId::from(got_canonical.as_str()); - let expected_id = gunbc_ir::TypeId::from(expected_canonical.as_str()); - registry.is_compatible(&got_id, &expected_id) + false } fn resolve_record_fields( ty: &str, registry: &RecordTypeRegistry, ) -> Option> { - let canonical = canonical_type_name(ty); + let canonical = normalize_type_id_text(ty); + let erased = erase_generic_args(&canonical); if let Some(fields) = registry.full.get(&canonical) { return Some(fields.clone()); } + if let Some(fields) = registry.full.get(&erased) { + return Some(fields.clone()); + } let short = canonical.rsplit('.').next().unwrap_or(canonical.as_str()); let Some(Some(full_name)) = registry.short.get(short) else { - return None; + let short_erased = erased.rsplit('.').next().unwrap_or(erased.as_str()); + let Some(Some(full_name)) = registry.short.get(short_erased) else { + return None; + }; + return registry.full.get(full_name).cloned(); }; registry.full.get(full_name).cloned() } @@ -2791,7 +2843,7 @@ fn resolve_interface_contract( implemented: &str, registry: &InterfaceRegistry, ) -> InterfaceResolution { - let canonical = canonical_type_name(implemented); + let canonical = erase_generic_args(&normalize_type_id_text(implemented)); if let Some(contract) = registry.full.get(&canonical) { return InterfaceResolution::Resolved(contract.clone()); } @@ -2804,7 +2856,7 @@ fn resolve_interface_contract( } fn canonical_interface_name(name: &str) -> String { - canonical_type_name(name) + erase_generic_args(&normalize_type_id_text(name)) } fn resolve_resource_type_name( diff --git a/core/ir/src/port_type.rs b/core/ir/src/port_type.rs index 7db34437e45..fa2a3db23a3 100644 --- a/core/ir/src/port_type.rs +++ b/core/ir/src/port_type.rs @@ -95,7 +95,7 @@ impl PortType { for primitive in &["String", "Int", "Float", "Bool", "Bytes", "Secret", "Json"] { let prim_id = crate::types::TypeId::from(*primitive); if registry.coercion_path(&type_id, &prim_id).is_some() { - return Ok(parse_known_type(primitive)); + return Ok(parse_known_primitive_type(primitive).expect("known primitive")); } } // Registered but no coercion to a primitive — treat as Json (structured) @@ -125,45 +125,9 @@ impl std::fmt::Display for PortType { } } -/// Parse a `TypeId` string into a structural `PortType`. -/// -/// Known domain types are resolved to their structural backing types. -/// Truly unrecognized type strings fall through to `PortType::Any`. -/// -/// For strict resolution that rejects unknown types, use -/// `PortType::from_registry()` instead. -impl From<&TypeId> for PortType { - fn from(type_id: &TypeId) -> Self { - parse_port_type(&type_id.0) - } -} - -impl From<&str> for PortType { - fn from(s: &str) -> Self { - parse_port_type(s) - } -} - -/// Parse a known structural type keyword. Always succeeds for the 8 primitives. -fn parse_known_type(s: &str) -> PortType { - match s { - "Json" => PortType::Json, - "String" => PortType::String, - "Bytes" => PortType::Bytes, - "Bool" => PortType::Bool, - "Int" => PortType::Int, - "Float" => PortType::Float, - "Secret" => PortType::Secret, - "Any" => PortType::Any, - _ => PortType::Any, - } -} - -/// Try to parse a type string into a structural PortType. -/// Returns `None` for unrecognized types (use `from_registry` for those). -fn try_parse_port_type(s: &str) -> Option { +/// Parse a known structural primitive type keyword. +fn parse_known_primitive_type(s: &str) -> Option { match s { - // Structural primitives "Json" => Some(PortType::Json), "String" => Some(PortType::String), "Bytes" => Some(PortType::Bytes), @@ -172,11 +136,22 @@ fn try_parse_port_type(s: &str) -> Option { "Float" => Some(PortType::Float), "Secret" => Some(PortType::Secret), "Any" => Some(PortType::Any), + _ => None, + } +} + +/// Try to parse a type string into a structural PortType. +/// Returns `None` for unrecognized types (use `from_registry` for those). +pub fn try_parse_port_type(s: &str) -> Option { + match s { + // Structural primitives + s if parse_known_primitive_type(s).is_some() => parse_known_primitive_type(s), // Generic List other if other.starts_with("List<") && other.ends_with('>') => { let inner = &other[5..other.len() - 1]; - Some(PortType::List(Box::new(parse_port_type(inner)))) + let inner_type = try_parse_port_type(inner)?; + Some(PortType::List(Box::new(inner_type))) } // Domain types — string-backed @@ -212,12 +187,6 @@ fn try_parse_port_type(s: &str) -> Option { } } -/// Full parse with fallback to `PortType::Any` for unrecognized types. -/// Prefer `try_parse_port_type` or `PortType::from_registry` for strict use. -fn parse_port_type(s: &str) -> PortType { - try_parse_port_type(s).unwrap_or(PortType::Any) -} - #[cfg(test)] mod tests { use super::*; @@ -268,7 +237,7 @@ mod tests { ]; for pt in &cases { let type_id = pt.to_type_id(); - let back: PortType = PortType::from(&type_id); + let back = try_parse_port_type(&type_id.0).expect("type should round-trip"); assert_eq!(pt, &back, "round-trip failed for {pt}"); } } @@ -276,42 +245,83 @@ mod tests { #[test] fn parse_legacy_type_ids() { assert_eq!( - PortType::from("StringList"), + try_parse_port_type("StringList").expect("StringList should parse"), PortType::List(Box::new(PortType::String)) ); - assert_eq!(PortType::from("OptionalString"), PortType::String); + assert_eq!( + try_parse_port_type("OptionalString").expect("OptionalString should parse"), + PortType::String + ); } #[test] fn domain_types_resolve_to_structural_backing() { // String-backed domain types - assert_eq!(PortType::from("FilePath"), PortType::String); - assert_eq!(PortType::from("TextFilePath"), PortType::String); - assert_eq!(PortType::from("Platform"), PortType::String); - assert_eq!(PortType::from("Url"), PortType::String); - assert_eq!(PortType::from("GcpProjectId"), PortType::String); - assert_eq!(PortType::from("SecretName"), PortType::String); + assert_eq!( + try_parse_port_type("FilePath").expect("FilePath should parse"), + PortType::String + ); + assert_eq!( + try_parse_port_type("TextFilePath").expect("TextFilePath should parse"), + PortType::String + ); + assert_eq!( + try_parse_port_type("Platform").expect("Platform should parse"), + PortType::String + ); + assert_eq!( + try_parse_port_type("Url").expect("Url should parse"), + PortType::String + ); + assert_eq!( + try_parse_port_type("GcpProjectId").expect("GcpProjectId should parse"), + PortType::String + ); + assert_eq!( + try_parse_port_type("SecretName").expect("SecretName should parse"), + PortType::String + ); // Bytes-backed domain types - assert_eq!(PortType::from("BinaryFilePath"), PortType::Bytes); + assert_eq!( + try_parse_port_type("BinaryFilePath").expect("BinaryFilePath should parse"), + PortType::Bytes + ); // Int-backed domain types - assert_eq!(PortType::from("Timestamp"), PortType::Int); + assert_eq!( + try_parse_port_type("Timestamp").expect("Timestamp should parse"), + PortType::Int + ); // Credential is a compound type, not a scalar secret - assert_eq!(PortType::from("Credential"), PortType::Json); + assert_eq!( + try_parse_port_type("Credential").expect("Credential should parse"), + PortType::Json + ); // Json/structured-backed domain types - assert_eq!(PortType::from("TransportRequest"), PortType::Json); - assert_eq!(PortType::from("FileResponse"), PortType::Json); - assert_eq!(PortType::from("ToolHandle"), PortType::Json); - assert_eq!(PortType::from("CliResult"), PortType::Json); + assert_eq!( + try_parse_port_type("TransportRequest").expect("TransportRequest should parse"), + PortType::Json + ); + assert_eq!( + try_parse_port_type("FileResponse").expect("FileResponse should parse"), + PortType::Json + ); + assert_eq!( + try_parse_port_type("ToolHandle").expect("ToolHandle should parse"), + PortType::Json + ); + assert_eq!( + try_parse_port_type("CliResult").expect("CliResult should parse"), + PortType::Json + ); } #[test] - fn unknown_type_still_falls_back_to_any() { - // Unrecognized types still get Any via parse_port_type - assert_eq!(PortType::from("SomeUnknownType"), PortType::Any); + fn unknown_type_is_not_structurally_parsed() { + assert!(try_parse_port_type("SomeUnknownType").is_none()); } #[test] diff --git a/core/ir/src/system_model.rs b/core/ir/src/system_model.rs index 8ec178d7834..ffc3c3a9168 100644 --- a/core/ir/src/system_model.rs +++ b/core/ir/src/system_model.rs @@ -876,6 +876,16 @@ fn rust_type_for_port_type(port_type: &PortType, original_type_id: &TypeId) -> S } } +fn strict_port_type_for_type_id(type_id: &TypeId) -> PortType { + let registry = crate::type_registry::TypeRegistry::with_core_types(); + PortType::from_registry(type_id.0.as_str(), ®istry).unwrap_or_else(|err| { + panic!( + "unknown contract harness type `{}`: {}", + type_id.0, err + ) + }) +} + fn sanitize_ident(input: &str) -> String { let mut out = String::new(); for ch in input.chars() { @@ -905,7 +915,7 @@ pub fn render_contract_test_harness(spec: &ContractTestSpec) -> String { .iter() .map(|input| { let type_id = input.input_type.type_id(); - let port_type = PortType::from(type_id); + let port_type = strict_port_type_for_type_id(type_id); format!( "{}: {}", sanitize_ident(&input.name), @@ -917,7 +927,7 @@ pub fn render_contract_test_harness(spec: &ContractTestSpec) -> String { let return_type = if spec.outputs.len() == 1 { let type_id = spec.outputs[0].output_type.type_id(); - let port_type = PortType::from(type_id); + let port_type = strict_port_type_for_type_id(type_id); rust_type_for_port_type(&port_type, type_id).to_string() } else { format!( @@ -926,7 +936,7 @@ pub fn render_contract_test_harness(spec: &ContractTestSpec) -> String { .iter() .map(|out| { let type_id = out.output_type.type_id(); - let port_type = PortType::from(type_id); + let port_type = strict_port_type_for_type_id(type_id); rust_type_for_port_type(&port_type, type_id) }) .collect::>() diff --git a/core/ir/src/type_registry.rs b/core/ir/src/type_registry.rs index 2f3245c02db..4a18d9fa05d 100644 --- a/core/ir/src/type_registry.rs +++ b/core/ir/src/type_registry.rs @@ -1294,9 +1294,9 @@ mod tests { missing.push(*type_name); } - // Must NOT fall through to PortType::Any - let pt = PortType::from(*type_name); - if pt == PortType::Any { + // Must resolve to a structural PortType (not unknown/None). + let pt = crate::port_type::try_parse_port_type(type_name); + if pt.is_none() || pt == Some(PortType::Any) { port_type_any.push(*type_name); } } diff --git a/core/ir/src/types.rs b/core/ir/src/types.rs index 665db1230cf..0ac194e26a8 100644 --- a/core/ir/src/types.rs +++ b/core/ir/src/types.rs @@ -844,7 +844,7 @@ impl ValueBacking { /// Uses `PortType` for structurally known types, then falls back to /// domain-specific knowledge for opaque types that map to `PortType::Any`. pub fn value_backing_for_type_id(type_id: &str) -> ValueBacking { - use crate::port_type::PortType; + use crate::port_type::{try_parse_port_type, PortType}; // Credential remains a structured map payload at runtime. if type_id == "Credential" { @@ -868,30 +868,30 @@ pub fn value_backing_for_type_id(type_id: &str) -> ValueBacking { return value_backing_for_type_id(inner); } - let port_type = PortType::from(type_id); - match port_type { - PortType::String => ValueBacking::String, - PortType::Bool => ValueBacking::Bool, - PortType::Int => ValueBacking::Int, - PortType::Float => ValueBacking::Float, - PortType::Json => ValueBacking::Json, - PortType::Bytes => ValueBacking::Bytes, - PortType::Secret => ValueBacking::String, - PortType::List(_) => ValueBacking::List, - PortType::Any => { - // Residual catch-all for types not structurally resolved. - // Most domain types now parse directly (FilePath→String, etc.). - match type_id { - // Legacy list aliases - s if s.ends_with("List") => ValueBacking::List, - // Legacy set aliases - s if s.ends_with("Set") => ValueBacking::Set, - // Optional wrappers inherit inner type's backing - s if s.starts_with("Optional") => value_backing_for_type_id(&s["Optional".len()..]), - // Default: Json accepts anything - _ => ValueBacking::Json, - } - } + if let Some(port_type) = try_parse_port_type(type_id) { + return match port_type { + PortType::String => ValueBacking::String, + PortType::Bool => ValueBacking::Bool, + PortType::Int => ValueBacking::Int, + PortType::Float => ValueBacking::Float, + PortType::Json => ValueBacking::Json, + PortType::Bytes => ValueBacking::Bytes, + PortType::Secret => ValueBacking::String, + PortType::List(_) => ValueBacking::List, + PortType::Any => ValueBacking::Json, + }; + } + + // Residual catch-all for non-structural aliases. + match type_id { + // Legacy list aliases + s if s.ends_with("List") => ValueBacking::List, + // Legacy set aliases + s if s.ends_with("Set") => ValueBacking::Set, + // Optional wrappers inherit inner type's backing + s if s.starts_with("Optional") => value_backing_for_type_id(&s["Optional".len()..]), + // Default: Json accepts anything + _ => ValueBacking::Json, } }