From ade82c3635efa279b42b79ad8ef022df3cec4e81 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 21:42:57 +0000 Subject: [PATCH 01/13] Fix three PR review issues: CI branch checkout, SSH URL parsing, optional commas - run_ci_validation: checkout the agent branch before running cargo test/clippy, then restore original branch afterward. Previously the branch parameter was ignored (let _ = branch), causing validation to run against whatever branch was checked out. - parse_github_owner_repo: normalize SSH remote URLs (git@host:org/repo) by converting the colon to a slash before splitting. HTTPS URLs with "://" are left unchanged. Fixes malformed owner/repo for SSH remotes. - parse_property_list_until_rbrace: make comma separator optional so newline-separated property lists in SDLC blocks parse correctly. Previously the loop broke after the first property when no comma followed. https://claude.ai/code/session_01GZ6E3UdBSQCEtjHvWWJt6H --- core/daglang/daglang-syntax/src/parser.rs | 5 ++- gunbc-dag/src/bin/sdlc.rs | 38 +++++++++++++++++++++-- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/core/daglang/daglang-syntax/src/parser.rs b/core/daglang/daglang-syntax/src/parser.rs index 65331ec0386..57c2eb62b25 100644 --- a/core/daglang/daglang-syntax/src/parser.rs +++ b/core/daglang/daglang-syntax/src/parser.rs @@ -1271,9 +1271,8 @@ impl Parser { self.expect(&TokenKind::Eq)?; let expr = self.parse_expr(0)?; props.push((name, expr)); - if !self.eat(&TokenKind::Comma) { - break; - } + // Comma is optional; newline-separated properties are valid. + self.eat(&TokenKind::Comma); } Ok(props) } diff --git a/gunbc-dag/src/bin/sdlc.rs b/gunbc-dag/src/bin/sdlc.rs index 4bf66212239..39946c5fdfa 100644 --- a/gunbc-dag/src/bin/sdlc.rs +++ b/gunbc-dag/src/bin/sdlc.rs @@ -1734,6 +1734,23 @@ fn run_ci_validation(branch: &str, dry_run: bool) -> Result Result Result Option<(String, String)> { .trim() .trim_end_matches(".git") .trim_end_matches('/'); - let parts: Vec<&str> = cleaned.rsplitn(3, '/').collect(); + // Handle SSH URLs like git@github.com:org/repo + // by normalizing the colon separator to a slash. + let normalized = if let Some(colon_pos) = cleaned.find(':') { + // Only treat as SSH if there's no "://" (which would be HTTPS). + if !cleaned[..colon_pos + 1].ends_with("://") { + cleaned.replacen(':', "/", 1) + } else { + cleaned.to_string() + } + } else { + cleaned.to_string() + }; + let parts: Vec<&str> = normalized.rsplitn(3, '/').collect(); if parts.len() >= 2 { Some((parts[1].to_string(), parts[0].to_string())) } else { From edddd53e3278fc26eab51d4104017d02812c18fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 22:00:07 +0000 Subject: [PATCH 02/13] Eliminate hardcoded registration lists (CL1-CL8) CL1: Replace 58-entry hardcoded module order test with filesystem discovery via discover_dag_files(). Also replace hardcoded parsed_count assertions (56) with dynamic counts. Tests no longer break when .dag files are added/removed. CL2+CL3: Replace 9 domain_passthrough_op! macro invocations (9 enum types, 9 Executable impls, 9 resolver functions) with a single PassthroughOp struct and centralized PASSTHROUGH_CALLABLES registry. Adding a new passthrough callable requires one line in the registry instead of a new macro invocation. resolve_domain() checks custom resolvers first, then service transport, then passthrough registry. CL4: Replace 3 manual match arms (tool_name, from_tool_name, component) and a counted ALL array with a workspace_binaries! macro that derives all accessors from a single definition table. Adding a binary requires one line instead of three edits. CL5: Consolidate TOOL_WORKFLOWS registry from 70+ lines of struct literals to compact tw() helper calls. CL6: Replace 11 per-workflow ProcessUnitRef helper functions with a single pu() helper. Consolidate verbose ProcessUnitSpec::new() calls into compact one-liners using r/w claim aliases. CL7: Document why pragma and build need MANUAL_TOOL_DEFS (custom Executable impl and non-standard short_name respectively). Both are DSL-gated and the 2-entry list is appropriate. CL8: Remove hardcoded resource name match in resolve_std_resources(). Resource names are now passed directly from the DSL callable name. Adding a new resource to std/resources.dag no longer requires a Rust match arm. Net: -527 lines, all tests pass, clippy clean. https://claude.ai/code/session_01GZ6E3UdBSQCEtjHvWWJt6H --- core/daglang/daglang-cli/src/pipeline.rs | 102 ++-- gunbc-dag/src/binaries.rs | 146 +++--- gunbc-dag/src/makegen/registry.rs | 21 +- gunbc-dag/src/resolve.rs | 240 +++------ gunbc-dag/src/workflow/process_registry.rs | 560 ++++----------------- gunbc-dag/src/workflow/spec_builders.rs | 96 +--- 6 files changed, 319 insertions(+), 846 deletions(-) diff --git a/core/daglang/daglang-cli/src/pipeline.rs b/core/daglang/daglang-cli/src/pipeline.rs index 103034769f5..e52cc864944 100644 --- a/core/daglang/daglang-cli/src/pipeline.rs +++ b/core/daglang/daglang-cli/src/pipeline.rs @@ -770,65 +770,22 @@ mod tests { )) } - fn expected_real_corpus_module_order() -> Vec<&'static str> { - vec![ - "examples.rich_types", - "infra.aws.services", - "infra.azure.services", - "infra.core", - "infra.gcp.services", - "infra.spec", - "infra.aws.config", - "infra.aws.resources", - "infra.azure.config", - "infra.azure.resources", - "infra.gcp.config", - "infra.gcp.resources", - "examples.integration_tests", - "infra.sdlc.deploy", - "services.agent.codex", - "services.cargo", - "services.gcp.iam", - "services.gcp.secret_manager", - "services.gcp.sts", - "services.github.gist", - "services.github.issues", - "funcs.test_control_flow", - "services.github.pull_request", - "services.llm.anthropic", - "services.llm.openai", - "services.sdlc.control_plane", - "funcs.agent_feedback", - "std.resources", - "std.types", - "cloud.aws.credential", - "cloud.azure.credential", - "examples.abstract_services", - "services.git", - "services.shell", - "std.patterns", - "cloud.gcp.credential", - "shared.dag_util", - "shared.gist_modes", - "tools.bootstrap", - "tools.build", - "tools.clippy", - "tools.codegen", - "tools.dag_viz", - "tools.deps", - "tools.design", - "pipelines.sdlc", - "funcs.sdlc_worker", - "tools.docgen", - "tools.gist", - "examples.deployment", - "tools.infra", - "tools.makegen", - "tools.pragma", - "tools.review", - "tools.testgen", - "pipelines.ci", - ] + /// Discover expected module IDs from the filesystem instead of a hardcoded list. + /// Globs `dsl/**/*.dag`, converts paths to module IDs, and returns a sorted set. + fn discover_expected_module_ids(dsl_root: &PathBuf) -> Vec { + let files = daglang_resolve::discover_dag_files(dsl_root) + .expect("discover_dag_files should succeed"); + let mut module_ids: Vec = files + .iter() + .filter_map(|path| { + let rel = path.strip_prefix(dsl_root).ok()?; + let segments = daglang_resolve::relative_path_to_module_path(rel); + Some(segments.join(".")) + }) + .collect(); + module_ids.sort(); + module_ids.dedup(); + module_ids } fn reported_modules_in_order(report: &str) -> Vec { @@ -1067,6 +1024,9 @@ mod tests { #[test] fn parse_pipeline_parses_full_real_dsl_corpus_without_diagnostics() { let dsl_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../dsl"); + let expected_count = daglang_resolve::discover_dag_files(&dsl_root) + .expect("discover_dag_files should succeed") + .len(); let result = run_pipeline( &PipelineContext { roots: vec![dsl_root], @@ -1076,7 +1036,7 @@ mod tests { ) .expect("pipeline should execute"); - assert_eq!(result.parsed_count(), 56); + assert_eq!(result.parsed_count(), expected_count); assert!( result.diagnostics().is_empty(), "real corpus parse stop should not emit parse diagnostics: {:?}", @@ -1111,6 +1071,9 @@ mod tests { #[test] fn report_pipeline_real_corpus_retains_parsed_count() { let dsl_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../dsl"); + let expected_count = daglang_resolve::discover_dag_files(&dsl_root) + .expect("discover_dag_files should succeed") + .len(); let result = run_pipeline( &PipelineContext { roots: vec![dsl_root], @@ -1121,29 +1084,30 @@ mod tests { .expect("pipeline should execute"); assert_eq!( result.parsed_count(), - 56, + expected_count, "report stop should retain parse-stage file count for real corpus" ); } #[test] - fn report_pipeline_real_corpus_module_order_matches_expected_snapshot() { + fn report_pipeline_real_corpus_module_set_matches_filesystem() { let dsl_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../dsl"); let result = run_pipeline( &PipelineContext { - roots: vec![dsl_root], + roots: vec![dsl_root.clone()], target_file: None, }, PipelineStop::Report, ) .expect("pipeline should execute"); let report = result.report().expect("report should be available"); - let actual = reported_modules_in_order(report); - let expected: Vec = expected_real_corpus_module_order() - .into_iter() - .map(String::from) - .collect(); - assert_eq!(actual, expected); + let mut actual = reported_modules_in_order(report); + actual.sort(); + let expected = discover_expected_module_ids(&dsl_root); + assert_eq!( + actual, expected, + "compiler-discovered modules should match filesystem-discovered .dag files" + ); } #[test] diff --git a/gunbc-dag/src/binaries.rs b/gunbc-dag/src/binaries.rs index 2a9efd628ff..4dda80fdec8 100644 --- a/gunbc-dag/src/binaries.rs +++ b/gunbc-dag/src/binaries.rs @@ -3,104 +3,72 @@ //! This is the single source of truth for how repo-local binaries are //! invoked via cargo (package + bin name). Centralizing this mapping //! prevents drift when binaries move between packages. +//! +//! **Adding a new binary**: add one line to the `workspace_binaries!` table +//! below. The enum variant, `ALL` array, `tool_name()`, `from_tool_name()`, +//! and `component()` are all derived automatically. use gunbc_ir::CargoInvocation; use gunbc_tool_registry::iter_tool_targets; -/// Repo-local workspace binaries (all live in gunbc-dag). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum WorkspaceBinary { - Build, - Bootstrap, - Ci, - Codegen, - CodegenDag, - DepsConfig, - Docgen, - Infra, - Makegen, - Pragma, - Sdlc, - Testgen, -} - -impl WorkspaceBinary { - /// Canonical ordered registry of all workspace binaries. - pub const ALL: [Self; 12] = [ - Self::Build, - Self::Bootstrap, - Self::Ci, - Self::Codegen, - Self::CodegenDag, - Self::DepsConfig, - Self::Docgen, - Self::Infra, - Self::Makegen, - Self::Pragma, - Self::Sdlc, - Self::Testgen, - ]; - - /// Iterate all known workspace binaries. - pub fn all() -> &'static [Self] { - &Self::ALL - } - - /// Tool registry name for this binary when present. - pub fn tool_name(self) -> &'static str { - match self { - WorkspaceBinary::Build => "build", - WorkspaceBinary::Bootstrap => "bootstrap", - WorkspaceBinary::Ci => "ci", - WorkspaceBinary::Codegen => "codegen", - WorkspaceBinary::CodegenDag => "codegen-dag", - WorkspaceBinary::DepsConfig => "deps-config", - WorkspaceBinary::Docgen => "docgen", - WorkspaceBinary::Infra => "infra", - WorkspaceBinary::Makegen => "makegen", - WorkspaceBinary::Pragma => "pragma", - WorkspaceBinary::Sdlc => "sdlc", - WorkspaceBinary::Testgen => "testgen", +/// Generates the `WorkspaceBinary` enum and its core accessors from a single +/// definition table. Each entry is `VariantName => "tool-name"`. +macro_rules! workspace_binaries { + ( $( $variant:ident => $tool_name:expr ),* $(,)? ) => { + /// Repo-local workspace binaries (all live in gunbc-dag). + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum WorkspaceBinary { + $( $variant, )* } - } - /// Resolve enum variant from tool registry name. - pub fn from_tool_name(name: &str) -> Option { - match name { - "build" => Some(Self::Build), - "bootstrap" => Some(Self::Bootstrap), - "ci" => Some(Self::Ci), - "codegen" => Some(Self::Codegen), - "codegen-dag" => Some(Self::CodegenDag), - "deps-config" => Some(Self::DepsConfig), - "docgen" => Some(Self::Docgen), - "infra" => Some(Self::Infra), - "makegen" => Some(Self::Makegen), - "pragma" => Some(Self::Pragma), - "sdlc" => Some(Self::Sdlc), - "testgen" => Some(Self::Testgen), - _ => None, + impl WorkspaceBinary { + /// Canonical ordered registry of all workspace binaries. + pub const ALL: &[Self] = &[ $( Self::$variant, )* ]; + + /// Iterate all known workspace binaries. + pub fn all() -> &'static [Self] { + Self::ALL + } + + /// Tool registry name for this binary. + pub fn tool_name(self) -> &'static str { + match self { + $( Self::$variant => $tool_name, )* + } + } + + /// Resolve enum variant from tool registry name. + pub fn from_tool_name(name: &str) -> Option { + match name { + $( $tool_name => Some(Self::$variant), )* + _ => None, + } + } + + /// Component name used to compose the binary name. + pub fn component(self) -> &'static str { + self.tool_name() + } } - } + }; +} - /// Component name used to compose the binary name. - pub fn component(self) -> &'static str { - match self { - WorkspaceBinary::Build => "build", - WorkspaceBinary::Bootstrap => "bootstrap", - WorkspaceBinary::Ci => "ci", - WorkspaceBinary::Codegen => "codegen", - WorkspaceBinary::CodegenDag => "codegen-dag", - WorkspaceBinary::DepsConfig => "deps-config", - WorkspaceBinary::Docgen => "docgen", - WorkspaceBinary::Infra => "infra", - WorkspaceBinary::Makegen => "makegen", - WorkspaceBinary::Pragma => "pragma", - WorkspaceBinary::Sdlc => "sdlc", - WorkspaceBinary::Testgen => "testgen", - } - } +workspace_binaries! { + Build => "build", + Bootstrap => "bootstrap", + Ci => "ci", + Codegen => "codegen", + CodegenDag => "codegen-dag", + DepsConfig => "deps-config", + Docgen => "docgen", + Infra => "infra", + Makegen => "makegen", + Pragma => "pragma", + Sdlc => "sdlc", + Testgen => "testgen", +} +impl WorkspaceBinary { /// Whether this binary corresponds to a DSL pipeline module. pub fn is_dsl_pipeline_module(self) -> bool { matches!(self, Self::Ci) diff --git a/gunbc-dag/src/makegen/registry.rs b/gunbc-dag/src/makegen/registry.rs index 3994104174d..86986965739 100644 --- a/gunbc-dag/src/makegen/registry.rs +++ b/gunbc-dag/src/makegen/registry.rs @@ -1688,9 +1688,23 @@ fn discover_dsl_pipeline_modules() -> BTreeSet { discover_dsl_modules(&dsl_pipelines_root(), "pipeline") } -/// Manual tool definitions: tools that need Makefile targets but aren't in the -/// tool registry (no `#[tool_target]` registration). Each entry declares its -/// required DSL module — validation and registration are co-located. +/// Manual tool definitions: tools that need Makefile targets but can't use +/// standard `#[tool_target]` registration. +/// +/// **Why these are manual** (CL7 investigation): +/// +/// - `pragma`: Has a custom `Executable` impl (`PragmaEntrypointOp`) that +/// inspects file-write transport responses. Standard tool-target macros +/// only support the generic passthrough path. +/// +/// - `build`: Uses the historical Makefile target name `build-all` (not +/// `build`), which can't be expressed via the standard short-name +/// derivation. +/// +/// Both are DSL-gated: the Makefile target is only generated when the +/// corresponding `.dag` module is discovered. If either can be made +/// standard in the future, remove it from this list and add +/// `#[tool_target]` to the binary. struct ManualToolDef { /// DSL module name (file stem in `dsl/tools/` or `dsl/pipelines/`). module: &'static str, @@ -1709,7 +1723,6 @@ impl ManualToolDef { /// All manual tool definitions. Adding a new manual tool here automatically /// validates its DSL module exists and registers its Makefile target. -// WF8: ci is now a core workflow (thin wrapper over gunbc-workflow), not a manual tool. const MANUAL_TOOL_DEFS: &[ManualToolDef] = &[ManualToolDef::tool("pragma"), ManualToolDef::tool("build")]; diff --git a/gunbc-dag/src/resolve.rs b/gunbc-dag/src/resolve.rs index a9c77738f1f..5bad7df7ecd 100644 --- a/gunbc-dag/src/resolve.rs +++ b/gunbc-dag/src/resolve.rs @@ -17,9 +17,11 @@ //! # Adding a new module //! //! To wire a new `.dag` module: -//! 1. Add a match arm in `resolve_domain()` for the module path -//! 2. Map each callable name to its domain op via `DynOp::new(...)` -//! 3. Infrastructure nodes (content_upsert, fs_env) are handled automatically +//! - **Passthrough callables** (forward inputs to outputs): add an entry +//! to `PASSTHROUGH_CALLABLES` — no new types or functions needed. +//! - **Custom callables**: add a match arm in `resolve_domain()` for the +//! module path and map each callable to its `DynOp`. +//! - Infrastructure nodes (content_upsert, fs_env) are handled automatically. use std::collections::HashMap; @@ -80,92 +82,64 @@ fn execute_with_declared_output_passthrough( Ok(outputs) } -/// Generates a port-forwarding domain op: enum definition, `Executable` impl, -/// and resolver function — all from a single declaration. -/// -/// Every variant carries `output_port_names: Vec` and the executor -/// delegates to `execute_with_declared_output_passthrough`. The resolver maps -/// DSL callable names to enum variants. -/// -/// # Example -/// -/// ```ignore -/// domain_passthrough_op! { -/// BuildToolOp, "tools.build", resolve_build { -/// "build_all" => BuildAll, -/// } -/// } -/// ``` -macro_rules! domain_passthrough_op { - ( - $enum_name:ident, $module:expr, $fn_name:ident { - $( $dsl_name:expr => $variant:ident ),* $(,)? - } - ) => { - #[derive(Debug, Clone)] - enum $enum_name { - $( $variant { output_port_names: Vec }, )* - } - - impl Executable for $enum_name { - fn execute( - &self, - inputs: HashMap, - ) -> Result, ExecError> { - let output_port_names = match self { - $( Self::$variant { output_port_names } )|* => output_port_names, - }; - execute_with_declared_output_passthrough(output_port_names, inputs) - } - } - - fn $fn_name( - node_id: &str, - name: &str, - outputs: &[Port], - ) -> Result { - let output_port_names = declared_output_names(outputs); - match name { - $( $dsl_name => Ok(DynOp::new($enum_name::$variant { output_port_names })), )* - _ => Err(unknown_callable(node_id, $module, name)), - } - } - }; -} - -domain_passthrough_op! { - BuildToolOp, "tools.build", resolve_build { - "build_all" => BuildAll, - } -} - -domain_passthrough_op! { - DocgenToolOp, "tools.docgen", resolve_docgen { - "docgen" => Docgen, - "render_ab_workflows_doc" => RenderAbWorkflowsDoc, - } -} - -domain_passthrough_op! { - TestgenToolOp, "tools.testgen", resolve_testgen { - "generate_tests" => GenerateTests, - "testgen" => Testgen, - } +/// Single passthrough op that replaces all `domain_passthrough_op!` macro +/// instances. Every registered callable gets the same behavior: forward +/// all inputs to outputs, filling any declared output port that has no +/// matching input with `Value::Skipped`. +#[derive(Debug, Clone)] +struct PassthroughOp { + output_port_names: Vec, } -domain_passthrough_op! { - ClippyToolOp, "tools.clippy", resolve_clippy { - "clippy_lint" => ClippyLint, +impl Executable for PassthroughOp { + fn execute(&self, inputs: HashMap) -> Result, ExecError> { + execute_with_declared_output_passthrough(&self.output_port_names, inputs) } } -domain_passthrough_op! { - DepsToolOp, "tools.deps", resolve_deps { - "render_deps_toml" => RenderDepsToml, - "select_platform_deps" => SelectPlatformDeps, - "deps_install" => DepsInstall, - "deps_generate" => DepsGenerate, +/// Centralized registry of `(module, &[callable_name])` pairs that use +/// passthrough dispatch. Adding a new passthrough callable only requires +/// appending to this list — no new enum, impl, or resolver function. +const PASSTHROUGH_CALLABLES: &[(&str, &[&str])] = &[ + ("tools.build", &["build_all"]), + ("tools.clippy", &["clippy_lint"]), + ("tools.deps", &["render_deps_toml", "select_platform_deps", "deps_install", "deps_generate"]), + ("tools.docgen", &["docgen", "render_ab_workflows_doc"]), + ("tools.testgen", &["generate_tests", "testgen"]), + ("pipelines.ci", &["ci"]), + ("shared.dag_util", &[ + "aggregate_results", "all_succeeded", "format_report", "stage_result", + "skipped_stage", "stage_from_output", "generated_header", "render_and_upsert", + ]), + ("shared.gist_modes", &[ + "branch_context", "resolve_recent_base", "gist_filename", + "gist_upload", "share_content", "detect_runtime", + ]), + ("std.patterns", &[ + "file_content_matches", "classify_files", "read_text_files", + "acquire_subject_token", "optional_impersonation", "ensure", + "upsert", "content_upsert", "credential_chain", "transaction", "retry", + ]), +]; + +/// Try to resolve a callable via the passthrough registry. +fn resolve_passthrough( + node_id: &str, + module: &str, + name: &str, + outputs: &[Port], +) -> Option> { + for &(mod_name, callables) in PASSTHROUGH_CALLABLES { + if mod_name == module { + if callables.contains(&name) { + return Some(Ok(DynOp::new(PassthroughOp { + output_port_names: declared_output_names(outputs), + }))); + } + return Some(Err(unknown_callable(node_id, module, name))); + } } + None } #[derive(Debug, Clone)] @@ -236,52 +210,6 @@ fn resolve_infra(node_id: &str, name: &str, _outputs: &[Port]) -> Result Ci, - } -} - -domain_passthrough_op! { - SharedDagUtilOp, "shared.dag_util", resolve_shared_dag_util { - "aggregate_results" => AggregateResults, - "all_succeeded" => AllSucceeded, - "format_report" => FormatReport, - "stage_result" => StageResult, - "skipped_stage" => SkippedStage, - "stage_from_output" => StageFromOutput, - "generated_header" => GeneratedHeader, - "render_and_upsert" => RenderAndUpsert, - } -} - -domain_passthrough_op! { - SharedGistModesOp, "shared.gist_modes", resolve_shared_gist_modes { - "branch_context" => BranchContext, - "resolve_recent_base" => ResolveRecentBase, - "gist_filename" => GistFilename, - "gist_upload" => GistUpload, - "share_content" => ShareContent, - "detect_runtime" => DetectRuntime, - } -} - -domain_passthrough_op! { - StdPatternsOp, "std.patterns", resolve_std_patterns { - "file_content_matches" => FileContentMatches, - "classify_files" => ClassifyFiles, - "read_text_files" => ReadTextFiles, - "acquire_subject_token" => AcquireSubjectToken, - "optional_impersonation" => OptionalImpersonation, - "ensure" => Ensure, - "upsert" => Upsert, - "content_upsert" => ContentUpsert, - "credential_chain" => CredentialChain, - "transaction" => Transaction, - "retry" => Retry, - } -} - /// Simple identity callable adapter for DSL entrypoint wrappers. #[derive(Debug, Clone)] struct IdentityCallableOp; @@ -344,9 +272,11 @@ impl Executable for LiteralSourceOp { /// Produces a resource handle value appropriate for the resource kind. /// In production, these will be real handle acquisitions; for now, they /// produce cross-platform default handles for dry-run/test execution. +/// The `resource_kind` is derived from the DSL callable name — no +/// hardcoded list of resource names needed in the resolver. #[derive(Debug, Clone)] struct ResourceAcquireOp { - resource_kind: &'static str, + resource_kind: String, } impl Executable for ResourceAcquireOp { @@ -354,7 +284,7 @@ impl Executable for ResourceAcquireOp { &self, _inputs: HashMap, ) -> Result, ExecError> { - let handle: Value = match self.resource_kind { + let handle: Value = match self.resource_kind.as_str() { "Filesystem" => { filename::FilesystemHandle::cross_platform(filename::Scope::Write).into() } @@ -622,27 +552,25 @@ fn resolve_domain( outputs: &[Port], service_metadata: Option<&ServiceCallMetadata>, ) -> Result { + // 1. Modules with custom resolvers (non-passthrough behavior). match module { - "tools.pragma" => resolve_pragma(node_id, name), - "tools.makegen" => resolve_makegen(node_id, name), - "tools.build" => resolve_build(node_id, name, outputs), - "tools.codegen" => resolve_codegen(node_id, name), - "tools.bootstrap" => resolve_bootstrap(node_id, name, outputs), - "tools.docgen" => resolve_docgen(node_id, name, outputs), - "tools.testgen" => resolve_testgen(node_id, name, outputs), - "tools.clippy" => resolve_clippy(node_id, name, outputs), - "tools.deps" => resolve_deps(node_id, name, outputs), - "tools.infra" => resolve_infra(node_id, name, outputs), - "pipelines.ci" => resolve_pipeline_ci(node_id, name, outputs), - "shared.dag_util" => resolve_shared_dag_util(node_id, name, outputs), - "shared.gist_modes" => resolve_shared_gist_modes(node_id, name, outputs), - "std.patterns" => resolve_std_patterns(node_id, name, outputs), - "std.resources" => resolve_std_resources(name), - _ if module.starts_with("services.") || module.starts_with("workspace.") => { - resolve_service_transport(node_id, module, name, service_metadata) - } - _ => Err(unknown_callable(node_id, module, name)), + "tools.pragma" => return resolve_pragma(node_id, name), + "tools.makegen" => return resolve_makegen(node_id, name), + "tools.codegen" => return resolve_codegen(node_id, name), + "tools.bootstrap" => return resolve_bootstrap(node_id, name, outputs), + "tools.infra" => return resolve_infra(node_id, name, outputs), + "std.resources" => return resolve_std_resources(name), + _ => {} + } + // 2. Service/workspace modules use generic transport dispatch. + if module.starts_with("services.") || module.starts_with("workspace.") { + return resolve_service_transport(node_id, module, name, service_metadata); + } + // 3. Passthrough registry (replaces per-module domain_passthrough_op! macros). + if let Some(result) = resolve_passthrough(node_id, module, name, outputs) { + return result; } + Err(unknown_callable(node_id, module, name)) } fn resolve_pragma(node_id: &str, name: &str) -> Result { @@ -685,16 +613,12 @@ fn resolve_std_resources(name: &str) -> Result { // Resource lifecycle acquire/release nodes from the DSL resource system. // Names follow the pattern: `resource_lifecycle::acquire::ResourceName` // or `resource_lifecycle::release::ResourceName`. + // The resource name is taken directly from the DSL callable — + // no hardcoded list needed. Adding a new resource to std/resources.dag + // works without changing resolver code. if let Some(resource_name) = name.strip_prefix("resource_lifecycle::acquire::") { - let kind = match resource_name { - "Filesystem" => "Filesystem", - "Network" => "Network", - "Clock" => "Clock", - "AuthContext" => "AuthContext", - _ => "unknown", - }; return Ok(DynOp::new(ResourceAcquireOp { - resource_kind: kind, + resource_kind: resource_name.to_string(), })); } if name.starts_with("resource_lifecycle::release::") { diff --git a/gunbc-dag/src/workflow/process_registry.rs b/gunbc-dag/src/workflow/process_registry.rs index bbb1794262e..35e01b3b08a 100644 --- a/gunbc-dag/src/workflow/process_registry.rs +++ b/gunbc-dag/src/workflow/process_registry.rs @@ -151,50 +151,6 @@ impl ProcessUnitRegistry { } } -fn ci_ref(unit: &str) -> ProcessUnitRef { - ProcessUnitRef::new("ci", unit) -} - -fn test_all_ref(unit: &str) -> ProcessUnitRef { - ProcessUnitRef::new("test_all", unit) -} - -fn bootstrap_ref(unit: &str) -> ProcessUnitRef { - ProcessUnitRef::new("bootstrap", unit) -} - -fn makegen_ref(unit: &str) -> ProcessUnitRef { - ProcessUnitRef::new("makegen", unit) -} - -fn pragma_ref(unit: &str) -> ProcessUnitRef { - ProcessUnitRef::new("pragma", unit) -} - -fn deps_ref(unit: &str) -> ProcessUnitRef { - ProcessUnitRef::new("deps", unit) -} - -fn dag_viz_ref(unit: &str) -> ProcessUnitRef { - ProcessUnitRef::new("dag_viz", unit) -} - -fn dag_snapshot_ref(unit: &str) -> ProcessUnitRef { - ProcessUnitRef::new("dag_snapshot", unit) -} - -fn gist_ref(unit: &str) -> ProcessUnitRef { - ProcessUnitRef::new("gist", unit) -} - -fn build_all_ref(unit: &str) -> ProcessUnitRef { - ProcessUnitRef::new("build_all", unit) -} - -fn sdlc_ref(unit: &str) -> ProcessUnitRef { - ProcessUnitRef::new("sdlc", unit) -} - fn compilation_ref() -> ProcessUnitRef { ProcessUnitRef::new(COMPILATION_PROCESS_ID, COMPILATION_ENSURE_UNIT) } @@ -203,6 +159,11 @@ fn codegen_ref() -> ProcessUnitRef { ProcessUnitRef::new(CODEGEN_PROCESS_ID, CODEGEN_ENSURE_UNIT) } +/// Compact helper: build a `ProcessUnitSpec` with cost 1. +fn pu(process: &str, unit: &str, claims: Vec) -> ProcessUnitSpec { + ProcessUnitSpec::new(ProcessUnitRef::new(process, unit), 1, claims) +} + /// Universal capability claims shared across all tool workflows. fn compilation_ensure_claims() -> Vec { vec![ @@ -217,6 +178,9 @@ fn codegen_ensure_claims() -> Vec { } /// Default registry for WF1/WF2 planner bootstrap. +/// +/// Each entry is `pu(process_id, unit_id, claims)`. Adding a new unit to +/// an existing workflow requires one `pu(...)` line here. pub fn default_process_unit_registry() -> ProcessUnitRegistry { let mut registry = ProcessUnitRegistry::new(); @@ -228,435 +192,119 @@ pub fn default_process_unit_registry() -> ProcessUnitRegistry { registry.register(spec); } + let r = UnitClaim::read; + let w = UnitClaim::write; + // CI workflow units for spec in [ - ProcessUnitSpec::new( - ci_ref("ci.lint_upsert"), - 1, - vec![ - UnitClaim::write("file:workspace"), - UnitClaim::write("ledger:workflow"), - ], - ), - ProcessUnitSpec::new( - ci_ref("ci.codegen"), - 1, - vec![UnitClaim::write("file:generated:cli")], - ), - ProcessUnitSpec::new( - ci_ref("ci.bootstrap"), - 1, - vec![UnitClaim::write("file:manifest")], - ), - ProcessUnitSpec::new( - ci_ref("ci.pragma"), - 1, - vec![UnitClaim::write("file:workspace")], - ), - ProcessUnitSpec::new( - ci_ref("ci.testgen"), - 1, - vec![UnitClaim::write("file:generated:tests")], - ), - ProcessUnitSpec::new( - ci_ref("ci.build_compile"), - 1, - vec![ - UnitClaim::write("file:target"), - UnitClaim::read("tool:cargo"), - ], - ), - ProcessUnitSpec::new( - ci_ref("ci.test_run"), - 1, - vec![ - UnitClaim::read("file:target"), - UnitClaim::read("tool:cargo"), - ], - ), - ProcessUnitSpec::new( - ci_ref("ci.clippy_run"), - 1, - vec![ - UnitClaim::read("file:target"), - UnitClaim::read("tool:cargo"), - ], - ), - ProcessUnitSpec::new( - ci_ref("ci.guardrails"), - 1, - vec![UnitClaim::read("file:workspace")], - ), - ProcessUnitSpec::new( - ci_ref("ci.verify"), - 1, - vec![UnitClaim::read("file:generated")], - ), - ProcessUnitSpec::new(ci_ref("ci.report"), 1, vec![]), - ] { - registry.register(spec); - } + pu("ci", "ci.lint_upsert", vec![w("file:workspace"), w("ledger:workflow")]), + pu("ci", "ci.codegen", vec![w("file:generated:cli")]), + pu("ci", "ci.bootstrap", vec![w("file:manifest")]), + pu("ci", "ci.pragma", vec![w("file:workspace")]), + pu("ci", "ci.testgen", vec![w("file:generated:tests")]), + pu("ci", "ci.build_compile", vec![w("file:target"), r("tool:cargo")]), + pu("ci", "ci.test_run", vec![r("file:target"), r("tool:cargo")]), + pu("ci", "ci.clippy_run", vec![r("file:target"), r("tool:cargo")]), + pu("ci", "ci.guardrails", vec![r("file:workspace")]), + pu("ci", "ci.verify", vec![r("file:generated")]), + pu("ci", "ci.report", vec![]), + ] { registry.register(spec); } // test-all workflow units for spec in [ - ProcessUnitSpec::new( - test_all_ref("test_all.lint_upsert"), - 1, - vec![ - UnitClaim::write("file:workspace"), - UnitClaim::write("ledger:workflow"), - ], - ), - ProcessUnitSpec::new( - test_all_ref("test_all.codegen"), - 1, - vec![UnitClaim::write("file:generated:cli")], - ), - ProcessUnitSpec::new( - test_all_ref("test_all.testgen"), - 1, - vec![UnitClaim::write("file:generated:tests")], - ), - ProcessUnitSpec::new( - test_all_ref("test_all.build_compile"), - 1, - vec![ - UnitClaim::write("file:target"), - UnitClaim::read("tool:cargo"), - ], - ), - ProcessUnitSpec::new( - test_all_ref("test_all.verify_fix"), - 1, - vec![UnitClaim::write("file:workspace")], - ), - ProcessUnitSpec::new( - test_all_ref("test_all.cargo_test_xl"), - 1, - vec![ - UnitClaim::read("file:target"), - UnitClaim::read("tool:cargo"), - ], - ), - ProcessUnitSpec::new(test_all_ref("test_all.report"), 1, vec![]), - ] { - registry.register(spec); - } - - // ========================================================================= - // WF16/WF17/WF18: Gist workflow units (snapshot/diff/recent) - // ========================================================================= + pu("test_all", "test_all.lint_upsert", vec![w("file:workspace"), w("ledger:workflow")]), + pu("test_all", "test_all.codegen", vec![w("file:generated:cli")]), + pu("test_all", "test_all.testgen", vec![w("file:generated:tests")]), + pu("test_all", "test_all.build_compile", vec![w("file:target"), r("tool:cargo")]), + pu("test_all", "test_all.verify_fix", vec![w("file:workspace")]), + pu("test_all", "test_all.cargo_test_xl", vec![r("file:target"), r("tool:cargo")]), + pu("test_all", "test_all.report", vec![]), + ] { registry.register(spec); } + + // Gist workflow units (WF16/WF17/WF18: snapshot/diff/recent) for spec in [ - // Shared base units - ProcessUnitSpec::new( - gist_ref("gist.branch_resolution"), - 1, - vec![UnitClaim::read("file:workspace")], - ), - ProcessUnitSpec::new( - gist_ref("gist.credential_resolve"), - 1, - vec![UnitClaim::read("credential:github")], - ), - ProcessUnitSpec::new( - gist_ref("gist.gist_create"), - 1, - vec![UnitClaim::write("network:github_gist")], - ), - // WF16: snapshot content acquisition - ProcessUnitSpec::new( - gist_ref("gist.list_files"), - 1, - vec![UnitClaim::read("file:workspace")], - ), - ProcessUnitSpec::new( - gist_ref("gist.read_files"), - 1, - vec![UnitClaim::read("file:workspace")], - ), - ProcessUnitSpec::new(gist_ref("gist.render_snapshot"), 1, vec![]), - // WF17/WF18 shared diff rendering - ProcessUnitSpec::new( - gist_ref("gist.diff"), - 1, - vec![UnitClaim::read("file:workspace")], - ), - ProcessUnitSpec::new(gist_ref("gist.render_diff"), 1, vec![]), - // WF18: recent-specific source selection - ProcessUnitSpec::new( - gist_ref("gist.rev_list"), - 1, - vec![UnitClaim::read("file:workspace")], - ), - ] { - registry.register(spec); - } - - // ========================================================================= - // WF19: Bootstrap workflow units - // ========================================================================= + pu("gist", "gist.branch_resolution", vec![r("file:workspace")]), + pu("gist", "gist.credential_resolve", vec![r("credential:github")]), + pu("gist", "gist.gist_create", vec![w("network:github_gist")]), + pu("gist", "gist.list_files", vec![r("file:workspace")]), + pu("gist", "gist.read_files", vec![r("file:workspace")]), + pu("gist", "gist.render_snapshot", vec![]), + pu("gist", "gist.diff", vec![r("file:workspace")]), + pu("gist", "gist.render_diff", vec![]), + pu("gist", "gist.rev_list", vec![r("file:workspace")]), + ] { registry.register(spec); } + + // Bootstrap workflow units (WF19) for spec in [ - // Tool-specific: workspace scan - ProcessUnitSpec::new( - bootstrap_ref("bootstrap.workspace_scan"), - 1, - vec![UnitClaim::read("file:workspace")], - ), - // Tool-specific: parallel generation - ProcessUnitSpec::new( - bootstrap_ref("bootstrap.generate_makefile"), - 1, - vec![], // pure computation - ), - ProcessUnitSpec::new( - bootstrap_ref("bootstrap.generate_gitignore"), - 1, - vec![], // pure computation - ), - // Tool-specific: parallel upsert (filesystem write) - ProcessUnitSpec::new( - bootstrap_ref("bootstrap.upsert_makefile"), - 1, - vec![UnitClaim::write("file:workspace")], - ), - ProcessUnitSpec::new( - bootstrap_ref("bootstrap.upsert_gitignore"), - 1, - vec![UnitClaim::write("file:workspace")], - ), - ProcessUnitSpec::new(bootstrap_ref("bootstrap.report"), 1, vec![]), - ] { - registry.register(spec); - } - - // ========================================================================= - // WF19: Makegen workflow units - // ========================================================================= + pu("bootstrap", "bootstrap.workspace_scan", vec![r("file:workspace")]), + pu("bootstrap", "bootstrap.generate_makefile", vec![]), + pu("bootstrap", "bootstrap.generate_gitignore", vec![]), + pu("bootstrap", "bootstrap.upsert_makefile", vec![w("file:workspace")]), + pu("bootstrap", "bootstrap.upsert_gitignore", vec![w("file:workspace")]), + pu("bootstrap", "bootstrap.report", vec![]), + ] { registry.register(spec); } + + // Makegen workflow units (WF19) for spec in [ - ProcessUnitSpec::new( - makegen_ref("makegen.load_registry"), - 1, - vec![], // pure: reads tool registry - ), - ProcessUnitSpec::new( - makegen_ref("makegen.render_makefile"), - 1, - vec![], // pure computation - ), - ProcessUnitSpec::new( - makegen_ref("makegen.upsert_makefile"), - 1, - vec![UnitClaim::write("file:workspace")], - ), - ProcessUnitSpec::new(makegen_ref("makegen.report"), 1, vec![]), - ] { - registry.register(spec); - } + pu("makegen", "makegen.load_registry", vec![]), + pu("makegen", "makegen.render_makefile", vec![]), + pu("makegen", "makegen.upsert_makefile", vec![w("file:workspace")]), + pu("makegen", "makegen.report", vec![]), + ] { registry.register(spec); } - // ========================================================================= - // WF19: Pragma workflow units - // ========================================================================= + // Pragma workflow units (WF19) for spec in [ - // Three independent parallel render+upsert chains - ProcessUnitSpec::new( - pragma_ref("pragma.render_clippy"), - 1, - vec![], // pure computation - ), - ProcessUnitSpec::new( - pragma_ref("pragma.upsert_clippy"), - 1, - vec![UnitClaim::write("file:workspace")], - ), - ProcessUnitSpec::new( - pragma_ref("pragma.render_allowlist"), - 1, - vec![], // pure computation - ), - ProcessUnitSpec::new( - pragma_ref("pragma.upsert_allowlist"), - 1, - vec![UnitClaim::write("file:workspace")], - ), - ProcessUnitSpec::new( - pragma_ref("pragma.render_policy"), - 1, - vec![], // pure computation - ), - ProcessUnitSpec::new( - pragma_ref("pragma.upsert_policy"), - 1, - vec![UnitClaim::write("file:workspace")], - ), - ProcessUnitSpec::new(pragma_ref("pragma.report"), 1, vec![]), - ] { - registry.register(spec); - } - - // ========================================================================= - // WF20: Deps workflow units (install + generate) - // ========================================================================= + pu("pragma", "pragma.render_clippy", vec![]), + pu("pragma", "pragma.upsert_clippy", vec![w("file:workspace")]), + pu("pragma", "pragma.render_allowlist", vec![]), + pu("pragma", "pragma.upsert_allowlist", vec![w("file:workspace")]), + pu("pragma", "pragma.render_policy", vec![]), + pu("pragma", "pragma.upsert_policy", vec![w("file:workspace")]), + pu("pragma", "pragma.report", vec![]), + ] { registry.register(spec); } + + // Deps workflow units (WF20: install + generate) for spec in [ - // Install graph - ProcessUnitSpec::new( - deps_ref("deps.platform_env"), - 1, - vec![], // pure: platform detection - ), - ProcessUnitSpec::new( - deps_ref("deps.load_manifest"), - 1, - vec![UnitClaim::read("file:workspace")], - ), - ProcessUnitSpec::new( - deps_ref("deps.generate_scripts"), - 1, - vec![], // pure computation - ), - ProcessUnitSpec::new( - deps_ref("deps.execute_installs"), - 1, - vec![UnitClaim::write("tool:package_manager")], - ), - // Generate graph - ProcessUnitSpec::new( - deps_ref("deps.load_tool_registry"), - 1, - vec![], // pure - ), - ProcessUnitSpec::new( - deps_ref("deps.render_deps_toml"), - 1, - vec![], // pure computation - ), - ProcessUnitSpec::new( - deps_ref("deps.write_deps_toml"), - 1, - vec![UnitClaim::write("file:workspace")], - ), - ProcessUnitSpec::new(deps_ref("deps.report"), 1, vec![]), - ] { - registry.register(spec); - } - - // ========================================================================= - // WF20: DAG Viz workflow units (shared base + mode-specific) - // ========================================================================= + pu("deps", "deps.platform_env", vec![]), + pu("deps", "deps.load_manifest", vec![r("file:workspace")]), + pu("deps", "deps.generate_scripts", vec![]), + pu("deps", "deps.execute_installs", vec![w("tool:package_manager")]), + pu("deps", "deps.load_tool_registry", vec![]), + pu("deps", "deps.render_deps_toml", vec![]), + pu("deps", "deps.write_deps_toml", vec![w("file:workspace")]), + pu("deps", "deps.report", vec![]), + ] { registry.register(spec); } + + // DAG Viz workflow units (WF20) for spec in [ - // Shared base units (same WorkIdentity as gist via canonical dedup) - ProcessUnitSpec::new( - dag_viz_ref("dag_viz.branch_resolution"), - 1, - vec![UnitClaim::read("file:workspace")], - ), - ProcessUnitSpec::new( - dag_viz_ref("dag_viz.credential_resolve"), - 1, - vec![UnitClaim::read("credential:github")], - ), - // Viz-specific content acquisition - ProcessUnitSpec::new( - dag_viz_ref("dag_viz.serialize_dag"), - 1, - vec![UnitClaim::read("file:workspace")], - ), - ProcessUnitSpec::new( - dag_viz_ref("dag_viz.render_viz"), - 1, - vec![], // pure computation - ), - // Network transport (volatile) - ProcessUnitSpec::new( - dag_viz_ref("dag_viz.gist_upload"), - 1, - vec![ - UnitClaim::write("network:github_gist"), - UnitClaim::read("credential:github"), - ], - ), - ProcessUnitSpec::new(dag_viz_ref("dag_viz.report"), 1, vec![]), - ] { - registry.register(spec); - } - - // ========================================================================= - // WF20: DAG Snapshot workflow units - // ========================================================================= + pu("dag_viz", "dag_viz.branch_resolution", vec![r("file:workspace")]), + pu("dag_viz", "dag_viz.credential_resolve", vec![r("credential:github")]), + pu("dag_viz", "dag_viz.serialize_dag", vec![r("file:workspace")]), + pu("dag_viz", "dag_viz.render_viz", vec![]), + pu("dag_viz", "dag_viz.gist_upload", vec![w("network:github_gist"), r("credential:github")]), + pu("dag_viz", "dag_viz.report", vec![]), + ] { registry.register(spec); } + + // DAG Snapshot workflow units (WF20) for spec in [ - ProcessUnitSpec::new( - dag_snapshot_ref("dag_snapshot.branch_resolution"), - 1, - vec![UnitClaim::read("file:workspace")], - ), - ProcessUnitSpec::new( - dag_snapshot_ref("dag_snapshot.credential_resolve"), - 1, - vec![UnitClaim::read("credential:github")], - ), - ProcessUnitSpec::new( - dag_snapshot_ref("dag_snapshot.list_files"), - 1, - vec![UnitClaim::read("file:workspace")], - ), - ProcessUnitSpec::new( - dag_snapshot_ref("dag_snapshot.read_files"), - 1, - vec![UnitClaim::read("file:workspace")], - ), - ProcessUnitSpec::new( - dag_snapshot_ref("dag_snapshot.render_snapshot"), - 1, - vec![], // pure computation - ), - ProcessUnitSpec::new( - dag_snapshot_ref("dag_snapshot.gist_upload"), - 1, - vec![ - UnitClaim::write("network:github_gist"), - UnitClaim::read("credential:github"), - ], - ), - ProcessUnitSpec::new(dag_snapshot_ref("dag_snapshot.report"), 1, vec![]), - ] { - registry.register(spec); - } + pu("dag_snapshot", "dag_snapshot.branch_resolution", vec![r("file:workspace")]), + pu("dag_snapshot", "dag_snapshot.credential_resolve", vec![r("credential:github")]), + pu("dag_snapshot", "dag_snapshot.list_files", vec![r("file:workspace")]), + pu("dag_snapshot", "dag_snapshot.read_files", vec![r("file:workspace")]), + pu("dag_snapshot", "dag_snapshot.render_snapshot", vec![]), + pu("dag_snapshot", "dag_snapshot.gist_upload", vec![w("network:github_gist"), r("credential:github")]), + pu("dag_snapshot", "dag_snapshot.report", vec![]), + ] { registry.register(spec); } - // ========================================================================= // Build-all workflow units - // ========================================================================= - registry.register(ProcessUnitSpec::new( - build_all_ref("build_all.build"), - 1, - vec![ - UnitClaim::write("file:target"), - UnitClaim::read("tool:cargo"), - ], - )); - - // ========================================================================= + registry.register(pu("build_all", "build_all.build", vec![w("file:target"), r("tool:cargo")])); + // SDLC workflow units - // ========================================================================= for spec in [ - ProcessUnitSpec::new( - sdlc_ref("sdlc.intake"), - 1, - vec![ - UnitClaim::write("file:target"), - UnitClaim::read("file:workspace"), - ], - ), - ProcessUnitSpec::new( - sdlc_ref("sdlc.worker"), - 1, - vec![ - UnitClaim::write("file:target"), - UnitClaim::read("network:github_issue"), - ], - ), - ProcessUnitSpec::new(sdlc_ref("sdlc.report"), 1, vec![]), - ] { - registry.register(spec); - } + pu("sdlc", "sdlc.intake", vec![w("file:target"), r("file:workspace")]), + pu("sdlc", "sdlc.worker", vec![w("file:target"), r("network:github_issue")]), + pu("sdlc", "sdlc.report", vec![]), + ] { registry.register(spec); } registry } diff --git a/gunbc-dag/src/workflow/spec_builders.rs b/gunbc-dag/src/workflow/spec_builders.rs index f401a7d5af8..bcbe361a204 100644 --- a/gunbc-dag/src/workflow/spec_builders.rs +++ b/gunbc-dag/src/workflow/spec_builders.rs @@ -1442,79 +1442,35 @@ struct ToolWorkflowDescriptor { build: fn() -> Result, } +/// Tool workflow registry. Adding a new workflow requires one line here +/// and the corresponding `*_workflow_spec()` builder function. +/// +/// Format: `(canonical_name, &[aliases], builder_fn)` const TOOL_WORKFLOWS: &[ToolWorkflowDescriptor] = &[ - ToolWorkflowDescriptor { - canonical_name: "gist", - aliases: &[], - build: gist_workflow_spec, - }, - ToolWorkflowDescriptor { - canonical_name: "gist-snapshot", - aliases: &["gist_snapshot"], - build: gist_snapshot_workflow_spec, - }, - ToolWorkflowDescriptor { - canonical_name: "gist-diff", - aliases: &["gist_diff"], - build: gist_diff_workflow_spec, - }, - ToolWorkflowDescriptor { - canonical_name: "gist-recent", - aliases: &["gist_recent"], - build: gist_recent_workflow_spec, - }, - ToolWorkflowDescriptor { - canonical_name: "bootstrap", - aliases: &[], - build: bootstrap_workflow_spec, - }, - ToolWorkflowDescriptor { - canonical_name: "makegen", - aliases: &[], - build: makegen_workflow_spec, - }, - ToolWorkflowDescriptor { - canonical_name: "pragma", - aliases: &[], - build: pragma_workflow_spec, - }, - ToolWorkflowDescriptor { - canonical_name: "deps", - aliases: &[], - build: deps_workflow_spec, - }, - ToolWorkflowDescriptor { - canonical_name: "dag-viz", - aliases: &["dag_viz"], - build: dag_viz_workflow_spec, - }, - ToolWorkflowDescriptor { - canonical_name: "dag-viz-diff", - aliases: &["dag_viz_diff"], - build: dag_viz_diff_workflow_spec, - }, - ToolWorkflowDescriptor { - canonical_name: "dag-viz-recent", - aliases: &["dag_viz_recent"], - build: dag_viz_recent_workflow_spec, - }, - ToolWorkflowDescriptor { - canonical_name: "dag-snapshot", - aliases: &["dag_snapshot"], - build: dag_snapshot_workflow_spec, - }, - ToolWorkflowDescriptor { - canonical_name: "build-all", - aliases: &["build_all"], - build: build_all_workflow_spec, - }, - ToolWorkflowDescriptor { - canonical_name: "sdlc", - aliases: &[], - build: sdlc_workflow_spec, - }, + tw("gist", &[], gist_workflow_spec), + tw("gist-snapshot", &["gist_snapshot"], gist_snapshot_workflow_spec), + tw("gist-diff", &["gist_diff"], gist_diff_workflow_spec), + tw("gist-recent", &["gist_recent"], gist_recent_workflow_spec), + tw("bootstrap", &[], bootstrap_workflow_spec), + tw("makegen", &[], makegen_workflow_spec), + tw("pragma", &[], pragma_workflow_spec), + tw("deps", &[], deps_workflow_spec), + tw("dag-viz", &["dag_viz"], dag_viz_workflow_spec), + tw("dag-viz-diff", &["dag_viz_diff"], dag_viz_diff_workflow_spec), + tw("dag-viz-recent", &["dag_viz_recent"], dag_viz_recent_workflow_spec), + tw("dag-snapshot", &["dag_snapshot"], dag_snapshot_workflow_spec), + tw("build-all", &["build_all"], build_all_workflow_spec), + tw("sdlc", &[], sdlc_workflow_spec), ]; +const fn tw( + canonical_name: &'static str, + aliases: &'static [&'static str], + build: fn() -> Result, +) -> ToolWorkflowDescriptor { + ToolWorkflowDescriptor { canonical_name, aliases, build } +} + /// Return canonical tool workflow names. pub fn all_tool_workflow_names() -> Vec<&'static str> { TOOL_WORKFLOWS From dde8e046b556881e3805ff7556bdc10a9330106f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 22:27:53 +0000 Subject: [PATCH 03/13] Add design: eliminate registration lists via default-passthrough + inventory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five-phase plan to make hardcoded metadata duplication structurally impossible: 1. Default-passthrough resolver: any callable without a custom Executable gets passthrough behavior. Eliminates PASSTHROUGH_CALLABLES entirely — zero Rust changes for new DSL callables. 2. Inventory-based custom resolver registration: co-locates Executable impls with their registration. Eliminates match-arm dispatch. 3. Inventory-based workflow spec registration: eliminates TOOL_WORKFLOWS const. 4. DAG-derived process units: infers resource claims from workflow DAG node metadata. Eliminates 80+ manual process unit entries. 5. Structural test assertions: replaces brittle node-count assertions with shape/connectivity checks. Also documents findings from full codebase audit of remaining hardcoded lists and which are inherently non-eliminable. https://claude.ai/code/session_01GZ6E3UdBSQCEtjHvWWJt6H --- TODO/design-eliminate-registration-lists.md | 280 ++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 TODO/design-eliminate-registration-lists.md diff --git a/TODO/design-eliminate-registration-lists.md b/TODO/design-eliminate-registration-lists.md new file mode 100644 index 00000000000..fdbcdf33a07 --- /dev/null +++ b/TODO/design-eliminate-registration-lists.md @@ -0,0 +1,280 @@ +# Eliminate Registration Lists: Default-Passthrough Resolver + Inventory Discovery + +**Status**: PROPOSED +**Date**: 2026-02-21 +**Track**: Cleanup — eliminate hardcoded metadata duplication +**Prerequisite**: CL1-CL8 completed (hardcoded lists consolidated) + +## Problem Statement + +The Rust runtime maintains handwritten registries that duplicate metadata the DSL +compiler already knows. Today (post-CL1-CL8), adding a new DSL module still +requires touching Rust code in up to 4 places: + +| What you add | Rust code you must also touch | +|---|---| +| New `.dag` callable (passthrough) | `PASSTHROUGH_CALLABLES` in `resolve.rs` | +| New `.dag` callable (custom behavior) | match arm in `resolve_domain()` + new `Executable` impl | +| New workflow | `TOOL_WORKFLOWS` in `spec_builders.rs` + builder fn | +| New process unit | `default_process_unit_registry()` in `process_registry.rs` | + +**Goal**: Make it so that adding a new DSL module/callable/workflow requires +**zero** Rust registration edits. The architecture should make drift structurally +impossible, not just tested-for. + +## Root Cause + +The `LoweredOp` enum already carries everything the resolver needs: + +``` +LoweredOp::Callable { + module: String, // "tools.build" + name: String, // "build_all" + obligation: ObligationCategory, // None, ServiceTransport*, Resource* + service_metadata: Option, // REST/Shell specs + ... +} +``` + +The resolver *could* dispatch entirely from this data. It doesn't because the +current architecture requires an explicit mapping from every `(module, name)` +pair to a concrete `Executable`. But for ~80% of callables, the `Executable` is +identical: forward inputs to outputs (passthrough). + +## Design + +### Change 1: Default-passthrough resolver (eliminates PASSTHROUGH_CALLABLES) + +**Current**: `resolve_domain()` checks custom resolvers, then `PASSTHROUGH_CALLABLES`, +then returns `unknown_callable` error. + +**Proposed**: `resolve_domain()` checks custom resolvers, then service transport, +then resource lifecycle, then **defaults to passthrough for any remaining callable**. + +```rust +fn resolve_domain( + node_id: &str, + module: &str, + name: &str, + outputs: &[Port], + service_metadata: Option<&ServiceCallMetadata>, +) -> Result { + // 1. Custom resolvers (modules with non-passthrough Executable impls). + if let Some(result) = resolve_custom(node_id, module, name, outputs) { + return result; + } + // 2. Service/workspace transport (generic, spec-driven). + if module.starts_with("services.") || module.starts_with("workspace.") { + return resolve_service_transport(node_id, module, name, service_metadata); + } + // 3. Resource lifecycle (generic, name-driven). + if module == "std.resources" { + return resolve_std_resources(name); + } + // 4. Default: passthrough. The compiler validated this callable exists. + // No list needed — if it compiled, it's resolvable. + Ok(DynOp::new(PassthroughOp { + output_port_names: declared_output_names(outputs), + })) +} +``` + +**Why this is safe**: The DagLang compiler already validates that every callable +reference resolves to a declared `fn`/`func` in the target module. If a +`LoweredOp::Callable` reaches the resolver, the compiler has proven the callable +exists. Passthrough is correct for any callable without custom side-effect logic +(I/O, resource acquisition, etc.), and those categories are already handled by +steps 1-3. + +**What this eliminates**: The entire `PASSTHROUGH_CALLABLES` const (9 modules, +30+ callable names). Adding a new passthrough callable to any DSL module requires +**zero Rust changes**. + +### Change 2: Inventory-based custom resolver registration (eliminates match arms) + +**Current**: `resolve_domain()` has a `match module { ... }` with 6 arms for +modules with custom `Executable` impls. + +**Proposed**: Custom resolvers register themselves via the `inventory` crate +(same pattern as `#[tool_target]`). + +```rust +// In gunbc-dag/src/pragma/ops.rs: +inventory::submit!(DomainResolver { + module: "tools.pragma", + resolve: resolve_pragma, +}); + +fn resolve_pragma(node_id: &str, name: &str, outputs: &[Port]) -> Option> { + match name { + "render_clippy_toml" => Some(Ok(DynOp::new(PragmaOp::RenderClippy))), + "pragma" => Some(Ok(DynOp::new(PragmaEntrypointOp))), + // ... + _ => None, // fall through to default passthrough + } +} +``` + +The resolver collects all registered `DomainResolver` entries at startup: + +```rust +fn resolve_custom( + node_id: &str, module: &str, name: &str, outputs: &[Port], +) -> Option> { + for resolver in inventory::iter::() { + if resolver.module == module { + return (resolver.resolve)(node_id, name, outputs); + } + } + None // no custom resolver → fall through to default passthrough +} +``` + +**What this eliminates**: The `match module { ... }` dispatch in `resolve_domain()`. +Adding a new module with custom behavior requires only the `Executable` impl and +an `inventory::submit!` call in the same file — the resolver never needs editing. + +**Note**: Individual callable match arms within custom resolvers (e.g., +`resolve_pragma`'s 4 arms) are **not** eliminable — they map DSL names to +specific Rust types, which is inherently manual. But they return `None` for +unknown names, falling through to passthrough instead of erroring. This means +even custom-resolver modules can have passthrough callables mixed in. + +### Change 3: Inventory-based workflow spec registration (eliminates TOOL_WORKFLOWS) + +**Current**: `TOOL_WORKFLOWS` is a 14-entry const array mapping names to builder +functions. + +**Proposed**: Each workflow builder registers itself: + +```rust +// In gunbc-dag/src/workflow/gist.rs: +inventory::submit!(WorkflowRegistration { + canonical_name: "gist", + aliases: &[], + build: gist_workflow_spec, +}); +``` + +Discovery becomes: + +```rust +pub fn all_tool_workflow_names() -> Vec<&'static str> { + inventory::iter::() + .map(|w| w.canonical_name) + .collect() +} + +pub fn tool_workflow_spec(name: &str) -> Result { + for w in inventory::iter::() { + if w.canonical_name == name || w.aliases.contains(&name) { + return (w.build)(); + } + } + Err(format!("unknown tool workflow: '{name}'")) +} +``` + +**What this eliminates**: The `TOOL_WORKFLOWS` const. Adding a new workflow +requires the builder function + `inventory::submit!` in the same file. + +### Change 4: Derive process units from workflow DAGs (eliminates process_registry) + +**Current**: `default_process_unit_registry()` has ~80 manual `pu(...)` entries +mapping process units to resource claims. + +**Proposed**: When a `WorkflowSpec` is built, it already contains a `Dag` with +named nodes. Process units can be derived from the DAG topology: + +```rust +impl WorkflowSpec { + /// Derive process units from this workflow's DAG nodes. + fn derive_process_units(&self) -> Vec { + self.dag.nodes.iter().map(|node| { + let claims = derive_claims_from_node(node); // see below + pu(&self.name, &node.id.0, claims) + }).collect() + } +} +``` + +Resource claims can be inferred from node metadata: +- Nodes with `file:write` resource ports → `UnitClaim::write("file:workspace")` +- Nodes with `file:read` resource ports → `UnitClaim::read("file:workspace")` +- Nodes with `network` resource ports → `UnitClaim::write("network:*")` +- Nodes with `tool:cargo` ports → `UnitClaim::read("tool:cargo")` +- Pure nodes (no resource ports) → `vec![]` + +**What this eliminates**: The entire hand-maintained process unit registry. +Adding a new workflow node automatically creates a process unit with correct +resource claims derived from the DAG's resource wiring. + +**Fallback**: If claim inference isn't precise enough for some nodes, allow +explicit `#[process_claim(...)]` annotations in the DSL or on the builder. + +### Change 5: Structural test assertions (eliminates brittle counts) + +**Current**: 11+ tests assert exact node counts: `assert_eq!(dag.nodes.len(), 9)` + +**Proposed**: Replace with structural assertions: + +```rust +// Instead of: assert_eq!(spec.dag.nodes.len(), 9); +// Use: +assert!(spec.dag.has_node("gist.branch_resolution")); +assert!(spec.dag.has_node("gist.credential_resolve")); +assert!(spec.dag.has_edge_between("gist.branch_resolution", "gist.gist_create")); +// Or for pure structure validation: +assert!(spec.dag.is_connected(), "workflow DAG must be connected"); +assert!(spec.dag.has_single_sink(), "workflow DAG must have one terminal node"); +``` + +This validates the DAG's **shape** rather than its **size**, so adding a node +(e.g., a new intermediate step) doesn't break unrelated tests. + +## Remaining hardcoded lists (not eliminable) + +Some lists are inherently manual because they map DSL concepts to Rust-specific +behavior that can't be auto-derived: + +| List | Why it stays | Mitigation | +|---|---|---| +| `WorkspaceBinary` enum (12 entries) | Maps binary names to Cargo invocation metadata. Binaries are a build system concept, not a DSL concept. | Already uses single-table macro. Consider deriving from `Cargo.toml` `[[bin]]` in a build script. | +| `MANUAL_TOOL_DEFS` (2 entries) | `pragma` needs custom `Executable`; `build` has non-standard short_name. | Already documented (CL7). Shrinks as tools move to standard path. | +| Custom `Executable` impls (5 modules) | By definition, custom behavior requires custom code. | Inventory registration (Change 2) keeps them co-located. | +| `STANDARD_SYMBOLS` (40 entries) | UI symbols are a presentation concern, not DSL metadata. | Use `const` count assertion: `const _: [(); SYMBOLS.len()] = [(); 40];` | +| `FORBIDDEN_CALLS` / `ALLOWED_FILES` (guardrails) | Architectural constraints, not DSL metadata. | Fine as-is; changes are intentional. | + +## Additional findings from audit + +Beyond the CL1-CL8 items and the changes above, the scanner found: + +| Finding | Location | Recommendation | +|---|---|---| +| Mock spec hardcoded paths (`"tools.bootstrap::bootstrap"`) | `ci/graph_mock.rs:76-88` | Derive from `LoweredOp` node IDs in the compiled CI DAG | +| Hardcoded command counts in workflow unit tests | `unit_commands.rs:541,554` | Same as Change 5: structural assertions | +| Makefile workflow name dispatch | `makegen/render.rs:230+` | Already uses generated registry; verify coverage | + +## Implementation order + +| Phase | Changes | Impact | Size | +|---|---|---|---| +| **Phase 1** | Change 1 (default passthrough) | Eliminates `PASSTHROUGH_CALLABLES`. Zero-touch for new passthrough callables. | S | +| **Phase 2** | Change 5 (structural test assertions) | Eliminates 13+ brittle count assertions. | S | +| **Phase 3** | Change 2 (inventory custom resolvers) | Eliminates resolver `match module` dispatch. Co-locates ops with registration. | M | +| **Phase 4** | Change 3 (inventory workflow specs) | Eliminates `TOOL_WORKFLOWS`. Co-locates builders with registration. | M | +| **Phase 5** | Change 4 (derived process units) | Eliminates process registry. Requires claim inference logic. | L | + +Phase 1 is the highest value: it makes the most common operation (adding a DSL +callable) require zero Rust changes. Phases 2-4 are incremental wins using the +proven inventory pattern. Phase 5 is the most complex but eliminates the largest +registry. + +## Success criteria + +After all phases: +- Adding a new `.dag` module with only passthrough callables: **0 Rust files touched** +- Adding a new `.dag` module with custom behavior: **1 Rust file** (the `Executable` impl + inventory registration, co-located) +- Adding a new workflow: **1 Rust file** (the builder + inventory registration, co-located) +- Adding a new process unit to a workflow: **0 Rust files** (derived from DAG) +- Adding a new resource to `std/resources.dag`: **0 Rust files** (already done in CL8) From f1f6a64e29e01c1c3bcd730cd4bc5e855909f3d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 22:39:39 +0000 Subject: [PATCH 04/13] Update design: analyze why Rust exists, path to pure-DSL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the design doc around the question "why can't we just write DSL?" — traces each remaining Rust registry to its root cause: 1. Resolver doesn't trust compiler (architecture gap, fixable now) 2. Workflow specs bypass compiler (migration gap, fixable with DSL pipelines) 3. No function body expressions (missing DSL feature, long-term) Reframes the 3-phase plan: - Phase 1: Resolver trusts compiler (default passthrough + inventory) - Phase 2: Migrate workflows to DSL pipeline definitions - Phase 3: DSL expression language eliminates custom Executable impls Documents that InfraToolOp is fully redundant (DSL already expresses the same logic), and that all 5 custom-resolver modules contain only pure computations that don't fundamentally require Rust. https://claude.ai/code/session_01GZ6E3UdBSQCEtjHvWWJt6H --- TODO/design-eliminate-registration-lists.md | 353 ++++++++++---------- 1 file changed, 176 insertions(+), 177 deletions(-) diff --git a/TODO/design-eliminate-registration-lists.md b/TODO/design-eliminate-registration-lists.md index fdbcdf33a07..aff3a7f1728 100644 --- a/TODO/design-eliminate-registration-lists.md +++ b/TODO/design-eliminate-registration-lists.md @@ -1,4 +1,4 @@ -# Eliminate Registration Lists: Default-Passthrough Resolver + Inventory Discovery +# Eliminate Registration Lists: Close the DSL→Runtime Gap **Status**: PROPOSED **Date**: 2026-02-21 @@ -9,47 +9,110 @@ The Rust runtime maintains handwritten registries that duplicate metadata the DSL compiler already knows. Today (post-CL1-CL8), adding a new DSL module still -requires touching Rust code in up to 4 places: +requires touching Rust code in up to 4 places. -| What you add | Rust code you must also touch | -|---|---| -| New `.dag` callable (passthrough) | `PASSTHROUGH_CALLABLES` in `resolve.rs` | -| New `.dag` callable (custom behavior) | match arm in `resolve_domain()` + new `Executable` impl | -| New workflow | `TOOL_WORKFLOWS` in `spec_builders.rs` + builder fn | -| New process unit | `default_process_unit_registry()` in `process_registry.rs` | +**Goal**: Make it so that adding DSL modules/callables/workflows requires **zero** +Rust registration edits. Drift should be structurally impossible. + +## Why can't we "just write DSL" for all of this? -**Goal**: Make it so that adding a new DSL module/callable/workflow requires -**zero** Rust registration edits. The architecture should make drift structurally -impossible, not just tested-for. +Short answer: **we almost can.** The remaining Rust exists for three reasons, +only one of which is fundamental. -## Root Cause +### What's already DSL-only -The `LoweredOp` enum already carries everything the resolver needs: +**Every tool graph is 100% DSL-compiled at runtime.** There are zero hand-coded +Rust DAGs. When `build_pragma_graph()` runs, it calls: ``` -LoweredOp::Callable { - module: String, // "tools.build" - name: String, // "build_all" - obligation: ObligationCategory, // None, ServiceTransport*, Resource* - service_metadata: Option, // REST/Shell specs - ... -} +dsl/tools/pragma.dag → daglang_driver::compile → Dag → resolve → Dag ``` -The resolver *could* dispatch entirely from this data. It doesn't because the -current architecture requires an explicit mapping from every `(module, name)` -pair to a concrete `Executable`. But for ~80% of callables, the `Executable` is -identical: forward inputs to outputs (passthrough). +All graph structure, wiring, and orchestration comes from the DSL. The `emit` +phase can even generate complete Rust/Go/C source code from the compiled DAG. +The DSL already expresses: +- Module dependencies and imports +- Function signatures (inputs, outputs, types) +- Graph topology (data flow, parallelism, stages) +- Resource annotations (`@file(READ/WRITE)`, `@hermetic`, `@mock_response`) +- Service protocol specs (REST endpoints, shell commands, field mappings) +- Pipeline stage ordering + +### The three reasons Rust code still exists + +#### Reason 1: Leaf-node function bodies (temporary — DSL doesn't implement yet) + +The DSL declares functions like `fn render_clippy_toml(directives) -> String` +but the **body** is implemented in Rust (`PragmaOp::RenderClippy`). These are +pure computations: string templating, list filtering, JSON serialization. The +DSL has the type system for this, but no expression language for function bodies. + +This is a **missing DSL feature**, not an architectural limitation. Evidence: +- `InfraToolOp` duplicates logic already expressed in `dsl/tools/infra.dag` + (match/filter/count) — the DSL CAN express it, but Rust reimplements it +- `PragmaOp` renders strings from config — expressible with string interpolation +- `MakegenOp::LoadRegistry` serializes a Rust struct — needs a DSL-side data + source or FFI mechanism + +**Fix**: Add expression-level DSL support (string ops, list ops, arithmetic). +This is a language evolution, not an architecture change. Each function body +migrated from Rust to DSL eliminates one custom `Executable` impl. + +#### Reason 2: The resolver (unnecessary — compiler already has the information) + +`resolve_lowered_dag()` maps `LoweredOp` (compiler output) to `DynOp` +(executable). The `LoweredOp` already carries module, name, obligation category, +and service metadata — everything needed to route. But the resolver maintains +its own copy of this routing table: + +| Registry | What it duplicates | +|---|---| +| `PASSTHROUGH_CALLABLES` (30+ entries) | "These callables exist and are passthrough" — compiler already validated this | +| `resolve_domain()` match arms (6 modules) | "These modules have custom ops" — could be inventory-discovered | +| `resolve_std_resources()` name match | "These resources exist" — compiler knows from `std/resources.dag` | + +This is **entirely eliminable** without DSL changes. The compiler proves +callables exist; the resolver should trust that proof. See Changes 1-2 below. + +#### Reason 3: Workflow specs are Rust-constructed DAGs (should be DSL pipelines) -## Design +The workflow builders (`gist_workflow_spec`, `bootstrap_workflow_spec`, etc.) +construct `Dag` objects in Rust using `dag.add_node()`/ +`dag.add_edge()`. This is the same thing the DSL does — defining graph topology +— but bypassing the compiler entirely. + +Meanwhile, `pipelines/ci.dag` and `pipelines/sdlc.dag` already express +pipelines in DSL that the compiler handles. The 12 remaining Rust-constructed +workflows exist because they were written before the DSL pipeline feature was +mature enough. + +This is a **migration gap**, not a limitation. The DSL's `pipeline` construct +can express everything the Rust builders do. Evidence: `pipelines/ci.dag` is +the most complex workflow and it's fully DSL. + +**Fix**: Migrate workflow builders to `dsl/pipelines/*.dag` files. The process +unit claims (currently in `process_registry.rs`) can be derived from the DSL's +`@file(READ/WRITE)` annotations, which already exist but aren't extracted. + +### Summary: What blocks "just write DSL" + +| Blocker | Category | How many registries it causes | Fix | +|---|---|---|---| +| Resolver doesn't trust compiler | Architecture gap | 3 (PASSTHROUGH_CALLABLES, match arms, resource names) | Default-passthrough + inventory (this design) | +| Workflow specs in Rust | Migration gap | 2 (TOOL_WORKFLOWS, process_registry) | Migrate to DSL pipeline definitions | +| No function body expressions | Missing DSL feature | 1 (custom Executable impls) | DSL expression language | +| Leaf-node Rust impls | Fundamental (for now) | 0 (these don't create registries) | Not a registry problem | + +The registries are caused by the first two. Neither is fundamental. + +## Design: Phase 1 — Resolver trusts compiler (immediate, no DSL changes) ### Change 1: Default-passthrough resolver (eliminates PASSTHROUGH_CALLABLES) -**Current**: `resolve_domain()` checks custom resolvers, then `PASSTHROUGH_CALLABLES`, -then returns `unknown_callable` error. +**Current**: `resolve_domain()` checks custom resolvers, then +`PASSTHROUGH_CALLABLES`, then returns `unknown_callable` error. -**Proposed**: `resolve_domain()` checks custom resolvers, then service transport, -then resource lifecycle, then **defaults to passthrough for any remaining callable**. +**Proposed**: Default to passthrough for any callable the compiler validated. ```rust fn resolve_domain( @@ -79,24 +142,17 @@ fn resolve_domain( } ``` -**Why this is safe**: The DagLang compiler already validates that every callable -reference resolves to a declared `fn`/`func` in the target module. If a -`LoweredOp::Callable` reaches the resolver, the compiler has proven the callable -exists. Passthrough is correct for any callable without custom side-effect logic -(I/O, resource acquisition, etc.), and those categories are already handled by -steps 1-3. +**Why this is safe**: The DagLang compiler validates every callable reference +resolves to a declared `fn`/`func`. If `LoweredOp::Callable` reaches the +resolver, the callable exists. Passthrough is correct for any callable without +custom side-effect logic, and those are already handled by steps 1-3. -**What this eliminates**: The entire `PASSTHROUGH_CALLABLES` const (9 modules, -30+ callable names). Adding a new passthrough callable to any DSL module requires -**zero Rust changes**. +**What this eliminates**: `PASSTHROUGH_CALLABLES` (9 modules, 30+ names). +Adding a new passthrough callable requires **zero Rust changes**. ### Change 2: Inventory-based custom resolver registration (eliminates match arms) -**Current**: `resolve_domain()` has a `match module { ... }` with 6 arms for -modules with custom `Executable` impls. - -**Proposed**: Custom resolvers register themselves via the `inventory` crate -(same pattern as `#[tool_target]`). +Custom resolvers register themselves co-located with their `Executable` impls: ```rust // In gunbc-dag/src/pragma/ops.rs: @@ -105,176 +161,119 @@ inventory::submit!(DomainResolver { resolve: resolve_pragma, }); -fn resolve_pragma(node_id: &str, name: &str, outputs: &[Port]) -> Option> { +fn resolve_pragma(node_id: &str, name: &str, outputs: &[Port]) + -> Option> +{ match name { "render_clippy_toml" => Some(Ok(DynOp::new(PragmaOp::RenderClippy))), "pragma" => Some(Ok(DynOp::new(PragmaEntrypointOp))), - // ... _ => None, // fall through to default passthrough } } ``` -The resolver collects all registered `DomainResolver` entries at startup: - -```rust -fn resolve_custom( - node_id: &str, module: &str, name: &str, outputs: &[Port], -) -> Option> { - for resolver in inventory::iter::() { - if resolver.module == module { - return (resolver.resolve)(node_id, name, outputs); - } - } - None // no custom resolver → fall through to default passthrough -} -``` - -**What this eliminates**: The `match module { ... }` dispatch in `resolve_domain()`. -Adding a new module with custom behavior requires only the `Executable` impl and -an `inventory::submit!` call in the same file — the resolver never needs editing. - -**Note**: Individual callable match arms within custom resolvers (e.g., -`resolve_pragma`'s 4 arms) are **not** eliminable — they map DSL names to -specific Rust types, which is inherently manual. But they return `None` for -unknown names, falling through to passthrough instead of erroring. This means -even custom-resolver modules can have passthrough callables mixed in. +**What this eliminates**: The `match module { ... }` dispatch. Adding a custom +module means adding the impl + registration in one file — `resolve.rs` never +needs editing. -### Change 3: Inventory-based workflow spec registration (eliminates TOOL_WORKFLOWS) +Returning `None` for unrecognized callables is the key: even modules with custom +ops can have passthrough callables mixed in. No need to enumerate every callable. -**Current**: `TOOL_WORKFLOWS` is a 14-entry const array mapping names to builder -functions. +### Change 3: Structural test assertions (eliminates brittle counts) -**Proposed**: Each workflow builder registers itself: +Replace `assert_eq!(dag.nodes.len(), 9)` (11+ instances) with: ```rust -// In gunbc-dag/src/workflow/gist.rs: -inventory::submit!(WorkflowRegistration { - canonical_name: "gist", - aliases: &[], - build: gist_workflow_spec, -}); +assert!(spec.dag.has_node("gist.branch_resolution")); +assert!(spec.dag.is_connected()); +assert!(spec.dag.has_single_sink()); ``` -Discovery becomes: - -```rust -pub fn all_tool_workflow_names() -> Vec<&'static str> { - inventory::iter::() - .map(|w| w.canonical_name) - .collect() -} - -pub fn tool_workflow_spec(name: &str) -> Result { - for w in inventory::iter::() { - if w.canonical_name == name || w.aliases.contains(&name) { - return (w.build)(); - } - } - Err(format!("unknown tool workflow: '{name}'")) -} -``` +## Design: Phase 2 — Workflows migrate to DSL (medium-term) -**What this eliminates**: The `TOOL_WORKFLOWS` const. Adding a new workflow -requires the builder function + `inventory::submit!` in the same file. +### Change 4: Express workflows as DSL pipelines -### Change 4: Derive process units from workflow DAGs (eliminates process_registry) +The 12 Rust-constructed workflow specs should become `dsl/workflows/*.dag` files, +compiled and resolved exactly like `pipelines/ci.dag` already is. This +eliminates: +- `TOOL_WORKFLOWS` registry (14 entries) +- `default_process_unit_registry()` (~80 entries) +- All `*_workflow_spec()` builder functions -**Current**: `default_process_unit_registry()` has ~80 manual `pu(...)` entries -mapping process units to resource claims. +### Change 5: Derive process unit claims from DSL annotations -**Proposed**: When a `WorkflowSpec` is built, it already contains a `Dag` with -named nodes. Process units can be derived from the DAG topology: +The DSL already has `@file(READ, "{path}")` and `@file(WRITE, "{path}")` +annotations. The compiler's derivation phase (`DerivedArtifacts`) already +extracts `ResourceUsage` per node. The process unit claims can be generated +from this: -```rust -impl WorkflowSpec { - /// Derive process units from this workflow's DAG nodes. - fn derive_process_units(&self) -> Vec { - self.dag.nodes.iter().map(|node| { - let claims = derive_claims_from_node(node); // see below - pu(&self.name, &node.id.0, claims) - }).collect() - } -} +``` +DSL: @file(WRITE, "clippy.toml") +Derived: ResourceUsage { resource: "Filesystem", usage: "Write" } +Claim: UnitClaim::write("file:workspace") ``` -Resource claims can be inferred from node metadata: -- Nodes with `file:write` resource ports → `UnitClaim::write("file:workspace")` -- Nodes with `file:read` resource ports → `UnitClaim::read("file:workspace")` -- Nodes with `network` resource ports → `UnitClaim::write("network:*")` -- Nodes with `tool:cargo` ports → `UnitClaim::read("tool:cargo")` -- Pure nodes (no resource ports) → `vec![]` - -**What this eliminates**: The entire hand-maintained process unit registry. -Adding a new workflow node automatically creates a process unit with correct -resource claims derived from the DAG's resource wiring. - -**Fallback**: If claim inference isn't precise enough for some nodes, allow -explicit `#[process_claim(...)]` annotations in the DSL or on the builder. - -### Change 5: Structural test assertions (eliminates brittle counts) - -**Current**: 11+ tests assert exact node counts: `assert_eq!(dag.nodes.len(), 9)` - -**Proposed**: Replace with structural assertions: +This closes the loop: DSL annotations → compiler derivation → process claims. +No Rust registry needed. -```rust -// Instead of: assert_eq!(spec.dag.nodes.len(), 9); -// Use: -assert!(spec.dag.has_node("gist.branch_resolution")); -assert!(spec.dag.has_node("gist.credential_resolve")); -assert!(spec.dag.has_edge_between("gist.branch_resolution", "gist.gist_create")); -// Or for pure structure validation: -assert!(spec.dag.is_connected(), "workflow DAG must be connected"); -assert!(spec.dag.has_single_sink(), "workflow DAG must have one terminal node"); -``` +## Design: Phase 3 — DSL function bodies (long-term) -This validates the DAG's **shape** rather than its **size**, so adding a node -(e.g., a new intermediate step) doesn't break unrelated tests. +### Change 6: Expression-level DSL support -## Remaining hardcoded lists (not eliminable) +Add basic expression support to the DSL language: +- String interpolation / templating +- List operations (map, filter, join) +- Arithmetic and comparison +- Pattern matching -Some lists are inherently manual because they map DSL concepts to Rust-specific -behavior that can't be auto-derived: +This allows migrating the 5 custom `Executable` modules to pure DSL: -| List | Why it stays | Mitigation | +| Module | Current Rust ops | DSL-expressible? | |---|---|---| -| `WorkspaceBinary` enum (12 entries) | Maps binary names to Cargo invocation metadata. Binaries are a build system concept, not a DSL concept. | Already uses single-table macro. Consider deriving from `Cargo.toml` `[[bin]]` in a build script. | -| `MANUAL_TOOL_DEFS` (2 entries) | `pragma` needs custom `Executable`; `build` has non-standard short_name. | Already documented (CL7). Shrinks as tools move to standard path. | -| Custom `Executable` impls (5 modules) | By definition, custom behavior requires custom code. | Inventory registration (Change 2) keeps them co-located. | -| `STANDARD_SYMBOLS` (40 entries) | UI symbols are a presentation concern, not DSL metadata. | Use `const` count assertion: `const _: [(); SYMBOLS.len()] = [(); 40];` | -| `FORBIDDEN_CALLS` / `ALLOWED_FILES` (guardrails) | Architectural constraints, not DSL metadata. | Fine as-is; changes are intentional. | +| `tools.pragma` | String rendering from config | Yes, with string interpolation | +| `tools.makegen` | Registry load + Makefile render | Partially — needs data source for registry | +| `tools.bootstrap` | Shell request prep + output parsing | Yes, with service transport patterns | +| `tools.codegen` | Build-time file existence checks | Partially — needs build metadata access | +| `tools.infra` | Filter/count/format | **Already expressed in DSL** — Rust version is redundant | -## Additional findings from audit +After Phase 3, the only Rust code is the compiler itself, the executor, and +the transport adapters. Everything else is DSL. -Beyond the CL1-CL8 items and the changes above, the scanner found: +## Remaining lists (inherently non-DSL) -| Finding | Location | Recommendation | -|---|---|---| -| Mock spec hardcoded paths (`"tools.bootstrap::bootstrap"`) | `ci/graph_mock.rs:76-88` | Derive from `LoweredOp` node IDs in the compiled CI DAG | -| Hardcoded command counts in workflow unit tests | `unit_commands.rs:541,554` | Same as Change 5: structural assertions | -| Makefile workflow name dispatch | `makegen/render.rs:230+` | Already uses generated registry; verify coverage | +| List | Why it stays | +|---|---| +| `WorkspaceBinary` (12 entries) | Build system concept (Cargo binary names), not DSL metadata | +| `STANDARD_SYMBOLS` (40 entries) | UI/presentation concern | +| `FORBIDDEN_CALLS` (guardrails) | Architectural constraint, not domain metadata | + +These are fine — they don't duplicate DSL metadata. ## Implementation order -| Phase | Changes | Impact | Size | +| Phase | Changes | Eliminates | Size | |---|---|---|---| -| **Phase 1** | Change 1 (default passthrough) | Eliminates `PASSTHROUGH_CALLABLES`. Zero-touch for new passthrough callables. | S | -| **Phase 2** | Change 5 (structural test assertions) | Eliminates 13+ brittle count assertions. | S | -| **Phase 3** | Change 2 (inventory custom resolvers) | Eliminates resolver `match module` dispatch. Co-locates ops with registration. | M | -| **Phase 4** | Change 3 (inventory workflow specs) | Eliminates `TOOL_WORKFLOWS`. Co-locates builders with registration. | M | -| **Phase 5** | Change 4 (derived process units) | Eliminates process registry. Requires claim inference logic. | L | +| **1a** | Default passthrough (Change 1) | `PASSTHROUGH_CALLABLES` | S | +| **1b** | Structural tests (Change 3) | 13+ brittle count assertions | S | +| **1c** | Inventory resolvers (Change 2) | `match module` dispatch | M | +| **2a** | Workflow DSL migration (Change 4) | `TOOL_WORKFLOWS` + builder fns | L | +| **2b** | Derived claims (Change 5) | `process_registry` (80+ entries) | M | +| **3** | DSL expressions (Change 6) | Custom `Executable` impls (5 modules) | XL | -Phase 1 is the highest value: it makes the most common operation (adding a DSL -callable) require zero Rust changes. Phases 2-4 are incremental wins using the -proven inventory pattern. Phase 5 is the most complex but eliminates the largest -registry. +Phase 1a is the highest-value immediate win. Phase 3 is the endgame where +"just write DSL" becomes literally true. ## Success criteria -After all phases: -- Adding a new `.dag` module with only passthrough callables: **0 Rust files touched** -- Adding a new `.dag` module with custom behavior: **1 Rust file** (the `Executable` impl + inventory registration, co-located) -- Adding a new workflow: **1 Rust file** (the builder + inventory registration, co-located) -- Adding a new process unit to a workflow: **0 Rust files** (derived from DAG) -- Adding a new resource to `std/resources.dag`: **0 Rust files** (already done in CL8) +**After Phase 1** (Rust-only, no DSL changes): +- New passthrough callable: **0 Rust files** +- New custom-behavior module: **1 file** (impl + inventory, co-located) +- New resource: **0 Rust files** (already done) + +**After Phase 2** (workflow migration): +- New workflow: **1 DSL file** (no Rust) +- New process unit: **0 files** (derived from DSL annotations) + +**After Phase 3** (DSL expressions): +- New tool with only pure computation: **1 DSL file** (no Rust at all) +- Rust code only needed for: new transport adapters, new resource handle types From c9fa572cb671f520fdb8f6b8de46d3b8452b9db0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 22:50:01 +0000 Subject: [PATCH 05/13] Update design: eliminate Rust escape hatch, add DSL feature inventory Reframes the design from "reduce registration boilerplate" to "DSL is the only language for business logic." Adds a concrete inventory of 7 DSL language features needed to migrate all 27 custom Executable op variants to pure DSL, including: string interpolation, string methods, list operations, pattern matching, arithmetic, structured data construction, and DSL-accessible data sources. Catalogs all static configuration data currently hiding in Rust (pragma policy rules, tool registry, build config, codegen paths) that should become DSL data files. https://claude.ai/code/session_01GZ6E3UdBSQCEtjHvWWJt6H --- TODO/design-eliminate-registration-lists.md | 303 +++++++++++++++++--- 1 file changed, 264 insertions(+), 39 deletions(-) diff --git a/TODO/design-eliminate-registration-lists.md b/TODO/design-eliminate-registration-lists.md index aff3a7f1728..e8942cf190b 100644 --- a/TODO/design-eliminate-registration-lists.md +++ b/TODO/design-eliminate-registration-lists.md @@ -1,23 +1,34 @@ -# Eliminate Registration Lists: Close the DSL→Runtime Gap +# Eliminate Registration Lists: Close the DSL-Runtime Gap **Status**: PROPOSED **Date**: 2026-02-21 **Track**: Cleanup — eliminate hardcoded metadata duplication **Prerequisite**: CL1-CL8 completed (hardcoded lists consolidated) +## Vision + +The DSL is the only programming language for tool, workflow, and pipeline logic. +Rust is infrastructure — compiler, executor, transport adapters — not a fallback +for "complex" logic. There is no escape hatch. If something can't be written in +DSL today, that's a missing DSL feature to be fixed, not a reason to write Rust. + ## Problem Statement The Rust runtime maintains handwritten registries that duplicate metadata the DSL compiler already knows. Today (post-CL1-CL8), adding a new DSL module still -requires touching Rust code in up to 4 places. +requires touching Rust code in up to 4 places. Worse, 5 modules implement their +function bodies in Rust — pure computations (string rendering, list filtering, +JSON construction) that belong in DSL but leak into Rust because the DSL lacks +expression-level primitives. -**Goal**: Make it so that adding DSL modules/callables/workflows requires **zero** -Rust registration edits. Drift should be structurally impossible. +**Goal**: Make it so that adding or modifying any tool, callable, workflow, or +configuration requires **only DSL changes**. Zero Rust edits. Drift is +structurally impossible. Rust as escape hatch is eliminated. ## Why can't we "just write DSL" for all of this? Short answer: **we almost can.** The remaining Rust exists for three reasons, -only one of which is fundamental. +none of which are fundamental. ### What's already DSL-only @@ -25,7 +36,7 @@ only one of which is fundamental. Rust DAGs. When `build_pragma_graph()` runs, it calls: ``` -dsl/tools/pragma.dag → daglang_driver::compile → Dag → resolve → Dag +dsl/tools/pragma.dag -> daglang_driver::compile -> Dag -> resolve -> Dag ``` All graph structure, wiring, and orchestration comes from the DSL. The `emit` @@ -40,7 +51,7 @@ The DSL already expresses: ### The three reasons Rust code still exists -#### Reason 1: Leaf-node function bodies (temporary — DSL doesn't implement yet) +#### Reason 1: Leaf-node function bodies (missing DSL feature) The DSL declares functions like `fn render_clippy_toml(directives) -> String` but the **body** is implemented in Rust (`PragmaOp::RenderClippy`). These are @@ -55,8 +66,7 @@ This is a **missing DSL feature**, not an architectural limitation. Evidence: source or FFI mechanism **Fix**: Add expression-level DSL support (string ops, list ops, arithmetic). -This is a language evolution, not an architecture change. Each function body -migrated from Rust to DSL eliminates one custom `Executable` impl. +See "DSL Language Features Required" below for the complete inventory. #### Reason 2: The resolver (unnecessary — compiler already has the information) @@ -101,9 +111,176 @@ unit claims (currently in `process_registry.rs`) can be derived from the DSL's | Resolver doesn't trust compiler | Architecture gap | 3 (PASSTHROUGH_CALLABLES, match arms, resource names) | Default-passthrough + inventory (this design) | | Workflow specs in Rust | Migration gap | 2 (TOOL_WORKFLOWS, process_registry) | Migrate to DSL pipeline definitions | | No function body expressions | Missing DSL feature | 1 (custom Executable impls) | DSL expression language | -| Leaf-node Rust impls | Fundamental (for now) | 0 (these don't create registries) | Not a registry problem | -The registries are caused by the first two. Neither is fundamental. +None of these are fundamental. All three are fixable. + +## DSL Language Features Required + +Auditing every custom `Executable` impl (22 op variants across 5 modules) +reveals the exact language primitives the DSL needs to eliminate Rust as an +escape hatch. Every computation in these modules is pure — no I/O, no FFI, +no unsafe — just data transformation between transport boundaries. + +### Feature 1: String interpolation and templating + +**Used by**: PragmaOp (3 variants), BootstrapOp (4), CodegenOp (5), BuildOp (7) + +The most common pattern. Rust uses `format!()`, `write!()`, and string +concatenation to build output strings from structured inputs. + +``` +// Current Rust (pragma/ops.rs): +format!("# {}\n{}", header.render(), body) + +// DSL equivalent: +let result = "# ${header.render()}\n${body}" +``` + +**Required primitives**: +- `"${expr}"` — interpolation within string literals +- Multi-line string literals (template blocks) +- String concatenation (`+` or implicit adjacency) + +### Feature 2: String methods + +**Used by**: BootstrapOp (parsing shell output), CodegenOp (path normalization) + +``` +// Current Rust: +line.trim() +line.strip_prefix("crates/") +path.replace('\\', "/") +output.lines() +name.contains('/') +text.is_empty() +text.ends_with('/') +``` + +**Required primitives**: +- `.trim()`, `.lines()`, `.split(sep)` +- `.strip_prefix(s)`, `.strip_suffix(s)` +- `.replace(old, new)` +- `.contains(s)`, `.starts_with(s)`, `.ends_with(s)` +- `.is_empty()`, `.len()` + +### Feature 3: List operations + +**Used by**: PragmaOp (allowlist rendering), MakegenOp (registry serialization), +BootstrapOp (crate name extraction), CodegenOp (path verification) + +``` +// Current Rust: +rules.iter().map(|r| r.render()).collect::>() +crate_names.sort() +patterns.dedup() +expected_paths.iter().all(|p| found.contains(p)) +``` + +**Required primitives**: +- `.map(fn)`, `.filter(fn)` — transform/select +- `.sort()`, `.dedup()` — ordering +- `.join(sep)` — list to string +- `.any(fn)`, `.all(fn)` — predicate testing +- `.len()` — count +- `.push(item)`, list literal `[a, b, c]` +- `.contains(item)` — membership + +### Feature 4: Pattern matching and conditionals + +**Used by**: All 5 modules + +``` +// Current Rust: +match response { + TransportResponse::Shell(shell) => ..., + _ => Err(...) +} +if build_success && !skip_tests { ... } +``` + +**Required primitives**: +- `match expr { pattern => body, ... }` — exhaustive matching +- `if cond { a } else { b }` — conditional expressions +- `let ... = ...` — binding with destructuring +- Boolean operators: `&&`, `||`, `!` + +### Feature 5: Integer arithmetic and comparison + +**Used by**: CodegenOp (manifest freshness), BuildOp (exit code checking), +MakegenOp (counting) + +``` +// Current Rust: +testgen_targets.len() +response.exit_code == 0 +``` + +**Required primitives**: +- `+`, `-`, `*`, `/`, `%` — arithmetic +- `==`, `!=`, `<`, `>`, `<=`, `>=` — comparison +- Integer literals + +### Feature 6: Structured data construction + +**Used by**: MakegenOp (JSON building for Makefile rendering) + +``` +// Current Rust: +serde_json::json!({ + "tools": tools.iter().map(|t| json!({"name": t.short_name})).collect::>(), + "testgen_targets": targets, +}) +``` + +**Required primitives**: +- Object literals: `{ key: value, ... }` +- Nested construction: objects containing lists containing objects +- This is close to what the DSL already has for `@mock_response` blocks + +### Feature 7: DSL-accessible data sources + +**Used by**: MakegenOp, BootstrapOp, CodegenOp, PragmaOp + +Currently, pure configuration data is embedded in Rust source files and accessed +via Rust API calls. This data has no reason to live in Rust — it's declarative +configuration that belongs in DSL data files. + +**Data currently hiding in Rust**: + +| Data | Location | Nature | +|---|---|---| +| Clippy allowlist rules (8 rules) | `policy/pragma.rs` | Static config: crate selectors, suffix paths, rationales | +| Dead code allow rules (5 rules) | `policy/pragma.rs` | Static config: crate names, relative paths | +| Pragma allow lints (3 lints) | `policy/pragma.rs` | Static list of lint IDs | +| Crate policies (1 entry) | `policy/pragma.rs` | Static config: crate name + policy flags | +| Tool registry (12 tools) | `gunbc-tool-registry` | Static config: tool names, packages, binaries | +| Testgen specs | `gunbc-testgen-registry` | Static config: test module names, DAG paths | +| Build config | `gunbc-makegen` | Static config: cargo commands, feature flags | +| Gitignore categories (14 categories) | `gunbc-makegen` | Static config: path patterns per category | +| Codegen path templates | `codegen/ops.rs` | Static config: `target/codegen/bin`, stamp paths | +| Workspace layout | `gunbc-ir` | Derivable from DSL module structure | + +**Required mechanism**: +- `@data` or `data` blocks in DSL for declaring static configuration +- `import data from "config/pragma-policy.dag"` — DSL-to-DSL data imports +- The compiler resolves data references at compile time, not runtime + +This eliminates the last category of "I need Rust because the data lives there." +The data moves to DSL, the logic that consumes it is already expressible with +Features 1-6 above. + +### Coverage matrix: Features vs. modules + +| Module | Variants | F1 String | F2 Methods | F3 Lists | F4 Match | F5 Arith | F6 Data | F7 Sources | +|---|---|---|---|---|---|---|---|---| +| **pragma** | 3 | YES | YES | YES | YES | - | - | YES | +| **makegen** | 3 | YES | - | YES | YES | YES | YES | YES | +| **bootstrap** | 4 | YES | YES | YES | YES | - | - | YES | +| **codegen** | 5 | YES | YES | YES | YES | YES | - | YES | +| **build** | 7 | YES | - | - | YES | YES | - | - | + +Every module is fully covered by these 7 features. No module requires anything +beyond basic data transformation primitives. ## Design: Phase 1 — Resolver trusts compiler (immediate, no DSL changes) @@ -213,41 +390,84 @@ Derived: ResourceUsage { resource: "Filesystem", usage: "Write" } Claim: UnitClaim::write("file:workspace") ``` -This closes the loop: DSL annotations → compiler derivation → process claims. +This closes the loop: DSL annotations -> compiler derivation -> process claims. No Rust registry needed. -## Design: Phase 3 — DSL function bodies (long-term) +## Design: Phase 3 — DSL expression language (eliminates Rust escape hatch) + +This is the core goal, not an optional long-term aspiration. Phases 1 and 2 +remove registration boilerplate; Phase 3 eliminates the reason Rust is used +for business logic at all. ### Change 6: Expression-level DSL support -Add basic expression support to the DSL language: -- String interpolation / templating -- List operations (map, filter, join) -- Arithmetic and comparison -- Pattern matching +Add the 7 feature categories documented in "DSL Language Features Required" +above. This is a language evolution within the existing DagLang compiler — +the type system, module system, and graph semantics are unchanged. + +### Change 7: Migrate configuration data to DSL data sources -This allows migrating the 5 custom `Executable` modules to pure DSL: +Move all static configuration currently embedded in Rust source files into +DSL data files: -| Module | Current Rust ops | DSL-expressible? | +``` +dsl/config/pragma-policy.dag -- clippy rules, lint policies, crate policies +dsl/config/tool-registry.dag -- tool names, packages, binaries +dsl/config/build.dag -- cargo commands, feature flags +dsl/config/codegen-paths.dag -- path templates, stamp files +``` + +The compiler resolves these at compile time. The data is version-controlled, +diffable, and requires zero Rust knowledge to modify. + +### Change 8: Migrate custom Executable impls to DSL function bodies + +With Features 1-7 available, each custom module migrates from Rust to DSL: + +| Module | Rust ops to migrate | What replaces them | |---|---|---| -| `tools.pragma` | String rendering from config | Yes, with string interpolation | -| `tools.makegen` | Registry load + Makefile render | Partially — needs data source for registry | -| `tools.bootstrap` | Shell request prep + output parsing | Yes, with service transport patterns | -| `tools.codegen` | Build-time file existence checks | Partially — needs build metadata access | -| `tools.infra` | Filter/count/format | **Already expressed in DSL** — Rust version is redundant | +| `tools.infra` | Filter/count/format (5 ops) | **Delete** — already redundant with `dsl/tools/infra.dag` | +| `tools.build` | Boolean cascade + string summary (7 ops) | DSL conditionals + string interpolation | +| `tools.pragma` | Config rendering (3 ops) | DSL string interpolation + data imports | +| `tools.bootstrap` | Shell output parsing + crate extraction (4 ops) | DSL string methods + list ops | +| `tools.codegen` | Path checking + manifest freshness (5 ops) | DSL string methods + conditionals + data sources | +| `tools.makegen` | Registry load + JSON construction (3 ops) | DSL data sources + structured data literals | -After Phase 3, the only Rust code is the compiler itself, the executor, and -the transport adapters. Everything else is DSL. +After this, zero `Executable` impls exist outside the compiler/executor +infrastructure. The `resolve_custom()` path in Change 1 becomes empty. +The inventory registrations from Change 2 disappear. The resolver reduces to: -## Remaining lists (inherently non-DSL) +```rust +fn resolve_domain(...) -> Result { + if module.starts_with("services.") || module.starts_with("workspace.") { + return resolve_service_transport(...); + } + if module == "std.resources" { + return resolve_std_resources(name); + } + // Everything is passthrough. The DSL handles all logic. + Ok(DynOp::new(PassthroughOp { ... })) +} +``` -| List | Why it stays | -|---|---| -| `WorkspaceBinary` (12 entries) | Build system concept (Cargo binary names), not DSL metadata | -| `STANDARD_SYMBOLS` (40 entries) | UI/presentation concern | -| `FORBIDDEN_CALLS` (guardrails) | Architectural constraint, not domain metadata | +## What stays in Rust (by design, not by escape hatch) -These are fine — they don't duplicate DSL metadata. +These are infrastructure concerns, not business logic. They don't duplicate +DSL metadata and don't grow when tools/workflows are added: + +| Component | Why it's Rust | Grows when... | +|---|---|---| +| DagLang compiler | Language implementation | New DSL syntax is added | +| DAG executor | Runtime engine | New execution semantics are added | +| Transport adapters (Shell, REST, Filesystem) | System boundary / FFI | New transport protocols are added | +| Resource handle types | Capability system | New resource kinds are added | +| `WorkspaceBinary` (12 entries) | Build system (Cargo binary names) | New crate binaries are added | +| `STANDARD_SYMBOLS` (40 entries) | UI/presentation | New display symbols are added | +| `FORBIDDEN_CALLS` (guardrails) | Architectural constraint | New safety rules are added | + +The key distinction: **infrastructure grows with the platform, not with the +domain.** Adding a new tool, workflow, or pipeline should never require touching +any of these. ## Implementation order @@ -258,10 +478,12 @@ These are fine — they don't duplicate DSL metadata. | **1c** | Inventory resolvers (Change 2) | `match module` dispatch | M | | **2a** | Workflow DSL migration (Change 4) | `TOOL_WORKFLOWS` + builder fns | L | | **2b** | Derived claims (Change 5) | `process_registry` (80+ entries) | M | -| **3** | DSL expressions (Change 6) | Custom `Executable` impls (5 modules) | XL | +| **3a** | DSL expression language (Change 6) | The escape hatch itself | L | +| **3b** | Data source migration (Change 7) | Config data in Rust files | M | +| **3c** | Custom op migration (Change 8) | All 5 custom `Executable` modules (27 ops) | L | -Phase 1a is the highest-value immediate win. Phase 3 is the endgame where -"just write DSL" becomes literally true. +Phase 1a is the highest-value immediate win. Phase 3 is where the vision +is realized: Rust is infrastructure, DSL is everything else. ## Success criteria @@ -274,6 +496,9 @@ Phase 1a is the highest-value immediate win. Phase 3 is the endgame where - New workflow: **1 DSL file** (no Rust) - New process unit: **0 files** (derived from DSL annotations) -**After Phase 3** (DSL expressions): -- New tool with only pure computation: **1 DSL file** (no Rust at all) +**After Phase 3** (DSL expressions + data migration): +- New tool of any complexity: **1 DSL file** (no Rust at all) +- New configuration/policy: **1 DSL data file** (no Rust at all) - Rust code only needed for: new transport adapters, new resource handle types +- Custom `Executable` impls: **0** (down from 27 op variants across 5 modules) +- The concept of "escape to Rust" no longer exists From 9a971de0a7fc735e1c5d7c2834cef3fd95866340 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 22:57:58 +0000 Subject: [PATCH 06/13] Redesign string handling: structured modeling over string methods Replaces the "add string method library" approach with "eliminate the need for string manipulation through proper structured modeling." Audit of ~60 string ops across 5 modules shows ~45% are symptoms of unstructured data (parsing raw transport output, path string manipulation, type predicates as string checks). These are eliminated by: structured transport responses, structured path/glob types, and structured document rendering. No general-purpose string method library needed. https://claude.ai/code/session_01GZ6E3UdBSQCEtjHvWWJt6H --- TODO/design-eliminate-registration-lists.md | 349 ++++++++++++++------ 1 file changed, 243 insertions(+), 106 deletions(-) diff --git a/TODO/design-eliminate-registration-lists.md b/TODO/design-eliminate-registration-lists.md index e8942cf190b..de447496acb 100644 --- a/TODO/design-eliminate-registration-lists.md +++ b/TODO/design-eliminate-registration-lists.md @@ -116,87 +116,187 @@ None of these are fundamental. All three are fixable. ## DSL Language Features Required -Auditing every custom `Executable` impl (22 op variants across 5 modules) +Auditing every custom `Executable` impl (27 op variants across 5 modules) reveals the exact language primitives the DSL needs to eliminate Rust as an escape hatch. Every computation in these modules is pure — no I/O, no FFI, no unsafe — just data transformation between transport boundaries. -### Feature 1: String interpolation and templating +**Key insight**: most "string manipulation" in these modules is actually a +**structured modeling failure**. Of ~60 string operations across all modules: +- ~25% are **deserialization** — parsing raw strings into structured data that + should have arrived structured in the first place +- ~12% are **normalization** — cleaning up data that should arrive clean +- ~8% are **path manipulation** — building paths from string parts instead + of using structured path types -**Used by**: PragmaOp (3 variants), BootstrapOp (4), CodegenOp (5), BuildOp (7) +These categories should be **eliminated by better modeling**, not supported +with string methods. The remaining ~55% is legitimate serialization (rendering +structured data to text output) which needs proper support. -The most common pattern. Rust uses `format!()`, `write!()`, and string -concatenation to build output strings from structured inputs. +### Principle: Eliminate string manipulation through structured modeling +Before adding string primitives to the DSL, we should ask: **why is this data +a string at all?** In most cases, the answer is "because the transport layer +or data model didn't provide structure." + +#### Problem 1: Transport responses arrive as raw strings + +Bootstrap parses shell output with 5 chained string operations: ``` -// Current Rust (pragma/ops.rs): -format!("# {}\n{}", header.render(), body) +shell.stdout.lines() // split raw text + .map(|l| l.trim()) // clean whitespace + .filter(|l| !l.is_empty()) // drop blanks + .filter_map(|l| l.strip_prefix("crates/")) // extract path component + .filter(|n| !n.contains('/')) // validate single segment +``` + +This entire chain exists because `find` returns raw text. The DSL shouldn't +need string methods for this — the **transport layer should return structured +records**. Similarly, codegen parses glob responses as newline-separated +strings when they should arrive as `List[FilePath]`. -// DSL equivalent: -let result = "# ${header.render()}\n${body}" +**Fix**: Structured transport responses. When a shell command's output format +is known (declared in the DSL's service spec), the transport adapter parses it +into typed records before the DAG node ever sees it. The DSL declares the +expected structure; the runtime delivers it. + +``` +// Instead of returning raw stdout: +service workspace.shell { + fn find_crates() -> List[CrateName] { + @shell("find crates -maxdepth 1 -mindepth 1 -type d") + @parse(lines, strip_prefix: "crates/", filter: single_segment) + } +} ``` -**Required primitives**: -- `"${expr}"` — interpolation within string literals -- Multi-line string literals (template blocks) -- String concatenation (`+` or implicit adjacency) +This eliminates `.lines()`, `.trim()`, `.strip_prefix()`, `.contains('/')`, +`.is_empty()` — not by supporting them in DSL, but by making them unnecessary. -### Feature 2: String methods +#### Problem 2: Paths are strings instead of structured types -**Used by**: BootstrapOp (parsing shell output), CodegenOp (path normalization) +Pragma and codegen do path manipulation via string operations: +``` +path.to_string_lossy().replace('\\', "/") // normalization +!normalized.ends_with('/') // validation +format!("{}/**/main.rs", codegen_bin_dir()) // pattern building +format!("{}/{}/main.rs", bin_dir, tool_name) // path construction +``` + +None of this should exist. The DSL already has `FilePath` as a type — it +should be a structured type with segments, not a string alias. + +**Fix**: Structured path and glob types in the DSL type system: ``` -// Current Rust: -line.trim() -line.strip_prefix("crates/") -path.replace('\\', "/") -output.lines() -name.contains('/') -text.is_empty() -text.ends_with('/') +// Path construction is structural, not string interpolation: +let path = codegen_bin_dir / tool_name / "main.rs" + +// Glob patterns are a type, not a formatted string: +let pattern = glob(codegen_bin_dir, "**", "main.rs") ``` -**Required primitives**: -- `.trim()`, `.lines()`, `.split(sep)` -- `.strip_prefix(s)`, `.strip_suffix(s)` -- `.replace(old, new)` -- `.contains(s)`, `.starts_with(s)`, `.ends_with(s)` -- `.is_empty()`, `.len()` +The compiler guarantees path separator handling, normalization, and +validation. No string operations needed. -### Feature 3: List operations +#### Problem 3: Type predicates encoded as string checks -**Used by**: PragmaOp (allowlist rendering), MakegenOp (registry serialization), -BootstrapOp (crate name extraction), CodegenOp (path verification) +Pragma filters crates by name prefix using `crate_name.starts_with(prefix)`. +This is a type predicate disguised as a string operation — the DSL should +express crate selection as structured matching: ``` -// Current Rust: -rules.iter().map(|r| r.render()).collect::>() -crate_names.sort() -patterns.dedup() -expected_paths.iter().all(|p| found.contains(p)) +// Instead of: crate_name.starts_with("gunbc-lib-") +// Use structured selector: +match crate { + Crate(prefix: "gunbc-lib-") => apply_policy(...) + _ => default_policy(...) +} ``` +#### What this eliminates from the "string methods" inventory + +| Original "string feature" | Eliminated by | Remaining need | +|---|---|---| +| `.lines()`, `.trim()`, `.split()` | Structured transport responses | None | +| `.strip_prefix()`, `.strip_suffix()` | Structured transport parsing | None | +| `.replace('\\', "/")` | Structured path types | None | +| `.contains('/')`, `.ends_with('/')` | Structured path types | None | +| `.starts_with(prefix)` | Structured pattern matching | None | +| `.is_empty()` | Option types / empty-collection handling | Minimal | +| `.len()` | Collection `.len()` (not string-specific) | As list op | + +**After proper modeling, no general-purpose string method library is needed.** + +### Feature 1: Structured rendering (text output from typed data) + +The ~40% of string operations that ARE legitimate — serialization — follow a +consistent pattern: rendering structured data into a text file format (TOML, +Makefile, gitignore, status reports). This is the one place where the DSL +genuinely needs text composition support. + +But even here, it shouldn't be ad-hoc string concatenation. It should be +**structured document rendering** — a DAG of typed blocks that compose into +the final output: + +``` +// Pragma renders a TOML-like config file: +render clippy_toml(policy: ClippyPolicy) -> TextFile { + section header { + comment "Generated by gunbc-pragma" + comment "Do not edit manually" + blank + } + section disallowed_methods { + for rule in policy.allowlist_rules { + comment rule.rationale + line rule.pattern + } + } + section allow_dead_code { + if policy.dead_code_paths.is_empty() { + comment "(none)" + } else { + for path in policy.dead_code_paths { + line path + } + } + } +} +``` + +This is fundamentally a **document DAG** — sections contain blocks, blocks +contain lines, lines contain values. The DSL already models DAGs. The +rendering engine handles: +- Line breaks between sections +- Comment prefixes (`#`, `//`) +- Indentation levels +- Empty-section placeholders + +**Required primitives**: +- `render` functions that produce `TextFile` / `Document` types +- `section`, `line`, `comment`, `blank` block constructors +- `for ... in` iteration within render blocks +- `if/else` conditional sections +- `"${expr}"` interpolation within line values + +### Feature 2: Collection operations + +List/set operations are genuinely needed and used across all modules. These +are not string operations — they operate on typed collections. + **Required primitives**: - `.map(fn)`, `.filter(fn)` — transform/select - `.sort()`, `.dedup()` — ordering -- `.join(sep)` — list to string - `.any(fn)`, `.all(fn)` — predicate testing - `.len()` — count -- `.push(item)`, list literal `[a, b, c]` - `.contains(item)` — membership +- `.join(sep)` — render list as delimited string (rendering only) +- List literals `[a, b, c]` -### Feature 4: Pattern matching and conditionals +### Feature 3: Pattern matching and conditionals -**Used by**: All 5 modules - -``` -// Current Rust: -match response { - TransportResponse::Shell(shell) => ..., - _ => Err(...) -} -if build_success && !skip_tests { ... } -``` +Used by all 5 modules for dispatch, validation, and branching. **Required primitives**: - `match expr { pattern => body, ... }` — exhaustive matching @@ -204,46 +304,30 @@ if build_success && !skip_tests { ... } - `let ... = ...` — binding with destructuring - Boolean operators: `&&`, `||`, `!` -### Feature 5: Integer arithmetic and comparison +### Feature 4: Integer arithmetic and comparison -**Used by**: CodegenOp (manifest freshness), BuildOp (exit code checking), -MakegenOp (counting) - -``` -// Current Rust: -testgen_targets.len() -response.exit_code == 0 -``` +Used by CodegenOp (manifest freshness), BuildOp (exit code checking), +MakegenOp (counting). **Required primitives**: - `+`, `-`, `*`, `/`, `%` — arithmetic - `==`, `!=`, `<`, `>`, `<=`, `>=` — comparison - Integer literals -### Feature 6: Structured data construction +### Feature 5: Structured data construction -**Used by**: MakegenOp (JSON building for Makefile rendering) - -``` -// Current Rust: -serde_json::json!({ - "tools": tools.iter().map(|t| json!({"name": t.short_name})).collect::>(), - "testgen_targets": targets, -}) -``` +Used by MakegenOp for building JSON-like data for template rendering. **Required primitives**: - Object literals: `{ key: value, ... }` - Nested construction: objects containing lists containing objects - This is close to what the DSL already has for `@mock_response` blocks -### Feature 7: DSL-accessible data sources - -**Used by**: MakegenOp, BootstrapOp, CodegenOp, PragmaOp +### Feature 6: DSL-accessible data sources -Currently, pure configuration data is embedded in Rust source files and accessed -via Rust API calls. This data has no reason to live in Rust — it's declarative -configuration that belongs in DSL data files. +Currently, pure configuration data is embedded in Rust source files and +accessed via Rust API calls. This data has no reason to live in Rust — it's +declarative configuration that belongs in DSL data files. **Data currently hiding in Rust**: @@ -261,26 +345,45 @@ configuration that belongs in DSL data files. | Workspace layout | `gunbc-ir` | Derivable from DSL module structure | **Required mechanism**: -- `@data` or `data` blocks in DSL for declaring static configuration +- `data` blocks in DSL for declaring static typed configuration - `import data from "config/pragma-policy.dag"` — DSL-to-DSL data imports - The compiler resolves data references at compile time, not runtime -This eliminates the last category of "I need Rust because the data lives there." -The data moves to DSL, the logic that consumes it is already expressible with -Features 1-6 above. +### Feature 7: Structured transport responses + +The transport layer should parse command output into typed records when the +DSL declares the expected output format. This eliminates the entire category +of "parse raw text" string operations. + +**Required mechanism**: +- `@parse` annotations on service calls declaring output structure +- Transport adapters that use the declared schema to parse responses +- The DSL node receives typed data, never raw strings + +### What this does NOT include + +Notably absent: a general-purpose string method library. No `.trim()`, +`.split()`, `.replace()`, `.strip_prefix()`, `.starts_with()`, etc. These +are symptoms of unstructured data flowing through the system. The proper fix +is structured data at the boundaries, not string manipulation in the middle. + +If a future use case genuinely needs string methods (not because data arrived +unstructured, but because the domain is inherently textual), individual +methods can be added to the `String` type. But the default answer should +always be: **model the data structurally and you won't need string methods.** ### Coverage matrix: Features vs. modules -| Module | Variants | F1 String | F2 Methods | F3 Lists | F4 Match | F5 Arith | F6 Data | F7 Sources | +| Module | Variants | F1 Render | F2 Collections | F3 Match | F4 Arith | F5 Data | F6 Sources | F7 Transport | |---|---|---|---|---|---|---|---|---| -| **pragma** | 3 | YES | YES | YES | YES | - | - | YES | -| **makegen** | 3 | YES | - | YES | YES | YES | YES | YES | -| **bootstrap** | 4 | YES | YES | YES | YES | - | - | YES | -| **codegen** | 5 | YES | YES | YES | YES | YES | - | YES | -| **build** | 7 | YES | - | - | YES | YES | - | - | +| **pragma** | 3 | YES | YES | YES | - | - | YES | - | +| **makegen** | 3 | YES | YES | YES | YES | YES | YES | - | +| **bootstrap** | 4 | - | YES | YES | - | - | YES | YES | +| **codegen** | 5 | - | YES | YES | YES | - | YES | YES | +| **build** | 7 | YES | - | YES | YES | - | - | YES | -Every module is fully covered by these 7 features. No module requires anything -beyond basic data transformation primitives. +Every module is fully covered by structured modeling + these 7 features. No +module requires general-purpose string manipulation. ## Design: Phase 1 — Resolver trusts compiler (immediate, no DSL changes) @@ -399,13 +502,40 @@ This is the core goal, not an optional long-term aspiration. Phases 1 and 2 remove registration boilerplate; Phase 3 eliminates the reason Rust is used for business logic at all. -### Change 6: Expression-level DSL support +The approach is: **fix the data model first, then add minimal expression +support.** Most "string manipulation" disappears when data is properly +structured. What remains is legitimate rendering and collection logic. + +### Change 6: Structured transport responses -Add the 7 feature categories documented in "DSL Language Features Required" -above. This is a language evolution within the existing DagLang compiler — -the type system, module system, and graph semantics are unchanged. +Extend service call declarations so the DSL specifies the expected output +structure. The transport layer parses raw output into typed records before +the DAG node receives it. This eliminates all ad-hoc parsing (`.lines()`, +`.trim()`, `.strip_prefix()`, etc.) from business logic. -### Change 7: Migrate configuration data to DSL data sources +### Change 7: Structured path and glob types + +Make `FilePath` a proper structured type with segments, not a string alias. +Add `GlobPattern` as a type. Path construction, joining, and pattern building +become type-safe operations. This eliminates all path string manipulation +(`.replace('\\', "/")`, `format!("{}/{}/main.rs", ...)`, `.ends_with('/')`). + +### Change 8: Structured document rendering + +Add `render` functions that produce typed document trees (`TextFile` / +`Document`). Sections, lines, comments, and blank lines are structural +blocks. The rendering engine handles formatting concerns (separators, +prefixes, indentation). This replaces all ad-hoc `format!()` / `write!()` / +`.push_str()` string concatenation. + +### Change 9: Expression-level DSL support + +Add the remaining expression features documented in "DSL Language Features +Required" above: collection operations, pattern matching, conditionals, +arithmetic, structured data construction. These are genuine computational +primitives, not string workarounds. + +### Change 10: Migrate configuration data to DSL data sources Move all static configuration currently embedded in Rust source files into DSL data files: @@ -420,18 +550,19 @@ dsl/config/codegen-paths.dag -- path templates, stamp files The compiler resolves these at compile time. The data is version-controlled, diffable, and requires zero Rust knowledge to modify. -### Change 8: Migrate custom Executable impls to DSL function bodies +### Change 11: Migrate custom Executable impls to DSL function bodies -With Features 1-7 available, each custom module migrates from Rust to DSL: +With structured modeling and expression support available, each custom module +migrates from Rust to DSL: -| Module | Rust ops to migrate | What replaces them | +| Module | Rust ops to migrate | What eliminates them | |---|---|---| | `tools.infra` | Filter/count/format (5 ops) | **Delete** — already redundant with `dsl/tools/infra.dag` | -| `tools.build` | Boolean cascade + string summary (7 ops) | DSL conditionals + string interpolation | -| `tools.pragma` | Config rendering (3 ops) | DSL string interpolation + data imports | -| `tools.bootstrap` | Shell output parsing + crate extraction (4 ops) | DSL string methods + list ops | -| `tools.codegen` | Path checking + manifest freshness (5 ops) | DSL string methods + conditionals + data sources | -| `tools.makegen` | Registry load + JSON construction (3 ops) | DSL data sources + structured data literals | +| `tools.build` | Boolean cascade + string summary (7 ops) | Conditionals + structured rendering | +| `tools.pragma` | Config rendering (3 ops) | Structured rendering + data source imports | +| `tools.bootstrap` | Shell output parsing + crate extraction (4 ops) | Structured transport responses + collection ops | +| `tools.codegen` | Path checking + manifest freshness (5 ops) | Structured paths + conditionals + data sources | +| `tools.makegen` | Registry load + JSON construction (3 ops) | Data sources + structured data literals | After this, zero `Executable` impls exist outside the compiler/executor infrastructure. The `resolve_custom()` path in Change 1 becomes empty. @@ -478,12 +609,18 @@ any of these. | **1c** | Inventory resolvers (Change 2) | `match module` dispatch | M | | **2a** | Workflow DSL migration (Change 4) | `TOOL_WORKFLOWS` + builder fns | L | | **2b** | Derived claims (Change 5) | `process_registry` (80+ entries) | M | -| **3a** | DSL expression language (Change 6) | The escape hatch itself | L | -| **3b** | Data source migration (Change 7) | Config data in Rust files | M | -| **3c** | Custom op migration (Change 8) | All 5 custom `Executable` modules (27 ops) | L | - -Phase 1a is the highest-value immediate win. Phase 3 is where the vision -is realized: Rust is infrastructure, DSL is everything else. +| **3a** | Structured transport responses (Change 6) | All ad-hoc parsing (~15 string ops) | M | +| **3b** | Structured path/glob types (Change 7) | All path string manipulation (~8 string ops) | M | +| **3c** | Structured document rendering (Change 8) | All ad-hoc string concatenation (~25 string ops) | M | +| **3d** | Expression support (Change 9) | Need for Rust computation logic | L | +| **3e** | Data source migration (Change 10) | Config data in Rust files | M | +| **3f** | Custom op migration (Change 11) | All 5 custom `Executable` modules (27 ops) | L | + +Phase 1a is the highest-value immediate win. Phase 3a-3c are the modeling +foundation — they eliminate most "string manipulation" by making it +unnecessary, not by supporting it. Phase 3d-3f build on that foundation +to complete the migration. The key principle: **model the data first, add +expression support for what remains.** ## Success criteria From 04c0d422f0bbaca3aa1753ff00f657a1f8fbfae3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 23:08:25 +0000 Subject: [PATCH 07/13] Phase 1a: default-passthrough resolver, delete InfraToolOp (-108 lines) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolver now defaults to passthrough for any callable the compiler validated. PASSTHROUGH_CALLABLES registry (9 modules, 30+ entries) eliminated — adding a new passthrough callable requires zero Rust changes. Also deletes InfraToolOp which was redundant with the DSL (dsl/tools/infra.dag already expresses the same filter/count logic). https://claude.ai/code/session_01GZ6E3UdBSQCEtjHvWWJt6H --- gunbc-dag/src/resolve.rs | 146 +++++---------------------------------- 1 file changed, 19 insertions(+), 127 deletions(-) diff --git a/gunbc-dag/src/resolve.rs b/gunbc-dag/src/resolve.rs index 5bad7df7ecd..ffad6ed2778 100644 --- a/gunbc-dag/src/resolve.rs +++ b/gunbc-dag/src/resolve.rs @@ -1,4 +1,4 @@ -//! Central resolver: `LoweredOp` → `DynOp` via existing domain ops. +//! Central resolver: `LoweredOp` -> `DynOp` via existing domain ops. //! //! Maps each lowered operation from a compiled `.dag` file to its concrete //! `Executable` implementation, wrapped in `DynOp`. This eliminates the need @@ -17,8 +17,9 @@ //! # Adding a new module //! //! To wire a new `.dag` module: -//! - **Passthrough callables** (forward inputs to outputs): add an entry -//! to `PASSTHROUGH_CALLABLES` — no new types or functions needed. +//! - **Passthrough callables** (forward inputs to outputs): no Rust changes +//! needed — the resolver defaults to passthrough for any callable the +//! compiler validated. //! - **Custom callables**: add a match arm in `resolve_domain()` for the //! module path and map each callable to its `DynOp`. //! - Infrastructure nodes (content_upsert, fs_env) are handled automatically. @@ -97,118 +98,6 @@ impl Executable for PassthroughOp { } } -/// Centralized registry of `(module, &[callable_name])` pairs that use -/// passthrough dispatch. Adding a new passthrough callable only requires -/// appending to this list — no new enum, impl, or resolver function. -const PASSTHROUGH_CALLABLES: &[(&str, &[&str])] = &[ - ("tools.build", &["build_all"]), - ("tools.clippy", &["clippy_lint"]), - ("tools.deps", &["render_deps_toml", "select_platform_deps", "deps_install", "deps_generate"]), - ("tools.docgen", &["docgen", "render_ab_workflows_doc"]), - ("tools.testgen", &["generate_tests", "testgen"]), - ("pipelines.ci", &["ci"]), - ("shared.dag_util", &[ - "aggregate_results", "all_succeeded", "format_report", "stage_result", - "skipped_stage", "stage_from_output", "generated_header", "render_and_upsert", - ]), - ("shared.gist_modes", &[ - "branch_context", "resolve_recent_base", "gist_filename", - "gist_upload", "share_content", "detect_runtime", - ]), - ("std.patterns", &[ - "file_content_matches", "classify_files", "read_text_files", - "acquire_subject_token", "optional_impersonation", "ensure", - "upsert", "content_upsert", "credential_chain", "transaction", "retry", - ]), -]; - -/// Try to resolve a callable via the passthrough registry. -fn resolve_passthrough( - node_id: &str, - module: &str, - name: &str, - outputs: &[Port], -) -> Option> { - for &(mod_name, callables) in PASSTHROUGH_CALLABLES { - if mod_name == module { - if callables.contains(&name) { - return Some(Ok(DynOp::new(PassthroughOp { - output_port_names: declared_output_names(outputs), - }))); - } - return Some(Err(unknown_callable(node_id, module, name))); - } - } - None -} - -#[derive(Debug, Clone)] -enum InfraToolOp { - Infra, -} - -impl Executable for InfraToolOp { - fn execute(&self, inputs: HashMap) -> Result, ExecError> { - let environment = inputs - .get("environment") - .and_then(Value::as_str) - .ok_or_else(|| ExecError::new("tools.infra.infra missing `environment` input"))?; - let runtime = inputs - .get("runtime") - .and_then(Value::as_str) - .ok_or_else(|| ExecError::new("tools.infra.infra missing `runtime` input"))?; - let spec_targets = inputs - .get("spec_targets") - .and_then(Value::as_str_list) - .ok_or_else(|| ExecError::new("tools.infra.infra missing `spec_targets` input"))?; - let target = inputs - .get("target") - .and_then(Value::as_str_list) - .unwrap_or_default(); - let skip = inputs - .get("skip") - .and_then(Value::as_str_list) - .unwrap_or_default(); - let execute = inputs - .get("execute") - .and_then(Value::as_bool) - .ok_or_else(|| ExecError::new("tools.infra.infra missing `execute` input"))?; - - let mut planned_targets: Vec = if target.is_empty() { - spec_targets.clone() - } else { - spec_targets - .iter() - .filter(|item| target.iter().any(|candidate| candidate == *item)) - .cloned() - .collect() - }; - planned_targets.retain(|item| !skip.iter().any(|candidate| candidate == item)); - - let target_count = planned_targets.len() as i64; - let applied_count = if execute { target_count } else { 0 }; - let mode = if execute { "apply" } else { "plan" }; - let report = format!( - "infra {mode} (env={environment}, runtime={runtime}): {target_count} target(s)" - ); - OutputMap::new() - .str("environment", environment) - .str("runtime", runtime) - .str("mode", mode) - .str_list("planned_targets", planned_targets) - .int("target_count", target_count) - .int("applied_count", applied_count) - .str("report", report) - .ok() - } -} - -fn resolve_infra(node_id: &str, name: &str, _outputs: &[Port]) -> Result { - match name { - "infra" => Ok(DynOp::new(InfraToolOp::Infra)), - _ => Err(unknown_callable(node_id, "tools.infra", name)), - } -} /// Simple identity callable adapter for DSL entrypoint wrappers. #[derive(Debug, Clone)] @@ -558,7 +447,6 @@ fn resolve_domain( "tools.makegen" => return resolve_makegen(node_id, name), "tools.codegen" => return resolve_codegen(node_id, name), "tools.bootstrap" => return resolve_bootstrap(node_id, name, outputs), - "tools.infra" => return resolve_infra(node_id, name, outputs), "std.resources" => return resolve_std_resources(name), _ => {} } @@ -566,11 +454,11 @@ fn resolve_domain( if module.starts_with("services.") || module.starts_with("workspace.") { return resolve_service_transport(node_id, module, name, service_metadata); } - // 3. Passthrough registry (replaces per-module domain_passthrough_op! macros). - if let Some(result) = resolve_passthrough(node_id, module, name, outputs) { - return result; - } - Err(unknown_callable(node_id, module, name)) + // 3. Default: passthrough. The compiler validated this callable exists. + // If it compiled, it's resolvable. No registry needed. + Ok(DynOp::new(PassthroughOp { + output_port_names: declared_output_names(outputs), + })) } fn resolve_pragma(node_id: &str, name: &str) -> Result { @@ -1342,15 +1230,19 @@ mod tests { } #[test] - fn resolve_unknown_module_fails_closed() { + fn resolve_unknown_module_defaults_to_passthrough() { let node = callable_node( "unknown_op", "tools.unknown", "do_something", ObligationCategory::None, ); - let err = resolve_node(&node).expect_err("unknown modules should fail closed"); - assert!(err.reason.contains("unknown callable")); + let result = resolve_node(&node).expect("unknown modules should default to passthrough"); + assert!( + format!("{:?}", result).contains("PassthroughOp"), + "expected PassthroughOp for unknown module, got {:?}", + result + ); } #[test] @@ -1366,12 +1258,12 @@ mod tests { } #[test] - fn resolve_infra_callable() { + fn resolve_infra_callable_uses_default_passthrough() { let node = callable_node("infra", "tools.infra", "infra", ObligationCategory::None); let result = resolve_node(&node).expect("infra"); assert!( - !format!("{:?}", result).contains("UnsupportedOp"), - "expected callable resolution for tools.infra.infra, got {:?}", + format!("{:?}", result).contains("PassthroughOp"), + "tools.infra should use default passthrough, got {:?}", result ); } From 55b25a064e15e05b1b346529193e4c4dd38eb5ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 23:19:35 +0000 Subject: [PATCH 08/13] Phase 1b+1c: inventory resolvers + behavioral test assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom resolvers (resolve_pragma, resolve_makegen, resolve_codegen, resolve_bootstrap) now return Option instead of Result. Unknown callables in custom-resolver modules fall through to default passthrough rather than erroring — adding a new passthrough callable to any module (even those with custom resolvers) requires zero Rust changes. resolve_std_resources simplified from Result to DynOp since it already handled all cases. Test assertions for generic ops (PassthroughOp, IdentityCallableOp, UnsupportedOp) replaced with behavioral helpers that test actual execution semantics rather than Debug string matching. https://claude.ai/code/session_01GZ6E3UdBSQCEtjHvWWJt6H --- gunbc-dag/src/resolve.rs | 133 +++++++++++++++++++++++++-------------- 1 file changed, 85 insertions(+), 48 deletions(-) diff --git a/gunbc-dag/src/resolve.rs b/gunbc-dag/src/resolve.rs index ffad6ed2778..e278a70ccd0 100644 --- a/gunbc-dag/src/resolve.rs +++ b/gunbc-dag/src/resolve.rs @@ -98,7 +98,6 @@ impl Executable for PassthroughOp { } } - /// Simple identity callable adapter for DSL entrypoint wrappers. #[derive(Debug, Clone)] struct IdentityCallableOp; @@ -441,14 +440,18 @@ fn resolve_domain( outputs: &[Port], service_metadata: Option<&ServiceCallMetadata>, ) -> Result { - // 1. Modules with custom resolvers (non-passthrough behavior). - match module { - "tools.pragma" => return resolve_pragma(node_id, name), - "tools.makegen" => return resolve_makegen(node_id, name), - "tools.codegen" => return resolve_codegen(node_id, name), - "tools.bootstrap" => return resolve_bootstrap(node_id, name, outputs), - "std.resources" => return resolve_std_resources(name), - _ => {} + // 1. Modules with custom resolvers — return Some for known callables, + // None for unknown (which falls through to passthrough). + let custom = match module { + "tools.pragma" => resolve_pragma(name), + "tools.makegen" => resolve_makegen(name), + "tools.codegen" => resolve_codegen(name), + "tools.bootstrap" => resolve_bootstrap(name), + "std.resources" => Some(resolve_std_resources(name)), + _ => None, + }; + if let Some(op) = custom { + return Ok(op); } // 2. Service/workspace modules use generic transport dispatch. if module.starts_with("services.") || module.starts_with("workspace.") { @@ -461,43 +464,43 @@ fn resolve_domain( })) } -fn resolve_pragma(node_id: &str, name: &str) -> Result { +fn resolve_pragma(name: &str) -> Option { match name { - "render_clippy_toml" => Ok(DynOp::new(PragmaOp::RenderClippy)), - "render_disallowed_methods_allowlist" => Ok(DynOp::new(PragmaOp::RenderAllowlist)), - "render_pragma_lint_policy" => Ok(DynOp::new(PragmaOp::RenderLintPolicy)), - "pragma" => Ok(DynOp::new(PragmaEntrypointOp)), - _ => Err(unknown_callable(node_id, "tools.pragma", name)), + "render_clippy_toml" => Some(DynOp::new(PragmaOp::RenderClippy)), + "render_disallowed_methods_allowlist" => Some(DynOp::new(PragmaOp::RenderAllowlist)), + "render_pragma_lint_policy" => Some(DynOp::new(PragmaOp::RenderLintPolicy)), + "pragma" => Some(DynOp::new(PragmaEntrypointOp)), + _ => None, } } -fn resolve_makegen(node_id: &str, name: &str) -> Result { +fn resolve_makegen(name: &str) -> Option { match name { - "load_registry" => Ok(DynOp::new(MakegenOp::LoadRegistry)), - "render_makefile" => Ok(DynOp::new(MakegenOp::RenderMakefile)), - "makegen" => Ok(DynOp::new(MakegenOp::Entrypoint)), - _ => Err(unknown_callable(node_id, "tools.makegen", name)), + "load_registry" => Some(DynOp::new(MakegenOp::LoadRegistry)), + "render_makefile" => Some(DynOp::new(MakegenOp::RenderMakefile)), + "makegen" => Some(DynOp::new(MakegenOp::Entrypoint)), + _ => None, } } -fn resolve_codegen(node_id: &str, name: &str) -> Result { +fn resolve_codegen(name: &str) -> Option { match name { - "codegen" => Ok(DynOp::new(IdentityCallableOp)), - _ => Err(unknown_callable(node_id, "tools.codegen", name)), + "codegen" => Some(DynOp::new(IdentityCallableOp)), + _ => None, } } -fn resolve_bootstrap(node_id: &str, name: &str, _outputs: &[Port]) -> Result { +fn resolve_bootstrap(name: &str) -> Option { match name { // The DSL `func bootstrap(...)` wrapper only aggregates upstream values. - "bootstrap" => Ok(DynOp::new(IdentityCallableOp)), - "render_bootstrap_makefile" => Ok(DynOp::new(BootstrapOp::GenerateMakefile)), - "render_bootstrap_gitignore" => Ok(DynOp::new(BootstrapOp::GenerateGitignore)), - _ => Err(unknown_callable(node_id, "tools.bootstrap", name)), + "bootstrap" => Some(DynOp::new(IdentityCallableOp)), + "render_bootstrap_makefile" => Some(DynOp::new(BootstrapOp::GenerateMakefile)), + "render_bootstrap_gitignore" => Some(DynOp::new(BootstrapOp::GenerateGitignore)), + _ => None, } } -fn resolve_std_resources(name: &str) -> Result { +fn resolve_std_resources(name: &str) -> DynOp { // Resource lifecycle acquire/release nodes from the DSL resource system. // Names follow the pattern: `resource_lifecycle::acquire::ResourceName` // or `resource_lifecycle::release::ResourceName`. @@ -505,15 +508,15 @@ fn resolve_std_resources(name: &str) -> Result { // no hardcoded list needed. Adding a new resource to std/resources.dag // works without changing resolver code. if let Some(resource_name) = name.strip_prefix("resource_lifecycle::acquire::") { - return Ok(DynOp::new(ResourceAcquireOp { + return DynOp::new(ResourceAcquireOp { resource_kind: resource_name.to_string(), - })); + }); } if name.starts_with("resource_lifecycle::release::") { - return Ok(DynOp::new(ResourceReleaseOp)); + return DynOp::new(ResourceReleaseOp); } // Other std.resources callables pass through as identity. - Ok(DynOp::new(IdentityCallableOp)) + DynOp::new(IdentityCallableOp) } fn resolve_service_transport( @@ -728,6 +731,47 @@ mod tests { ) } + // ---- Behavioral assertion helpers ---- + + /// Assert a resolved op behaves as passthrough: inputs forwarded, + /// declared output ports filled with Skipped when no matching input. + fn assert_passthrough_behavior(op: &DynOp) { + let mut inputs = HashMap::new(); + inputs.insert("x".to_string(), Value::Str("hello".to_string())); + let outputs = op.execute(inputs).expect("passthrough should succeed"); + assert_eq!( + outputs.get("x").and_then(Value::as_str), + Some("hello"), + "passthrough should forward inputs" + ); + // Declared output port "out" should be filled with Skipped + assert_eq!( + outputs.get("out"), + Some(&Value::Skipped), + "passthrough should fill undeclared output ports with Skipped" + ); + } + + /// Assert a resolved op behaves as identity: inputs == outputs. + fn assert_identity_behavior(op: &DynOp) { + let mut inputs = HashMap::new(); + inputs.insert("a".to_string(), Value::Str("v1".to_string())); + inputs.insert("b".to_string(), Value::Int(42)); + let outputs = op.execute(inputs.clone()).expect("identity should succeed"); + assert_eq!(outputs, inputs, "identity op should return inputs unchanged"); + } + + /// Assert a resolved op is unsupported: execution fails with error. + fn assert_unsupported_behavior(op: &DynOp) { + let err = op + .execute(HashMap::new()) + .expect_err("unsupported op should fail on execute"); + assert!( + err.to_string().contains("unsupported operation"), + "expected unsupported error, got: {err}" + ); + } + fn collection_node(id: &str, kind: CollectionOpKind) -> Node { Node::opaque( id, @@ -951,7 +995,7 @@ mod tests { ObligationCategory::None, ); let result = resolve_node(&node).expect("tools.codegen::codegen"); - assert!(format!("{:?}", result).contains("IdentityCallableOp")); + assert_identity_behavior(&result); } #[test] @@ -1226,7 +1270,7 @@ mod tests { fn resolve_collection_map() { let node = collection_node("map_items", CollectionOpKind::Map); let result = resolve_node(&node).expect("map"); - assert!(format!("{:?}", result).contains("UnsupportedOp")); + assert_unsupported_behavior(&result); } #[test] @@ -1238,34 +1282,27 @@ mod tests { ObligationCategory::None, ); let result = resolve_node(&node).expect("unknown modules should default to passthrough"); - assert!( - format!("{:?}", result).contains("PassthroughOp"), - "expected PassthroughOp for unknown module, got {:?}", - result - ); + assert_passthrough_behavior(&result); } #[test] - fn resolve_unknown_callable_fails() { + fn resolve_unknown_callable_in_custom_module_falls_through_to_passthrough() { let node = callable_node( "bad_op", "tools.pragma", "nonexistent_op", ObligationCategory::None, ); - let err = resolve_node(&node).unwrap_err(); - assert!(err.reason.contains("unknown callable")); + let result = + resolve_node(&node).expect("unknown callable should fall through to passthrough"); + assert_passthrough_behavior(&result); } #[test] fn resolve_infra_callable_uses_default_passthrough() { let node = callable_node("infra", "tools.infra", "infra", ObligationCategory::None); let result = resolve_node(&node).expect("infra"); - assert!( - format!("{:?}", result).contains("PassthroughOp"), - "tools.infra should use default passthrough, got {:?}", - result - ); + assert_passthrough_behavior(&result); } #[test] From 19c45efddb5da78bd0df12ee271fd9064a7811a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 23:30:16 +0000 Subject: [PATCH 09/13] Fix CI: use 'master' as default branch, not 'main' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo's default branch is `master` but GitConfig::default() was hardcoding `main`, causing the generated CI workflow to filter on `branches: [main]` — which never matches. PRs targeting `master` were silently skipped by GitHub Actions. https://claude.ai/code/session_01GZ6E3UdBSQCEtjHvWWJt6H --- .github/workflows/ci.yml | 4 ++-- gunbc-dag/src/bin/codegen_cli.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8818b8bed76..b3918f88591 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,11 +6,11 @@ name: ci on: push: branches: - - main + - master pull_request: branches: - - main + - master permissions: contents: read diff --git a/gunbc-dag/src/bin/codegen_cli.rs b/gunbc-dag/src/bin/codegen_cli.rs index a8f95c422b7..e4a0fe6a5ec 100644 --- a/gunbc-dag/src/bin/codegen_cli.rs +++ b/gunbc-dag/src/bin/codegen_cli.rs @@ -389,7 +389,7 @@ fn cmd_cigen(dry_run: bool) { .with_generator(&codegen.binary, &format!("{} -- cigen", codegen.command())) .with_runner(gunbc_ir::transport::github_actions::ubuntu_latest()) .with_cargo_env(gunbc_ir::CargoEnv::ci()) - .with_git(gunbc_ir::GitConfig::default()) + .with_git(gunbc_ir::GitConfig::new("master")) .with_cache(CacheConfig::rust()) .with_permissions(ci_perms) .with_secrets_env(ci_secrets); From 6619229b29a73e07dc806fc39080a5249e92bc59 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 23:44:25 +0000 Subject: [PATCH 10/13] Revert CI branch change: default branch is 'main', PR target was wrong The CI config was correct (branches: [main]). The issue was the PR targeting the wrong base branch, not a misconfigured workflow. https://claude.ai/code/session_01GZ6E3UdBSQCEtjHvWWJt6H --- .github/workflows/ci.yml | 4 ++-- gunbc-dag/src/bin/codegen_cli.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3918f88591..8818b8bed76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,11 +6,11 @@ name: ci on: push: branches: - - master + - main pull_request: branches: - - master + - main permissions: contents: read diff --git a/gunbc-dag/src/bin/codegen_cli.rs b/gunbc-dag/src/bin/codegen_cli.rs index e4a0fe6a5ec..a8f95c422b7 100644 --- a/gunbc-dag/src/bin/codegen_cli.rs +++ b/gunbc-dag/src/bin/codegen_cli.rs @@ -389,7 +389,7 @@ fn cmd_cigen(dry_run: bool) { .with_generator(&codegen.binary, &format!("{} -- cigen", codegen.command())) .with_runner(gunbc_ir::transport::github_actions::ubuntu_latest()) .with_cargo_env(gunbc_ir::CargoEnv::ci()) - .with_git(gunbc_ir::GitConfig::new("master")) + .with_git(gunbc_ir::GitConfig::default()) .with_cache(CacheConfig::rust()) .with_permissions(ci_perms) .with_secrets_env(ci_secrets); From 4b9acbb4df84a980922f2073d31c28195809284b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 23:56:44 +0000 Subject: [PATCH 11/13] Update design doc status: Phase 1 complete, Phases 2-3 remain https://claude.ai/code/session_01GZ6E3UdBSQCEtjHvWWJt6H --- TODO/design-eliminate-registration-lists.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TODO/design-eliminate-registration-lists.md b/TODO/design-eliminate-registration-lists.md index de447496acb..254dae57c62 100644 --- a/TODO/design-eliminate-registration-lists.md +++ b/TODO/design-eliminate-registration-lists.md @@ -1,9 +1,10 @@ # Eliminate Registration Lists: Close the DSL-Runtime Gap -**Status**: PROPOSED +**Status**: PHASE 1 COMPLETE — Phases 2-3 remain as future work **Date**: 2026-02-21 **Track**: Cleanup — eliminate hardcoded metadata duplication **Prerequisite**: CL1-CL8 completed (hardcoded lists consolidated) +**Completed**: Phase 1a (default passthrough), 1b (behavioral test helpers), 1c (Option-returning resolvers), Phase 0 (InfraToolOp deletion) ## Vision From c08e7497d659de9f424a232524b1cdcb0741bbd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 00:14:17 +0000 Subject: [PATCH 12/13] Add Lane G (workflow migration) and Lane H (DSL expressions) to task sheet Lane G: 9 tasks to migrate 14 Rust-constructed workflows to DSL pipelines, derive 69 process unit claims from DSL annotations, and delete the workflow builder functions. WM-1 through WM-7 are fully parallelizable (one worker per workflow). Lane H: 15 tasks to add expression-level DSL features and migrate all 46 custom Executable ops to DSL. Three phases: structured data at boundaries (EX-1..EX-3), expression primitives (EX-4..EX-8), and per-module migration (EX-9..EX-15). After completion, adding any tool requires 1 DSL file and 0 Rust changes. Also marks CL2, CL3, CL8 as done (resolved by Phase 1 work). https://claude.ai/code/session_01GZ6E3UdBSQCEtjHvWWJt6H --- TODO/tasks.md | 87 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 72 insertions(+), 15 deletions(-) diff --git a/TODO/tasks.md b/TODO/tasks.md index 8158217394d..13cb2348615 100644 --- a/TODO/tasks.md +++ b/TODO/tasks.md @@ -1,6 +1,6 @@ # Task Sheet — Dependency-Ordered, Parallelizable -**Last updated**: 2026-02-21 +**Last updated**: 2026-02-22 **Verification**: `cargo test --workspace` + `cargo clippy --all-targets -- -D warnings` **Archive**: Completed items in `TODO/TODONE/tasks-completed.md`. Backlog in `TODO/backlog.md`. @@ -110,6 +110,8 @@ All 27 design contracts below are implemented and tested. Owner tasks are archiv | D: Daglang convergence | **DONE** | — | | E: Runtime infra | **DONE** | — | | F: Codegen-first SDLC | **DONE** | `CG1` superseded (SDLC modules are runtime-authored) | +| G: Workflow DSL migration | **ACTIVE** | WM-1..WM-9 (14 workflows, 69 process units, 16 builder fns) | +| H: DSL expression language | **ACTIVE** | EX-1..EX-15 (46 custom ops → 0, structured data model, expression primitives) | --- @@ -198,28 +200,83 @@ Archived to `TODO/TODONE/tasks-completed.md`. All 6 tasks (`AI1`-`AI3`, `PR1`-`P ## Cleanup: Eliminate Hardcoded Registration Lists +**Design doc**: [TODO/design-eliminate-registration-lists.md](design-eliminate-registration-lists.md) **Goal**: Replace manually maintained lists with discovery/derivation. Every time a new `.dag` module or tool is added, several Rust files require manual updates. These should either be auto-discovered from the filesystem, derived from the compiled DAG metadata, or eliminated entirely. +### Phase 1 — Resolver trusts compiler — **DONE** + +Implemented: default-passthrough resolver, `Option`-returning inventory resolvers, `InfraToolOp` deletion, behavioral test helpers. See design doc for details. CL2, CL3, CL8 are resolved. + +### Phase 1 Remaining (small items) + | ID | Task | Location | Problem | Fix | Size | Status | |----|------|----------|---------|-----|------|--------| -| **CL1** | **Module order test fixture** | `daglang-cli/src/pipeline.rs:773-832` | 58 hardcoded module names in `expected_real_corpus_module_order()`. Breaks every time a `.dag` file is added/removed/renamed. | Replace with filesystem discovery: glob `dsl/**/*.dag`, extract module IDs, sort. The test asserts the compiler discovers the same set, not a hardcoded list. | S | | -| **CL2** | **Domain resolver dispatch** | `gunbc-dag/src/resolve.rs:625-645` | Match arms manually map `"tools.makegen"`, `"tools.build"`, etc. to resolver functions. New DSL modules require adding a match arm. | The `services.*` branch already uses generic dispatch via `resolve_service_transport()`. Extend this pattern: modules with `ServiceCallMetadata` use generic dispatch; remaining tool modules should derive their op mapping from the DSL definition (callable name -> op enum variant can be generated by a build script or macro from the `.dag` file). | L | | -| **CL3** | **`domain_passthrough_op!` macros** | `gunbc-dag/src/resolve.rs:136-283` | Each tool module has a handwritten macro invocation mapping callable names to enum variants (e.g., `"aggregate_results" => AggregateResults`). These duplicate information already present in the `.dag` files. | Short term: collapse into a single data-driven registry (map of `(module, callable_name) -> Box`). Long term: generate from DSL metadata -- the callable names are in the compiled DAG, so the resolver can look them up dynamically. | M | | -| **CL4** | **`WorkspaceBinary::ALL` array** | `gunbc-dag/src/binaries.rs:29-42` | 12-element `const ALL` array + match arms in `tool_name()`/`from_tool_name()`. New binaries require three manual edits. | Derive from `Cargo.toml` `[[bin]]` sections or from the filesystem (`gunbc-dag/src/bin/*.rs`). A build script can enumerate binaries and generate the enum. | S | | -| **CL5** | **`TOOL_WORKFLOWS` registry** | `gunbc-dag/src/workflow/spec_builders.rs:1445-1516` | 14 hardcoded `ToolWorkflowDescriptor` entries. New tool workflows need a manual entry. | Derive from DSL: each `tools.*.dag` that exports an entrypoint `func` is a tool workflow. The workflow spec builder can discover these from compiled DAG metadata instead of a static array. | M | | -| **CL6** | **Process unit registry** | `gunbc-dag/src/workflow/process_registry.rs:220-298` | Hardcoded CI and test-all workflow unit arrays. New CI steps need manual entries. | Derive from `pipelines/ci.dag`: the CI pipeline stages define the process units. The registry can be generated from the compiled pipeline DAG. | M | | -| **CL7** | **`MANUAL_TOOL_DEFS`** | `gunbc-dag/src/makegen/registry.rs:1713-1714` | 2 hardcoded manual tool definitions (`pragma`, `build`). | Investigate why these can't use the standard discovery path. If they need special treatment, document why; otherwise fold into the standard tool registry. | S | | -| **CL8** | **`std.resources` name match** | `gunbc-dag/src/resolve.rs:688-695` | Hardcoded resource names (`"Filesystem"`, `"Network"`, `"Clock"`, `"AuthContext"`). Adding a new resource to `std/resources.dag` requires a Rust match arm. | Derive from the compiled `std/resources.dag` metadata. The resource names are already in the DAG -- the resolver should read them from there. | S | | +| **CL1** | **Module order test fixture** | `daglang-cli/src/pipeline.rs` | 58 hardcoded module names in `expected_real_corpus_module_order()`. Breaks every time a `.dag` file is added/removed/renamed. | Replace with filesystem discovery: glob `dsl/**/*.dag`, extract module IDs, sort. The test asserts the compiler discovers the same set, not a hardcoded list. | S | | +| **CL4** | **`WorkspaceBinary::ALL` array** | `gunbc-dag/src/binaries.rs` | 12-element `const ALL` array + match arms. New binaries require three manual edits. | Derive from `Cargo.toml` `[[bin]]` sections or from the filesystem. | S | | +| **CL7** | **`MANUAL_TOOL_DEFS`** | `gunbc-dag/src/makegen/registry.rs` | 2 hardcoded manual tool definitions (`pragma`, `build`). | Investigate why these can't use the standard discovery path. Fold in or document. | S | | + +### Lane G: Workflow DSL Migration (Phase 2) + +**Design doc**: [TODO/design-eliminate-registration-lists.md](design-eliminate-registration-lists.md) — Changes 4-5 +**Goal**: Migrate 14 Rust-constructed workflow specs to DSL pipeline files. Eliminate `TOOL_WORKFLOWS` (14 entries), workflow builder functions (16 functions), and `process_registry` (69 hardcoded entries). +**Prerequisite**: None (independent of Phase 3). + +**Context**: `dsl/pipelines/ci.dag` (126 lines), `dsl/pipelines/sdlc.dag` (552 lines), and `dsl/pipelines/reconciler.dag` (258 lines) already demonstrate the target format. Each workflow is a `pipeline` with `stage` declarations, `[after ...]` dependencies, and `[when ...]` conditional guards. + +| ID | Task | Deps | Size | Status | +|----|------|------|------|--------| +| **WM-1** | **Migrate `build-all` workflow to DSL**: Simplest workflow (1 core unit). Create `dsl/workflows/build-all.dag`. Verify the compiled DAG matches the Rust-constructed spec structurally. | — | S | | +| **WM-2** | **Migrate `makegen` workflow to DSL**: 4 process units. Create `dsl/workflows/makegen.dag`. | — | S | | +| **WM-3** | **Migrate `bootstrap` workflow to DSL**: 5 process units. Create `dsl/workflows/bootstrap.dag`. | — | M | | +| **WM-4** | **Migrate `pragma` workflow to DSL**: 7 process units, most complex single-tool workflow. Create `dsl/workflows/pragma.dag`. | — | M | | +| **WM-5** | **Migrate `deps` workflow to DSL**: 8 process units. Create `dsl/workflows/deps.dag`. | — | M | | +| **WM-6** | **Migrate `gist` workflow family to DSL**: 3 variants (`gist-snapshot`, `gist-diff`, `gist-recent`) sharing 9 process units. Create `dsl/workflows/gist.dag` with parameterized mode. | — | M | | +| **WM-7** | **Migrate `dag-viz` workflow family to DSL**: 3 variants (`dag-viz`, `dag-viz-diff`, `dag-viz-recent`) + `dag-snapshot`. 6-7 process units each. Create `dsl/workflows/dag-viz.dag`. | — | M | | +| **WM-8** | **Derive process unit claims from DSL annotations**: The DSL already has `@file(READ/WRITE)` annotations and the compiler extracts `ResourceUsage`. Generate `UnitClaim` entries from compiled pipeline metadata instead of the hardcoded 69-entry `default_process_unit_registry()`. | WM-1..WM-7 | M | | +| **WM-9** | **Delete Rust workflow builders**: Remove `TOOL_WORKFLOWS` registry, all `*_workflow_spec()` builder functions, and `default_process_unit_registry()`. Wire workspace subdag discovery to load compiled DSL workflows. | WM-8 | M | | + +**Parallelism**: WM-1 through WM-7 are fully independent — each workflow can be migrated by a separate worker. WM-8 and WM-9 depend on all migrations completing. + +### Lane H: DSL Expression Language (Phase 3) + +**Design doc**: [TODO/design-eliminate-registration-lists.md](design-eliminate-registration-lists.md) — Changes 6-11 +**Goal**: Add expression-level DSL features so that all 46 custom `Executable` op variants (across 9 modules) can be expressed in DSL. After this lane, zero `Executable` impls exist outside compiler/executor infrastructure. Rust is no longer an escape hatch for business logic. -### Priority +**Principle**: Fix the data model first, then add minimal expression support. Most "string manipulation" disappears when data is properly structured. See design doc "DSL Language Features Required" for the full analysis. -- **CL1** is the most fragile (58 entries, breaks on any module change). Fix first. -- **CL2 + CL3** are the largest impact (the resolver is the main bottleneck for adding DSL modules without touching Rust). -- **CL4-CL8** are smaller wins but compound over time. +#### Phase 3a: Structured data at boundaries (eliminates ~45% of custom ops) + +| ID | Task | Deps | Size | Status | +|----|------|------|------|--------| +| **EX-1** | **Structured transport responses**: Extend service call declarations with `@parse` annotations so the transport layer parses shell output into typed records. Eliminates all ad-hoc `.lines()/.trim()/.strip_prefix()` parsing in bootstrap (4 ops) and codegen (2 ops). | — | M | | +| **EX-2** | **Structured path and glob types**: Make `FilePath` a proper structured type with segments (not a string alias). Add `GlobPattern` type. Path construction, joining, and pattern building become type-safe operations. Eliminates all path string manipulation in pragma and codegen (~8 string ops). | — | M | | +| **EX-3** | **DSL data source declarations**: Add `data` blocks in DSL for declaring static typed configuration. Move clippy allowlist rules (8), dead code rules (5), allow lints (3), tool registry (12 tools), gitignore categories (14), and codegen path templates from Rust into `dsl/config/*.dag` files. Compiler resolves data references at compile time. | — | M | | + +#### Phase 3b: Expression primitives (eliminates remaining ~55%) + +| ID | Task | Deps | Size | Status | +|----|------|------|------|--------| +| **EX-4** | **Collection operations in DSL**: Add `.map(fn)`, `.filter(fn)`, `.sort()`, `.dedup()`, `.any(fn)`, `.all(fn)`, `.len()`, `.contains(item)`, `.join(sep)`, and list literals `[a, b, c]` to the DSL expression language. Used by all 9 modules. | — | L | | +| **EX-5** | **Pattern matching and conditionals**: Add `match expr { pattern => body }`, `if cond { a } else { b }`, `let ... = ...` bindings, and boolean operators (`&&`, `||`, `!`) to the DSL. Used by all 9 modules for dispatch, validation, and branching. | — | L | | +| **EX-6** | **Integer arithmetic and comparison**: Add `+`, `-`, `*`, `/`, `%` arithmetic and `==`, `!=`, `<`, `>`, `<=`, `>=` comparison operators. Used by codegen (manifest freshness), build (exit code checking), makegen (counting). | EX-5 | M | | +| **EX-7** | **Structured document rendering**: Add `render` functions producing typed document trees (`TextFile`/`Document`). Sections, lines, comments, and blank lines are structural blocks. Rendering engine handles formatting (separators, prefixes, indentation). Replaces all `format!()`/`write!()`/`.push_str()` in pragma (3 ops), makegen (2 ops), build (1 op), docgen (1 op). | EX-4, EX-5 | L | | +| **EX-8** | **Structured data construction**: Add object literals `{ key: value }` and nested construction (objects containing lists containing objects). Used by makegen for building template data. Close to existing `@mock_response` syntax. | EX-5 | S | | + +#### Phase 3c: Custom op migration (the payoff) + +| ID | Task | Deps | Size | Status | +|----|------|------|------|--------| +| **EX-9** | **Migrate `tools.pragma` ops to DSL**: 3 ops (RenderClippy, RenderAllowlist, RenderLintPolicy). Depends on structured rendering (EX-7) and data sources (EX-3). | EX-3, EX-7 | M | | +| **EX-10** | **Migrate `tools.makegen` ops to DSL**: 3 ops (LoadRegistry, RenderMakefile, Entrypoint). Depends on data sources (EX-3), rendering (EX-7), and data construction (EX-8). | EX-3, EX-7, EX-8 | M | | +| **EX-11** | **Migrate `tools.bootstrap` ops to DSL**: 4 ops (PrepareScanWorkspace, ParseScanResult, GenerateMakefile, GenerateGitignore). Depends on structured transport (EX-1) and collections (EX-4). | EX-1, EX-4 | M | | +| **EX-12** | **Migrate `tools.codegen` ops to DSL**: 5 ops (PrepareCodegenExists, ParseCodegenExists, PrepareCodegenCommand, ParseCodegenResult, PrepareStampWrite). Depends on structured transport (EX-1), structured paths (EX-2), and conditionals (EX-5). | EX-1, EX-2, EX-5 | M | | +| **EX-13** | **Migrate `tools.build` ops to DSL**: 7 ops (PrepareBuild, ParseBuild, PrepareTest, ParseTest, PrepareClippy, ParseClippy, Summary). Depends on conditionals (EX-5), arithmetic (EX-6), and rendering (EX-7). | EX-5, EX-6, EX-7 | M | | +| **EX-14** | **Migrate remaining ops (ci, docgen, testgen, dag-viz) to DSL**: ~24 ops total. These follow the same patterns as EX-9 through EX-13. Can be parallelized per module. | EX-1..EX-8 | L | | +| **EX-15** | **Delete custom resolver path**: After all custom ops are migrated, the `resolve_domain` match arms for custom modules become empty. Remove them — `resolve_domain` reduces to service transport + default passthrough. | EX-9..EX-14 | S | | -### Architectural direction +**Parallelism**: EX-1, EX-2, EX-3 are fully independent (data model fixes). EX-4 and EX-5 are independent of each other but require parser/compiler changes. EX-9 through EX-14 are independent per module once their expression dependencies are met — each module can be migrated by a separate worker. -All of these share a root cause: the Rust runtime has manually duplicated metadata that already exists in the DSL. The fix is always the same pattern: **read the metadata from the compiled DAG** instead of hardcoding it. This aligns with the codegen-first policy (mega design Section 5.3): "New SDLC behavior must be added to DSL/codegen first." +**Success criteria**: After Lane H, adding a tool of any complexity requires **1 DSL file** and **0 Rust changes**. Custom `Executable` impls drop from 46 to 0. --- From 16275eea72877bafa421900a884e4ff092a607bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 00:16:11 +0000 Subject: [PATCH 13/13] Revise Lane H scope: parser already has expressions, gap is lowering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DSL parser already supports 21 expression types (if/else, match, for, all binary/unary ops, list/map/record literals, string interp, lambdas, pipes). Phase 3b revised to focus on the lowering pass and execution path — ensuring these parsed expressions work in function bodies — rather than adding syntax that already exists. Custom tool op count corrected from 46 to 22 (5 core modules). The remaining 24+ are in ci/docgen/testgen/dag-viz (EX-14). Added EX-8 integration gate test before migration begins. https://claude.ai/code/session_01GZ6E3UdBSQCEtjHvWWJt6H --- TODO/tasks.md | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/TODO/tasks.md b/TODO/tasks.md index 13cb2348615..aa972ffde0e 100644 --- a/TODO/tasks.md +++ b/TODO/tasks.md @@ -111,7 +111,7 @@ All 27 design contracts below are implemented and tested. Owner tasks are archiv | E: Runtime infra | **DONE** | — | | F: Codegen-first SDLC | **DONE** | `CG1` superseded (SDLC modules are runtime-authored) | | G: Workflow DSL migration | **ACTIVE** | WM-1..WM-9 (14 workflows, 69 process units, 16 builder fns) | -| H: DSL expression language | **ACTIVE** | EX-1..EX-15 (46 custom ops → 0, structured data model, expression primitives) | +| H: DSL expression language | **ACTIVE** | EX-1..EX-15 (22 tool ops → 0; parser has syntax, gap is lowering + execution) | --- @@ -240,43 +240,47 @@ Implemented: default-passthrough resolver, `Option`-returning inventory resolver ### Lane H: DSL Expression Language (Phase 3) **Design doc**: [TODO/design-eliminate-registration-lists.md](design-eliminate-registration-lists.md) — Changes 6-11 -**Goal**: Add expression-level DSL features so that all 46 custom `Executable` op variants (across 9 modules) can be expressed in DSL. After this lane, zero `Executable` impls exist outside compiler/executor infrastructure. Rust is no longer an escape hatch for business logic. +**Goal**: Make the existing DSL expression features usable in function bodies so that all 22 custom tool op variants (across 5 modules) can be expressed in DSL. After this lane, zero tool `Executable` impls exist outside compiler/executor infrastructure. Rust is no longer an escape hatch for business logic. **Principle**: Fix the data model first, then add minimal expression support. Most "string manipulation" disappears when data is properly structured. See design doc "DSL Language Features Required" for the full analysis. +**Key finding**: The parser already supports 21 expression types including `if/else`, `match`, `for`, all binary ops (+, -, *, /, %, ==, !=, <, >, <=, >=, &&, ||), unary ops (!, -), list/map/record literals, string interpolation, lambdas, and pipe operators. The gap is in the **lowering and execution path** — ensuring these expressions compile to executable operations when used in function bodies. The compiler pipeline is: parser (2.8k LOC) → resolve → typecheck → lower (7.5k LOC) → emit (1.2k LOC+). + #### Phase 3a: Structured data at boundaries (eliminates ~45% of custom ops) | ID | Task | Deps | Size | Status | |----|------|------|------|--------| | **EX-1** | **Structured transport responses**: Extend service call declarations with `@parse` annotations so the transport layer parses shell output into typed records. Eliminates all ad-hoc `.lines()/.trim()/.strip_prefix()` parsing in bootstrap (4 ops) and codegen (2 ops). | — | M | | | **EX-2** | **Structured path and glob types**: Make `FilePath` a proper structured type with segments (not a string alias). Add `GlobPattern` type. Path construction, joining, and pattern building become type-safe operations. Eliminates all path string manipulation in pragma and codegen (~8 string ops). | — | M | | -| **EX-3** | **DSL data source declarations**: Add `data` blocks in DSL for declaring static typed configuration. Move clippy allowlist rules (8), dead code rules (5), allow lints (3), tool registry (12 tools), gitignore categories (14), and codegen path templates from Rust into `dsl/config/*.dag` files. Compiler resolves data references at compile time. | — | M | | +| **EX-3** | **DSL data source declarations**: Add `data` blocks in DSL for declaring static typed configuration. Move clippy allowlist rules (8), dead code rules (5), allow lints (3), tool registry (12 tools), gitignore categories (14), and codegen path templates from Rust into `dsl/config/*.dag` files. Compiler resolves data references at compile time. No `dsl/config/` directory exists yet — create it. | — | M | | + +#### Phase 3b: Expression lowering (the real gap) -#### Phase 3b: Expression primitives (eliminates remaining ~55%) +The parser has the syntax. The gap is making it executable. These tasks focus on the **lowering pass** (`daglang-lower/src/lib.rs`, 7.5k LOC) and **execution runtime** — ensuring parsed expressions in function bodies lower to `LoweredOp` nodes that the resolver can execute. | ID | Task | Deps | Size | Status | |----|------|------|------|--------| -| **EX-4** | **Collection operations in DSL**: Add `.map(fn)`, `.filter(fn)`, `.sort()`, `.dedup()`, `.any(fn)`, `.all(fn)`, `.len()`, `.contains(item)`, `.join(sep)`, and list literals `[a, b, c]` to the DSL expression language. Used by all 9 modules. | — | L | | -| **EX-5** | **Pattern matching and conditionals**: Add `match expr { pattern => body }`, `if cond { a } else { b }`, `let ... = ...` bindings, and boolean operators (`&&`, `||`, `!`) to the DSL. Used by all 9 modules for dispatch, validation, and branching. | — | L | | -| **EX-6** | **Integer arithmetic and comparison**: Add `+`, `-`, `*`, `/`, `%` arithmetic and `==`, `!=`, `<`, `>`, `<=`, `>=` comparison operators. Used by codegen (manifest freshness), build (exit code checking), makegen (counting). | EX-5 | M | | -| **EX-7** | **Structured document rendering**: Add `render` functions producing typed document trees (`TextFile`/`Document`). Sections, lines, comments, and blank lines are structural blocks. Rendering engine handles formatting (separators, prefixes, indentation). Replaces all `format!()`/`write!()`/`.push_str()` in pragma (3 ops), makegen (2 ops), build (1 op), docgen (1 op). | EX-4, EX-5 | L | | -| **EX-8** | **Structured data construction**: Add object literals `{ key: value }` and nested construction (objects containing lists containing objects). Used by makegen for building template data. Close to existing `@mock_response` syntax. | EX-5 | S | | +| **EX-4** | **Lower collection method calls**: Parser has `List`, `Pipe`, `Lambda`, `Call`. Lowering must generate executable nodes for `.map(fn)`, `.filter(fn)`, `.sort()`, `.dedup()`, `.any(fn)`, `.all(fn)`, `.len()`, `.contains(item)`, `.join(sep)` when used in function bodies. Verify existing `CollectionOp` / `MapOp` / `FilterOp` etc. in `core/exec` are wired through. | — | M | | +| **EX-5** | **Lower control flow in function bodies**: Parser has `If`, `Match`, `For`, `Let`, `BinOp`, `UnaryOp`. Verify the lowering pass emits correct `LoweredOp` graph structures for branching, iteration, and variable bindings within `fn` bodies. Existing `BranchOp`, `GuardOp`, `LoopOp` in `core/exec` may already cover this. | — | M | | +| **EX-6** | **Lower string interpolation and formatting**: Parser has `StringInterp` with `Literal`/`Expr` parts. Ensure lowering emits nodes that evaluate interpolated expressions and concatenate results. This is the bridge to structured rendering. | — | S | | +| **EX-7** | **Structured document rendering**: Add `render` functions producing typed document trees (`TextFile`/`Document`). Sections, lines, comments, and blank lines are structural blocks. Rendering engine handles formatting. Replaces all `format!()`/`write!()`/`.push_str()` in pragma (3 ops), makegen (2 ops), build (1 op), docgen (1 op). | EX-5, EX-6 | L | | +| **EX-8** | **End-to-end function body test**: Write a `.dag` file with a `fn` that uses `if/else`, `match`, `for`, list ops, string interpolation, and record construction in its body. Compile, resolve, and execute it. This is the integration gate proving the full pipeline works before migrating real ops. | EX-4, EX-5, EX-6 | S | | -#### Phase 3c: Custom op migration (the payoff) +#### Phase 3c: Custom op migration (the payoff — 22 ops across 5 modules) | ID | Task | Deps | Size | Status | |----|------|------|------|--------| | **EX-9** | **Migrate `tools.pragma` ops to DSL**: 3 ops (RenderClippy, RenderAllowlist, RenderLintPolicy). Depends on structured rendering (EX-7) and data sources (EX-3). | EX-3, EX-7 | M | | -| **EX-10** | **Migrate `tools.makegen` ops to DSL**: 3 ops (LoadRegistry, RenderMakefile, Entrypoint). Depends on data sources (EX-3), rendering (EX-7), and data construction (EX-8). | EX-3, EX-7, EX-8 | M | | +| **EX-10** | **Migrate `tools.makegen` ops to DSL**: 3 ops (LoadRegistry, RenderMakefile, Entrypoint). Depends on data sources (EX-3) and rendering (EX-7). | EX-3, EX-7 | M | | | **EX-11** | **Migrate `tools.bootstrap` ops to DSL**: 4 ops (PrepareScanWorkspace, ParseScanResult, GenerateMakefile, GenerateGitignore). Depends on structured transport (EX-1) and collections (EX-4). | EX-1, EX-4 | M | | -| **EX-12** | **Migrate `tools.codegen` ops to DSL**: 5 ops (PrepareCodegenExists, ParseCodegenExists, PrepareCodegenCommand, ParseCodegenResult, PrepareStampWrite). Depends on structured transport (EX-1), structured paths (EX-2), and conditionals (EX-5). | EX-1, EX-2, EX-5 | M | | -| **EX-13** | **Migrate `tools.build` ops to DSL**: 7 ops (PrepareBuild, ParseBuild, PrepareTest, ParseTest, PrepareClippy, ParseClippy, Summary). Depends on conditionals (EX-5), arithmetic (EX-6), and rendering (EX-7). | EX-5, EX-6, EX-7 | M | | -| **EX-14** | **Migrate remaining ops (ci, docgen, testgen, dag-viz) to DSL**: ~24 ops total. These follow the same patterns as EX-9 through EX-13. Can be parallelized per module. | EX-1..EX-8 | L | | +| **EX-12** | **Migrate `tools.codegen` ops to DSL**: 5 ops (PrepareCodegenExists, ParseCodegenExists, PrepareCodegenCommand, ParseCodegenResult, PrepareStampWrite). Depends on structured transport (EX-1), structured paths (EX-2), and control flow (EX-5). | EX-1, EX-2, EX-5 | M | | +| **EX-13** | **Migrate `tools.build` ops to DSL**: 7 ops (PrepareBuild, ParseBuild, PrepareTest, ParseTest, PrepareClippy, ParseClippy, Summary). Depends on control flow (EX-5) and rendering (EX-7). | EX-5, EX-7 | M | | +| **EX-14** | **Migrate remaining ops (ci, docgen, testgen, dag-viz) to DSL**: ~24 additional ops beyond the 5 core modules. These follow the same patterns as EX-9 through EX-13. Can be parallelized per module. | EX-1..EX-8 | L | | | **EX-15** | **Delete custom resolver path**: After all custom ops are migrated, the `resolve_domain` match arms for custom modules become empty. Remove them — `resolve_domain` reduces to service transport + default passthrough. | EX-9..EX-14 | S | | -**Parallelism**: EX-1, EX-2, EX-3 are fully independent (data model fixes). EX-4 and EX-5 are independent of each other but require parser/compiler changes. EX-9 through EX-14 are independent per module once their expression dependencies are met — each module can be migrated by a separate worker. +**Parallelism**: EX-1, EX-2, EX-3 are fully independent (data model fixes). EX-4, EX-5, EX-6 are independent of each other (lowering path work). EX-9 through EX-14 are independent per module once their expression dependencies are met — each module can be migrated by a separate worker. EX-8 is the integration gate before any migration begins. -**Success criteria**: After Lane H, adding a tool of any complexity requires **1 DSL file** and **0 Rust changes**. Custom `Executable` impls drop from 46 to 0. +**Success criteria**: After Lane H, adding a tool of any complexity requires **1 DSL file** and **0 Rust changes**. Custom tool `Executable` impls drop from 22 to 0 (46 total impls including infrastructure ops that stay in Rust by design). ---