Create starlark attr crate

Note for reviewers - I strongly advise reading the code in the following order:
* globals.rs - Contains the starlark attr.* functions, which creates
* schema.rs - Contains the schema of an attribute (all the metadata about the type), which is used to create an
* attr.rs - Contains the value of an attribute, which can be converted to
* value.rs - Converts an attribute to a starlark value (this part is done in a follow-up CL).

Bug: 528225104
Change-Id: I14f205864735184f8a23295151f0a6596a6a6964
Reviewed-on: https://gn-review.googlesource.com/c/gn/+/23500
Commit-Queue: Matt Stark <msta@google.com>
Reviewed-by: Philipp Wollermann <philwo@google.com>
Reviewed-by: Cole Faust <colefaust@google.com>
Reviewed-by: Matt Stark <msta@google.com>
Reviewed-by: Richard Wang <richardwa@google.com>
diff --git a/src/gn/starlark/Cargo.lock b/src/gn/starlark/Cargo.lock
index 512a232..f68f352 100644
--- a/src/gn/starlark/Cargo.lock
+++ b/src/gn/starlark/Cargo.lock
@@ -109,6 +109,18 @@
 ]
 
 [[package]]
+name = "attr"
+version = "0.1.0"
+dependencies = [
+ "allocative",
+ "either",
+ "starlark",
+ "starlark_derive",
+ "thiserror",
+ "types",
+]
+
+[[package]]
 name = "autocfg"
 version = "1.5.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/src/gn/starlark/Cargo.toml b/src/gn/starlark/Cargo.toml
index 57abfff..9d2a06d 100644
--- a/src/gn/starlark/Cargo.toml
+++ b/src/gn/starlark/Cargo.toml
@@ -13,6 +13,7 @@
 [workspace]
 members = [
     ".",
+    "crates/attr",
     "crates/ffi",
     "crates/types",
 ]
diff --git a/src/gn/starlark/crates/attr/Cargo.toml b/src/gn/starlark/crates/attr/Cargo.toml
new file mode 100644
index 0000000..32ded57
--- /dev/null
+++ b/src/gn/starlark/crates/attr/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "attr"
+version = "0.1.0"
+edition = "2021"
+
+[lints]
+workspace = true
+
+[lib]
+doctest = false
+
+[dependencies]
+starlark = { workspace = true }
+starlark_derive = { workspace = true }
+thiserror = { workspace = true }
+allocative = { workspace = true }
+types = { path = "../types" }
+either = { workspace = true }
diff --git a/src/gn/starlark/crates/attr/src/allow_files.rs b/src/gn/starlark/crates/attr/src/allow_files.rs
new file mode 100644
index 0000000..fca90f8
--- /dev/null
+++ b/src/gn/starlark/crates/attr/src/allow_files.rs
@@ -0,0 +1,147 @@
+// 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 starlark::{
+    typing::Ty,
+    values::{
+        list::UnpackList, type_repr::StarlarkTypeRepr, Freeze, FreezeError, Freezer, UnpackValue,
+        Value,
+    },
+};
+use types::{Label, PackageRef, PathResolver};
+
+use crate::attr::LabelOrFile;
+
+/// The rust type for the starlark value passed to attr.label(allow_files = ...)
+#[derive(Debug, Clone, PartialEq, Eq, allocative::Allocative)]
+pub enum AllowFiles {
+    None,
+    All,
+    Some(Vec<String>),
+}
+
+impl StarlarkTypeRepr for AllowFiles {
+    type Canonical = either::Either<bool, UnpackList<String>>;
+
+    fn starlark_type_repr() -> Ty {
+        Self::Canonical::starlark_type_repr()
+    }
+}
+
+impl<'v> UnpackValue<'v> for AllowFiles {
+    type Error = starlark::Error;
+
+    fn unpack_value_impl(value: Value<'v>) -> Result<Option<Self>, Self::Error> {
+        Ok(Self::Canonical::unpack_value(value)?.map(|c| match c {
+            either::Either::Left(false) => Self::None,
+            either::Either::Left(true) => Self::All,
+            either::Either::Right(list) => Self::Some(list.items),
+        }))
+    }
+}
+
+impl AllowFiles {
+    pub(crate) fn validate(&self, path: &str) -> starlark::Result<()> {
+        match self {
+            Self::None => Err(crate::Error::NotALabel(path.to_owned()).into()),
+            Self::All => Ok(()),
+            Self::Some(exts) => {
+                let p = std::path::Path::new(path);
+                let file_name = p.file_name().and_then(|e| e.to_str()).unwrap_or("");
+                if exts.iter().any(|ext| {
+                    if ext.starts_with('.') {
+                        file_name.ends_with(ext)
+                    } else {
+                        file_name
+                            .strip_suffix(ext)
+                            .is_some_and(|prefix| prefix.is_empty() || prefix.ends_with('.'))
+                    }
+                }) {
+                    Ok(())
+                } else {
+                    Err(crate::Error::DisallowedExtension {
+                        file: p.to_path_buf(),
+                        allowed: exts.clone(),
+                    }
+                    .into())
+                }
+            },
+        }
+    }
+}
+
+impl Freeze for AllowFiles {
+    type Frozen = Self;
+
+    fn freeze(self, _freezer: &Freezer) -> Result<Self::Frozen, FreezeError> {
+        Ok(self)
+    }
+}
+
+pub(crate) fn parse_label_like(
+    s: &str,
+    allow_files: &AllowFiles,
+    relative_to: &PackageRef,
+    path_resolver: &PathResolver,
+) -> starlark::Result<LabelOrFile> {
+    Ok(
+        if let Some(label) = Label::parse_maybe_label(s, relative_to)? {
+            LabelOrFile::Label(label)
+        } else {
+            // It's a file.
+            allow_files.validate(s)?;
+            LabelOrFile::File(path_resolver.source_file(relative_to, s)?)
+        },
+    )
+}
+
+#[cfg(test)]
+mod tests {
+    use starlark::values::FrozenHeap;
+
+    use super::*;
+    use crate::{
+        cfg::AttrCfg,
+        schema::{AllowFilesSchema, AttrKind, AttrSchema},
+        Attr,
+    };
+
+    #[test]
+    fn test_allow_files_matching() {
+        let path_resolver = types::PathResolver::new_for_testing();
+        let heap = FrozenHeap::new();
+
+        let check_match = |pattern: &str, file: &str| -> bool {
+            let schema = AttrSchema {
+                kind: AttrKind::Label,
+                default: None,
+                disallow_empty: false,
+                allow_files: AllowFilesSchema::Many(AllowFiles::Some(vec![pattern.to_owned()])),
+                cfg: AttrCfg::CurrentToolchain,
+                doc: String::new(),
+            };
+            Attr::create(
+                &schema,
+                Some(Value::new_frozen(heap.alloc(file))),
+                PackageRef::new("//allow_files").unwrap(),
+                &path_resolver,
+            )
+            .is_ok()
+        };
+
+        assert!(check_match(".cc", "file.cc"));
+        assert!(check_match(".cc", "subdir/file.cc"));
+        assert!(!check_match(".cc", "file.h"));
+        assert!(!check_match(".cc", "cc"));
+        assert!(!check_match(".cc", "nonexistent.cc"));
+
+        assert!(check_match("cc", "file.cc"));
+        assert!(!check_match("cc", "file.h"));
+        assert!(check_match("cc", "cc"));
+
+        assert!(check_match("foo.cc", "foo.cc"));
+        assert!(check_match("foo.cc", "test.foo.cc"));
+        assert!(!check_match("foo.cc", "bar.cc"));
+    }
+}
diff --git a/src/gn/starlark/crates/attr/src/attr.rs b/src/gn/starlark/crates/attr/src/attr.rs
new file mode 100644
index 0000000..5e1fc56
--- /dev/null
+++ b/src/gn/starlark/crates/attr/src/attr.rs
@@ -0,0 +1,535 @@
+// 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;
+
+use allocative::Allocative;
+use starlark::{
+    collections::{SmallMap, SmallSet},
+    values::{
+        list::UnpackList, none::NoneOr, Freeze, FreezeResult, Freezer, Heap, UnpackValue as _,
+        Value,
+    },
+};
+use types::{File, Label, LabelRef, PackageRef, PathResolver};
+
+use crate::{
+    allow_files::AllowFiles,
+    schema::{AttrKind, AttrSchema},
+};
+
+/// A Starlark value that can be either a target `Label` or a source `File`.
+///
+/// We do this because at the time we resolve attributes, the dependency has
+/// not yet been resolved, and thus we don't know what files it expands to.
+#[derive(Clone, Debug, PartialEq, Eq, Hash, Allocative)]
+pub enum LabelOrFile {
+    Label(Label),
+    File(File),
+}
+
+impl LabelOrFile {
+    /// Converts this `LabelOrFile` into a Starlark `Value` allocated on the
+    /// heap.
+    pub fn to_value<'v>(&self, heap: &Heap<'v>) -> Value<'v> {
+        match self {
+            Self::Label(l) => heap.alloc(l.clone()),
+            Self::File(f) => heap.alloc(f.clone()),
+        }
+    }
+}
+
+impl std::fmt::Display for LabelOrFile {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::Label(l) => write!(f, "{l}"),
+            Self::File(file) => write!(f, "{file}"),
+        }
+    }
+}
+
+/// Represents an actual attribute passed to a target.
+///
+/// For example, sources = ["foo.cc"] => Attr::LabelList(vec![file for foo.cc])
+///
+/// Guaranteed to match the type of the corresponding `AttrSchema`'s `AttrKind`.
+/// For example, the bool type defaults to false, attr.bool() can never produce
+/// `Attr::Label(None)`. On the other hand, `attr.label()` defaults to
+/// `Attr::Label(None)`.
+#[derive(Clone, Debug, PartialEq, Eq, Allocative)]
+pub enum Attr {
+    Bool(bool),
+    Int(i32),
+    String(String),
+    IntList(Vec<i32>),
+    StringList(Vec<String>),
+    StringListDict(SmallMap<String, Vec<String>>),
+    Label(Option<LabelOrFile>),
+    LabelList(Vec<LabelOrFile>),
+    StringDict(SmallMap<String, String>),
+    LabelKeyedStringDict(SmallMap<LabelOrFile, String>),
+    StringKeyedLabelDict(SmallMap<String, LabelOrFile>),
+    LabelListDict(SmallMap<String, Vec<LabelOrFile>>),
+}
+
+impl std::fmt::Display for Attr {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "{self:?}")
+    }
+}
+
+impl Freeze for Attr {
+    type Frozen = Self;
+
+    fn freeze(self, _freezer: &Freezer) -> FreezeResult<Self::Frozen> {
+        Ok(self)
+    }
+}
+
+impl Attr {
+    /// Creates an `Attr` value from an input Starlark `Value`, applying default
+    /// values from the schema if not provided.
+    pub fn create(
+        schema: &AttrSchema,
+        // Distinguish between an explicit = None and an implicit value not provided.
+        value: Option<Value<'_>>,
+        package: &PackageRef,
+        path_resolver: &PathResolver,
+    ) -> starlark::Result<Self> {
+        if let Some(value) = value {
+            Self::create_without_defaults(schema, value, package, path_resolver)
+        } else if let Some(default) = schema.default.as_ref() {
+            // We already validated the default during attr schema creation.
+            Ok(default.clone())
+        } else {
+            Err(crate::Error::MandatoryAttribute.into())
+        }
+    }
+
+    /// Coerces a Starlark `Value` into an `Attr` based on the validation rules
+    /// in the `AttrSchema`.
+    pub fn create_without_defaults(
+        schema: &AttrSchema,
+        value: Value<'_>,
+        package: &PackageRef,
+        path_resolver: &PathResolver,
+    ) -> starlark::Result<Self> {
+        let parse_label = |s: &str| -> starlark::Result<LabelOrFile> {
+            crate::allow_files::parse_label_like(
+                s,
+                schema.file_matcher().unwrap_or(&AllowFiles::None),
+                package,
+                path_resolver,
+            )
+        };
+
+        let parse_label_list = |items: &[&str]| -> starlark::Result<Vec<LabelOrFile>> {
+            let mut unique = SmallSet::new();
+            items
+                .iter()
+                .map(|s| {
+                    let lf = parse_label(s)?;
+                    if !unique.insert(lf.clone()) {
+                        return Err(crate::Error::DuplicateLabel(lf).into());
+                    }
+                    Ok(lf)
+                })
+                .collect()
+        };
+
+        let parse_int = |val: Value<'_>| -> starlark::Result<i32> {
+            match i32::unpack_value_err(val) {
+                Ok(n) => Ok(n),
+                Err(e) => {
+                    if let Ok(n) = i64::unpack_value_err(val) {
+                        Err(starlark::Error::new_other(crate::Error::Int32Expected(n)))
+                    } else {
+                        Err(e)
+                    }
+                },
+            }
+        };
+
+        match &schema.kind {
+            AttrKind::Bool => Ok(Self::Bool(bool::unpack_value_err(value)?)),
+            AttrKind::Int { allowed } => {
+                let v = parse_int(value)?;
+                if allowed.as_ref().is_some_and(|a| !a.contains(&v)) {
+                    return Err(crate::Error::IntNotAllowed(v).into());
+                }
+                Ok(Self::Int(v))
+            },
+            AttrKind::String { allowed } => {
+                let v = <String>::unpack_value_err(value)?;
+                if allowed.as_ref().is_some_and(|a| !a.contains(&v)) {
+                    return Err(crate::Error::StringNotAllowed(v).into());
+                }
+                Ok(Self::String(v))
+            },
+            AttrKind::IntList => {
+                let list = UnpackList::<Value<'_>>::unpack_value_err(value)?;
+                if schema.disallow_empty && list.items.is_empty() {
+                    return Err(crate::Error::Empty.into());
+                }
+                let items = list
+                    .items
+                    .iter()
+                    .map(|v| parse_int(*v))
+                    .collect::<starlark::Result<Vec<_>>>()?;
+                Ok(Self::IntList(items))
+            },
+            AttrKind::StringList => {
+                let list = UnpackList::<String>::unpack_value_err(value)?;
+                if schema.disallow_empty && list.items.is_empty() {
+                    return Err(crate::Error::Empty.into());
+                }
+                Ok(Self::StringList(list.items))
+            },
+            AttrKind::StringListDict => {
+                let dict = SmallMap::<&str, UnpackList<String>>::unpack_value_err(value)?;
+                if schema.disallow_empty && dict.is_empty() {
+                    return Err(crate::Error::Empty.into());
+                }
+                Ok(Self::StringListDict(
+                    dict.into_iter()
+                        .map(|(k, v)| (k.to_string(), v.items))
+                        .collect(),
+                ))
+            },
+            AttrKind::Label => match NoneOr::<&str>::unpack_value_err(value)? {
+                // An explicit None is disallowed if the attribute is mandatory.
+                NoneOr::None => {
+                    if schema.default.is_none() {
+                        Err(crate::Error::MandatoryAttribute.into())
+                    } else {
+                        Ok(Self::Label(None))
+                    }
+                },
+                NoneOr::Other(s) => Ok(Self::Label(Some(parse_label(s)?))),
+            },
+            AttrKind::LabelList => {
+                let list = UnpackList::<&str>::unpack_value_err(value)?;
+                if schema.disallow_empty && list.items.is_empty() {
+                    return Err(crate::Error::Empty.into());
+                }
+                Ok(Self::LabelList(parse_label_list(&list.items)?))
+            },
+            AttrKind::StringDict => {
+                let dict = SmallMap::<String, String>::unpack_value_err(value)?;
+                if schema.disallow_empty && dict.is_empty() {
+                    return Err(crate::Error::Empty.into());
+                }
+                Ok(Self::StringDict(dict))
+            },
+            AttrKind::LabelKeyedStringDict => {
+                let dict = SmallMap::<&str, &str>::unpack_value_err(value)?;
+                if schema.disallow_empty && dict.is_empty() {
+                    return Err(crate::Error::Empty.into());
+                }
+                let mut resolved = SmallMap::with_capacity(dict.len());
+                for (k, v) in dict {
+                    let lf = parse_label(k)?;
+                    if resolved.insert(lf.clone(), v.to_string()).is_some() {
+                        return Err(crate::Error::DuplicateLabel(lf).into());
+                    }
+                }
+                Ok(Self::LabelKeyedStringDict(resolved))
+            },
+            AttrKind::StringKeyedLabelDict => {
+                let dict = SmallMap::<&str, &str>::unpack_value_err(value)?;
+                if schema.disallow_empty && dict.is_empty() {
+                    return Err(crate::Error::Empty.into());
+                }
+                Ok(Self::StringKeyedLabelDict(
+                    dict.into_iter()
+                        .map(|(k, v)| Ok((k.to_string(), parse_label(v)?)))
+                        .collect::<starlark::Result<SmallMap<_, _>>>()?,
+                ))
+            },
+            AttrKind::LabelListDict => {
+                let dict = SmallMap::<&str, UnpackList<&str>>::unpack_value_err(value)?;
+                if schema.disallow_empty && dict.is_empty() {
+                    return Err(crate::Error::Empty.into());
+                }
+                Ok(Self::LabelListDict(
+                    dict.into_iter()
+                        .map(|(k, v)| Ok((k.to_string(), parse_label_list(&v.items)?)))
+                        .collect::<starlark::Result<SmallMap<_, _>>>()?,
+                ))
+            },
+        }
+    }
+
+    /// Registers target dependencies contained within this attribute value.
+    pub fn register_dependencies<S: crate::Session>(
+        &self,
+        session: &S,
+        source: S::TargetRef,
+        toolchain: LabelRef<'_>,
+    ) {
+        match self {
+            Self::Label(Some(LabelOrFile::Label(lbl))) => {
+                session.register_dependency(source, lbl.as_ref(), toolchain);
+            },
+            Self::LabelList(list) => {
+                for lf in list {
+                    if let LabelOrFile::Label(lbl) = lf {
+                        session.register_dependency(source.clone(), lbl.as_ref(), toolchain);
+                    }
+                }
+            },
+            Self::LabelKeyedStringDict(dict) => {
+                for (lf, _) in dict {
+                    if let LabelOrFile::Label(lbl) = lf {
+                        session.register_dependency(source.clone(), lbl.as_ref(), toolchain);
+                    }
+                }
+            },
+            Self::StringKeyedLabelDict(dict) => {
+                for (_, lf) in dict {
+                    if let LabelOrFile::Label(lbl) = lf {
+                        session.register_dependency(source.clone(), lbl.as_ref(), toolchain);
+                    }
+                }
+            },
+            Self::LabelListDict(dict) => {
+                for (_, list) in dict {
+                    for lf in list {
+                        if let LabelOrFile::Label(lbl) = lf {
+                            session.register_dependency(source.clone(), lbl.as_ref(), toolchain);
+                        }
+                    }
+                }
+            },
+            _ => {},
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use starlark::values::FrozenHeap;
+
+    use super::*;
+    use crate::{cfg::AttrCfg, schema::AllowFilesSchema};
+
+    #[test]
+    fn test_attr_bool() {
+        let pkg = PackageRef::new("//foo").unwrap();
+        let path_resolver = types::PathResolver::new_for_testing();
+        let heap = FrozenHeap::new();
+
+        let schema = AttrSchema {
+            kind: AttrKind::Bool,
+            default: Some(Attr::Bool(false)),
+            disallow_empty: false,
+            allow_files: AllowFilesSchema::None,
+            cfg: AttrCfg::CurrentToolchain,
+            doc: String::new(),
+        };
+
+        // Test explicit true
+        assert_eq!(
+            Attr::create(
+                &schema,
+                Some(Value::new_frozen(heap.alloc(true))),
+                pkg,
+                &path_resolver,
+            )
+            .unwrap(),
+            Attr::Bool(true)
+        );
+
+        // Test default when value is None
+        assert_eq!(
+            Attr::create(&schema, None, pkg, &path_resolver).unwrap(),
+            Attr::Bool(false)
+        );
+
+        // Test non-boolean value fails
+        assert!(Attr::create(
+            &schema,
+            Some(Value::new_frozen(heap.alloc(42))),
+            pkg,
+            &path_resolver,
+        )
+        .is_err());
+    }
+
+    #[test]
+    fn test_attr_label_no_files() {
+        let pkg = PackageRef::new("//foo").unwrap();
+        let path_resolver = types::PathResolver::new_for_testing();
+        let heap = FrozenHeap::new();
+
+        let schema = AttrSchema {
+            kind: AttrKind::Label,
+            default: None,
+            disallow_empty: false,
+            allow_files: AllowFilesSchema::None,
+            cfg: AttrCfg::CurrentToolchain,
+            doc: String::new(),
+        };
+
+        // Test parsing a label ":bar"
+        assert_eq!(
+            Attr::create(
+                &schema,
+                Some(Value::new_frozen(heap.alloc(":bar"))),
+                pkg,
+                &path_resolver,
+            )
+            .unwrap(),
+            Attr::Label(Some(LabelOrFile::Label(Label::new(
+                PackageRef::new("//foo").unwrap().to_owned(),
+                "bar".to_owned(),
+            ))))
+        );
+
+        // Test that a file string fails because files are not allowed
+        assert!(Attr::create(
+            &schema,
+            Some(Value::new_frozen(heap.alloc("file.cc"))),
+            pkg,
+            &path_resolver,
+        )
+        .is_err());
+
+        // Test that passing None to a mandatory label fails
+        assert!(Attr::create(&schema, Some(Value::new_none()), pkg, &path_resolver,).is_err());
+    }
+
+    #[test]
+    fn test_attr_label_allow_files() {
+        let pkg = PackageRef::new("//foo").unwrap();
+        let path_resolver = types::PathResolver::new_for_testing();
+        let heap = FrozenHeap::new();
+
+        let schema = AttrSchema {
+            kind: AttrKind::Label,
+            default: None,
+            disallow_empty: false,
+            allow_files: AllowFilesSchema::Many(AllowFiles::Some(vec![".cc".to_owned()])),
+            cfg: AttrCfg::CurrentToolchain,
+            doc: String::new(),
+        };
+
+        // Test a valid file "file.cc" (exists in testdata/foo/file.cc)
+        assert_eq!(
+            Attr::create(
+                &schema,
+                Some(Value::new_frozen(heap.alloc("file.cc"))),
+                pkg,
+                &path_resolver,
+            )
+            .unwrap(),
+            Attr::Label(Some(LabelOrFile::File(
+                path_resolver.source_file(pkg, "file.cc").unwrap()
+            )))
+        );
+
+        // Test an invalid file "file.h" (extension not in allowed list)
+        assert!(Attr::create(
+            &schema,
+            Some(Value::new_frozen(heap.alloc("file.h"))),
+            pkg,
+            &path_resolver,
+        )
+        .is_err());
+
+        // Test that a label still resolves
+        assert_eq!(
+            Attr::create(
+                &schema,
+                Some(Value::new_frozen(heap.alloc(":bar"))),
+                pkg,
+                &path_resolver,
+            )
+            .unwrap(),
+            Attr::Label(Some(LabelOrFile::Label(Label::new(
+                PackageRef::new("//foo").unwrap().to_owned(),
+                "bar".to_owned(),
+            ))))
+        );
+    }
+
+    #[test]
+    fn test_attr_label_list_duplicate_fail() {
+        use starlark::environment::Module;
+
+        use crate::{cfg::AttrCfg, schema::AllowFilesSchema};
+
+        let pkg = PackageRef::new("//foo").unwrap();
+        let path_resolver = types::PathResolver::new_for_testing();
+
+        Module::with_temp_heap(|module| {
+            let heap = module.heap();
+
+            let schema_list = AttrSchema {
+                kind: AttrKind::LabelList,
+                default: None,
+                disallow_empty: false,
+                allow_files: AllowFilesSchema::None,
+                cfg: AttrCfg::CurrentToolchain,
+                doc: String::new(),
+            };
+
+            // Test same string label duplicate
+            assert!(Attr::create(
+                &schema_list,
+                Some(heap.alloc(vec![":bar", ":bar"])),
+                pkg,
+                &path_resolver,
+            )
+            .is_err());
+
+            // Test different string representations resolving to same Label
+            assert!(Attr::create(
+                &schema_list,
+                Some(heap.alloc(vec![":bar", "//foo:bar"])),
+                pkg,
+                &path_resolver,
+            )
+            .is_err());
+        });
+    }
+
+    #[test]
+    fn test_attr_label_keyed_string_dict_duplicate_fail() {
+        use starlark::{collections::SmallMap, environment::Module, values::dict::Dict};
+
+        use crate::{cfg::AttrCfg, schema::AllowFilesSchema};
+
+        let pkg = PackageRef::new("//foo").unwrap();
+        let path_resolver = types::PathResolver::new_for_testing();
+
+        Module::with_temp_heap(|module| {
+            let heap = module.heap();
+
+            let schema_dict = AttrSchema {
+                kind: AttrKind::LabelKeyedStringDict,
+                default: None,
+                disallow_empty: false,
+                allow_files: AllowFilesSchema::None,
+                cfg: AttrCfg::CurrentToolchain,
+                doc: String::new(),
+            };
+
+            // Test different string keys resolving to same Label
+            let mut map = SmallMap::new();
+            map.insert_hashed(heap.alloc(":bar").get_hashed().unwrap(), heap.alloc("val1"));
+            map.insert_hashed(
+                heap.alloc("//foo:bar").get_hashed().unwrap(),
+                heap.alloc("val2"),
+            );
+            assert!(Attr::create(
+                &schema_dict,
+                Some(heap.alloc(Dict::new(map))),
+                pkg,
+                &path_resolver,
+            )
+            .is_err());
+        });
+    }
+}
diff --git a/src/gn/starlark/crates/attr/src/cfg.rs b/src/gn/starlark/crates/attr/src/cfg.rs
new file mode 100644
index 0000000..e87f30d
--- /dev/null
+++ b/src/gn/starlark/crates/attr/src/cfg.rs
@@ -0,0 +1,35 @@
+// 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 allocative::Allocative;
+use starlark::{
+    typing::Ty,
+    values::{type_repr::StarlarkTypeRepr, UnpackValue, Value},
+};
+
+/// The rust type for the starlark value passed to attr.label(cfg = ...)
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Allocative)]
+pub enum AttrCfg {
+    CurrentToolchain,
+}
+
+impl StarlarkTypeRepr for AttrCfg {
+    type Canonical = String;
+
+    fn starlark_type_repr() -> Ty {
+        String::starlark_type_repr()
+    }
+}
+
+impl<'v> UnpackValue<'v> for AttrCfg {
+    type Error = starlark::Error;
+
+    fn unpack_value_impl(value: Value<'v>) -> Result<Option<Self>, Self::Error> {
+        match value.unpack_str() {
+            Some("target") => Ok(Some(Self::CurrentToolchain)),
+            Some(s) => Err(crate::Error::ConfigTransitionNotImplemented(s.to_owned()).into()),
+            None => Ok(None),
+        }
+    }
+}
diff --git a/src/gn/starlark/crates/attr/src/errors.rs b/src/gn/starlark/crates/attr/src/errors.rs
new file mode 100644
index 0000000..f697bdd
--- /dev/null
+++ b/src/gn/starlark/crates/attr/src/errors.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 std::path::PathBuf;
+
+use starlark::values::UnpackValueError;
+
+/// Errors returned by target attribute validation and coercion.
+#[derive(thiserror::Error, Debug, Clone)]
+pub enum Error {
+    #[error("value is mandatory")]
+    MandatoryAttribute,
+    #[error("value cannot be empty")]
+    Empty,
+    #[error("file \"{file:?}\" has disallowed extension, allowed extensions are: {allowed:?}")]
+    DisallowedExtension { file: PathBuf, allowed: Vec<String> },
+    #[error("config transition not implemented: {0}")]
+    ConfigTransitionNotImplemented(String),
+    #[error("mandatory and default are mutually exclusive")]
+    MandatoryAndDefaultMutuallyExclusive,
+    #[error("value {0} is not in allowed set")]
+    IntNotAllowed(i32),
+    #[error("value {0:?} is not in allowed set")]
+    StringNotAllowed(String),
+    #[error("allow_files and allow_single_file are mutually exclusive")]
+    AllowFilesMutuallyExclusive,
+    #[error("allow_empty = False requires the attribute to be mandatory or have a non-empty default value")]
+    AllowEmptyRequiresMandatoryOrDefault,
+    #[error("value `{0}` is not a label")]
+    NotALabel(String),
+    #[error("label `{0}` is duplicated")]
+    DuplicateLabel(crate::LabelOrFile),
+    #[error("got {0}, want value in signed 32-bit range")]
+    Int32Expected(i64),
+}
+
+impl From<Error> for starlark::Error {
+    fn from(err: Error) -> Self {
+        Self::new_other(err)
+    }
+}
+
+impl UnpackValueError for Error {
+    fn into_error(this: Self) -> starlark::Error {
+        starlark::Error::new_other(this)
+    }
+}
diff --git a/src/gn/starlark/crates/attr/src/globals.rs b/src/gn/starlark/crates/attr/src/globals.rs
new file mode 100644
index 0000000..e483de9
--- /dev/null
+++ b/src/gn/starlark/crates/attr/src/globals.rs
@@ -0,0 +1,370 @@
+// 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};
+
+use allocative::{Allocative, Visitor};
+use starlark::{
+    environment::{Methods, MethodsBuilder, MethodsStatic},
+    eval::Evaluator,
+    values::{list::UnpackList, none::NoneOr, ProvidesStaticType, StarlarkValue, Value},
+};
+use starlark_derive::{starlark_module, starlark_value, NoSerialize};
+
+use crate::{allow_files::AllowFiles, cfg::AttrCfg, AttrKind};
+
+/// The type that all parameters of attr.* get converted to.
+/// Mostly used because rust doesn't have default parameters,
+/// so we just ..Default::default() the fields that aren't used.
+#[derive(Default)]
+pub struct AttrSpecArgs<'v> {
+    pub(crate) default: Option<Value<'v>>,
+    pub(crate) mandatory: Option<bool>,
+    pub(crate) allow_empty: Option<bool>,
+    pub(crate) allow_files: Option<AllowFiles>,
+    pub(crate) allow_single_file: Option<AllowFiles>,
+    pub(crate) cfg: Option<AttrCfg>,
+    pub(crate) doc: Option<NoneOr<String>>,
+}
+
+/// The Starlark `attr` module containing functions to declare rule attributes.
+#[derive(Debug, ProvidesStaticType, NoSerialize)]
+pub struct AttrModule {
+    pub make_attr_schema: for<'v, 'a, 'e> fn(
+        AttrKind,
+        AttrSpecArgs<'v>,
+        &mut Evaluator<'v, 'a, 'e>,
+    ) -> starlark::Result<Value<'v>>,
+}
+
+impl Allocative for AttrModule {
+    fn visit<'a, 'b: 'a>(&self, visitor: &'a mut Visitor<'b>) {
+        let visitor = visitor.enter_self_sized::<Self>();
+        visitor.exit();
+    }
+}
+
+starlark::starlark_simple_value!(AttrModule);
+
+impl Display for AttrModule {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        write!(f, "attr")
+    }
+}
+
+#[starlark_value(type = "attr")]
+impl<'v> StarlarkValue<'v> for AttrModule {
+    fn get_methods() -> Option<&'static Methods> {
+        static RES: MethodsStatic = MethodsStatic::new("attr", attr_methods);
+        Some(RES.methods())
+    }
+}
+
+#[starlark_module]
+pub fn attr_methods(builder: &mut MethodsBuilder) {
+    fn bool<'v>(
+        #[starlark(this)] this: &AttrModule,
+        #[starlark(require = named)] default: Option<Value<'v>>,
+        doc: Option<NoneOr<String>>,
+        mandatory: Option<bool>,
+        eval: &mut Evaluator<'v, '_, '_>,
+    ) -> starlark::Result<Value<'v>> {
+        (this.make_attr_schema)(
+            AttrKind::Bool,
+            AttrSpecArgs {
+                default,
+                doc,
+                mandatory,
+                ..Default::default()
+            },
+            eval,
+        )
+    }
+
+    fn int<'v>(
+        #[starlark(this)] this: &AttrModule,
+        #[starlark(require = named)] default: Option<Value<'v>>,
+        doc: Option<NoneOr<String>>,
+        mandatory: Option<bool>,
+        values: Option<UnpackList<i32>>,
+        eval: &mut Evaluator<'v, '_, '_>,
+    ) -> starlark::Result<Value<'v>> {
+        (this.make_attr_schema)(
+            AttrKind::Int {
+                allowed: values.map(|v| v.into_iter().collect()),
+            },
+            AttrSpecArgs {
+                default,
+                doc,
+                mandatory,
+                ..Default::default()
+            },
+            eval,
+        )
+    }
+
+    fn int_list<'v>(
+        #[starlark(this)] this: &AttrModule,
+        #[starlark(require = named)] default: Option<Value<'v>>,
+        doc: Option<NoneOr<String>>,
+        mandatory: Option<bool>,
+        allow_empty: Option<bool>,
+        eval: &mut Evaluator<'v, '_, '_>,
+    ) -> starlark::Result<Value<'v>> {
+        (this.make_attr_schema)(
+            AttrKind::IntList,
+            AttrSpecArgs {
+                default,
+                doc,
+                mandatory,
+                allow_empty,
+                ..Default::default()
+            },
+            eval,
+        )
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    fn label<'v>(
+        #[starlark(this)] this: &AttrModule,
+        #[starlark(require = named)] default: Option<Value<'v>>,
+        doc: Option<NoneOr<String>>,
+        mandatory: Option<bool>,
+        allow_files: Option<AllowFiles>,
+        allow_single_file: Option<AllowFiles>,
+        cfg: Option<AttrCfg>,
+        eval: &mut Evaluator<'v, '_, '_>,
+    ) -> starlark::Result<Value<'v>> {
+        (this.make_attr_schema)(
+            AttrKind::Label,
+            AttrSpecArgs {
+                default,
+                doc,
+                mandatory,
+                allow_files,
+                allow_single_file,
+                cfg,
+                ..Default::default()
+            },
+            eval,
+        )
+    }
+
+    fn label_keyed_string_dict<'v>(
+        #[starlark(this)] this: &AttrModule,
+        #[starlark(require = named)] default: Option<Value<'v>>,
+        doc: Option<NoneOr<String>>,
+        mandatory: Option<bool>,
+        allow_empty: Option<bool>,
+        allow_files: Option<AllowFiles>,
+        eval: &mut Evaluator<'v, '_, '_>,
+    ) -> starlark::Result<Value<'v>> {
+        (this.make_attr_schema)(
+            AttrKind::LabelKeyedStringDict,
+            AttrSpecArgs {
+                default,
+                doc,
+                mandatory,
+                allow_empty,
+                allow_files,
+                ..Default::default()
+            },
+            eval,
+        )
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    fn label_list<'v>(
+        #[starlark(this)] this: &AttrModule,
+        #[starlark(require = named)] default: Option<Value<'v>>,
+        doc: Option<NoneOr<String>>,
+        mandatory: Option<bool>,
+        allow_empty: Option<bool>,
+        allow_files: Option<AllowFiles>,
+        cfg: Option<AttrCfg>,
+        eval: &mut Evaluator<'v, '_, '_>,
+    ) -> starlark::Result<Value<'v>> {
+        (this.make_attr_schema)(
+            AttrKind::LabelList,
+            AttrSpecArgs {
+                default,
+                doc,
+                mandatory,
+                allow_empty,
+                allow_files,
+                cfg,
+                ..Default::default()
+            },
+            eval,
+        )
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    fn label_list_dict<'v>(
+        #[starlark(this)] this: &AttrModule,
+        #[starlark(require = named)] default: Option<Value<'v>>,
+        doc: Option<NoneOr<String>>,
+        mandatory: Option<bool>,
+        allow_empty: Option<bool>,
+        allow_files: Option<AllowFiles>,
+        cfg: Option<AttrCfg>,
+        eval: &mut Evaluator<'v, '_, '_>,
+    ) -> starlark::Result<Value<'v>> {
+        (this.make_attr_schema)(
+            AttrKind::LabelListDict,
+            AttrSpecArgs {
+                default,
+                doc,
+                mandatory,
+                allow_empty,
+                allow_files,
+                cfg,
+                ..Default::default()
+            },
+            eval,
+        )
+    }
+
+    fn string<'v>(
+        #[starlark(this)] this: &AttrModule,
+        #[starlark(require = named)] default: Option<Value<'v>>,
+        doc: Option<NoneOr<String>>,
+        mandatory: Option<bool>,
+        values: Option<UnpackList<String>>,
+        eval: &mut Evaluator<'v, '_, '_>,
+    ) -> starlark::Result<Value<'v>> {
+        (this.make_attr_schema)(
+            AttrKind::String {
+                allowed: values.map(|v| v.into_iter().collect()),
+            },
+            AttrSpecArgs {
+                default,
+                doc,
+                mandatory,
+                ..Default::default()
+            },
+            eval,
+        )
+    }
+
+    fn string_dict<'v>(
+        #[starlark(this)] this: &AttrModule,
+        #[starlark(require = named)] default: Option<Value<'v>>,
+        doc: Option<NoneOr<String>>,
+        mandatory: Option<bool>,
+        allow_empty: Option<bool>,
+        eval: &mut Evaluator<'v, '_, '_>,
+    ) -> starlark::Result<Value<'v>> {
+        (this.make_attr_schema)(
+            AttrKind::StringDict,
+            AttrSpecArgs {
+                default,
+                doc,
+                mandatory,
+                allow_empty,
+                ..Default::default()
+            },
+            eval,
+        )
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    fn string_keyed_label_dict<'v>(
+        #[starlark(this)] this: &AttrModule,
+        #[starlark(require = named)] default: Option<Value<'v>>,
+        doc: Option<NoneOr<String>>,
+        mandatory: Option<bool>,
+        allow_empty: Option<bool>,
+        allow_files: Option<AllowFiles>,
+        cfg: Option<AttrCfg>,
+        eval: &mut Evaluator<'v, '_, '_>,
+    ) -> starlark::Result<Value<'v>> {
+        (this.make_attr_schema)(
+            AttrKind::StringKeyedLabelDict,
+            AttrSpecArgs {
+                default,
+                doc,
+                mandatory,
+                allow_empty,
+                allow_files,
+                cfg,
+                ..Default::default()
+            },
+            eval,
+        )
+    }
+
+    fn string_list<'v>(
+        #[starlark(this)] this: &AttrModule,
+        #[starlark(require = named)] default: Option<Value<'v>>,
+        doc: Option<NoneOr<String>>,
+        mandatory: Option<bool>,
+        allow_empty: Option<bool>,
+        eval: &mut Evaluator<'v, '_, '_>,
+    ) -> starlark::Result<Value<'v>> {
+        (this.make_attr_schema)(
+            AttrKind::StringList,
+            AttrSpecArgs {
+                default,
+                doc,
+                mandatory,
+                allow_empty,
+                ..Default::default()
+            },
+            eval,
+        )
+    }
+
+    fn string_list_dict<'v>(
+        #[starlark(this)] this: &AttrModule,
+        #[starlark(require = named)] default: Option<Value<'v>>,
+        doc: Option<NoneOr<String>>,
+        mandatory: Option<bool>,
+        allow_empty: Option<bool>,
+        eval: &mut Evaluator<'v, '_, '_>,
+    ) -> starlark::Result<Value<'v>> {
+        (this.make_attr_schema)(
+            AttrKind::StringListDict,
+            AttrSpecArgs {
+                default,
+                doc,
+                mandatory,
+                allow_empty,
+                ..Default::default()
+            },
+            eval,
+        )
+    }
+}
+
+#[cfg(test)]
+pub(crate) mod tests {
+    use starlark::assert::Assert;
+
+    use super::*;
+    use crate::AttrSchema;
+
+    fn make_attr_schema<'v>(
+        kind: crate::AttrKind,
+        args: AttrSpecArgs<'v>,
+        eval: &mut Evaluator<'v, '_, '_>,
+    ) -> starlark::Result<starlark::values::Value<'v>> {
+        let package = types::PackageRef::root();
+        AttrSchema::create(
+            kind,
+            args,
+            package,
+            &types::PathResolver::new_for_testing(),
+            &eval.heap(),
+        )
+    }
+
+    pub fn new_attr_assert() -> Assert<'static> {
+        let mut assert = Assert::new();
+        assert.globals_add(|builder| {
+            builder.set("attr", super::AttrModule { make_attr_schema });
+        });
+        assert
+    }
+}
diff --git a/src/gn/starlark/crates/attr/src/lib.rs b/src/gn/starlark/crates/attr/src/lib.rs
new file mode 100644
index 0000000..c3819d6
--- /dev/null
+++ b/src/gn/starlark/crates/attr/src/lib.rs
@@ -0,0 +1,19 @@
+// 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.
+
+pub mod allow_files;
+pub mod attr;
+pub mod cfg;
+pub mod errors;
+pub mod globals;
+pub mod schema;
+pub mod traits;
+
+pub use allow_files::AllowFiles;
+pub use attr::{Attr, LabelOrFile};
+pub use cfg::AttrCfg;
+pub use errors::Error;
+pub use globals::{AttrModule, AttrSpecArgs};
+pub use schema::{AllowFilesSchema, AttrKind, AttrSchema};
+pub use traits::{EvalContext, EvalContextAttrExt, Session, TargetRef};
diff --git a/src/gn/starlark/crates/attr/src/schema.rs b/src/gn/starlark/crates/attr/src/schema.rs
new file mode 100644
index 0000000..642f99e
--- /dev/null
+++ b/src/gn/starlark/crates/attr/src/schema.rs
@@ -0,0 +1,422 @@
+// 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,
+    fmt::{Display, Formatter},
+};
+
+use allocative::Allocative;
+use starlark::{
+    starlark_simple_value,
+    values::{
+        none::NoneOr, Freeze, FreezeResult, Freezer, Heap, ProvidesStaticType, StarlarkValue,
+        Trace, Value,
+    },
+};
+use starlark_derive::{starlark_value, NoSerialize};
+use types::{PackageRef, PathResolver};
+
+use crate::{allow_files::AllowFiles, cfg::AttrCfg, globals::AttrSpecArgs, Attr};
+
+/// The underlying data type of a target attribute (e.g. Bool, String,
+/// LabelList).
+#[derive(Debug, Clone, PartialEq, Eq, Allocative)]
+pub enum AttrKind {
+    Bool,
+    // Allowed is typically *very* small, so we use a Vec.
+    Int { allowed: Option<Vec<i32>> },
+    IntList,
+    Label,
+    LabelKeyedStringDict,
+    LabelList,
+    LabelListDict,
+    // Allowed is typically *very* small, so we use a Vec.
+    String { allowed: Option<Vec<String>> },
+    StringDict,
+    StringKeyedLabelDict,
+    StringList,
+    StringListDict,
+}
+
+/// Schema specifying what files (single or multiple) are allowed on a
+/// label-like attribute.
+#[derive(Debug, Clone, PartialEq, Eq, Allocative)]
+pub enum AllowFilesSchema {
+    None,
+    Single(AllowFiles),
+    Many(AllowFiles),
+}
+
+/// Represents an attr.foo(...) parameter.
+/// Eg. attr.label(allow_single_file = True)
+#[derive(Debug, Clone, PartialEq, Eq, Trace, NoSerialize, Allocative)]
+pub struct AttrSchema {
+    pub(crate) kind: AttrKind,
+    pub(crate) default: Option<Attr>,
+    pub(crate) disallow_empty: bool,
+    pub(crate) allow_files: AllowFilesSchema,
+    pub(crate) cfg: AttrCfg,
+    pub(crate) doc: String,
+}
+
+// Safety: AttrSchema does not contain lifetime parameters, so it satisfies the
+// lifetime requirement of StaticType.
+unsafe impl ProvidesStaticType<'_> for AttrSchema {
+    type StaticType = Self;
+}
+
+starlark_simple_value!(AttrSchema);
+
+impl Freeze for AttrSchema {
+    type Frozen = Self;
+
+    fn freeze(self, _freezer: &Freezer) -> FreezeResult<Self::Frozen> {
+        Ok(self)
+    }
+}
+
+impl Display for AttrSchema {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        write!(f, "{self:?}")
+    }
+}
+
+#[starlark_value(type = "AttrSchema")]
+// Clippy recommends eliding the 'v lifetime, but the #[starlark_value] macro
+// requires it to be explicitly declared.
+#[allow(clippy::elidable_lifetime_names)]
+impl<'v> StarlarkValue<'v> for AttrSchema {
+    fn collect_repr(&self, collector: &mut String) {
+        use std::fmt::Write as _;
+        write!(collector, "{self:?}").unwrap();
+    }
+}
+
+impl AttrSchema {
+    /// Creates an `AttrSchema` from validation attributes and registers it.
+    pub fn create<'v>(
+        kind: AttrKind,
+        args: AttrSpecArgs<'v>,
+        package: &PackageRef,
+        path_resolver: &PathResolver,
+        heap: &Heap<'v>,
+    ) -> starlark::Result<Value<'v>> {
+        let mut schema = Self {
+            kind,
+            default: None,
+            disallow_empty: match args.allow_empty {
+                None => false,
+                Some(b) => !b,
+            },
+            allow_files: match (
+                args.allow_single_file.unwrap_or(AllowFiles::None),
+                args.allow_files.unwrap_or(AllowFiles::None),
+            ) {
+                (AllowFiles::None, AllowFiles::None) => AllowFilesSchema::None,
+                (af, AllowFiles::None) => AllowFilesSchema::Single(af),
+                (AllowFiles::None, af) => AllowFilesSchema::Many(af),
+                _ => return Err(crate::Error::AllowFilesMutuallyExclusive.into()),
+            },
+            cfg: args.cfg.unwrap_or(AttrCfg::CurrentToolchain),
+            doc: match args.doc {
+                None | Some(NoneOr::None) => String::new(),
+                Some(NoneOr::Other(s)) => s,
+            },
+        };
+
+        // Unify explicit and implicit none specifically for labels.
+        // attr.label is the only type for which None is a valid value.
+        let default = match args.default {
+            None => None,
+            Some(x) => {
+                if schema.kind == AttrKind::Label && x.is_none() {
+                    None
+                } else {
+                    Some(x)
+                }
+            },
+        };
+
+        let mandatory = args.mandatory.unwrap_or(false);
+        if schema.disallow_empty && !mandatory && default.is_none() {
+            return Err(crate::Error::AllowEmptyRequiresMandatoryOrDefault.into());
+        }
+
+        schema.default = match (mandatory, default) {
+            (true, None) => None,
+            (false, None) => Some(match &schema.kind {
+                AttrKind::Bool => Attr::Bool(false),
+                // If I create an `attr.int(values = [1])` and don't provide a value to bazel, it
+                // sets the default value 0. This is wierd, but by providing this,
+                // we maintain consistency with bazel.
+                AttrKind::Int { .. } => Attr::Int(0),
+                AttrKind::String { .. } => Attr::String(Default::default()),
+                AttrKind::IntList => Attr::IntList(Default::default()),
+                AttrKind::StringList => Attr::StringList(Default::default()),
+                AttrKind::LabelList => Attr::LabelList(Default::default()),
+                AttrKind::StringListDict => Attr::StringListDict(Default::default()),
+                AttrKind::StringDict => Attr::StringDict(Default::default()),
+                AttrKind::LabelKeyedStringDict => Attr::LabelKeyedStringDict(Default::default()),
+                AttrKind::StringKeyedLabelDict => Attr::StringKeyedLabelDict(Default::default()),
+                AttrKind::LabelListDict => Attr::LabelListDict(Default::default()),
+                AttrKind::Label => Attr::Label(None),
+            }),
+            (false, Some(v)) => {
+                // We provide an empty param name because you can write the following code:
+                // p = attr.string(default = 1)
+                // rule(..., attrs = {"foo": p})
+                // This means that at the time you call attr.string, no parameter name has
+                // been set.
+                Some(Attr::create_without_defaults(
+                    &schema,
+                    v,
+                    package,
+                    path_resolver,
+                )?)
+            },
+            (true, Some(_)) => {
+                return Err(crate::Error::MandatoryAndDefaultMutuallyExclusive.into());
+            },
+        };
+
+        Ok(heap.alloc(schema))
+    }
+
+    /// Returns the allowed files schema for this attribute.
+    pub fn allow_files(&self) -> &AllowFilesSchema {
+        &self.allow_files
+    }
+
+    /// Returns the default value of this attribute, if any.
+    pub fn default(&self) -> Option<&Attr> {
+        self.default.as_ref()
+    }
+
+    /// Returns the file matcher if this attribute schema allows files.
+    pub fn file_matcher(&self) -> Option<&AllowFiles> {
+        match &self.allow_files {
+            AllowFilesSchema::Single(s) => Some(s),
+            AllowFilesSchema::Many(s) => Some(s),
+            AllowFilesSchema::None => None,
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use types::Label;
+
+    use super::*;
+    use crate::{globals::tests::new_attr_assert, LabelOrFile};
+
+    #[track_caller]
+    fn assert_eq_schema(
+        a: &mut starlark::assert::Assert<'static>,
+        code: &str,
+        expected: &AttrSchema,
+    ) {
+        let val = a.pass(code);
+        let unpacked: &AttrSchema =
+            starlark::values::UnpackValue::unpack_value_err(val.value()).unwrap();
+        assert_eq!(unpacked, expected);
+    }
+
+    #[test]
+    fn test_schema_bool() {
+        let mut a = new_attr_assert();
+
+        assert_eq_schema(
+            &mut a,
+            "attr.bool()",
+            &AttrSchema {
+                kind: AttrKind::Bool,
+                default: Some(Attr::Bool(false)),
+                disallow_empty: false,
+                allow_files: AllowFilesSchema::None,
+                cfg: AttrCfg::CurrentToolchain,
+                doc: String::new(),
+            },
+        );
+
+        a.fail("attr.bool(default=None)", "Expected `bool`");
+    }
+
+    #[test]
+    fn test_schema_int() {
+        let mut a = new_attr_assert();
+
+        assert_eq_schema(
+            &mut a,
+            "attr.int(default=42, doc='An integer')",
+            &AttrSchema {
+                kind: AttrKind::Int { allowed: None },
+                default: Some(Attr::Int(42)),
+                disallow_empty: false,
+                allow_files: AllowFilesSchema::None,
+                cfg: AttrCfg::CurrentToolchain,
+                doc: "An integer".to_string(),
+            },
+        );
+
+        a.fail(
+            "attr.int(values=[1, 2], default=3)",
+            "value 3 is not in allowed set",
+        );
+    }
+
+    #[test]
+    fn test_schema_string() {
+        let mut a = new_attr_assert();
+
+        assert_eq_schema(
+            &mut a,
+            "attr.string(values=['foo', 'bar'], mandatory=True)",
+            &AttrSchema {
+                kind: AttrKind::String {
+                    allowed: Some(vec!["foo".to_string(), "bar".to_string()]),
+                },
+                default: None,
+                disallow_empty: false,
+                allow_files: AllowFilesSchema::None,
+                cfg: AttrCfg::CurrentToolchain,
+                doc: String::new(),
+            },
+        );
+
+        a.fail(
+            "attr.string(values=['a', 'b'], default='c')",
+            "value \"c\" is not in allowed set",
+        );
+
+        a.fail("attr.string('hello')", "Found 1 extra positional argument");
+    }
+
+    #[test]
+    fn test_schema_label() {
+        let mut a = new_attr_assert();
+
+        assert_eq_schema(
+            &mut a,
+            "attr.label(allow_files=True, default=':foo')",
+            &AttrSchema {
+                kind: AttrKind::Label,
+                default: Some(Attr::Label(Some(LabelOrFile::Label(Label::new(
+                    PackageRef::root().to_owned(),
+                    "foo".to_owned(),
+                ))))),
+                disallow_empty: false,
+                allow_files: AllowFilesSchema::Many(AllowFiles::All),
+                cfg: AttrCfg::CurrentToolchain,
+                doc: String::new(),
+            },
+        );
+
+        assert_eq_schema(
+            &mut a,
+            "attr.label(default=None)",
+            &AttrSchema {
+                kind: AttrKind::Label,
+                default: Some(Attr::Label(None)),
+                disallow_empty: false,
+                allow_files: AllowFilesSchema::None,
+                cfg: AttrCfg::CurrentToolchain,
+                doc: String::new(),
+            },
+        );
+    }
+
+    #[test]
+    fn test_schema_string_list() {
+        let mut a = new_attr_assert();
+
+        assert_eq_schema(
+            &mut a,
+            "attr.string_list(allow_empty=False, mandatory=True)",
+            &AttrSchema {
+                kind: AttrKind::StringList,
+                default: None,
+                disallow_empty: true,
+                allow_files: AllowFilesSchema::None,
+                cfg: AttrCfg::CurrentToolchain,
+                doc: String::new(),
+            },
+        );
+
+        a.fail(
+            "attr.string_list(allow_empty=False)",
+            "allow_empty = False requires the attribute to be mandatory or have a non-empty default value",
+        );
+        a.fail(
+            "attr.string_list(allow_empty=False, default=[])",
+            "value cannot be empty",
+        );
+    }
+
+    #[test]
+    fn test_schema_string_default() {
+        let mut a = new_attr_assert();
+
+        assert_eq_schema(
+            &mut a,
+            "attr.string(default='hello')",
+            &AttrSchema {
+                kind: AttrKind::String { allowed: None },
+                default: Some(Attr::String("hello".to_string())),
+                disallow_empty: false,
+                allow_files: AllowFilesSchema::None,
+                cfg: AttrCfg::CurrentToolchain,
+                doc: String::new(),
+            },
+        );
+    }
+
+    #[test]
+    fn test_schema_string_dict() {
+        let a = new_attr_assert();
+
+        a.fail(
+            "attr.string_dict(allow_empty=False, default={})",
+            "value cannot be empty",
+        );
+    }
+
+    #[test]
+    fn test_schema_allow_files_error() {
+        let a = new_attr_assert();
+
+        a.fail(
+            "attr.label(allow_files=True, allow_single_file=True)",
+            "allow_files and allow_single_file are mutually exclusive",
+        );
+    }
+
+    #[test]
+    fn test_schema_mandatory_default_error() {
+        let a = new_attr_assert();
+
+        a.fail(
+            "attr.bool(mandatory=True, default=True)",
+            "mandatory and default are mutually exclusive",
+        );
+    }
+
+    #[test]
+    fn test_schema_label_keyed_string_dict() {
+        let mut a = new_attr_assert();
+
+        assert_eq_schema(
+            &mut a,
+            "attr.label_keyed_string_dict(allow_files=True, mandatory=True)",
+            &AttrSchema {
+                kind: AttrKind::LabelKeyedStringDict,
+                default: None,
+                disallow_empty: false,
+                allow_files: AllowFilesSchema::Many(AllowFiles::All),
+                cfg: AttrCfg::CurrentToolchain,
+                doc: String::new(),
+            },
+        );
+    }
+}
diff --git a/src/gn/starlark/crates/attr/src/traits.rs b/src/gn/starlark/crates/attr/src/traits.rs
new file mode 100644
index 0000000..87132af
--- /dev/null
+++ b/src/gn/starlark/crates/attr/src/traits.rs
@@ -0,0 +1,16 @@
+// 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.
+
+/// Re-export the traits from types so caller crates can access them seamlessly.
+pub use types::{EvalContext, Session, TargetRef};
+
+/// Extension trait for EvalContext to support target creation.
+pub trait EvalContextAttrExt: types::EvalContext {
+    fn create_starlark_target(
+        &self,
+        target_name: &str,
+        rule: starlark::values::FrozenValue,
+        attrs: Vec<crate::Attr>,
+    ) -> starlark::Result<<Self::Session as types::Session>::TargetRef>;
+}
diff --git a/src/gn/starlark/src/testdata/allow_files/bar.cc b/src/gn/starlark/src/testdata/allow_files/bar.cc
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/gn/starlark/src/testdata/allow_files/bar.cc
diff --git a/src/gn/starlark/src/testdata/allow_files/cc b/src/gn/starlark/src/testdata/allow_files/cc
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/gn/starlark/src/testdata/allow_files/cc
diff --git a/src/gn/starlark/src/testdata/allow_files/file.cc b/src/gn/starlark/src/testdata/allow_files/file.cc
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/gn/starlark/src/testdata/allow_files/file.cc
diff --git a/src/gn/starlark/src/testdata/allow_files/file.h b/src/gn/starlark/src/testdata/allow_files/file.h
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/gn/starlark/src/testdata/allow_files/file.h
diff --git a/src/gn/starlark/src/testdata/allow_files/foo.cc b/src/gn/starlark/src/testdata/allow_files/foo.cc
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/gn/starlark/src/testdata/allow_files/foo.cc
diff --git a/src/gn/starlark/src/testdata/allow_files/subdir/file.cc b/src/gn/starlark/src/testdata/allow_files/subdir/file.cc
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/gn/starlark/src/testdata/allow_files/subdir/file.cc
diff --git a/src/gn/starlark/src/testdata/allow_files/test.foo.cc b/src/gn/starlark/src/testdata/allow_files/test.foo.cc
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/gn/starlark/src/testdata/allow_files/test.foo.cc
diff --git a/src/gn/starlark/src/testdata/foo/file.cc b/src/gn/starlark/src/testdata/foo/file.cc
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/gn/starlark/src/testdata/foo/file.cc