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 = Scope {
479        artifacts: config
480            .artifacts
481            .iter()
482            .map(|(id, artifact)| (id.clone(), artifact))
483            .collect(),
484    }
485    .tool_scope();
486    let mut resolved = Vec::new();
487    for eval in &config.evals {
488        let error = |field: &[&str], failure: ScopeError| {
489            let mut keys = vec!["evals", eval.declaration.id.as_ref()];
490            keys.extend_from_slice(field);
491            ConfigError::declaration(
492                config
493                    .root
494                    .join(config.artifacts[&eval.target].declaration_path()),
495                &keys,
496                format!("Eval {}: {failure}", eval.id),
497            )
498        };
499        let mut references = BTreeMap::new();
500        let mut deps = BTreeSet::new();
501        let instruction = eval
502            .declaration
503            .payload()
504            .map_or("", |payload| payload.instruction.as_str());
505        for reference in instruction::references(instruction) {
506            let name = reference.name;
507            let source = reference_target(config, &eval.target, &name)
508                .map_err(|failure| error(&["payload", "instruction"], failure))?;
509            if instruction[reference.end..].starts_with('/') {
510                reference_path(config, source, "/")
511                    .map_err(|failure| error(&["payload", "instruction"], failure))?;
512            }
513            if references.insert(name.clone(), source.clone()).is_some() {
514                continue;
515            }
516            if source != &eval.target {
517                deps.insert(source.clone());
518                relations.push(Relation {
519                    source: source.clone(),
520                    target: eval.target.clone(),
521                    kind: RelationKind::Instruction {
522                        eval_id: eval.id.clone(),
523                        name,
524                    },
525                });
526            }
527        }
528        if let Profile::Runtime { args, .. } = eval.declaration.profile() {
529            for (index, argument) in args.iter().enumerate() {
530                let Argument::Reference {
531                    name: ref_name,
532                    path,
533                    ..
534                } = argument
535                else {
536                    continue;
537                };
538                let source = reference_target(config, &eval.target, ref_name)
539                    .map_err(|failure| error(&["profile", "args"], failure))?;
540                reference_path(config, source, path)
541                    .map_err(|failure| error(&["profile", "args"], failure))?;
542                nested_path(&everything, source, path)
543                    .map_err(|failure| error(&["profile", "args"], failure))?;
544                if source != &eval.target {
545                    deps.insert(source.clone());
546                    relations.push(Relation {
547                        source: source.clone(),
548                        target: eval.target.clone(),
549                        kind: RelationKind::Argument {
550                            eval_id: eval.id.clone(),
551                            index,
552                            name: ref_name.clone(),
553                            path: path.clone(),
554                        },
555                    });
556                }
557            }
558        }
559        resolved.push((references, deps.into_iter().collect()));
560    }
561    for (eval, (references, deps)) in config.evals.iter_mut().zip(resolved) {
562        eval.references = references;
563        eval.deps = deps;
564    }
565    for artifact in config.artifacts.values() {
566        for part in &artifact.parts {
567            let error = |message: String| {
568                ConfigError::declaration(
569                    config.root.join(artifact.declaration_path()),
570                    &["parts"],
571                    message,
572                )
573            };
574            match config.artifacts.get(part) {
575                None => return Err(error(format!("Unknown Artifact {part}."))),
576                Some(part) if part.kind == crate::config::ArtifactKind::Graph => {
577                    return Err(error(format!(
578                        "The graph Artifact {} is a part of nothing.",
579                        part.name
580                    )));
581                }
582                Some(_) => relations.push(Relation {
583                    source: part.clone(),
584                    target: artifact.name.clone(),
585                    kind: RelationKind::Part,
586                }),
587            }
588        }
589    }
590    parts_acyclic(config)?;
591    config.relations = relations;
592    human::validate_references(config)?;
593    crate::graph::Graph::new(config).map_err(|error| ConfigError::new(&config.root, error))?;
594    Ok(())
595}
596
597/// Nothing is a part of itself, however many parts lie between.
598fn parts_acyclic(config: &RepoConfig) -> Result<(), ConfigError> {
599    for artifact in config.artifacts.values() {
600        if config.descendants(&artifact.name).contains(&&artifact.name) {
601            return Err(ConfigError::declaration(
602                config.root.join(artifact.declaration_path()),
603                &["parts"],
604                format!("{} is a part of itself through its parts.", artifact.name),
605            ));
606        }
607    }
608    Ok(())
609}
610
611#[cfg(test)]
612mod tests;