Skip to main content

artifactize_core/
scope.rs

1//! Artifact references and canonical scoped paths.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5
6use serde::Serialize;
7
8use crate::config::{Artifact, ArtifactName, ConfigError, Eval, Fingerprint, Profile, RepoConfig};
9
10mod argument;
11pub use argument::{Argument, LogicalSuffix, LogicalTarget};
12
13mod human;
14mod instruction;
15pub(crate) use human::{
16    builtin_args, human_arguments, resolve_human_argv, validate_human_argument,
17};
18pub use instruction::instruction_references;
19
20pub use artifactize_tools::scope::{ArtifactId, ScopeError, ScopedPath, scoped_path};
21pub(crate) use artifactize_tools::scope::{logical_path, open_child, open_scoped};
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
24pub struct Relation {
25    /// Input Artifact.
26    pub source: ArtifactName,
27    /// Consumer Artifact.
28    pub target: ArtifactName,
29    #[serde(flatten)]
30    pub kind: RelationKind,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34#[serde(tag = "kind", rename_all = "lowercase")]
35pub enum RelationKind {
36    /// The source is a part of the target: its folder holds it, or the target's `parts`
37    /// names it.
38    Part,
39    Instruction {
40        #[serde(rename = "evalId")]
41        eval_id: crate::config::EvalId,
42        name: crate::config::ReferenceName,
43    },
44    #[serde(rename = "argv")]
45    Argument {
46        #[serde(rename = "evalId")]
47        eval_id: crate::config::EvalId,
48        index: usize,
49        name: crate::config::ReferenceName,
50        path: LogicalTarget,
51    },
52}
53
54#[derive(Debug, Clone)]
55pub struct Scope<'a> {
56    pub artifacts: BTreeMap<ArtifactName, &'a Artifact>,
57}
58
59impl Scope<'_> {
60    /// Snapshot only access data; composition and eval dependencies are decided here.
61    pub(crate) fn tool_scope(&self) -> artifactize_tools::scope::Scope {
62        artifactize_tools::scope::Scope {
63            artifacts: self
64                .artifacts
65                .iter()
66                .map(|(id, artifact)| (tool_id(id), artifact.tool_scope()))
67                .collect(),
68        }
69    }
70
71    pub fn resolve_path(
72        &self,
73        artifact_id: &ArtifactName,
74        path: &str,
75    ) -> Result<ScopedPath, ScopeError> {
76        self.tool_scope().resolve_path(&tool_id(artifact_id), path)
77    }
78
79    pub fn resolve_input(
80        &self,
81        root: &Path,
82        artifact_id: &ArtifactName,
83        path: &str,
84    ) -> Result<PathBuf, ScopeError> {
85        self.tool_scope()
86            .resolve_input(root, &tool_id(artifact_id), path)
87    }
88}
89
90/// The tool-side ID of a configured Artifact. Configuration validates every Artifact name
91/// and child, so a name that fails here is a bug in that validation.
92pub(crate) fn tool_id(name: &ArtifactName) -> ArtifactId {
93    ArtifactId::new(name.as_str()).expect("configuration validates Artifact names")
94}
95
96/// Revalidate file targets at every preparation/read boundary, without following links.
97pub(crate) fn validate_file_target(root: &Path, artifact: &Artifact) -> Result<(), ScopeError> {
98    if artifact.file_name().is_some() {
99        artifact
100            .path
101            .to_str()
102            .ok_or_else(|| ScopeError("Artifact paths must be UTF-8.".into()))?;
103        let target = crate::platform::path_text(&artifact.path);
104        let file = open_scoped(root, &target).map_err(|error| {
105            ScopeError(format!(
106                "File Artifact {} target {} is unavailable: {error}",
107                artifact.name,
108                crate::platform::path_text(&artifact.path)
109            ))
110        })?;
111        if !file
112            .metadata()
113            .map_err(|error| ScopeError(error.to_string()))?
114            .is_file()
115        {
116            return Err(ScopeError(format!(
117                "File Artifact {} target {} must remain a regular file.",
118                artifact.name,
119                crate::platform::path_text(&artifact.path)
120            )));
121        }
122    }
123    Ok(())
124}
125
126fn reference_path(config: &RepoConfig, id: &ArtifactName, path: &str) -> Result<(), ScopeError> {
127    if config.artifacts[id].file_name().is_some() && !path.is_empty() {
128        return Err(ScopeError(format!(
129            "File Artifact {{{id}}} cannot have a /path suffix."
130        )));
131    }
132    Ok(())
133}
134
135/// A path under `{source}` that lies in a nested folder Artifact is that Artifact's input,
136/// which only a reference of its own brings into a review.
137fn nested_path(
138    everything: &artifactize_tools::scope::Scope,
139    source: &ArtifactName,
140    path: &str,
141) -> Result<(), ScopeError> {
142    let resolved = everything.resolve_path(&tool_id(source), path)?;
143    if resolved.artifact_id.as_str() != source.as_str() {
144        return Err(ScopeError(format!(
145            "{{{source}}}/{path} is in Artifact {owner}; reference {{{owner}}}/{rest} instead.",
146            owner = resolved.artifact_id,
147            rest = resolved.path,
148        )));
149    }
150    Ok(())
151}
152
153/// Exactly the given Artifacts. Neither composition nor a referenced Artifact's Eval
154/// instructions grant access: a path into a part or a nested folder Artifact needs a
155/// reference of its own, so that what a review reads is what its reuse key covers.
156pub fn artifact_scope<'a>(
157    config: &'a RepoConfig,
158    roots: &[&ArtifactName],
159) -> Result<Scope<'a>, ScopeError> {
160    let artifacts = roots
161        .iter()
162        .map(|&id| {
163            config
164                .artifacts
165                .get_key_value(id)
166                .map(|(id, artifact)| (id.clone(), artifact))
167                .ok_or_else(|| ScopeError(format!("Unknown Artifact: {id}")))
168        })
169        .collect::<Result<_, _>>()?;
170    Ok(Scope { artifacts })
171}
172
173/// Validate scope-relative command paths before process lookup. Bare names and absolute
174/// paths stay literal here; preflight and both launchers use the shared PATH/PATHEXT rule.
175pub(crate) fn executable(
176    root: &Path,
177    scope: &Scope<'_>,
178    owner: &ArtifactName,
179    command: &str,
180) -> Result<std::ffi::OsString, String> {
181    let artifact = scope.artifacts[owner];
182    let cwd = scoped_path(root, artifact.folder()).map_err(|error| error.to_string())?;
183    let program = if let Some(relative) = crate::platform::program::folder_relative(command) {
184        let physical = if artifact.file_name().is_some() {
185            artifactize_tools::scope::logical_path(relative).map_err(|error| error.to_string())?;
186            cwd.join(relative)
187        } else {
188            let resolved = scope
189                .resolve_path(owner, relative)
190                .map_err(|error| error.to_string())?;
191            root.join(scope.artifacts[resolved.artifact_id.as_str()].folder())
192                .join(resolved.path)
193        };
194        // Even an extensionless declaration must not hide a link behind PATHEXT.
195        crate::platform::program::reject_link_ancestors(&physical)?;
196        let mut failure = None;
197        crate::platform::program::candidates(Path::new(relative))
198            .into_iter()
199            .find_map(|candidate| {
200                // A suffix the lookup added is matched to the entry's actual spelling.
201                let candidate = if candidate.as_path() != Path::new(relative) {
202                    executable_spelling(root, scope, owner, &cwd, &candidate)?
203                } else {
204                    candidate
205                };
206                let resolved = if artifact.file_name().is_some() {
207                    scoped_path(&cwd, &candidate)
208                } else {
209                    scope.resolve_input(
210                        root,
211                        owner,
212                        &artifactize_tools::scope::logical_from_native(&candidate)?,
213                    )
214                };
215                match resolved {
216                    Ok(program) if program.is_file() => Some(program),
217                    Ok(_) => None,
218                    Err(error) => {
219                        failure.get_or_insert_with(|| error.to_string());
220                        None
221                    }
222                }
223            })
224            .ok_or_else(|| {
225                failure.unwrap_or_else(|| "Executable path is unavailable or outside scope.".into())
226            })?
227    } else {
228        PathBuf::from(command)
229    };
230    Ok(program.into_os_string())
231}
232
233/// PATHEXT supplies a suffix, not a model-authored name: use its actual directory-entry
234/// spelling before strict scoped validation. Parent components retain their input spelling.
235fn executable_spelling(
236    root: &Path,
237    scope: &Scope<'_>,
238    owner: &ArtifactName,
239    cwd: &Path,
240    candidate: &Path,
241) -> Option<PathBuf> {
242    let parent = candidate.parent().unwrap_or(Path::new(""));
243    let directory = if scope.artifacts[owner].file_name().is_some() {
244        scoped_path(cwd, parent).ok()?
245    } else {
246        scope.resolve_input(root, owner, parent.to_str()?).ok()?
247    };
248    crate::platform::program::suffix_spelling(&directory, candidate)
249}
250
251pub fn eval_scope<'a>(config: &'a RepoConfig, eval: &Eval) -> Result<Scope<'a>, ScopeError> {
252    let roots: Vec<_> = std::iter::once(&eval.target)
253        .chain(eval.deps.iter())
254        .collect();
255    artifact_scope(config, &roots)
256}
257
258/// Admit an owner plus the explicit references in its argv, as an Eval's scope admits its deps.
259pub fn argv_scope<'a>(
260    config: &'a RepoConfig,
261    owner: &ArtifactName,
262    args: &[Argument],
263) -> Result<Scope<'a>, ScopeError> {
264    let mut roots = vec![owner];
265    for argument in args {
266        if let Argument::Reference { name, .. } = argument {
267            roots.push(reference_target(config, owner, name)?);
268        }
269    }
270    artifact_scope(config, &roots)
271}
272
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub enum InstructionPart {
275    Text(String),
276    Artifact(ArtifactName),
277}
278
279/// Presentation tokens only: never expand content or mutate the authored payload.
280/// Artifact tokens carry names already validated by configuration.
281pub fn parse_artifact_instruction(
282    source: &str,
283    scope: &Scope<'_>,
284    references: &BTreeMap<crate::config::ReferenceName, ArtifactName>,
285) -> Vec<InstructionPart> {
286    let mut parts = Vec::new();
287    let mut start = 0;
288    for reference in instruction::references(source) {
289        let artifact = match references.get(&reference.name) {
290            Some(id) => scope.artifacts.get(id),
291            None => scope.artifacts.get(reference.name.as_str()),
292        };
293        let Some(artifact) = artifact else {
294            continue;
295        };
296        if start < reference.start {
297            parts.push(InstructionPart::Text(source[start..reference.start].into()));
298        }
299        parts.push(InstructionPart::Artifact(artifact.name.clone()));
300        start = reference.end;
301    }
302    if start < source.len() {
303        parts.push(InstructionPart::Text(source[start..].into()));
304    }
305    parts
306}
307
308fn reference_target<'a>(
309    config: &'a RepoConfig,
310    owner: &ArtifactName,
311    name: &crate::config::ReferenceName,
312) -> Result<&'a ArtifactName, ScopeError> {
313    if !config.artifacts.contains_key(owner) {
314        return Err(ScopeError(format!("Unknown Artifact: {owner}")));
315    }
316    let target = config.artifacts.get_key_value(name.as_str());
317    target.map(|(id, _)| id).ok_or_else(|| {
318        ScopeError(format!(
319            "Unknown Artifact reference {{{name}}}. Escape literal braces with a backslash."
320        ))
321    })
322}
323
324/// Resolve `{name}[/path]` and `--flag={name}[/path]` operands to existing scoped inputs.
325/// Other arguments, including escaped braces, stay literal. Never interpolate the command.
326/// Call at execution preparation, after static config validation; it opens input metadata.
327pub fn resolve_argv(
328    config: &RepoConfig,
329    scope: &Scope<'_>,
330    owner: &ArtifactName,
331    args: &[Argument],
332) -> Result<Vec<String>, ScopeError> {
333    args.iter()
334        .map(|argument| {
335            let Argument::Reference { prefix, name, path } = argument else {
336                return Ok(argument.to_string());
337            };
338            let id = reference_target(config, owner, name)?;
339            reference_path(config, id, path)?;
340            let resolved = scope.resolve_input(&config.root, id, path)?;
341            resolved
342                .to_str()
343                .ok_or_else(|| ScopeError("Artifact paths must be UTF-8.".into()))?;
344            let resolved = crate::platform::path_text(&resolved);
345            Ok(format!("{prefix}{resolved}"))
346        })
347        .collect()
348}
349
350/// Every path an aspect selects exists below its folder, without symlink traversal.
351pub(crate) fn validate_selections(config: &RepoConfig) -> Result<(), ConfigError> {
352    for (id, artifact) in &config.artifacts {
353        let scope = artifact_scope(config, &[id]).expect("a configured Artifact");
354        for selected in &artifact.select {
355            scope
356                .resolve_input(&config.root, id, selected)
357                .map_err(|failure| {
358                    ConfigError::declaration(
359                        config.root.join(artifact.declaration_path()),
360                        &["select"],
361                        format!("select: {selected}: {failure}"),
362                    )
363                })?;
364        }
365    }
366    Ok(())
367}
368
369/// Static relationships only; scheduling and dependency closure belong to graph.
370pub(crate) fn resolve_config(config: &mut RepoConfig) -> Result<(), ConfigError> {
371    let mut relations = Vec::new();
372    for (id, artifact) in &config.artifacts {
373        let error = |keys: &[&str], message: String| {
374            ConfigError::declaration(config.root.join(artifact.declaration_path()), keys, message)
375        };
376        for (name, tool) in &artifact.views.agent_tools {
377            if let crate::config::AgentTool::Builtin(tool) = tool
378                && let Some(args) = &tool.args
379            {
380                let scope = artifact_scope(config, &[id]).map_err(|failure| {
381                    error(&["views", "agent_tools", name, "args"], failure.to_string())
382                })?;
383                let owner = ArtifactId::new(id.as_str()).map_err(|failure| {
384                    error(&["views", "agent_tools", name, "args"], failure.to_string())
385                })?;
386                artifactize_tools::builtin::validate_target(
387                    tool.builtin,
388                    args,
389                    &config.root,
390                    &scope.tool_scope(),
391                    &owner,
392                )
393                .map_err(|failure| error(&["views", "agent_tools", name, "args"], failure))?;
394            }
395            if let crate::config::AgentTool::Command(tool) = tool
396                && tool.protocol == crate::config::ToolProtocol::Json
397            {
398                for argument in &tool.args {
399                    if let Argument::Reference {
400                        name: ref_name,
401                        path,
402                        ..
403                    } = argument
404                    {
405                        let target = reference_target(config, id, ref_name).map_err(|failure| {
406                            error(
407                                &["views", "agent_tools", name, "args"],
408                                format!("Agent tool {name}: {failure}"),
409                            )
410                        })?;
411                        reference_path(config, target, path).map_err(|failure| {
412                            error(
413                                &["views", "agent_tools", name, "args"],
414                                format!("Agent tool {name}: {failure}"),
415                            )
416                        })?;
417                    }
418                }
419            }
420        }
421        if let Some(Fingerprint::Script { args, .. }) = &artifact.fingerprint {
422            for argument in args {
423                if let Argument::Reference {
424                    name: ref_name,
425                    path,
426                    ..
427                } = argument
428                {
429                    let target = reference_target(config, id, ref_name).map_err(|failure| {
430                        error(
431                            &["fingerprint", "script", "args"],
432                            format!("fingerprint.script: {failure}"),
433                        )
434                    })?;
435                    reference_path(config, target, path).map_err(|failure| {
436                        error(
437                            &["fingerprint", "script", "args"],
438                            format!("fingerprint.script: {failure}"),
439                        )
440                    })?;
441                }
442            }
443        }
444    }
445    for (id, artifact) in &config.artifacts {
446        let Some(Fingerprint::Artifactsum { files: inputs, .. }) = &artifact.fingerprint else {
447            continue;
448        };
449        let error = |message: String| {
450            ConfigError::declaration(
451                config.root.join(artifact.declaration_path()),
452                &["fingerprint", "files"],
453                format!("fingerprint.files: {message}"),
454            )
455        };
456        let scope = artifact_scope(config, &[id]).map_err(|failure| error(failure.0))?;
457        for input in inputs.iter().filter(|input| *input != ".") {
458            if input.ends_with(".artf")
459                && !crate::platform::path_kind(&config.root.join(artifact.folder()).join(input))
460                    .is_ok_and(|kind| kind == crate::platform::FileKind::Directory)
461            {
462                return Err(error(
463                    "Artifact declarations (*.artf) cannot be explicit artifactsum inputs; declarations are excluded from artifactsum."
464                        .into(),
465                ));
466            }
467            let location = scope
468                .resolve_path(id, input)
469                .map_err(|failure| error(failure.0))?;
470            if location.artifact_id.as_str() != id.as_str() {
471                return Err(error(format!(
472                    "{input} belongs to Artifact {}; a fingerprint covers only its own Artifact's files.",
473                    location.artifact_id
474                )));
475            }
476        }
477    }
478    let everything = everything(config);
479    let mut resolved = Vec::new();
480    for eval in &config.evals {
481        let mut found = eval_references(config, &everything, eval)?;
482        relations.append(&mut found.relations);
483        resolved.push(found);
484    }
485    for (eval, found) in config.evals.iter_mut().zip(resolved) {
486        eval.references = found.references;
487        eval.deps = found.deps;
488    }
489    for artifact in config.artifacts.values() {
490        for part in &artifact.parts {
491            let error = |message: String| {
492                ConfigError::declaration(
493                    config.root.join(artifact.declaration_path()),
494                    &["parts"],
495                    message,
496                )
497            };
498            match config.artifacts.get(part) {
499                None => return Err(error(format!("Unknown Artifact {part}."))),
500                Some(part) if part.kind == crate::config::ArtifactKind::Graph => {
501                    return Err(error(format!(
502                        "The graph Artifact {} is a part of nothing.",
503                        part.name
504                    )));
505                }
506                Some(_) => relations.push(Relation {
507                    source: part.clone(),
508                    target: artifact.name.clone(),
509                    kind: RelationKind::Part,
510                }),
511            }
512        }
513    }
514    parts_acyclic(config)?;
515    config.relations = relations;
516    human::validate_references(config)?;
517    crate::graph::Graph::new(config).map_err(|error| ConfigError::new(&config.root, error))?;
518    Ok(())
519}
520
521/// Every Artifact of `config` as one tool scope, which nested paths are checked against.
522fn everything(config: &RepoConfig) -> artifactize_tools::scope::Scope {
523    Scope {
524        artifacts: config
525            .artifacts
526            .iter()
527            .map(|(id, artifact)| (id.clone(), artifact))
528            .collect(),
529    }
530    .tool_scope()
531}
532
533/// Resolve the evals' references against the Artifacts `config` holds, and nothing else: a
534/// read of part of the workspace, whose checks across the workspace stay with a full read.
535pub(crate) fn resolve_evals(config: &mut RepoConfig) -> Result<(), ConfigError> {
536    let everything = everything(config);
537    let resolved = config
538        .evals
539        .iter()
540        .map(|eval| eval_references(config, &everything, eval))
541        .collect::<Result<Vec<_>, _>>()?;
542    for (eval, found) in config.evals.iter_mut().zip(resolved) {
543        eval.references = found.references;
544        eval.deps = found.deps;
545    }
546    Ok(())
547}
548
549/// What an eval's instruction and runtime arguments reference.
550struct EvalReferences {
551    references: BTreeMap<crate::config::ReferenceName, ArtifactName>,
552    /// The referenced Artifacts other than the eval's target.
553    deps: Vec<ArtifactName>,
554    /// The relations that record the deps.
555    relations: Vec<Relation>,
556}
557
558fn eval_references(
559    config: &RepoConfig,
560    everything: &artifactize_tools::scope::Scope,
561    eval: &Eval,
562) -> Result<EvalReferences, ConfigError> {
563    let mut relations = Vec::new();
564    let error = |field: &[&str], failure: ScopeError| {
565        let mut keys = vec!["evals", eval.declaration.id.as_ref()];
566        keys.extend_from_slice(field);
567        ConfigError::declaration(
568            config
569                .root
570                .join(config.artifacts[&eval.target].declaration_path()),
571            &keys,
572            format!("Eval {}: {failure}", eval.id),
573        )
574    };
575    let mut references = BTreeMap::new();
576    let mut deps = BTreeSet::new();
577    let instruction = eval
578        .declaration
579        .payload()
580        .map_or("", |payload| payload.instruction.as_str());
581    for reference in instruction::references(instruction) {
582        let name = reference.name;
583        let source = reference_target(config, &eval.target, &name)
584            .map_err(|failure| error(&["payload", "instruction"], failure))?;
585        if instruction[reference.end..].starts_with('/') {
586            reference_path(config, source, "/")
587                .map_err(|failure| error(&["payload", "instruction"], failure))?;
588        }
589        if references.insert(name.clone(), source.clone()).is_some() {
590            continue;
591        }
592        if source != &eval.target {
593            deps.insert(source.clone());
594            relations.push(Relation {
595                source: source.clone(),
596                target: eval.target.clone(),
597                kind: RelationKind::Instruction {
598                    eval_id: eval.id.clone(),
599                    name,
600                },
601            });
602        }
603    }
604    if let Profile::Runtime { args, .. } = eval.declaration.profile() {
605        for (index, argument) in args.iter().enumerate() {
606            let Argument::Reference {
607                name: ref_name,
608                path,
609                ..
610            } = argument
611            else {
612                continue;
613            };
614            let source = reference_target(config, &eval.target, ref_name)
615                .map_err(|failure| error(&["profile", "args"], failure))?;
616            reference_path(config, source, path)
617                .map_err(|failure| error(&["profile", "args"], failure))?;
618            nested_path(everything, source, path)
619                .map_err(|failure| error(&["profile", "args"], failure))?;
620            if source != &eval.target {
621                deps.insert(source.clone());
622                relations.push(Relation {
623                    source: source.clone(),
624                    target: eval.target.clone(),
625                    kind: RelationKind::Argument {
626                        eval_id: eval.id.clone(),
627                        index,
628                        name: ref_name.clone(),
629                        path: path.clone(),
630                    },
631                });
632            }
633        }
634    }
635    Ok(EvalReferences {
636        references,
637        deps: deps.into_iter().collect(),
638        relations,
639    })
640}
641
642/// Nothing is a part of itself, however many parts lie between.
643fn parts_acyclic(config: &RepoConfig) -> Result<(), ConfigError> {
644    for artifact in config.artifacts.values() {
645        if config.descendants(&artifact.name).contains(&&artifact.name) {
646            return Err(ConfigError::declaration(
647                config.root.join(artifact.declaration_path()),
648                &["parts"],
649                format!("{} is a part of itself through its parts.", artifact.name),
650            ));
651        }
652    }
653    Ok(())
654}
655
656#[cfg(test)]
657mod tests;