///|
pub(all) struct Request {
  selector : String
  all : Bool
  invocation : String
  file : String?
  directory : String?
  script : String
  overlay : @configuration.Document
}

///|
pub fn Request::new() -> Request {
  {
    selector: "",
    all: false,
    invocation: "",
    file: None,
    directory: None,
    script: "",
    overlay: @configuration.empty(),
  }
}

///|
/// The project of a `PerProject` source compiler: one unit for all source files of the project.
priv struct ProjectJob {
  unit_id : String
  package_name : @ids.SourcePackageName
  exposed_modules : Array[@ids.SourceModuleName]
}

///|
priv struct Job {
  retained_unit : @data.IRUnit?
  root : String
  source_root : String
  destination : String
  paths : Array[String]
  settings : Config
  module_prefix : String
  exposed : Array[String]?
  runtime : @scheme.Runtime
  frontend : Frontend?
  backend : Backend?
  /// A rich frontend with a backend that has `emit_unit`: the unit goes to the backend with its metadata.
  unit_backend : Bool
  read_bytes : Bool
  suffix : String
  boundary : Checkpoint?
  generator : GenerationBackend?
  generator_options : Json
  build_policy : BuildPolicy
  components : Array[ComponentStep]
  strict_naming : Bool
  inference : @typing.Mode?
  project : ProjectJob?
}

///|
pub struct Plan {
  priv jobs : Array[Job]
  priv mut consumed : Bool
  priv sources : Array[String]
  priv destinations : Array[String]
  output_root : String
}

///|
fn Config::overlay(
  self : Config,
  values : @configuration.Document,
) -> Unit raise {
  self.frontend = values.text("frontend.language", self.frontend)
  if values.get("frontend.provider") is Some(_) {
    self.frontend_provider = Some(
      source_constraint(values.text("frontend.provider", ""), "provider"),
    )
  }
  if values.get("frontend.profile") is Some(_) {
    self.frontend_profile = Some(
      source_constraint(values.text("frontend.profile", ""), "profile"),
    )
  }
  self.backend = values.text("pipeline.backend", self.backend)
  self.source = normalize(values.text("project.source_directory", self.source))
  self.package_name = values.text("project.name", self.package_name)
  self.inference = values.text("pipeline.inference", self.inference)
}

///|
/// True when `message` names `key` as a whole path, not as the start of a longer one.
fn names_key(message : String, key : String) -> Bool {
  message.has_suffix(key) ||
  [":", " ", ",", ")", "\n"].iter().any(end => message.contains(key + end))
}

///|
/// Where an option came from: the file of the key that the message names, or of its nearest table.
fn option_origin(
  document : @configuration.Document,
  table : String,
  message : String,
) -> String {
  let mut best_path = ""
  let mut best_origin = ""
  let origins : Array[String] = []
  for path, origin in document.provenance {
    if path != table && !path.has_prefix(table + ".") {
      continue
    }
    if !origins.contains(origin) {
      origins.push(origin)
    }
    // Try the leaf path and then each enclosing table, longest first.
    let mut candidate = path
    while candidate.length() > best_path.length() {
      if names_key(message, candidate) {
        best_path = candidate
        best_origin = origin
        break
      }
      match candidate.rev_find(".") {
        Some(i) => candidate = candidate[:i].to_owned()
        None => break
      }
    }
  }
  if best_origin != "" {
    " (set in " + best_origin + ")"
  } else if !origins.is_empty() {
    origins.sort()
    " (" + table + " set in " + origins.join(", ") + ")"
  } else {
    ""
  }
}

///|
pub fn Engine::plan(
  self : Engine,
  host : Host,
  workspace : @workspace.Workspace,
  request : Request,
  cancelled~ : () -> Bool = fn() { false },
  bind_runtime~ : (@scheme.Runtime) -> Unit raise = fn(_) { () },
  codecs~ : @data.Registry? = None,
) -> Plan raise {
  let jobs = []
  let sources = []
  let destinations = []
  let out_dir = normalize(
    workspace.document.text("workspace.out_dir", ".morphir/out"),
  )
  if out_dir == "" {
    raise @configuration.Invalid("workspace.out_dir must be nonempty")
  }
  let output_root = join(workspace.root, out_dir)
  let selection = workspace.select(
    selector=request.selector,
    all=request.all,
    invocation=request.invocation,
  )
  for selected in selection {
    let effective = @configuration.merge(selected.document, request.overlay)
    let (settings, runtime, version) = self.resolve_project_config(
      host,
      workspace,
      selected,
      request.script,
      request.overlay,
      cancelled,
      bind_runtime,
    )
    let module_prefix = effective.text("project.module_prefix", "")
    let exposed = match effective.get("project.exposed_modules") {
      Some(_) =>
        Some(effective.strings("project.exposed_modules").map(canonical_path))
      None => None
    }
    let root_script = join(workspace.root, "morphir.config.scm")
    let project_script = join(selected.root, "morphir.config.scm")
    let generator = self.generators.get(settings.backend)
    let checkpoint = settings.backend == "checkpoint" || generator is Some(_)
    let generator_options = match generator {
      Some(g) => {
        let options = match effective.json("backends." + settings.backend) {
          Some(value) => value
          None => Json::object(Map([]))
        }
        (g.validate_options)(options) catch {
          error => {
            let message = match error {
              @configuration.Invalid(text) => text
              other => other.to_string()
            }
            raise @configuration.Invalid(
              message +
              option_origin(effective, "backends." + settings.backend, message),
            )
          }
        }
        options
      }
      None => Json::object(Map([]))
    }
    let build_policy = match generator {
      Some(g) => {
        let fallback = if g.targets.is_empty() {
          "source-only"
        } else {
          "required"
        }
        match effective.text("pipeline.validation", fallback) {
          "source-only" => SourceOnly
          "required" => {
            let target = effective.text("pipeline.target", "wasm")
            if !g.targets.contains(target) {
              raise @configuration.Invalid(
                "build.unsupported_target: " + target,
              )
            }
            Required(target)
          }
          other =>
            raise @configuration.Invalid(
              "Unknown project validation policy: " + other,
            )
        }
      }
      None => SourceOnly
    }
    let component_names = effective.strings("pipeline.components")
    if !checkpoint && !component_names.is_empty() {
      raise @configuration.Invalid("component.typed_backend_required")
    }
    let policy_name = effective.text("pipeline.metadata_policy", "retain")
    let components = component_names.map(name => {
      let component = match self.components.get(name) {
        Some(c) => c
        None => raise @configuration.Invalid("component.unknown: " + name)
      }
      let policy = match self.metadata_policies.get(policy_name) {
        Some(p) => p
        None =>
          raise @configuration.Invalid(
            "component.unknown_metadata_policy: " + policy_name,
          )
      }
      ComponentStep::{ name, component, policy_name, policy, }
    })
    let input_format = self.data_formats.get(
      if settings.frontend == "ir-json" {
        "morphir-json"
      } else {
        settings.frontend
      },
    )
    let frontend = if input_format is Some(_) {
      if settings.frontend_provider is Some(_) ||
        settings.frontend_profile is Some(_) {
        raise @configuration.Invalid(
          "frontend.source_constraint_on_data_format: " + settings.frontend,
        )
      }
      self.frontends.get(settings.frontend)
    } else {
      self.select_source_frontend(settings)
    }
    if frontend is None && input_format is None {
      raise @configuration.Invalid("Unknown frontend: " + settings.frontend)
    }
    let project_compiler = frontend is Some(f) &&
      f.compiler is ProjectCompiler(_, _)
    let rich_frontend = frontend is Some(f) && !(f.compiler is FileCompiler(_))
    let plain = if checkpoint {
      None
    } else {
      self.backends.get(settings.backend)
    }
    // A unit backend keeps the unit's metadata; a plain backend with `rich_units` emits the IR and warns.
    let unit_backend = rich_frontend &&
      plain is Some({ emit_unit: Some(_), .. })
    if !checkpoint && rich_frontend && !(plain is Some(b) && b.rich_units) {
      raise @configuration.Invalid(
        "frontend.typed_backend_required: Rich source units require checkpoint, generation or unit-backend output",
      )
    }
    let whole_project = "frontend.project_compiler_file: " +
      settings.frontend +
      " compiles whole projects; select the project, not a file or directory"
    if project_compiler && request.file is Some(_) {
      raise @configuration.Invalid(whole_project)
    }
    let boundary = if checkpoint {
      let format = effective.text("pipeline.checkpoint_format", "ion-binary")
      let output = self.data_format(format)
      let registry = match codecs {
        Some(registry) => registry
        None => @data.Registry::new()
      }
      let required_codecs = [output.codec] +
        (match input_format {
          Some(input) => [input.codec]
          None => []
        })
      for codec in required_codecs {
        if !registry.has_codec(codec) {
          raise @configuration.Invalid("data.unknown_codec: " + codec)
        }
      }
      if version.major != 4U {
        raise @configuration.Invalid(
          "Checkpoint IR uses current version 4; use ir-json for historical export",
        )
      }
      if !settings.transforms.is_empty() ||
        (exposed is Some(_) && !project_compiler) {
        raise @configuration.Invalid(
          "Checkpoint identity processing does not yet support transforms or exposed_modules; metadata lineage is required",
        )
      }
      Some(Checkpoint::{ input: input_format, output, codecs: registry, })
    } else {
      if frontend is None && input_format is Some(_) {
        raise @configuration.Invalid(
          "Data-boundary inputs require the checkpoint backend",
        )
      }
      None
    }
    let backend = if checkpoint {
      None
    } else {
      let registered = match self.backends.get(settings.backend) {
        Some(b) => b
        None =>
          raise @configuration.Invalid("Unknown backend: " + settings.backend)
      }
      Some(
        if settings.backend == "ir-json" {
          Backend::{
            extension: registered.extension,
            emit: fn(ir) raise {
              @versioned_json.write(
                @migration.migrate(@versioned.V4(ir), version),
              )
            },
            rich_units: registered.rich_units,
            emit_unit: None,
          }
        } else {
          registered
        },
      )
    }
    if unit_backend &&
      (
        !settings.transforms.is_empty() ||
        (exposed is Some(_) && !project_compiler)
      ) {
      raise @configuration.Invalid(
        "Unit backends do not yet support transforms or exposed_modules; metadata lineage is required",
      )
    }
    let suffix = if generator is Some(_) {
      ".project"
    } else {
      match boundary {
        Some(boundary) => boundary.output.suffix
        None => backend.unwrap().extension
      }
    }
    let extensions = match boundary {
      Some(b) =>
        match b.input {
          Some(input) => input.extensions
          None => frontend.unwrap().extensions
        }
      None => frontend.unwrap().extensions
    }
    let read_bytes = match boundary {
      Some(b) =>
        match b.input {
          Some(input) => input.binary
          None => false
        }
      None => false
    }
    if !suffix.has_prefix(".") || suffix.contains("/") || suffix.contains("\\") {
      raise @configuration.Invalid("Invalid backend file suffix")
    }
    fn owned(path : String) -> Bool {
      !workspace.projects.any(other => {
        other.root != selected.root &&
        @workspace.within(selected.root, other.root) &&
        @workspace.within(other.root, path)
      })
    }
    let project_source = join(selected.root, settings.source)
    let requested = match request.directory {
      Some(directory) => normalize(directory)
      None => project_source
    }
    if !owned(requested) {
      raise @configuration.Invalid(
        "Input directory belongs to another workspace project",
      )
    }
    if !@workspace.within(selected.root, requested) {
      raise @configuration.Invalid(
        "Input directory is outside selected project",
      )
    }
    // A project compiler compiles from the project's source directory. A directory request selects the project
    // only when it holds that directory; a narrower one would compile part of the project.
    if project_compiler && !@workspace.within(requested, project_source) {
      raise @configuration.Invalid(whole_project)
    }
    let source_root = if project_compiler { project_source } else { requested }
    if @workspace.within(output_root, source_root) {
      raise @configuration.Invalid(
        "Source directory is inside output directory",
      )
    }
    let relative_project = if selected.root == workspace.root {
      ""
    } else if workspace.root == "" {
      selected.root
    } else {
      selected.root[workspace.root.length() + 1:].to_owned()
    }
    let destination = join(join(output_root, relative_project), "compile.dest")
    let output = join(destination, settings.output)
    let paths = match request.file {
      Some(file) => {
        let path = normalize(file)
        if !owned(path) {
          raise @configuration.Invalid(
            "File belongs to another workspace project: " + path,
          )
        }
        if !@workspace.within(source_root, path) || path == source_root {
          raise @configuration.Invalid(
            "File is outside selected source directory: " + path,
          )
        }
        [path]
      }
      None =>
        (host.list)(source_root).filter(fn(path) {
          owned(path) &&
          !@workspace.within(output_root, path) &&
          path != root_script &&
          path != project_script &&
          !["morphir.json", "morphir-workspace.json"].contains(
            path.split("/").last().unwrap_or("").to_owned(),
          ) &&
          extensions.any(extension => path.has_suffix(extension))
        })
    }
    paths.sort()
    let unique = []
    let seen = Map([])
    for path in paths {
      let path = normalize(path)
      if !owned(path) {
        continue
      }
      if !@workspace.within(source_root, path) || path == source_root {
        raise @configuration.Invalid(
          "Host listed a path outside source root: " + path,
        )
      }
      // A file compiler writes one output per source. A project compiler writes one output for the
      // project, so nested module paths (`Api` and `Api/Util`) do not collide.
      if !project_compiler {
        let rel = relative(source_root, path)
        let target = join(
          output,
          stem(rel) + (if generator is Some(_) { "" } else { suffix }),
        )
        let key = target.to_lower()
        if seen.contains(key) ||
          seen
          .keys()
          .any(existing => {
            @workspace.within(existing, key) || @workspace.within(key, existing)
          }) {
          raise @configuration.Invalid("Duplicate output path: " + target)
        }
        seen[key] = true
      }
      unique.push(path)
      sources.push(path)
    }
    if project_compiler &&
      frontend
      is Some({ capabilities: Some({ max_documents: Some(limit), .. }), .. }) &&
      unique.length() > limit {
      raise @configuration.Invalid(
        "frontend.too_many_documents: " +
        unique.length().to_string() +
        " sources; " +
        settings.frontend +
        " accepts at most " +
        limit.to_string(),
      )
    }
    if destinations.any(existing => {
        @workspace.within(existing, destination) ||
        @workspace.within(destination, existing)
      }) {
      raise @configuration.Invalid(
        "Task output destinations overlap: " + destination,
      )
    }
    destinations.push(destination)
    let inference = inference_mode(settings.inference) catch {
      @configuration.Invalid(message) =>
        raise @configuration.Invalid(
          message + option_origin(effective, "pipeline", message),
        )
      e => raise e
    }
    let generator = self.bind_project_metadata(settings.backend, generator, {
      workspace_root: workspace.root,
      project_root: selected.root,
      project_name: selected.name,
      request: effective.metadata_provider_request(),
    })
    jobs.push({
      retained_unit: None,
      root: selected.root,
      source_root,
      destination: output,
      paths: unique,
      settings,
      module_prefix,
      exposed: if project_compiler {
        None
      } else {
        exposed
      },
      runtime,
      frontend,
      backend,
      unit_backend,
      suffix,
      read_bytes,
      boundary,
      generator,
      generator_options,
      build_policy,
      components,
      strict_naming: effective.boolean("pipeline.strict_naming", false),
      inference,
      project: if project_compiler {
        let exposed = []
        if effective.get("project.exposed_modules") is Some(_) {
          for text in effective.strings("project.exposed_modules") {
            exposed.push(@ids.SourceModuleName::parse(text))
          }
        }
        Some({
          unit_id: canonical_path(settings.package_name),
          package_name: @ids.SourcePackageName::parse(settings.package_name),
          exposed_modules: exposed,
        })
      } else {
        None
      },
    })
  }
  { jobs, consumed: false, sources, destinations, output_root, }
}

///|
/// The frontend source of one file. File and project compilers both use it, so they name modules the same way.
fn Job::source(
  self : Job,
  path : String,
  relative_path : String,
  content : @data.Content,
) -> Source raise {
  {
    path,
    relative_path,
    content: match content {
      Text(text) => text
      Binary(_) =>
        raise @configuration.Invalid(
          "data.encoding_mismatch: Expected text frontend content",
        )
    },
    package_name: self.settings.package_name,
    module_name: if self.module_prefix == "" {
      stem(relative_path)
    } else {
      self.module_prefix + "/" + stem(relative_path)
    },
  }
}

///|
pub fn Engine::execute_content(
  _self : Engine,
  plan : Plan,
  read : (String, Bool) -> @data.Content raise,
  cancelled~ : () -> Bool = fn() { false },
  on_unit~ : (CompiledUnit) -> Unit = _ => (),
) -> ContentReport {
  let artifacts = []
  let projects = []
  let mut processed = 0
  let diagnostics = []
  let warnings = []
  if plan.consumed {
    return {
      artifacts,
      projects,
      warnings,
      processed: 0,
      diagnostics: [
        {
          path: "",
          stage: "plan",
          message: "Execution plans are single-use; prepare a fresh plan",
          details: None,
          problem: None,
        },
      ],
    }
  }
  plan.consumed = true
  for job in plan.jobs {
    job.runtime.set_cancelled(cancelled)
    // A project compiler gives one unit for all sources of its project; it reports at the source directory.
    let inputs = match job.project {
      Some(_) => if job.paths.is_empty() { [] } else { [job.source_root] }
      None => job.paths
    }
    for path in inputs {
      let mut stage = "read"
      // The file of a `read` diagnostic: a project reads every source before its compile.
      let mut diagnostic_path = path
      try {
        if cancelled() {
          raise @configuration.Invalid("Cancelled")
        }
        let mut checkpoint_unit : @data.IRUnit? = None
        let unit_id = match (job.retained_unit, job.project) {
          (Some(unit), _) => {
            checkpoint_unit = Some(unit)
            stage = "frontend"
            unit.id
          }
          (None, Some(project)) => {
            let sources : Array[Source] = []
            for source_path in job.paths {
              diagnostic_path = source_path
              sources.push(
                job.source(
                  source_path,
                  relative(job.source_root, source_path),
                  read(source_path, false),
                ),
              )
            }
            diagnostic_path = path
            stage = "frontend"
            checkpoint_unit = Some(
              job.frontend
              .unwrap()
              .compile_project(
                {
                  package_name: project.package_name,
                  exposed_modules: project.exposed_modules.copy(),
                  sources,
                },
                project.unit_id,
                cancelled,
              ),
            )
            project.unit_id
          }
          (None, None) => {
            let relative_path = relative(job.source_root, path)
            let content = read(path, job.read_bytes)
            let unit_id = stem(relative_path)
            stage = "frontend"
            checkpoint_unit = match job.boundary {
              Some(boundary) =>
                match boundary.input {
                  Some(format) =>
                    Some(
                      boundary.codecs.decode(format.codec, { unit_id, content, }),
                    )
                  None => None
                }
              None => None
            }
            if checkpoint_unit is None {
              checkpoint_unit = Some(
                job.frontend
                .unwrap()
                .compile_unit(
                  job.source(path, relative_path, content),
                  unit_id,
                  cancelled,
                ),
              )
            }
            unit_id
          }
        }
        let seen_warnings : Map[String, Bool] = Map([])
        for item in checkpoint_unit.unwrap().provenance {
          if @diagnostics.warning_from_ion(item) is Some(warning) {
            let key = warning.key()
            if seen_warnings.contains(key) {
              continue
            }
            seen_warnings[key] = true
            warnings.push({
              path,
              stage: warning.stage,
              message: warning.display(),
              details: Some(warning),
              problem: None,
            })
            if job.strict_naming &&
              (
                warning.code.has_prefix("moonbit_frontend.name_") ||
                warning.code.has_prefix("moonbit_backend.name_")
              ) {
              raise @configuration.Invalid(
                "naming.strict: " + warning.display(),
              )
            }
          }
        }
        let mut ir = checkpoint_unit.unwrap().distribution
        if job.exposed is Some(exposed) {
          match ir.distribution {
            Library(name, dependencies, package_def) => {
              let modules = package_def.modules.map(fn(module_name, access) {
                {
                  ..access,
                  access: if exposed.contains(module_name.to_canonical()) {
                    @ir.Access::Public
                  } else {
                    @ir.Access::Private
                  },
                }
              })
              ir = @ir.IRFile::new(
                @ir.Distribution::Library(name, dependencies, { modules, }),
              )
            }
            _ =>
              raise @configuration.Invalid(
                "exposed_modules requires a library distribution",
              )
          }
        }
        stage = "component"
        if !job.components.is_empty() {
          let mut unit = checkpoint_unit.unwrap_or(
            @data.IRUnit::new(unit_id, ir),
          )
          for component in job.components {
            if cancelled() {
              raise @configuration.Invalid("Cancelled")
            }
            unit = apply_component(unit, component)
          }
          ir = unit.distribution
          checkpoint_unit = Some(unit)
        }
        stage = "transform"
        job.runtime.set_global("source-path", @scheme.Text(path))
        job.runtime.set_global("project-root", @scheme.Text(job.root))
        for transform in job.settings.transforms {
          if cancelled() {
            raise @configuration.Invalid("Cancelled")
          }
          ir = job.runtime.call(transform, [@scheme.IRFile(ir)]).as_file()
        }
        stage = "inference"
        if job.inference is Some(mode) {
          let checkpoint = job.boundary is Some(_) ||
            job.generator is Some(_) ||
            job.unit_backend
          let unit = match checkpoint_unit {
            Some(unit) if checkpoint => unit
            _ => @data.IRUnit::new(unit_id, ir)
          }
          let result = infer_unit(unit, mode, checkpoint)
          ir = result.distribution
          if checkpoint {
            checkpoint_unit = Some(result)
          }
        }
        on_unit({
          source: path,
          unit: { ..checkpoint_unit.unwrap(), distribution: ir, },
        })
        stage = "backend"
        if cancelled() {
          raise @configuration.Invalid("Cancelled")
        }
        if job.generator is Some(generator) {
          let unit = match checkpoint_unit {
            Some(unit) => unit
            None => @data.IRUnit::new(unit_id, ir)
          }
          let batch = (generator.generate)(
            unit,
            job.generator_options,
            cancelled,
          )
          for warning in batch.warnings {
            warnings.push({
              path,
              stage: "backend",
              message: warning,
              details: None,
              problem: None,
            })
          }
          for warning in batch.structured_warnings {
            let key = warning.key()
            if !seen_warnings.contains(key) {
              seen_warnings[key] = true
              warnings.push({
                path,
                stage: warning.stage,
                message: warning.display(),
                details: Some(warning),
                problem: None,
              })
            }
            if job.strict_naming &&
              warning.code.has_prefix("moonbit_backend.name_") {
              raise @configuration.Invalid(
                "naming.strict: " + warning.display(),
              )
            }
          }
          validate_batch(
            batch,
            join(
              job.destination,
              if job.retained_unit is Some(_) {
                "model"
              } else {
                unit_id
              },
            ),
            path,
            job.build_policy,
            artifacts,
            projects,
          )
          processed += 1
          continue
        }
        if job.unit_backend {
          let unit = match checkpoint_unit {
            Some(unit) => { ..unit, distribution: ir, }
            None => @data.IRUnit::new(unit_id, ir)
          }
          let files = job.backend.unwrap().emit_unit.unwrap()(unit, path)
          for file in files {
            artifacts.push({
              path: join(job.destination, file.path),
              source: path,
              content: file.content,
              format: file.format,
            })
          }
          processed += 1
          continue
        }
        let content = match job.boundary {
          Some(boundary) =>
            boundary.codecs.encode(
              boundary.output.codec,
              match checkpoint_unit {
                Some(unit) => unit
                None => @data.IRUnit::new(unit_id, ir)
              },
            )
          None => {
            let unit = checkpoint_unit.unwrap()
            // every unit carries the pipeline marker annotation; only other annotations are dropped data
            let marker = @data.IRUnit::new(unit.id, ir).annotations
            let (n, m, k) = (
              unit.metadata.length(),
              unit.provenance.length(),
              unit.annotations.filter(a => !marker.contains(a)).length(),
            )
            if n + m + k > 0 {
              warnings.push({
                path,
                stage: "backend",
                message: "backend.metadata_dropped: " +
                job.settings.backend +
                " keeps the IR only; it drops \{n} node metadata entries, \{m} provenance entries and \{k} annotations",
                details: None,
                problem: None,
              })
            }
            @data.Text((job.backend.unwrap().emit)(ir))
          }
        }
        if job.boundary is Some(boundary) {
          if (content is @data.Binary(_)) != boundary.output.binary {
            raise @configuration.Invalid(
              "data.encoding_mismatch: Codec output disagrees with registered format",
            )
          }
        }
        processed += 1
        artifacts.push({
          path: join(job.destination, unit_id + job.suffix),
          source: path,
          content,
          format: match job.boundary {
            Some(b) => b.output.codec
            None => job.settings.backend
          },
        })
      } catch {
        RejectedSources(problems) =>
          for problem in problems {
            diagnostics.push({
              path: if problem.path == "" {
                path
              } else {
                join(job.source_root, problem.path) catch {
                  _ => path
                }
              },
              stage,
              message: rejection_message(
                problem.code,
                problem.detail,
                problem.location,
              ),
              details: None,
              problem: Some(Source(problem)),
            })
          }
        Rejected(code, detail, location) =>
          diagnostics.push({
            path: diagnostic_path,
            stage,
            message: rejection_message(code, detail, location),
            details: None,
            problem: Some(
              Source({
                code,
                detail,
                path: relative(job.source_root, diagnostic_path) catch {
                  _ => diagnostic_path
                },
                location,
              }),
            ),
          })
        GenerationFailed(problems) =>
          for problem in problems {
            diagnostics.push({
              path,
              stage: "backend",
              message: problem.code +
              " at " +
              problem.node_path +
              ": " +
              problem.detail,
              details: None,
              problem: Some(Generation(problem)),
            })
          }
        @typing.InferenceFailed(problems) =>
          for problem in problems {
            diagnostics.push({
              path,
              stage: "inference",
              message: problem.display(),
              details: None,
              problem: None,
            })
          }
        e =>
          diagnostics.push({
            path: diagnostic_path,
            stage,
            message: diagnostic_message(e),
            details: None,
            problem: None,
          })
      }
    }
  }
  { processed, artifacts, projects, diagnostics, warnings, }
}

///|
pub fn Plan::sources(self : Plan) -> Array[String] {
  self.sources.copy()
}

///|
pub fn Plan::destinations(self : Plan) -> Array[String] {
  self.destinations.copy()
}