Implement more features on ctx.

* ctx.label
* ctx.actions.declare_file
* ctx.actions.args
* ctx.actions.write

Change-Id: I9e2aaa33f4f3cfa3ceb7d8cfc94c11156a6a6964
Reviewed-on: https://gn-review.googlesource.com/c/gn/+/27140
Reviewed-by: Takuto Ikuta <tikuta@google.com>
Commit-Queue: Matt Stark <msta@google.com>
diff --git a/integration_tests/starlark/filegroup.scl b/integration_tests/starlark/filegroup.scl
index 958e9d1..80a5c5a 100644
--- a/integration_tests/starlark/filegroup.scl
+++ b/integration_tests/starlark/filegroup.scl
@@ -2,13 +2,19 @@
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
 
-FilegroupInfo = provider(fields = ["files"])
+FilegroupInfo = provider(fields = ["files", "manifest"])
 
 def _filegroup_impl(ctx):
   files = depset(ctx.files.srcs)
+  manifest = ctx.actions.declare_file(ctx.label.name + ".manifest")
+  ctx.actions.write(
+    manifest,
+    "manifest for " + str(ctx.label) + "\n" +
+    "\n".join(sorted([f.path for f in ctx.files.srcs]))
+  )
   return [
     DefaultInfo(files = files),
-    FilegroupInfo(files = files),
+    FilegroupInfo(files = files, manifest = manifest),
   ]
 
 filegroup = rule(
diff --git a/integration_tests/starlark/goldens/obj/filegroup/filegroup.manifest b/integration_tests/starlark/goldens/obj/filegroup/filegroup.manifest
new file mode 100644
index 0000000..a717d42
--- /dev/null
+++ b/integration_tests/starlark/goldens/obj/filegroup/filegroup.manifest
@@ -0,0 +1,2 @@
+manifest for //:filegroup
+../first.txt
\ No newline at end of file
diff --git a/src/gn/starlark/Cargo.lock b/src/gn/starlark/Cargo.lock
index 40cb2aa..c4cba15 100644
--- a/src/gn/starlark/Cargo.lock
+++ b/src/gn/starlark/Cargo.lock
@@ -788,6 +788,12 @@
 ]
 
 [[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
 name = "ffi"
 version = "0.1.0"
 dependencies = [
@@ -1370,6 +1376,12 @@
 ]
 
 [[package]]
+name = "normalize-path"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f5438dd2b2ff4c6df6e1ce22d825ed2fa93ee2922235cc45186991717f0a892d"
+
+[[package]]
 name = "num-bigint"
 version = "0.5.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1731,12 +1743,15 @@
 version = "0.1.0"
 dependencies = [
  "allocative",
+ "anyhow",
+ "args",
  "attr",
  "depset",
  "providers",
  "starlark",
  "starlark_derive",
  "strum",
+ "tempfile",
  "testutils",
  "thiserror",
  "types",
@@ -2197,6 +2212,19 @@
 checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60"
 
 [[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
 name = "termcolor"
 version = "1.4.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2309,6 +2337,7 @@
 dependencies = [
  "allocative",
  "cxx",
+ "normalize-path",
  "starlark",
  "starlark_derive",
  "strum",
diff --git a/src/gn/starlark/Cargo.toml b/src/gn/starlark/Cargo.toml
index 7b1e6c1..fd70dfb 100644
--- a/src/gn/starlark/Cargo.toml
+++ b/src/gn/starlark/Cargo.toml
@@ -37,10 +37,12 @@
 cxx = { version = "1.0", features = ["c++20"] }
 either = "1.18"
 link-cplusplus = "1.0"
+normalize-path = "0.2"
 starlark = "0.14.2"
 starlark_derive = "0.14.2"
 thiserror = "2.0"
 strum = { version = "0.28", features = ["derive"] }
+tempfile = "3.27"
 
 [features]
 # When compiling with ninja, ninja has its own set of link flags.
diff --git a/src/gn/starlark/crates/rule/Cargo.toml b/src/gn/starlark/crates/rule/Cargo.toml
index bf568c7..c4cd8b0 100644
--- a/src/gn/starlark/crates/rule/Cargo.toml
+++ b/src/gn/starlark/crates/rule/Cargo.toml
@@ -8,6 +8,7 @@
 
 [dependencies]
 allocative = { workspace = true }
+args = { path = "../args" }
 attr = { path = "../attr" }
 depset = { path = "../depset" }
 providers = { path = "../providers" }
@@ -18,4 +19,6 @@
 types = { path = "../types" }
 
 [dev-dependencies]
+anyhow = { workspace = true }
+tempfile = { workspace = true }
 testutils = { path = "../testutils" }
diff --git a/src/gn/starlark/crates/rule/src/actions.rs b/src/gn/starlark/crates/rule/src/actions.rs
new file mode 100644
index 0000000..52317c0
--- /dev/null
+++ b/src/gn/starlark/crates/rule/src/actions.rs
@@ -0,0 +1,154 @@
+// Copyright 2026 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use std::{
+    fmt::{self, Display, Formatter},
+    marker::PhantomData,
+};
+
+use allocative::Allocative;
+use attr::{traits::EvalContextAttrExt, TargetAttrExt};
+use starlark::{
+    any::ProvidesStaticType,
+    environment::Methods,
+    values::{AllocValue, Freeze, FreezeResult, Freezer, Heap, StarlarkValue, Trace, Value},
+};
+use starlark_derive::{starlark_value, NoSerialize};
+use types::{CtxMethods, Session};
+
+/// The `ctx.actions` object which provides APIs to declare output files and
+/// register build actions.
+#[derive(Allocative, NoSerialize, Trace)]
+#[trace(bound = "C: EvalContextAttrExt + CtxMethods")]
+pub struct Actions<C: EvalContextAttrExt + CtxMethods> {
+    // We don't need to store ctx because it's stored already in the EvalContext.
+    _marker: PhantomData<fn() -> C>,
+}
+
+// Safety: This is a zero-sized type. All are equivalent.
+unsafe impl<'v, C: EvalContextAttrExt + CtxMethods> ProvidesStaticType<'v> for Actions<C> {
+    type StaticType = Actions<C>;
+}
+
+impl<C: EvalContextAttrExt + CtxMethods> Default for Actions<C> {
+    fn default() -> Self {
+        Self {
+            _marker: PhantomData,
+        }
+    }
+}
+
+impl<C: EvalContextAttrExt + CtxMethods> std::fmt::Debug for Actions<C> {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        write!(f, "ctx.actions")
+    }
+}
+
+impl<C: EvalContextAttrExt + CtxMethods> Display for Actions<C> {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        write!(f, "ctx.actions")
+    }
+}
+
+#[starlark_value(type = "ctx.actions")]
+impl<'v, C: EvalContextAttrExt + CtxMethods> StarlarkValue<'v> for Actions<C>
+where
+    <C::Session as Session>::TargetRef: TargetAttrExt,
+{
+    type Canonical = Self;
+
+    fn get_methods() -> Option<&'static Methods> {
+        Some(<C as CtxMethods>::actions_methods())
+    }
+}
+
+impl<'v, C: EvalContextAttrExt + CtxMethods> AllocValue<'v> for Actions<C>
+where
+    <C::Session as Session>::TargetRef: TargetAttrExt,
+{
+    fn alloc_value(self, heap: Heap<'v>) -> Value<'v> {
+        heap.alloc_complex(self)
+    }
+}
+
+impl<'v, C: EvalContextAttrExt + CtxMethods> Freeze<'v> for Actions<C>
+where
+    <C::Session as Session>::TargetRef: TargetAttrExt,
+{
+    type Frozen<'fv> = starlark::values::none::NoneType;
+
+    fn freeze<'fv>(self, _packer: &Freezer<'v, 'fv>) -> FreezeResult<Self::Frozen<'fv>> {
+        Err(crate::errors::Error::ObjectUnfreezable("ctx.actions").into())
+    }
+}
+
+#[macro_export]
+macro_rules! impl_actions_methods {
+    ($ctx_type:ty) => {
+        #[starlark_derive::starlark_module]
+        pub fn actions_methods(builder: &mut starlark::environment::MethodsBuilder) {
+            fn declare_file<'v>(
+                this: starlark::values::Value<'v>,
+                filename: &str,
+                eval: &mut starlark::eval::Evaluator<'v, '_, '_>,
+            ) -> starlark::Result<starlark::values::Value<'v>> {
+                let _ = this;
+                use types::{EvalContext as _, EvaluatorContextExt as _};
+                let file = eval
+                    .context::<$ctx_type>()
+                    .require_rule_impl()?
+                    .borrow_mut()
+                    .declare_file(filename)?;
+                Ok(eval.heap().alloc(file))
+            }
+
+            fn args<'v>(
+                this: starlark::values::Value<'v>,
+                eval: &mut starlark::eval::Evaluator<'v, '_, '_>,
+            ) -> starlark::Result<starlark::values::Value<'v>> {
+                let _ = this;
+                Ok(eval.heap().alloc($crate::args::Args::default()))
+            }
+
+            fn write<'v>(
+                this: starlark::values::Value<'v>,
+                output: &types::File,
+                content: &str,
+                #[starlark(default = false)] is_executable: bool,
+                eval: &mut starlark::eval::Evaluator<'v, '_, '_>,
+            ) -> starlark::Result<starlark::values::none::NoneType> {
+                let _ = this;
+                use types::{EvalContext as _, EvaluatorContextExt as _};
+                let ctx = eval.context::<$ctx_type>();
+                ctx.require_rule_impl()?
+                    .borrow_mut()
+                    .generates_file(output)?;
+
+                let path = ctx.path_resolver().resolve(output);
+                if let Some(parent) = path.parent() {
+                    std::fs::create_dir_all(parent).map_err(starlark::Error::new_other)?;
+                }
+
+                std::fs::write(&path, content).map_err(starlark::Error::new_other)?;
+
+                // On Windows, executability is determined by file extension,
+                // so no permission bits need to be modified.
+                #[cfg(unix)]
+                {
+                    use std::os::unix::fs::PermissionsExt;
+                    // On unix, std::fs::write preserves existing permissions.
+                    // So in case the user changed the is_executable flag, we need to set the
+                    // permissions explicitly every single time.
+                    std::fs::set_permissions(
+                        &path,
+                        std::fs::Permissions::from_mode(if is_executable { 0o755 } else { 0o644 }),
+                    )
+                    .map_err(starlark::Error::new_other)?;
+                }
+
+                Ok(starlark::values::none::NoneType)
+            }
+        }
+    };
+}
diff --git a/src/gn/starlark/crates/rule/src/ctx.rs b/src/gn/starlark/crates/rule/src/ctx.rs
index d0d0ec5..35ca4f6 100644
--- a/src/gn/starlark/crates/rule/src/ctx.rs
+++ b/src/gn/starlark/crates/rule/src/ctx.rs
@@ -20,6 +20,10 @@
 pub struct Ctx<'v, C: EvalContextAttrExt> {
     /// Contains ctx.attr/files/file
     attrs: CtxAttr<'v>,
+    /// Target label.
+    label: Value<'v>,
+    /// The actions object.
+    actions: Value<'v>,
     /// The rule currently being evaluated.
     /// If you have a parent and child rule, this will start as [child], then
     /// when you call ctx.super() it will be [child, parent].
@@ -28,9 +32,16 @@
 }
 
 impl<'v, C: EvalContextAttrExt> Ctx<'v, C> {
-    pub fn new(attrs: CtxAttr<'v>, rule: &'v Rule<'v, C>) -> Self {
+    pub fn new(
+        attrs: CtxAttr<'v>,
+        rule: &'v Rule<'v, C>,
+        label: Value<'v>,
+        actions: Value<'v>,
+    ) -> Self {
         Self {
             attrs,
+            label,
+            actions,
             rule_stack: RefCell::new(vec![rule]),
         }
     }
@@ -73,6 +84,8 @@
         self.attrs.attr.trace(tracer);
         self.attrs.files.trace(tracer);
         self.attrs.file.trace(tracer);
+        self.label.trace(tracer);
+        self.actions.trace(tracer);
     }
 }
 
@@ -93,15 +106,23 @@
 
     fn get_attr(&self, attribute: &str, _heap: Heap<'v>) -> Option<Value<'v>> {
         match attribute {
+            "actions" => Some(self.actions),
             "attr" => Some(self.attrs.attr),
             "files" => Some(self.attrs.files),
             "file" => Some(self.attrs.file),
+            "label" => Some(self.label),
             _ => None,
         }
     }
 
     fn dir_attr(&self) -> Vec<String> {
-        vec!["attr".to_owned(), "files".to_owned(), "file".to_owned()]
+        vec![
+            "actions".to_owned(),
+            "attr".to_owned(),
+            "files".to_owned(),
+            "file".to_owned(),
+            "label".to_owned(),
+        ]
     }
 }
 
@@ -141,11 +162,18 @@
             }
         }
 
+        $crate::impl_actions_methods!($ctx_type);
+
         impl $crate::CtxMethods for $ctx_type {
             fn methods() -> &'static starlark::environment::Methods {
                 starlark::methods_static!(RES = ctx_methods);
                 RES.methods()
             }
+
+            fn actions_methods() -> &'static starlark::environment::Methods {
+                starlark::methods_static!(RES = actions_methods);
+                RES.methods()
+            }
         }
     };
 }
diff --git a/src/gn/starlark/crates/rule/src/implementation.rs b/src/gn/starlark/crates/rule/src/implementation.rs
index 0cb03c9..e42d959 100644
--- a/src/gn/starlark/crates/rule/src/implementation.rs
+++ b/src/gn/starlark/crates/rule/src/implementation.rs
@@ -45,9 +45,16 @@
                     &eval.heap(),
                 )?,
                 rule,
+                eval.heap().alloc(target.label().to_owned()),
+                eval.heap().alloc(crate::actions::Actions::<C>::default()),
             ));
 
-            eval.eval_function(rule.implementation, &[ctx], &[])?
+            let res = eval.eval_function(rule.implementation, &[ctx], &[])?;
+            rule_context
+                .require_rule_impl()?
+                .borrow()
+                .rule_impl_complete()?;
+            res
         });
 
         let frozen = module.freeze()?;
diff --git a/src/gn/starlark/crates/rule/src/lib.rs b/src/gn/starlark/crates/rule/src/lib.rs
index cab2bb1..fe77928 100644
--- a/src/gn/starlark/crates/rule/src/lib.rs
+++ b/src/gn/starlark/crates/rule/src/lib.rs
@@ -1,9 +1,12 @@
+pub mod actions;
 pub mod ctx;
 pub mod errors;
 pub mod globals;
 pub mod implementation;
 pub mod rule;
 
+pub use actions::Actions;
+pub use args;
 pub use attr::AttrSchema;
 pub use ctx::Ctx;
 pub use errors::Error;
diff --git a/src/gn/starlark/crates/rule/tests/actions.rs b/src/gn/starlark/crates/rule/tests/actions.rs
new file mode 100644
index 0000000..45d9654
--- /dev/null
+++ b/src/gn/starlark/crates/rule/tests/actions.rs
@@ -0,0 +1,157 @@
+// Copyright 2026 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use std::path::Path;
+
+use anyhow::Context as _;
+use rule::run;
+use tempfile::tempdir;
+use testutils::Assert;
+use types::{Label, PackageRef, Session};
+
+#[test]
+fn test_actions_suite() {
+    let _temp_dir = tempdir().unwrap();
+    let mut assert = Assert::new_rule_assert();
+    assert.modify_globals(|builder| {
+        depset::depset_globals!(builder, testutils::FakeEvalContext);
+    });
+    assert.load_module("//rules:actions.scl");
+    assert.pass(
+        r#"
+load(
+    "//rules:actions.scl",
+    "already_generated",
+    "duplicate_declare_file",
+    "generated_file",
+    "label",
+    "undeclared_output",
+    "write",
+)
+
+write(name = "write")
+
+label(name = "label")
+
+generated_file(
+    name = "empty_filename",
+    files = {"": ""}
+)
+
+generated_file(
+    name = "simple_filename",
+    files = {"simple/filename": "content"}
+)
+
+generated_file(
+    name = "relative_file",
+    files = {"../dummy": ""},
+)
+
+generated_file(
+    name = "backslashes",
+    files = {"with\\backslashes": ""},
+)
+
+generated_file(
+    name = "never_generated",
+    files = {"dummy": ""},
+)
+
+duplicate_declare_file(name = "duplicate_declare_file")
+
+already_generated(name = "already_generated")
+
+undeclared_output(name = "undeclared_output", src = "foo/file.cc")
+"#,
+    );
+
+    type OutputFile<'a> = (&'a Path, &'a str);
+    type TestCase<'a> = (&'a str, Result<(&'a str, &'a [OutputFile<'a>]), &'a str>);
+
+    let test_cases: &[TestCase<'_>] = &[
+        (
+            "write",
+            Ok((
+                "obj/write/declared.txt",
+                &[
+                    (Path::new("obj/write/declared.txt"), "//:write"),
+                    (Path::new("obj/write/undeclared.txt"), "undeclared"),
+                ],
+            )),
+        ),
+        (
+            "label",
+            Ok(("", &[(Path::new("obj/label/label.txt"), "label")])),
+        ),
+        (
+            "simple_filename",
+            Ok((
+                "obj/simple_filename/simple/filename",
+                &[(Path::new("obj/simple_filename/simple/filename"), "content")],
+            )),
+        ),
+        (
+            "empty_filename",
+            Err("Invalid ctx.actions.declare_file filename: \"\""),
+        ),
+        (
+            "relative_file",
+            Err("Invalid ctx.actions.declare_file filename: \"../dummy\""),
+        ),
+        (
+            "backslashes",
+            Err("Invalid ctx.actions.declare_file filename: \"with\\\\backslashes\""),
+        ),
+        ("duplicate_declare_file", Err("was already declared")),
+        ("already_generated", Err("has already been generated")),
+        ("undeclared_output", Err("was not declared by this target")),
+        ("never_generated", Err("was never generated")),
+    ];
+
+    let context = assert.context();
+    for &(target_name, expected) in test_cases {
+        let label = Label::new(PackageRef::root().to_owned(), target_name.to_owned());
+        let target = context
+            .session
+            .get_target(label.as_ref(), context.session.default_toolchain.as_ref());
+        let session = assert.session();
+        let res = run(&target, move |t: &testutils::FakeTargetRef| {
+            testutils::FakeEvalContext::rule_impl(session.clone(), t.clone())
+        });
+
+        match expected {
+            Ok((defaultinfo_phony, files)) => {
+                let rooted = res.expect(target_name);
+                let providers = providers::Providers::try_from(rooted).expect(target_name);
+
+                if defaultinfo_phony.is_empty() {
+                    assert_eq!(providers.outputs_phony, None);
+                } else {
+                    assert_eq!(
+                        providers.outputs_phony.unwrap().as_path(),
+                        Path::new(defaultinfo_phony)
+                    );
+                }
+
+                for &(file_path, expected_content) in files {
+                    let resolved = context
+                        .path_resolver
+                        .resolve(&types::File::intern(&file_path.to_string_lossy()));
+                    let content = std::fs::read_to_string(&resolved)
+                        .context(resolved.display().to_string())
+                        .unwrap();
+                    assert_eq!(content, expected_content);
+                }
+            },
+            Err(expected_error) => {
+                let err = res.expect_err(target_name);
+                assert!(
+                    err.to_string().contains(expected_error),
+                    "Target '{target_name}' error '{err}' did not contain expected substring '{expected_error}'"
+                );
+            },
+        }
+    }
+}
diff --git a/src/gn/starlark/crates/types/Cargo.toml b/src/gn/starlark/crates/types/Cargo.toml
index 120aacb..e1280a3 100644
--- a/src/gn/starlark/crates/types/Cargo.toml
+++ b/src/gn/starlark/crates/types/Cargo.toml
@@ -12,6 +12,7 @@
 [dependencies]
 allocative = { workspace = true }
 cxx = { workspace = true }
+normalize-path = { workspace = true }
 starlark = { workspace = true }
 starlark_derive = { workspace = true }
 strum = { workspace = true }
diff --git a/src/gn/starlark/crates/types/src/ctx_state.rs b/src/gn/starlark/crates/types/src/ctx_state.rs
index 2cb10f5..9004a30 100644
--- a/src/gn/starlark/crates/types/src/ctx_state.rs
+++ b/src/gn/starlark/crates/types/src/ctx_state.rs
@@ -2,6 +2,8 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+use std::path::{Component, Path};
+
 use starlark::collections::SmallSet;
 
 use crate::{File, TargetRef};
@@ -14,7 +16,9 @@
     pub target: T,
     /// All files declared by ctx.actions.declare_file that were never
     /// generated.
-    pub unused_declared_outputs: SmallSet<File>,
+    unused_declared_outputs: SmallSet<File>,
+    /// All files declared by ctx.actions.declare_file.
+    declared_outputs: SmallSet<File>,
     /// A list of phonies declared during this execution step.
     pub phonies: Vec<(File, Vec<File>)>,
 }
@@ -25,6 +29,7 @@
         Self {
             target,
             unused_declared_outputs: SmallSet::new(),
+            declared_outputs: SmallSet::new(),
             phonies: Vec::new(),
         }
     }
@@ -49,7 +54,15 @@
     }
 
     /// Declares a new output file relative to the target's output directory.
-    pub fn declare_file(&mut self, name: &str) -> File {
+    pub fn declare_file(&mut self, name: &str) -> starlark::Result<File> {
+        if name.is_empty()
+            // Even on windows, for declare_file, we require using / for a path separator.
+            || name.contains('\\')
+            // Validate that the path is both relative and normalized.
+            || !Path::new(name).components().all(|c| matches!(c, Component::Normal(_)))
+        {
+            return Err(crate::errors::Error::UnsupportedFilename(name.to_owned()).into());
+        }
         let mut path = String::new();
         if !self.target.is_default_toolchain() {
             path.push_str(self.target.toolchain().name());
@@ -65,7 +78,31 @@
         path.push('/');
         path.push_str(name);
         let file = File::intern(&path);
+        if !self.declared_outputs.insert(file.clone()) {
+            return Err(crate::errors::Error::DuplicateDeclaredOutput(file).into());
+        }
         self.unused_declared_outputs.insert(file.clone());
-        file
+        Ok(file)
+    }
+
+    /// Declares that we are generating a given file.
+    pub fn generates_file(&mut self, f: &File) -> starlark::Result<()> {
+        if !self.declared_outputs.contains(f) {
+            return Err(crate::errors::Error::OutputNotDeclaredByTarget(f.clone()).into());
+        }
+        if !self.unused_declared_outputs.shift_remove(f) {
+            return Err(crate::errors::Error::OutputAlreadyGenerated(f.clone()).into());
+        }
+        Ok(())
+    }
+
+    /// Verifies that all declared outputs were generated.
+    pub fn rule_impl_complete(&self) -> starlark::Result<()> {
+        match self.unused_declared_outputs.first() {
+            Some(unused) => {
+                Err(crate::errors::Error::DeclaredOutputNeverGenerated(unused.clone()).into())
+            },
+            None => Ok(()),
+        }
     }
 }
diff --git a/src/gn/starlark/crates/types/src/errors.rs b/src/gn/starlark/crates/types/src/errors.rs
index c88aa1d..646d13c 100644
--- a/src/gn/starlark/crates/types/src/errors.rs
+++ b/src/gn/starlark/crates/types/src/errors.rs
@@ -24,6 +24,21 @@
     FileNotFound(Package, String),
     #[error("Invalid package, must start with \"//\": \"{0}\"")]
     NotAPackage(String),
+    /// The output file was not declared by this target.
+    #[error("Output file '{0}' was not declared by this target")]
+    OutputNotDeclaredByTarget(crate::File),
+    /// The output file has already been generated.
+    #[error("Output file '{0}' has already been generated")]
+    OutputAlreadyGenerated(crate::File),
+    /// Declared file name is unsupported.
+    #[error("Invalid ctx.actions.declare_file filename: {0:?}")]
+    UnsupportedFilename(String),
+    /// The output file was already declared.
+    #[error("Output file '{0}' was already declared")]
+    DuplicateDeclaredOutput(crate::File),
+    /// Declared output file was never generated.
+    #[error("Declared output file '{0}' was never generated")]
+    DeclaredOutputNeverGenerated(crate::File),
 }
 
 impl From<Error> for starlark::Error {
diff --git a/src/gn/starlark/crates/types/src/eval_context.rs b/src/gn/starlark/crates/types/src/eval_context.rs
index bbe0254..d89ba2e 100644
--- a/src/gn/starlark/crates/types/src/eval_context.rs
+++ b/src/gn/starlark/crates/types/src/eval_context.rs
@@ -80,4 +80,5 @@
 
 pub trait CtxMethods {
     fn methods() -> &'static starlark::environment::Methods;
+    fn actions_methods() -> &'static starlark::environment::Methods;
 }
diff --git a/src/gn/starlark/crates/types/src/path_resolver.rs b/src/gn/starlark/crates/types/src/path_resolver.rs
index 9cdf05d..66d274a 100644
--- a/src/gn/starlark/crates/types/src/path_resolver.rs
+++ b/src/gn/starlark/crates/types/src/path_resolver.rs
@@ -4,6 +4,8 @@
 
 use std::path::PathBuf;
 
+use normalize_path::NormalizePath as _;
+
 use crate::{File, PackageRef};
 
 /// Resolves paths to Files relative to the root_build_dir.
@@ -24,8 +26,8 @@
     pub fn new(source_root: PathBuf, build_root: PathBuf, source_root_rel: String) -> Self {
         assert!(source_root_rel.ends_with('/'));
         Self {
-            source_root,
-            build_root,
+            source_root: source_root.normalize(),
+            build_root: build_root.normalize(),
             source_root_rel,
         }
     }
@@ -42,10 +44,7 @@
     /// directory.
     pub fn resolve(&self, file: &File) -> PathBuf {
         let joined = self.build_root.join(file.as_path());
-        debug_assert!(joined
-            .canonicalize()
-            .unwrap()
-            .starts_with(self.build_root.clone()));
+        debug_assert!(joined.normalize().starts_with(&self.build_root));
         joined
     }
 
diff --git a/src/gn/starlark/src/testdata/rules/actions.scl b/src/gn/starlark/src/testdata/rules/actions.scl
new file mode 100644
index 0000000..162fc5b
--- /dev/null
+++ b/src/gn/starlark/src/testdata/rules/actions.scl
@@ -0,0 +1,84 @@
+# Copyright 2026 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+ActionInfo = provider(fields = ["out_file"])
+
+def _write_impl(ctx):
+  declared = ctx.actions.declare_file("declared.txt")
+  ctx.actions.write(output = declared, content = str(ctx.label))
+  undeclared = ctx.actions.declare_file("undeclared.txt")
+  ctx.actions.write(output = undeclared, content = "undeclared")
+  return [
+    DefaultInfo(files = depset([declared])),
+    ActionInfo(out_file = undeclared),
+  ]
+
+write = rule(
+  implementation = _write_impl,
+)
+
+def _label_impl(ctx):
+  out = ctx.actions.declare_file(ctx.label.name + ".txt")
+  ctx.actions.write(
+    output = out,
+    content = "label",
+  )
+  return [ActionInfo(out_file = out)]
+
+label = rule(
+  implementation = _label_impl,
+)
+
+def _generated_file_impl(ctx):
+  files = []
+  for k, v in ctx.attr.files.items():
+    f = ctx.actions.declare_file(k)
+    files.append(f)
+    if v:
+      ctx.actions.write(output = f, content = v)
+  return [DefaultInfo(files = depset(files))]
+
+generated_file = rule(
+  implementation = _generated_file_impl,
+  attrs = {"files": attr.string_dict()},
+)
+
+def _duplicate_declare_file_impl(ctx):
+  ctx.actions.declare_file("dup.txt")
+  ctx.actions.declare_file("dup.txt")
+  return []
+
+duplicate_declare_file = rule(
+  implementation = _duplicate_declare_file_impl,
+)
+
+def _already_generated_impl(ctx):
+  out = ctx.actions.declare_file("gen.txt")
+  ctx.actions.write(
+    output = out,
+    content = "first",
+  )
+  ctx.actions.write(
+    output = out,
+    content = "second",
+  )
+  return []
+
+already_generated = rule(
+  implementation = _already_generated_impl,
+)
+
+def _undeclared_output_impl(ctx):
+  ctx.actions.write(
+    output = ctx.files.src[0],
+    content = "fail",
+  )
+  return []
+
+undeclared_output = rule(
+  implementation = _undeclared_output_impl,
+  attrs = {
+    "src": attr.label(allow_files = True),
+  },
+)