Implement rule implementation execution.

Note that I've had to move frozen rule tests to their own tests
directory. This is because I've introduced a dependency chain:
#[cfg(test)] rule -> testutil -> rule

Tests in the tests directory compile against rule, while tests not in
that directory compile against #[cfg(test)] rule, which is incompatible
with testutil's rule.

Bug: 528225104
Change-Id: Iee3614fc522580a1114b67d7948252276a6a6964
Reviewed-on: https://gn-review.googlesource.com/c/gn/+/24300
Reviewed-by: Takuto Ikuta <tikuta@google.com>
Commit-Queue: Matt Stark <msta@google.com>
diff --git a/src/gn/starlark/Cargo.lock b/src/gn/starlark/Cargo.lock
index c8e078a..2a744ae 100644
--- a/src/gn/starlark/Cargo.lock
+++ b/src/gn/starlark/Cargo.lock
@@ -1714,6 +1714,8 @@
 dependencies = [
  "allocative",
  "attr",
+ "providers",
+ "rule",
  "starlark",
  "starlark_derive",
  "types",
diff --git a/src/gn/starlark/crates/attr/src/ctx.rs b/src/gn/starlark/crates/attr/src/ctx.rs
index f82650a..833d052 100644
--- a/src/gn/starlark/crates/attr/src/ctx.rs
+++ b/src/gn/starlark/crates/attr/src/ctx.rs
@@ -22,6 +22,7 @@
 /// Contains ctx.attr, ctx.files, and ctx.file.
 ///
 /// See https://bazel.build/rules/lib/builtins/ctx for more info on what they are.
+#[derive(Debug, Clone, Copy, Allocative)]
 pub struct CtxAttr<'v> {
     pub attr: Value<'v>,
     pub files: Value<'v>,
diff --git a/src/gn/starlark/crates/attr/src/lib.rs b/src/gn/starlark/crates/attr/src/lib.rs
index 1729bd7..65ce51b 100644
--- a/src/gn/starlark/crates/attr/src/lib.rs
+++ b/src/gn/starlark/crates/attr/src/lib.rs
@@ -19,4 +19,4 @@
 pub(crate) use errors::Error;
 pub use globals::{AttrModule, AttrSpecArgs};
 pub use schema::{AllowFilesSchema, AttrKind, AttrSchema};
-pub use traits::{EvalContext, EvalContextAttrExt, Session, TargetRef};
+pub use traits::{EvalContext, EvalContextAttrExt, Session, TargetAttrExt, TargetRef};
diff --git a/src/gn/starlark/crates/attr/src/traits.rs b/src/gn/starlark/crates/attr/src/traits.rs
index 7f67ad1..894a5ec 100644
--- a/src/gn/starlark/crates/attr/src/traits.rs
+++ b/src/gn/starlark/crates/attr/src/traits.rs
@@ -5,6 +5,13 @@
 /// Re-export the traits from types so caller crates can access them seamlessly.
 pub use types::{EvalContext, OutputType, Session, TargetRef};
 
+/// Represents a target with attributes that can be executed by custom Starlark
+/// rules.
+pub trait TargetAttrExt: TargetRef {
+    /// Returns the resolved custom attributes of the target.
+    fn attrs(&self) -> &[crate::Attr];
+}
+
 /// Extension trait for EvalContext to support target creation.
 pub trait EvalContextAttrExt: types::EvalContext {
     fn create_target(
diff --git a/src/gn/starlark/crates/rule/src/ctx.rs b/src/gn/starlark/crates/rule/src/ctx.rs
new file mode 100644
index 0000000..26ed24b
--- /dev/null
+++ b/src/gn/starlark/crates/rule/src/ctx.rs
@@ -0,0 +1,152 @@
+// 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::cell::RefCell;
+
+use allocative::Allocative;
+use attr::{traits::EvalContextAttrExt, CtxAttr, TargetAttrExt};
+use starlark::{
+    any::ProvidesStaticType,
+    environment::Methods,
+    values::{AllocValue, Freeze, FreezeResult, Freezer, Heap, StarlarkValue, Value},
+};
+use starlark_derive::{starlark_value, NoSerialize};
+use types::{CtxMethods, Session};
+
+use crate::FrozenRule;
+
+#[derive(Allocative, NoSerialize)]
+pub struct Ctx<'v, C: EvalContextAttrExt> {
+    /// Contains ctx.attr/files/file
+    attrs: CtxAttr<'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].
+    #[allocative(skip)]
+    rule_stack: RefCell<Vec<&'v FrozenRule<C>>>,
+}
+
+impl<'v, C: EvalContextAttrExt> Ctx<'v, C> {
+    pub fn new(attrs: CtxAttr<'v>, rule: &'v FrozenRule<C>) -> Self {
+        Self {
+            attrs,
+            rule_stack: RefCell::new(vec![rule]),
+        }
+    }
+
+    /// Runs ctx.super()
+    pub fn run_super(
+        &self,
+        this: Value<'v>,
+        eval: &mut starlark::eval::Evaluator<'v, '_, '_>,
+    ) -> starlark::Result<Value<'v>> {
+        let parent = {
+            let rule_stack = self.rule_stack.borrow();
+            let current = rule_stack.last().expect("rule_stack is never empty");
+            current.parent.ok_or(crate::errors::Error::NoParentRule)?
+        };
+        self.rule_stack.borrow_mut().push(parent);
+        let res = eval.eval_function(parent.implementation.to_value(), &[this], &[]);
+        self.rule_stack.borrow_mut().pop();
+        res
+    }
+}
+
+impl<'v, C: EvalContextAttrExt> std::fmt::Debug for Ctx<'v, C> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "ctx")
+    }
+}
+
+impl<'v, C: EvalContextAttrExt> std::fmt::Display for Ctx<'v, C> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "ctx")
+    }
+}
+
+unsafe impl<'v, C: EvalContextAttrExt> starlark::values::Trace<'v> for Ctx<'v, C> {
+    fn trace(&mut self, tracer: &starlark::values::Tracer<'v>) {
+        self.attrs.attr.trace(tracer);
+        self.attrs.files.trace(tracer);
+        self.attrs.file.trace(tracer);
+    }
+}
+
+unsafe impl<'v, EvalCtx: EvalContextAttrExt> ProvidesStaticType<'v> for Ctx<'v, EvalCtx> {
+    type StaticType = Ctx<'static, EvalCtx>;
+}
+
+#[starlark_value(type = "ctx")]
+impl<'v, C: EvalContextAttrExt + CtxMethods> StarlarkValue<'v> for Ctx<'v, C>
+where
+    <C::Session as Session>::TargetRef: TargetAttrExt,
+{
+    type Canonical = Self;
+
+    fn get_methods() -> Option<&'static Methods> {
+        Some(<C as CtxMethods>::methods())
+    }
+
+    fn get_attr(&self, attribute: &str, _heap: Heap<'v>) -> Option<Value<'v>> {
+        match attribute {
+            "attr" => Some(self.attrs.attr),
+            "files" => Some(self.attrs.files),
+            "file" => Some(self.attrs.file),
+            _ => None,
+        }
+    }
+
+    fn dir_attr(&self) -> Vec<String> {
+        vec!["attr".to_owned(), "files".to_owned(), "file".to_owned()]
+    }
+}
+
+impl<'v, C: EvalContextAttrExt + CtxMethods> AllocValue<'v> for Ctx<'v, 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 for Ctx<'v, C>
+where
+    <C::Session as Session>::TargetRef: TargetAttrExt,
+{
+    type Frozen = starlark::values::none::NoneType;
+
+    fn freeze(self, _packer: &Freezer) -> FreezeResult<Self::Frozen> {
+        Err(crate::errors::Error::ObjectUnfreezable("ctx").into())
+    }
+}
+
+#[macro_export]
+macro_rules! impl_ctx_methods {
+    ($ctx_type:ty) => {
+        #[starlark_derive::starlark_module]
+        pub fn ctx_methods(builder: &mut starlark::environment::MethodsBuilder) {
+            #[starlark(name = "super")]
+            fn super_<'v>(
+                this: starlark::values::Value<'v>,
+                eval: &mut starlark::eval::Evaluator<'v, '_, '_>,
+            ) -> starlark::Result<starlark::values::Value<'v>> {
+                use starlark::values::ValueLike as _;
+                this.downcast_ref::<$crate::Ctx<'v, $ctx_type>>()
+                    .unwrap()
+                    .run_super(this, eval)
+            }
+        }
+
+        impl $crate::CtxMethods for $ctx_type {
+            fn methods() -> &'static starlark::environment::Methods {
+                static RES: starlark::environment::MethodsStatic =
+                    starlark::environment::MethodsStatic::new("Ctx", |builder| {
+                        ctx_methods(builder);
+                    });
+                RES.methods()
+            }
+        }
+    };
+}
diff --git a/src/gn/starlark/crates/rule/src/errors.rs b/src/gn/starlark/crates/rule/src/errors.rs
index 7fdd277..335c2f0 100644
--- a/src/gn/starlark/crates/rule/src/errors.rs
+++ b/src/gn/starlark/crates/rule/src/errors.rs
@@ -4,13 +4,20 @@
 
 /// Errors returned by the GN Starlark rule system.
 #[derive(thiserror::Error, Debug)]
-pub(crate) enum Error {
+pub enum Error {
     #[error("Rule must be assigned to a global variable to be used")]
     RuleMustBeNamed,
     #[error("Parent must be a rule")]
     ParentMustBeARule,
     #[error("Attribute '{0}' is reserved")]
     ReservedAttribute(String),
+    #[error("Rule does not have a parent rule")]
+    NoParentRule,
+    #[error(
+        "The '{0}' object is ephemeral and cannot be stored in providers or other long-lived \
+         structures."
+    )]
+    ObjectUnfreezable(&'static str),
 }
 
 impl From<Error> for starlark::Error {
diff --git a/src/gn/starlark/crates/rule/src/frozen_rule.rs b/src/gn/starlark/crates/rule/src/frozen_rule.rs
index 2b5aac0..cb9442f 100644
--- a/src/gn/starlark/crates/rule/src/frozen_rule.rs
+++ b/src/gn/starlark/crates/rule/src/frozen_rule.rs
@@ -145,188 +145,3 @@
     }
 }
 
-#[cfg(test)]
-mod tests {
-    use std::{
-        collections::{HashMap, HashSet},
-        sync::Mutex,
-    };
-
-    use attr::{Attr, LabelOrFile};
-    use starlark::environment::FrozenModule;
-    use testutils::FakeTarget;
-    use types::{Label, OutputType, PackageRef, Session};
-
-    use crate::globals::tests::new_assert;
-
-    #[test]
-    fn test_pure_rule_inheritance() {
-        let mut assert = new_assert();
-        let native = assert.load_module("//rules:native.scl");
-        let pure = assert.load_module("//rules:pure.scl");
-
-        let rule = |module: &FrozenModule, name: &str| {
-            module.get(name).unwrap().value().unpack_frozen().unwrap()
-        };
-
-        assert.pass(
-            r#"
-load("//rules:pure.scl", "child_rule", "parent_rule")
-load("//rules:native.scl", "custom_shared_library", "static_library")
-
-custom_shared_library(
-    name = "shared_library",
-    mandatory = "mandatory_val",
-    optional = "optional_val",
-    unknown = "unknown",
-)
-
-static_library(
-   name = "static_library",
-   optional = "optional_val",
-   unknown = "unknown"
-)
-
-parent_rule(
-    name = "parent_defaulted",
-    parent_only = "p",
-)
-
-child_rule(
-    name = "child_defaulted",
-    parent_only = "p",
-    child_only = "c",
-)
-
-child_rule(
-    name = "child_override",
-    parent_only = "parent_val",
-    child_only = "child_val",
-    override = "//:custom_val",
-)
-"#,
-        );
-
-        let heap = starlark::values::FrozenHeap::new();
-        let mut unknown_attrs = HashMap::new();
-        unknown_attrs.insert(
-            "unknown".to_owned(),
-            starlark::values::Value::new_frozen(heap.alloc("unknown")),
-        );
-
-        let context = assert.context();
-        let load = |name: &str| {
-            let label = Label::new(PackageRef::root().to_owned(), name.to_owned());
-            context
-                .session
-                .get_target(label.as_ref(), context.session.default_toolchain.as_ref())
-        };
-
-        assert_eq!(
-            *load("shared_library"),
-            FakeTarget {
-                label: Label::new(PackageRef::root().to_owned(), "shared_library".to_owned()),
-                toolchain: context.session.default_toolchain.clone(),
-                outputs: vec![],
-                attrs: vec![
-                    Attr::String("optional_val".to_owned()),
-                    Attr::String("mandatory_val".to_owned()),
-                ],
-                output_type: Some(OutputType::SharedLibrary),
-                rule: rule(&native, "custom_shared_library"),
-                cxx_attrs: unknown_attrs.clone(),
-                dependencies: Mutex::new(HashSet::new()),
-            }
-        );
-
-        assert_eq!(
-            *load("static_library"),
-            FakeTarget {
-                label: Label::new(PackageRef::root().to_owned(), "static_library".to_owned()),
-                toolchain: context.session.default_toolchain.clone(),
-                outputs: vec![],
-                attrs: vec![Attr::String("optional_val".to_owned())],
-                output_type: Some(OutputType::StaticLibrary),
-                rule: rule(&native, "static_library"),
-                cxx_attrs: unknown_attrs.clone(),
-                dependencies: Mutex::new(HashSet::new()),
-            }
-        );
-
-        let toolchain = Label::new(
-            PackageRef::root().to_owned(),
-            "default_toolchain".to_owned(),
-        );
-
-        assert_eq!(
-            *load("parent_defaulted"),
-            FakeTarget {
-                label: Label::new(PackageRef::root().to_owned(), "parent_defaulted".to_owned()),
-                toolchain: toolchain.clone(),
-                outputs: vec![],
-                attrs: vec![
-                    Attr::String("p".to_owned()),
-                    Attr::Label(Some(LabelOrFile::Label(Label::new(
-                        PackageRef::root().to_owned(),
-                        "parent".to_owned()
-                    )))),
-                ],
-                output_type: None,
-                rule: rule(&pure, "parent_rule"),
-                cxx_attrs: HashMap::new(),
-                dependencies: Mutex::new(HashSet::from([(
-                    Label::new(PackageRef::root().to_owned(), "parent".to_owned()),
-                    toolchain.clone(),
-                )])),
-            }
-        );
-
-        assert_eq!(
-            *load("child_defaulted"),
-            FakeTarget {
-                label: Label::new(PackageRef::root().to_owned(), "child_defaulted".to_owned()),
-                toolchain: toolchain.clone(),
-                outputs: vec![],
-                attrs: vec![
-                    Attr::String("p".to_owned()),
-                    Attr::Label(Some(LabelOrFile::Label(Label::new(
-                        PackageRef::root().to_owned(),
-                        "child".to_owned()
-                    )))),
-                    Attr::String("c".to_owned()),
-                ],
-                output_type: None,
-                rule: rule(&pure, "child_rule"),
-                cxx_attrs: HashMap::new(),
-                dependencies: Mutex::new(HashSet::from([(
-                    Label::new(PackageRef::root().to_owned(), "child".to_owned()),
-                    toolchain.clone(),
-                )])),
-            }
-        );
-
-        assert_eq!(
-            *load("child_override"),
-            FakeTarget {
-                label: Label::new(PackageRef::root().to_owned(), "child_override".to_owned()),
-                toolchain: toolchain.clone(),
-                outputs: vec![],
-                attrs: vec![
-                    Attr::String("parent_val".to_owned()),
-                    Attr::Label(Some(LabelOrFile::Label(Label::new(
-                        PackageRef::root().to_owned(),
-                        "custom_val".to_owned()
-                    )))),
-                    Attr::String("child_val".to_owned()),
-                ],
-                output_type: None,
-                rule: rule(&pure, "child_rule"),
-                cxx_attrs: HashMap::new(),
-                dependencies: Mutex::new(HashSet::from([(
-                    Label::new(PackageRef::root().to_owned(), "custom_val".to_owned()),
-                    toolchain.clone(),
-                )])),
-            }
-        );
-    }
-}
diff --git a/src/gn/starlark/crates/rule/src/globals.rs b/src/gn/starlark/crates/rule/src/globals.rs
index 463e032..d676cfd 100644
--- a/src/gn/starlark/crates/rule/src/globals.rs
+++ b/src/gn/starlark/crates/rule/src/globals.rs
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-use attr::traits::EvalContextAttrExt;
+use attr::{traits::EvalContextAttrExt, TargetAttrExt};
 use starlark::{
     environment::{FrozenModule, Module},
     values::FrozenHeapName,
@@ -20,7 +20,10 @@
 ///   sources = [...],
 ///   ...
 /// )
-pub fn register_builtin_rules<C: EvalContextAttrExt>() -> FrozenModule {
+pub fn register_builtin_rules<C: EvalContextAttrExt>() -> FrozenModule
+where
+    <C::Session as types::Session>::TargetRef: TargetAttrExt,
+{
     Module::with_temp_heap(|module| {
         for output_type in OutputType::iter() {
             let name = output_type.to_string();
@@ -78,31 +81,9 @@
 #[cfg(test)]
 pub(crate) mod tests {
     use starlark::values::list::UnpackList;
-    use testutils::FakeEvalContext;
-
-    pub(crate) fn make_attr_schema<'v>(
-        kind: attr::AttrKind,
-        args: attr::AttrSpecArgs<'v>,
-        eval: &mut starlark::eval::Evaluator<'v, '_, '_>,
-    ) -> starlark::Result<starlark::values::Value<'v>> {
-        attr::AttrSchema::create(
-            kind,
-            args,
-            types::PackageRef::root(),
-            &types::PathResolver::new_for_testing(),
-            &eval.heap(),
-        )
-    }
 
     pub(crate) fn new_assert() -> testutils::Assert {
-        let mut assert = testutils::Assert::default();
-        assert.modify_globals(|builder| {
-            crate::register_rule_globals!(builder, FakeEvalContext);
-            builder.set("attr", attr::AttrModule { make_attr_schema });
-        });
-        let builtins = crate::register_builtin_rules::<FakeEvalContext>();
-        assert.module_add(builtins);
-        assert
+        testutils::Assert::new_rule_assert()
     }
 
     #[test]
diff --git a/src/gn/starlark/crates/rule/src/implementation.rs b/src/gn/starlark/crates/rule/src/implementation.rs
new file mode 100644
index 0000000..e45b35c
--- /dev/null
+++ b/src/gn/starlark/crates/rule/src/implementation.rs
@@ -0,0 +1,50 @@
+// 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 attr::{traits::EvalContextAttrExt, TargetAttrExt, TargetRef};
+use starlark::{environment::Module, eval::Evaluator, values::OwnedFrozenValue};
+use types::{EvaluatorContextExt, Session};
+
+use crate::{Ctx, FrozenRule};
+
+/// Runs the rule implementation function for a given target.
+/// This matches the execution phase where rule implementation is run
+/// synchronously.
+pub fn run<C: EvalContextAttrExt + crate::CtxMethods>(
+    target: &<C::Session as Session>::TargetRef,
+    create_context: impl FnOnce(&<C::Session as Session>::TargetRef) -> C,
+) -> starlark::Result<OwnedFrozenValue>
+where
+    <C::Session as Session>::TargetRef: TargetAttrExt<Rule = FrozenRule<C>>,
+{
+    // Safety: rule is always a rule for custom rule-built targets.
+    let rule = target.rule().unwrap();
+
+    Module::with_temp_heap(|module| {
+        let rule_context = create_context(target);
+
+        // When the module is frozen, only things transitively required by extra_value
+        // are kept.
+        module.set_extra_value({
+            let mut eval = Evaluator::new(&module);
+            let ctx = eval.heap().alloc(Ctx::<C>::new(
+                rule.schema.create_ctx_fields(
+                    target.attrs(),
+                    rule_context.session(),
+                    &rule_context.current_toolchain(),
+                    rule.builtin,
+                    target.builtin_attrs(&eval.heap()),
+                    &eval.heap(),
+                )?,
+                rule,
+            ));
+
+            eval.set_context(&rule_context);
+            eval.eval_function(rule.implementation.to_value(), &[ctx], &[])?
+        });
+
+        let frozen = module.freeze()?;
+        Ok(frozen.owned_extra_value().unwrap())
+    })
+}
diff --git a/src/gn/starlark/crates/rule/src/lib.rs b/src/gn/starlark/crates/rule/src/lib.rs
index 52fb090..60080fe 100644
--- a/src/gn/starlark/crates/rule/src/lib.rs
+++ b/src/gn/starlark/crates/rule/src/lib.rs
@@ -1,10 +1,15 @@
+pub mod ctx;
 pub mod errors;
 pub mod frozen_rule;
 pub mod globals;
+pub mod implementation;
 pub mod rule;
 
 pub use attr::AttrSchema;
-pub(crate) use errors::Error;
+pub use ctx::Ctx;
+pub use errors::Error;
 pub use frozen_rule::FrozenRule;
 pub use globals::register_builtin_rules;
+pub use implementation::run;
 pub use rule::{OutputType, Rule};
+pub use types::CtxMethods;
diff --git a/src/gn/starlark/crates/rule/tests/ctx_super.rs b/src/gn/starlark/crates/rule/tests/ctx_super.rs
new file mode 100644
index 0000000..f2f8f12
--- /dev/null
+++ b/src/gn/starlark/crates/rule/tests/ctx_super.rs
@@ -0,0 +1,48 @@
+// 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 rule::run;
+use starlark::values::list::ListRef;
+use testutils::Assert;
+use types::{Label, PackageRef, Session};
+
+#[test]
+fn test_ctx_super() {
+    let mut assert = Assert::new_rule_assert();
+    assert.load_module("//rules:pure.scl");
+    assert.pass(
+        r#"
+load("//rules:pure.scl", "child_rule", "parent_rule")
+
+parent_rule(
+    name = "child",
+    parent_only = "child_parent_val",
+)
+
+child_rule(
+    name = "target",
+    parent_only = "parent_val",
+    child_only = "child_val",
+)
+"#,
+    );
+
+    let context = assert.context();
+    let label = Label::new(PackageRef::root().to_owned(), "target".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())
+    })
+    .unwrap();
+
+    let list = ListRef::from_value(res.value()).unwrap();
+    let items: Vec<starlark::values::Value<'_>> = list.iter().collect();
+    assert_eq!(items.len(), 2);
+    assert_eq!(items[0].to_repr(), r#"ParentInfo(parent = "parent_val")"#);
+    assert_eq!(items[1].to_repr(), r#"ChildInfo(child = "child_val")"#);
+}
diff --git a/src/gn/starlark/crates/rule/tests/frozen_rule.rs b/src/gn/starlark/crates/rule/tests/frozen_rule.rs
new file mode 100644
index 0000000..45b814f
--- /dev/null
+++ b/src/gn/starlark/crates/rule/tests/frozen_rule.rs
@@ -0,0 +1,191 @@
+// 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::{
+    collections::{HashMap, HashSet},
+    sync::Mutex,
+};
+
+use attr::{Attr, LabelOrFile};
+use rule::FrozenRule;
+use starlark::{environment::FrozenModule, values::FrozenValueTyped};
+use testutils::{Assert, FakeEvalContext, FakeTarget};
+use types::{Label, OutputType, PackageRef, Session};
+
+fn new_assert() -> Assert {
+    Assert::new_rule_assert()
+}
+
+#[test]
+fn test_pure_rule_inheritance() {
+    let mut assert = new_assert();
+    let native = assert.load_module("//rules:native.scl");
+    let pure = assert.load_module("//rules:pure.scl");
+
+    let rule = |module: &FrozenModule, name: &str| {
+        let val = module.get(name).unwrap().value().unpack_frozen().unwrap();
+        let typed = FrozenValueTyped::<FrozenRule<FakeEvalContext>>::new(val).unwrap();
+        Some(typed.as_ref())
+    };
+
+    assert.pass(
+        r#"
+load("//rules:pure.scl", "child_rule", "parent_rule")
+load("//rules:native.scl", "custom_shared_library", "static_library")
+
+custom_shared_library(
+    name = "shared_library",
+    mandatory = "mandatory_val",
+    optional = "optional_val",
+    unknown = "unknown",
+)
+
+static_library(
+   name = "static_library",
+   optional = "optional_val",
+   unknown = "unknown"
+)
+
+parent_rule(
+    name = "parent_defaulted",
+    parent_only = "p",
+)
+
+child_rule(
+    name = "child_defaulted",
+    parent_only = "p",
+    child_only = "c",
+)
+
+child_rule(
+    name = "child_override",
+    parent_only = "parent_val",
+    child_only = "child_val",
+    override = "//:custom_val",
+)
+"#,
+    );
+
+    let heap = starlark::values::FrozenHeap::new();
+    let mut unknown_attrs = HashMap::new();
+    unknown_attrs.insert(
+        "unknown".to_owned(),
+        starlark::values::Value::new_frozen(heap.alloc("unknown")),
+    );
+
+    let context = assert.context();
+    let load = |name: &str| {
+        let label = Label::new(PackageRef::root().to_owned(), name.to_owned());
+        context
+            .session
+            .get_target(label.as_ref(), context.session.default_toolchain.as_ref())
+    };
+
+    assert_eq!(
+        *load("shared_library"),
+        FakeTarget {
+            label: Label::new(PackageRef::root().to_owned(), "shared_library".to_owned()),
+            toolchain: context.session.default_toolchain.clone(),
+            outputs: vec![],
+            attrs: vec![
+                Attr::String("optional_val".to_owned()),
+                Attr::String("mandatory_val".to_owned()),
+            ],
+            output_type: Some(OutputType::SharedLibrary),
+            rule: rule(&native, "custom_shared_library"),
+            cxx_attrs: unknown_attrs.clone(),
+            dependencies: Mutex::new(HashSet::new()),
+        }
+    );
+
+    assert_eq!(
+        *load("static_library"),
+        FakeTarget {
+            label: Label::new(PackageRef::root().to_owned(), "static_library".to_owned()),
+            toolchain: context.session.default_toolchain.clone(),
+            outputs: vec![],
+            attrs: vec![Attr::String("optional_val".to_owned())],
+            output_type: Some(OutputType::StaticLibrary),
+            rule: rule(&native, "static_library"),
+            cxx_attrs: unknown_attrs.clone(),
+            dependencies: Mutex::new(HashSet::new()),
+        }
+    );
+
+    let toolchain = Label::new(
+        PackageRef::root().to_owned(),
+        "default_toolchain".to_owned(),
+    );
+
+    assert_eq!(
+        *load("parent_defaulted"),
+        FakeTarget {
+            label: Label::new(PackageRef::root().to_owned(), "parent_defaulted".to_owned()),
+            toolchain: toolchain.clone(),
+            outputs: vec![],
+            attrs: vec![
+                Attr::String("p".to_owned()),
+                Attr::Label(Some(LabelOrFile::Label(Label::new(
+                    PackageRef::root().to_owned(),
+                    "parent".to_owned()
+                )))),
+            ],
+            output_type: None,
+            rule: rule(&pure, "parent_rule"),
+            cxx_attrs: HashMap::new(),
+            dependencies: Mutex::new(HashSet::from([(
+                Label::new(PackageRef::root().to_owned(), "parent".to_owned()),
+                toolchain.clone(),
+            )])),
+        }
+    );
+
+    assert_eq!(
+        *load("child_defaulted"),
+        FakeTarget {
+            label: Label::new(PackageRef::root().to_owned(), "child_defaulted".to_owned()),
+            toolchain: toolchain.clone(),
+            outputs: vec![],
+            attrs: vec![
+                Attr::String("p".to_owned()),
+                Attr::Label(Some(LabelOrFile::Label(Label::new(
+                    PackageRef::root().to_owned(),
+                    "child".to_owned()
+                )))),
+                Attr::String("c".to_owned()),
+            ],
+            output_type: None,
+            rule: rule(&pure, "child_rule"),
+            cxx_attrs: HashMap::new(),
+            dependencies: Mutex::new(HashSet::from([(
+                Label::new(PackageRef::root().to_owned(), "child".to_owned()),
+                toolchain.clone(),
+            )])),
+        }
+    );
+
+    assert_eq!(
+        *load("child_override"),
+        FakeTarget {
+            label: Label::new(PackageRef::root().to_owned(), "child_override".to_owned()),
+            toolchain: toolchain.clone(),
+            outputs: vec![],
+            attrs: vec![
+                Attr::String("parent_val".to_owned()),
+                Attr::Label(Some(LabelOrFile::Label(Label::new(
+                    PackageRef::root().to_owned(),
+                    "custom_val".to_owned()
+                )))),
+                Attr::String("child_val".to_owned()),
+            ],
+            output_type: None,
+            rule: rule(&pure, "child_rule"),
+            cxx_attrs: HashMap::new(),
+            dependencies: Mutex::new(HashSet::from([(
+                Label::new(PackageRef::root().to_owned(), "custom_val".to_owned()),
+                toolchain.clone(),
+            )])),
+        }
+    );
+}
diff --git a/src/gn/starlark/crates/testutils/Cargo.toml b/src/gn/starlark/crates/testutils/Cargo.toml
index d307753..410da82 100644
--- a/src/gn/starlark/crates/testutils/Cargo.toml
+++ b/src/gn/starlark/crates/testutils/Cargo.toml
@@ -10,6 +10,8 @@
 [dependencies]
 allocative = { workspace = true }
 attr = { path = "../attr" }
+providers = { path = "../providers" }
+rule = { path = "../rule" }
 starlark = { workspace = true }
 starlark_derive = { workspace = true }
 types = { path = "../types" }
diff --git a/src/gn/starlark/crates/testutils/src/assert.rs b/src/gn/starlark/crates/testutils/src/assert.rs
index 565240d..d8e7681 100644
--- a/src/gn/starlark/crates/testutils/src/assert.rs
+++ b/src/gn/starlark/crates/testutils/src/assert.rs
@@ -8,7 +8,7 @@
     environment::{FrozenModule, GlobalsBuilder},
     values::UnpackValue,
 };
-use types::{EvaluatorContextExt, Label, PathResolver, UnpackedOwnedValue};
+use types::{EvalContext, EvaluatorContextExt, Label, PathResolver, UnpackedOwnedValue};
 
 use crate::{register_globals, FakeEvalContext, FakeSession};
 
@@ -61,6 +61,35 @@
         s
     }
 
+    /// Creates a new `Assert` helper instance with rule evaluation globals and
+    /// builtins registered.
+    pub fn new_rule_assert() -> Self {
+        let mut assert = Self::default();
+        assert.modify_globals(|builder| {
+            rule::register_rule_globals!(builder, FakeEvalContext);
+            providers::register_providers(builder);
+
+            fn make_attr_schema<'v>(
+                kind: attr::AttrKind,
+                args: attr::AttrSpecArgs<'v>,
+                eval: &mut starlark::eval::Evaluator<'v, '_, '_>,
+            ) -> starlark::Result<starlark::values::Value<'v>> {
+                let context: &FakeEvalContext = eval.context();
+                attr::AttrSchema::create(
+                    kind,
+                    args,
+                    context.current_package(),
+                    &context.path_resolver,
+                    &eval.heap(),
+                )
+            }
+            builder.set("attr", attr::AttrModule { make_attr_schema });
+        });
+        let builtins = rule::register_builtin_rules::<FakeEvalContext>();
+        assert.module_add(builtins);
+        assert
+    }
+
     /// Adds a modifier to globals.
     /// This modifier is applied after all existing modifiers.
     pub fn modify_globals(&mut self, f: impl Fn(&mut GlobalsBuilder) + 'static) {
diff --git a/src/gn/starlark/crates/testutils/src/eval_context.rs b/src/gn/starlark/crates/testutils/src/eval_context.rs
index ad2004a..3efc0d9 100644
--- a/src/gn/starlark/crates/testutils/src/eval_context.rs
+++ b/src/gn/starlark/crates/testutils/src/eval_context.rs
@@ -5,7 +5,7 @@
 
 use attr::{Attr, EvalContext as AttrEvalContext, EvalContextAttrExt, Session as AttrSession};
 use starlark::{
-    values::{FrozenValue, Heap, ProvidesStaticType, Value},
+    values::{FrozenValue, FrozenValueTyped, Heap, ProvidesStaticType, Value},
     Result,
 };
 use types::{
@@ -139,7 +139,13 @@
             label,
             toolchain,
             output_type: target_type,
-            rule,
+            rule: if rule.is_none() {
+                None
+            } else {
+                let typed =
+                    FrozenValueTyped::<rule::FrozenRule<FakeEvalContext>>::new(rule).unwrap();
+                Some(typed.as_ref())
+            },
             cxx_attrs: scope.0.clone(),
             outputs: vec![],
             attrs,
@@ -147,3 +153,5 @@
         }))
     }
 }
+
+rule::impl_ctx_methods!(FakeEvalContext);
diff --git a/src/gn/starlark/crates/testutils/src/session.rs b/src/gn/starlark/crates/testutils/src/session.rs
index 78d1e94..9118d7c 100644
--- a/src/gn/starlark/crates/testutils/src/session.rs
+++ b/src/gn/starlark/crates/testutils/src/session.rs
@@ -10,6 +10,7 @@
 use crate::{FakeTarget, FakeTargetRef};
 
 /// A fake implementation of the `Session` trait for testing.
+#[derive(Clone)]
 pub struct FakeSession {
     /// The preconfigured default toolchain label.
     pub default_toolchain: Label,
@@ -80,12 +81,18 @@
     type TargetRef = FakeTargetRef;
 
     fn get_target(&self, label: LabelRef<'_>, current_toolchain: LabelRef<'_>) -> Self::TargetRef {
-        self.targets
-            .borrow()
+        let targets = self.targets.borrow();
+        if let Some(target) = targets
             .iter()
             .find(|target| target.label() == label && target.toolchain() == current_toolchain)
-            .unwrap()
-            .clone()
+        {
+            target.clone()
+        } else {
+            panic!(
+                "get_target failed to find label: {:?}, toolchain: {:?}. Available targets: {:?}",
+                label, current_toolchain, targets
+            );
+        }
     }
 
     fn register_dependency<'a>(
diff --git a/src/gn/starlark/crates/testutils/src/target.rs b/src/gn/starlark/crates/testutils/src/target.rs
index f1a6ee8..4889eed 100644
--- a/src/gn/starlark/crates/testutils/src/target.rs
+++ b/src/gn/starlark/crates/testutils/src/target.rs
@@ -12,13 +12,15 @@
 use attr::Attr;
 use starlark::{
     starlark_simple_value,
-    values::{FrozenValue, ProvidesStaticType, StarlarkValue, Value, ValueLike},
+    values::{Heap, ProvidesStaticType, StarlarkValue, Value, ValueLike},
 };
 use starlark_derive::{starlark_value, NoSerialize};
 use types::{
     File, IPromiseToImplementStarlarkEqAndHash, Label, LabelRef, OutputType, Session, TargetRef,
 };
 
+use crate::FakeEvalContext;
+
 /// A fake target struct for testing.
 #[derive(Allocative, Debug)]
 pub struct FakeTarget {
@@ -29,7 +31,7 @@
     /// A list of attributes.
     pub attrs: Vec<Attr>,
     pub output_type: Option<OutputType>,
-    pub rule: FrozenValue,
+    pub rule: Option<&'static rule::FrozenRule<FakeEvalContext>>,
     #[allocative(skip)]
     pub cxx_attrs: HashMap<String, Value<'static>>,
     /// Registered target dependencies.
@@ -44,7 +46,7 @@
             && self.outputs == other.outputs
             && self.attrs == other.attrs
             && self.output_type == other.output_type
-            && self.rule == other.rule
+            && self.rule.map(|r| r as *const _) == other.rule.map(|r| r as *const _)
             && self.cxx_attrs.len() == other.cxx_attrs.len()
             && self.cxx_attrs.iter().all(|(k, v)| {
                 other
@@ -132,6 +134,8 @@
 }
 
 impl TargetRef for FakeTargetRef {
+    type Rule = rule::FrozenRule<FakeEvalContext>;
+
     fn label(&self) -> LabelRef<'_> {
         self.get().label.as_ref()
     }
@@ -140,6 +144,14 @@
         self.get().toolchain.as_ref()
     }
 
+    fn rule(&self) -> Option<&'static Self::Rule> {
+        self.get().rule
+    }
+
+    fn output_type(&self) -> Option<OutputType> {
+        self.get().output_type
+    }
+
     fn outputs(&self) -> Vec<File> {
         self.get().outputs.clone()
     }
@@ -157,4 +169,20 @@
             attr.register_dependencies(session, self.clone(), toolchain);
         }
     }
+
+    fn builtin_attrs<'v>(&self, _heap: &Heap<'v>) -> Vec<Value<'v>> {
+        self.get()
+            .output_type
+            .map(|ot| {
+                let (file_fields, target_fields) = ot.attrs();
+                vec![Value::new_none(); file_fields.len() + target_fields.len()]
+            })
+            .unwrap_or_default()
+    }
+}
+
+impl attr::TargetAttrExt for FakeTargetRef {
+    fn attrs(&self) -> &[Attr] {
+        &self.get().attrs
+    }
 }
diff --git a/src/gn/starlark/crates/types/src/eval_context.rs b/src/gn/starlark/crates/types/src/eval_context.rs
index 9886372..f42cbb9 100644
--- a/src/gn/starlark/crates/types/src/eval_context.rs
+++ b/src/gn/starlark/crates/types/src/eval_context.rs
@@ -78,3 +78,7 @@
         self.extra = Some(context);
     }
 }
+
+pub trait CtxMethods {
+    fn methods() -> &'static starlark::environment::Methods;
+}
diff --git a/src/gn/starlark/crates/types/src/lib.rs b/src/gn/starlark/crates/types/src/lib.rs
index ae22eaf..ea3ed1a 100644
--- a/src/gn/starlark/crates/types/src/lib.rs
+++ b/src/gn/starlark/crates/types/src/lib.rs
@@ -20,7 +20,7 @@
 
 pub use ctx_state::CtxState;
 pub(crate) use errors::Error;
-pub use eval_context::{EvalContext, EvaluatorContextExt};
+pub use eval_context::{CtxMethods, EvalContext, EvaluatorContextExt};
 pub use file::{intern_string, File};
 pub use label::Label;
 pub use label_ref::LabelRef;
diff --git a/src/gn/starlark/crates/types/src/target_ref.rs b/src/gn/starlark/crates/types/src/target_ref.rs
index 98c7b2c..f24c749 100644
--- a/src/gn/starlark/crates/types/src/target_ref.rs
+++ b/src/gn/starlark/crates/types/src/target_ref.rs
@@ -2,9 +2,9 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-use starlark::values::{AllocValue, StarlarkValue};
+use starlark::values::{AllocValue, Heap, StarlarkValue, Value};
 
-use crate::{File, LabelRef, Session};
+use crate::{File, LabelRef, OutputType, Session};
 
 /// Unfortunately while we could specify that Eq and Hash are implemented, there
 /// is no way to delegate starlark's equality and hash function to it
@@ -23,6 +23,11 @@
     /// Returns the toolchain the label was defined in.
     fn toolchain(&self) -> LabelRef<'_>;
 
+    type Rule: for<'v> StarlarkValue<'v>;
+
+    /// Returns the rule that this target was built from.
+    /// May return None if the target is a pure GN target.
+    fn rule(&self) -> Option<&'static Self::Rule>;
     /// Returns the output files produced by this target.
     fn outputs(&self) -> Vec<File>;
 
@@ -37,11 +42,16 @@
         label_prefix: &str,
         package_name_separator: &str,
     ) -> String;
-
     /// Registers target dependencies contained within this target's attributes.
     fn register_dependencies<S: Session<TargetRef = Self>>(
         &self,
         session: &S,
         toolchain: LabelRef<'_>,
     );
+
+    /// Returns the target's output type.
+    fn output_type(&self) -> Option<OutputType>;
+
+    /// Returns the resolved built-in attributes as Starlark values.
+    fn builtin_attrs<'v>(&self, heap: &Heap<'v>) -> Vec<Value<'v>>;
 }
diff --git a/src/gn/starlark/src/testdata/rules/pure.scl b/src/gn/starlark/src/testdata/rules/pure.scl
index 6be8bb6..368b3eb 100644
--- a/src/gn/starlark/src/testdata/rules/pure.scl
+++ b/src/gn/starlark/src/testdata/rules/pure.scl
@@ -1,5 +1,8 @@
+ParentInfo = provider(fields = ["parent"])
+ChildInfo = provider(fields = ["child"])
+
 def _parent_impl(ctx):
-  return []
+  return [ParentInfo(parent = ctx.attr.parent_only)]
 
 parent_rule = rule(
   implementation = _parent_impl,
@@ -10,7 +13,7 @@
 )
 
 def _child_impl(ctx):
-  return []
+  return ctx.super() + [ChildInfo(child = ctx.attr.child_only)]
 
 child_rule = rule(
   implementation = _child_impl,