From ee83d2fb36f5e081e02de4b1217398e17c1b3c6f Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Fri, 29 May 2026 15:50:05 -0500 Subject: [PATCH 1/9] =?UTF-8?q?feat(r3):=20add=20FactoryTarget::Service=20?= =?UTF-8?q?and=20=C9=B5=C9=B5defineService=20identifier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the AOT @Service decorator handler. Routes Service through the same ɵɵinject token resolution as Injectable — upstream's getInjectFn in r3_factory.ts falls through to inject() for unrecognized targets, and the v22 service runtime expects deps to be resolved via inject() calls in the constructor body rather than the ɵfac. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/oxc_angular_compiler/src/factory/compiler.rs | 4 +++- crates/oxc_angular_compiler/src/factory/metadata.rs | 4 ++++ crates/oxc_angular_compiler/src/r3/identifiers.rs | 5 +++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/oxc_angular_compiler/src/factory/compiler.rs b/crates/oxc_angular_compiler/src/factory/compiler.rs index 91dd0fa6c..ec7a75e60 100644 --- a/crates/oxc_angular_compiler/src/factory/compiler.rs +++ b/crates/oxc_angular_compiler/src/factory/compiler.rs @@ -661,7 +661,9 @@ fn get_inject_fn(target: FactoryTarget) -> &'static str { FactoryTarget::Component | FactoryTarget::Directive | FactoryTarget::Pipe => { Identifiers::DIRECTIVE_INJECT } - FactoryTarget::NgModule | FactoryTarget::Injectable => Identifiers::INJECT, + FactoryTarget::NgModule | FactoryTarget::Injectable | FactoryTarget::Service => { + Identifiers::INJECT + } } } diff --git a/crates/oxc_angular_compiler/src/factory/metadata.rs b/crates/oxc_angular_compiler/src/factory/metadata.rs index 43f90cd10..7d7b9f2dd 100644 --- a/crates/oxc_angular_compiler/src/factory/metadata.rs +++ b/crates/oxc_angular_compiler/src/factory/metadata.rs @@ -20,6 +20,10 @@ pub enum FactoryTarget { NgModule, /// Injectable factory. Injectable, + /// Service factory (Angular v22+ `@Service`). Uses the same `ɵɵinject` token + /// resolution as `Injectable` — the v22 service runtime expects deps to be + /// resolved via `inject()` calls in the constructor body, not the ɵfac. + Service, } /// Delegate type for delegated factories. diff --git a/crates/oxc_angular_compiler/src/r3/identifiers.rs b/crates/oxc_angular_compiler/src/r3/identifiers.rs index 47d167788..3787ffe5a 100644 --- a/crates/oxc_angular_compiler/src/r3/identifiers.rs +++ b/crates/oxc_angular_compiler/src/r3/identifiers.rs @@ -673,6 +673,11 @@ impl Identifiers { /// Injectable declaration type. pub const INJECTABLE_DECLARATION: &'static str = "ɵɵInjectableDeclaration"; + /// Define a service (Angular v22+ `@Service`). The declared static field is + /// still `ɵprov` and the `.d.ts` type is still `ɵɵInjectableDeclaration` — + /// only the initializer call changes. + pub const DEFINE_SERVICE: &'static str = "ɵɵdefineService"; + // ======================================================================== // Resolution Instructions // ======================================================================== From 66fd9e3a264b17f0e856a31581d13cf3ef2995be Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Fri, 29 May 2026 15:54:13 -0500 Subject: [PATCH 2/9] feat(service): port R3ServiceMetadata and compile_service from upstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New src/service/ module mirrors src/injectable/ but slimmer to match the v22 @Service decorator's scope: - No providedIn or useClass/Factory/Value/Existing provider variants. - ɵfac is generated with empty constructor deps — upstream's service.ts:178 calls `toFactoryMetadata({...meta, deps: []}, FactoryTarget.Service)`, since the v22 service runtime resolves DI via `inject()` calls in the constructor body rather than the ɵfac. - ɵprov emits `ɵɵdefineService({token, factory, autoProvided?})`. The `autoProvided` entry is only present when the user explicitly disables it, matching upstream's `if (meta.autoProvided === false)` gate. - User-supplied `factory:` from the decorator is re-parsed and wrapped in an arrow `() => factory()`, per service_compiler.ts:35-42. The decorator parser matches by identifier name only; the AOT wiring that follows will mirror the JIT path's import-map gate to ensure a non-Angular `@Service` library export doesn't shadow real decorators. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/oxc_angular_compiler/src/lib.rs | 8 + .../src/service/compiler.rs | 278 ++++++++++++++++++ .../src/service/decorator.rs | 184 ++++++++++++ .../src/service/definition.rs | 110 +++++++ .../src/service/metadata.rs | 41 +++ .../oxc_angular_compiler/src/service/mod.rs | 26 ++ 6 files changed, 647 insertions(+) create mode 100644 crates/oxc_angular_compiler/src/service/compiler.rs create mode 100644 crates/oxc_angular_compiler/src/service/decorator.rs create mode 100644 crates/oxc_angular_compiler/src/service/definition.rs create mode 100644 crates/oxc_angular_compiler/src/service/metadata.rs create mode 100644 crates/oxc_angular_compiler/src/service/mod.rs diff --git a/crates/oxc_angular_compiler/src/lib.rs b/crates/oxc_angular_compiler/src/lib.rs index 9a87a1a27..9dd7685a0 100644 --- a/crates/oxc_angular_compiler/src/lib.rs +++ b/crates/oxc_angular_compiler/src/lib.rs @@ -47,6 +47,7 @@ pub mod pipe; pub mod pipeline; pub mod r3; pub mod schema; +pub mod service; pub mod styles; pub mod transform; @@ -110,6 +111,13 @@ pub use injectable::{ generate_injectable_definition, generate_injectable_definition_from_decorator, }; +// Re-export service types +pub use service::{ + R3ServiceMetadata, ServiceCompileResult, ServiceDefinition, ServiceMetadata, compile_service, + extract_service_metadata, find_service_decorator, find_service_decorator_span, + generate_service_definition, generate_service_definition_from_decorator, +}; + // Re-export ng_module types pub use ng_module::{ NgModuleCompileResult, NgModuleDefinition, NgModuleMetadata, R3NgModuleMetadata, diff --git a/crates/oxc_angular_compiler/src/service/compiler.rs b/crates/oxc_angular_compiler/src/service/compiler.rs new file mode 100644 index 000000000..87f95e94a --- /dev/null +++ b/crates/oxc_angular_compiler/src/service/compiler.rs @@ -0,0 +1,278 @@ +//! Service compilation implementation. +//! +//! Ported from Angular's `service_compiler.ts:compileService`. +//! +//! Generates the `ɵprov` initializer for `@Service`-decorated classes: +//! ```javascript +//! ɵprov = /*@__PURE__*/ ɵɵdefineService({ +//! token: MyService, +//! factory: MyService.ɵfac +//! }); +//! ``` +//! +//! When the user passes `autoProvided: false`, the field is emitted. When the +//! user supplies a custom `factory` expression, it is wrapped in an arrow +//! function: `() => factory()` (per upstream `service_compiler.ts:35-42`). + +use oxc_allocator::{Allocator, Box, Vec}; +use oxc_str::Ident; + +use super::metadata::R3ServiceMetadata; +use crate::output::ast::{ + FunctionExpr, InvokeFunctionExpr, LiteralExpr, LiteralMapEntry, LiteralMapExpr, LiteralValue, + OutputExpression, OutputStatement, ReadPropExpr, ReadVarExpr, ReturnStatement, +}; +use crate::r3::Identifiers; + +/// Result of compiling a service. +#[derive(Debug)] +pub struct ServiceCompileResult<'a> { + /// The compiled expression: `ɵɵdefineService({...})`. + pub expression: OutputExpression<'a>, +} + +/// Compiles a service from its metadata into an `ɵɵdefineService(...)` call. +pub fn compile_service<'a>( + allocator: &'a Allocator, + metadata: &R3ServiceMetadata<'a>, +) -> ServiceCompileResult<'a> { + let factory_expr = build_factory_expression(allocator, metadata); + let definition_map = build_definition_map(allocator, metadata, factory_expr); + let expression = create_define_service_call(allocator, definition_map); + + ServiceCompileResult { expression } +} + +/// Build the `factory` entry of the definition map. +/// +/// Two cases, mirroring `service_compiler.ts:33-42`: +/// - `meta.factory === undefined` → `delegateToFactory(...)` → `MyService.ɵfac`. +/// - `meta.factory` supplied → arrow wrapper `() => factory()`. +fn build_factory_expression<'a>( + allocator: &'a Allocator, + metadata: &R3ServiceMetadata<'a>, +) -> OutputExpression<'a> { + match &metadata.factory { + None => create_factory_delegation(allocator, &metadata.r#type), + Some(user_factory) => create_factory_arrow(allocator, user_factory), + } +} + +/// `MyService.ɵfac` +fn create_factory_delegation<'a>( + allocator: &'a Allocator, + type_expr: &OutputExpression<'a>, +) -> OutputExpression<'a> { + OutputExpression::ReadProp(Box::new_in( + ReadPropExpr { + receiver: Box::new_in(type_expr.clone_in(allocator), allocator), + name: Ident::from("ɵfac"), + optional: false, + source_span: None, + }, + allocator, + )) +} + +/// `() => factory()` — wraps a user-supplied factory in an arrow that calls it. +fn create_factory_arrow<'a>( + allocator: &'a Allocator, + factory: &OutputExpression<'a>, +) -> OutputExpression<'a> { + let params = Vec::new_in(allocator); + + let factory_call = OutputExpression::InvokeFunction(Box::new_in( + InvokeFunctionExpr { + fn_expr: Box::new_in(factory.clone_in(allocator), allocator), + args: Vec::new_in(allocator), + pure: false, + optional: false, + source_span: None, + }, + allocator, + )); + + let mut body = Vec::new_in(allocator); + body.push(OutputStatement::Return(Box::new_in( + ReturnStatement { value: factory_call, source_span: None }, + allocator, + ))); + + OutputExpression::Function(Box::new_in( + FunctionExpr { name: None, params, statements: body, source_span: None }, + allocator, + )) +} + +fn build_definition_map<'a>( + allocator: &'a Allocator, + metadata: &R3ServiceMetadata<'a>, + factory_expr: OutputExpression<'a>, +) -> Vec<'a, LiteralMapEntry<'a>> { + let mut entries = Vec::new_in(allocator); + + entries.push(LiteralMapEntry::new( + Ident::from("token"), + metadata.r#type.clone_in(allocator), + false, + )); + + entries.push(LiteralMapEntry::new(Ident::from("factory"), factory_expr, false)); + + // Only emit autoProvided when explicitly disabled — matches upstream's + // `if (meta.autoProvided === false)` gate in service_compiler.ts:45-47. + if matches!(metadata.auto_provided, Some(false)) { + entries.push(LiteralMapEntry::new( + Ident::from("autoProvided"), + OutputExpression::Literal(Box::new_in( + LiteralExpr { value: LiteralValue::Boolean(false), source_span: None }, + allocator, + )), + false, + )); + } + + entries +} + +/// `/*@__PURE__*/ i0.ɵɵdefineService({...})` +fn create_define_service_call<'a>( + allocator: &'a Allocator, + definition_map: Vec<'a, LiteralMapEntry<'a>>, +) -> OutputExpression<'a> { + let define_service_fn = OutputExpression::ReadProp(Box::new_in( + ReadPropExpr { + receiver: Box::new_in( + OutputExpression::ReadVar(Box::new_in( + ReadVarExpr { name: Ident::from("i0"), source_span: None }, + allocator, + )), + allocator, + ), + name: Ident::from(Identifiers::DEFINE_SERVICE), + optional: false, + source_span: None, + }, + allocator, + )); + + let map_expr = OutputExpression::LiteralMap(Box::new_in( + LiteralMapExpr { entries: definition_map, source_span: None }, + allocator, + )); + + let mut args = Vec::new_in(allocator); + args.push(map_expr); + + OutputExpression::InvokeFunction(Box::new_in( + InvokeFunctionExpr { + fn_expr: Box::new_in(define_service_fn, allocator), + args, + pure: true, + optional: false, + source_span: None, + }, + allocator, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::emitter::JsEmitter; + + fn make_type_expr<'a>(allocator: &'a Allocator, name: &'static str) -> OutputExpression<'a> { + OutputExpression::ReadVar(Box::new_in( + ReadVarExpr { name: Ident::from(name), source_span: None }, + allocator, + )) + } + + #[test] + fn compiles_basic_service() { + let allocator = Allocator::default(); + let metadata = R3ServiceMetadata { + name: Ident::from("MyService"), + r#type: make_type_expr(&allocator, "MyService"), + type_argument_count: 0, + auto_provided: None, + factory: None, + }; + + let result = compile_service(&allocator, &metadata); + let js = JsEmitter::new().emit_expression(&result.expression); + + assert!(js.contains("ɵɵdefineService"), "should emit defineService. Got: {js}"); + assert!(js.contains("token:MyService"), "should reference class as token. Got: {js}"); + assert!(js.contains("factory:MyService.ɵfac"), "should delegate to ɵfac. Got: {js}"); + assert!( + !js.contains("autoProvided"), + "autoProvided should be omitted when None. Got: {js}" + ); + } + + #[test] + fn emits_auto_provided_false() { + let allocator = Allocator::default(); + let metadata = R3ServiceMetadata { + name: Ident::from("MyService"), + r#type: make_type_expr(&allocator, "MyService"), + type_argument_count: 0, + auto_provided: Some(false), + factory: None, + }; + + let result = compile_service(&allocator, &metadata); + let js = JsEmitter::new().emit_expression(&result.expression); + + assert!(js.contains("autoProvided:false"), "should emit autoProvided: false. Got: {js}"); + } + + #[test] + fn does_not_emit_auto_provided_true() { + let allocator = Allocator::default(); + let metadata = R3ServiceMetadata { + name: Ident::from("MyService"), + r#type: make_type_expr(&allocator, "MyService"), + type_argument_count: 0, + auto_provided: Some(true), + factory: None, + }; + + let result = compile_service(&allocator, &metadata); + let js = JsEmitter::new().emit_expression(&result.expression); + + assert!( + !js.contains("autoProvided"), + "autoProvided: true should match default and be omitted. Got: {js}" + ); + } + + #[test] + fn wraps_custom_factory_in_arrow() { + let allocator = Allocator::default(); + let factory_expr = OutputExpression::ReadVar(Box::new_in( + ReadVarExpr { name: Ident::from("makeService"), source_span: None }, + &allocator, + )); + let metadata = R3ServiceMetadata { + name: Ident::from("MyService"), + r#type: make_type_expr(&allocator, "MyService"), + type_argument_count: 0, + auto_provided: None, + factory: Some(factory_expr), + }; + + let result = compile_service(&allocator, &metadata); + let js = JsEmitter::new().emit_expression(&result.expression); + + assert!( + js.contains("makeService()"), + "should call user factory inside arrow. Got: {js}" + ); + assert!( + !js.contains("MyService.ɵfac"), + "should not delegate to ɵfac when custom factory supplied. Got: {js}" + ); + } +} diff --git a/crates/oxc_angular_compiler/src/service/decorator.rs b/crates/oxc_angular_compiler/src/service/decorator.rs new file mode 100644 index 000000000..fa4464ce0 --- /dev/null +++ b/crates/oxc_angular_compiler/src/service/decorator.rs @@ -0,0 +1,184 @@ +//! Angular `@Service` decorator parser (v22+). +//! +//! Extracts metadata from `@Service({...})` decorators on TypeScript class +//! declarations. Ported from `packages/compiler-cli/src/ngtsc/annotations/src/service.ts`. +//! +//! The caller is responsible for confirming that the `Service` identifier +//! resolves to `@angular/core` (via the file's import map) before invoking +//! `extract_service_metadata`. `Service` is a common library export name, so +//! name-only matching would misclassify unrelated decorators. The matching +//! helpers here intentionally only check the identifier name — see the JIT +//! pipeline at `component/transform.rs::find_angular_decorator` for the +//! upstream import-map gate that the AOT caller mirrors. + +use oxc_allocator::{Allocator, Box}; +use oxc_ast::ast::{ + Argument, Class, Decorator, Expression, ObjectPropertyKind, PropertyKey, +}; +use oxc_span::{GetSpan, Span}; +use oxc_str::Ident; + +use super::metadata::R3ServiceMetadata; +use crate::output::ast::{OutputExpression, ReadVarExpr}; + +/// Extracted metadata from a `@Service` decorator. +#[derive(Debug)] +pub struct ServiceMetadata<'a> { + /// The name of the service class. + pub class_name: Ident<'a>, + /// Span of the class declaration. + pub class_span: Span, + /// `autoProvided: false` flag, if explicitly disabled by the user. + pub auto_provided: Option, + /// User-supplied `factory: ...` expression, captured as raw source text. + pub factory: Option<&'a str>, +} + +/// Find the `@Service` decorator node on a class (by identifier name only). +pub fn find_service_decorator<'a>( + decorators: &'a [Decorator<'a>], +) -> Option<&'a Decorator<'a>> { + decorators.iter().find(|d| is_service_decorator(d)) +} + +/// Find the span of the `@Service` decorator on a class. +pub fn find_service_decorator_span(class: &Class<'_>) -> Option { + class.decorators.iter().find(|d| is_service_decorator(d)).map(|d| d.span) +} + +/// Extract `@Service` metadata from a class. +/// +/// The caller must have already confirmed that the `Service` identifier +/// resolves to `@angular/core` — this function matches by name only and will +/// happily return metadata for an unrelated `@Service` from a third-party +/// library. +pub fn extract_service_metadata<'a>( + _allocator: &'a Allocator, + class: &'a Class<'a>, + source_text: Option<&'a str>, +) -> Option> { + let class_name: Ident<'a> = class.id.as_ref()?.name.clone().into(); + let class_span = class.span; + + let decorator = class.decorators.iter().find(|d| is_service_decorator(d))?; + + let call_expr = match &decorator.expression { + Expression::CallExpression(call) => call, + _ => return None, + }; + + if call_expr.arguments.is_empty() { + return Some(ServiceMetadata { + class_name, + class_span, + auto_provided: None, + factory: None, + }); + } + + let config_obj = match &call_expr.arguments[0] { + Argument::ObjectExpression(obj) => obj, + _ => return None, + }; + + let mut auto_provided: Option = None; + let mut factory: Option<&'a str> = None; + + for prop in &config_obj.properties { + let ObjectPropertyKind::ObjectProperty(prop) = prop else { continue }; + let Some(key) = get_property_key_name(&prop.key) else { continue }; + + match key.as_str() { + "autoProvided" => { + if let Expression::BooleanLiteral(bool_lit) = &prop.value { + auto_provided = Some(bool_lit.value); + } + } + "factory" => { + if let Some(src) = source_text { + let span = prop.value.span(); + factory = Some(&src[span.start as usize..span.end as usize]); + } + } + _ => {} + } + } + + Some(ServiceMetadata { class_name, class_span, auto_provided, factory }) +} + +impl<'a> ServiceMetadata<'a> { + /// Convert to `R3ServiceMetadata` for compilation. + /// + /// The factory expression captured from the decorator source text is + /// re-parsed by the converter so that it lands in the compiler's + /// OutputExpression form. Falls back to `None` when re-parsing fails. + pub fn to_r3_metadata( + &self, + allocator: &'a Allocator, + type_argument_count: u32, + ) -> R3ServiceMetadata<'a> { + let type_expr = OutputExpression::ReadVar(Box::new_in( + ReadVarExpr { name: self.class_name.clone(), source_span: None }, + allocator, + )); + + let factory = self.factory.and_then(|src| parse_factory_expression(allocator, src)); + + R3ServiceMetadata { + name: self.class_name.clone(), + r#type: type_expr, + type_argument_count, + auto_provided: self.auto_provided, + factory, + } + } +} + +fn is_service_decorator(decorator: &Decorator<'_>) -> bool { + match &decorator.expression { + Expression::CallExpression(call) => match &call.callee { + Expression::Identifier(id) => id.name == "Service", + _ => false, + }, + Expression::Identifier(id) => id.name == "Service", + _ => false, + } +} + +fn get_property_key_name<'a>(key: &'a PropertyKey<'a>) -> Option> { + match key { + PropertyKey::StaticIdentifier(id) => Some(id.name.clone().into()), + PropertyKey::StringLiteral(s) => Some(s.value.clone().into()), + _ => None, + } +} + +/// Re-parse a user-supplied factory expression so it can flow back into the +/// compiler's output AST. Returns `None` if the source text doesn't parse as +/// a single expression. +fn parse_factory_expression<'a>( + allocator: &'a Allocator, + src: &'a str, +) -> Option> { + use crate::output::oxc_converter::convert_oxc_expression; + use oxc_parser::Parser; + use oxc_span::SourceType; + + // Wrap the expression so the parser treats it as a standalone module. + let wrapped = allocator.alloc_str(&format!("({});", src)); + let parser_ret = Parser::new(allocator, wrapped, SourceType::ts()).parse(); + if !parser_ret.errors.is_empty() { + return None; + } + let stmt = parser_ret.program.body.first()?; + let oxc_ast::ast::Statement::ExpressionStatement(expr_stmt) = stmt else { + return None; + }; + // Unwrap the parenthesized expression. + let expr = match &expr_stmt.expression { + Expression::ParenthesizedExpression(p) => &p.expression, + other => other, + }; + convert_oxc_expression(allocator, expr, Some(wrapped)) +} diff --git a/crates/oxc_angular_compiler/src/service/definition.rs b/crates/oxc_angular_compiler/src/service/definition.rs new file mode 100644 index 000000000..aed9d3746 --- /dev/null +++ b/crates/oxc_angular_compiler/src/service/definition.rs @@ -0,0 +1,110 @@ +//! Service definition generation (ɵfac + ɵprov). +//! +//! Generates both static fields that Angular's `@Service` runtime expects: +//! - `ɵfac`: the standard factory function. For `@Service`, ctor deps are +//! intentionally empty — the v22 service runtime resolves dependencies via +//! `inject()` calls in the constructor body, not the ɵfac. See upstream +//! `compiler-cli/src/ngtsc/annotations/src/service.ts:178`, which calls +//! `toFactoryMetadata({...meta, deps: []}, FactoryTarget.Service)`. +//! - `ɵprov`: the `ɵɵdefineService({...})` initializer. + +use oxc_allocator::{Allocator, Vec as OxcVec}; + +use super::compiler::compile_service; +use super::decorator::ServiceMetadata; +use super::metadata::R3ServiceMetadata; +use crate::factory::{ + FactoryTarget, R3ConstructorFactoryMetadata, R3FactoryDeps, R3FactoryMetadata, + compile_factory_function, +}; +use crate::output::ast::OutputExpression; + +/// Both static field initializers for a `@Service` class. +pub struct ServiceDefinition<'a> { + /// The `ɵprov` initializer: `ɵɵdefineService({...})`. + pub prov_definition: OutputExpression<'a>, + /// The `ɵfac` initializer: a constructor factory with empty deps. + pub fac_definition: OutputExpression<'a>, +} + +/// Generate `ɵfac` and `ɵprov` definitions from R3 metadata. +pub fn generate_service_definition<'a>( + allocator: &'a Allocator, + metadata: &R3ServiceMetadata<'a>, +) -> ServiceDefinition<'a> { + // Generate ɵfac BEFORE ɵprov so namespace-index assignment order matches + // upstream's [fac, prov, ...] ordering. + let fac_definition = generate_fac_definition(allocator, metadata); + let prov_result = compile_service(allocator, metadata); + + ServiceDefinition { prov_definition: prov_result.expression, fac_definition } +} + +/// Convenience: extract `R3ServiceMetadata` from `ServiceMetadata` and emit. +pub fn generate_service_definition_from_decorator<'a>( + allocator: &'a Allocator, + metadata: &ServiceMetadata<'a>, + type_argument_count: u32, +) -> ServiceDefinition<'a> { + let r3_metadata = metadata.to_r3_metadata(allocator, type_argument_count); + generate_service_definition(allocator, &r3_metadata) +} + +/// Emit the ɵfac factory function. `@Service` factories never inject ctor +/// params — upstream passes `deps: []` deliberately, since the v22 service +/// runtime expects `inject()` calls inside the constructor body. +fn generate_fac_definition<'a>( + allocator: &'a Allocator, + metadata: &R3ServiceMetadata<'a>, +) -> OutputExpression<'a> { + let factory_name = allocator.alloc_str(&format!("{}_Factory", metadata.name)); + + let factory_meta = R3FactoryMetadata::Constructor(R3ConstructorFactoryMetadata { + name: metadata.name, + type_expr: metadata.r#type.clone_in(allocator), + type_decl: metadata.r#type.clone_in(allocator), + type_argument_count: metadata.type_argument_count, + deps: R3FactoryDeps::Valid(OxcVec::new_in(allocator)), + target: FactoryTarget::Service, + }); + + let result = compile_factory_function(allocator, &factory_meta, factory_name); + result.expression +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::ast::{ReadVarExpr}; + use crate::output::emitter::JsEmitter; + use oxc_allocator::Box; + use oxc_str::Ident; + + #[test] + fn fac_has_no_inject_calls() { + let allocator = Allocator::default(); + let type_expr = OutputExpression::ReadVar(Box::new_in( + ReadVarExpr { name: Ident::from("MyService"), source_span: None }, + &allocator, + )); + let metadata = R3ServiceMetadata { + name: Ident::from("MyService"), + r#type: type_expr, + type_argument_count: 0, + auto_provided: None, + factory: None, + }; + + let def = generate_service_definition(&allocator, &metadata); + let emitter = JsEmitter::new(); + let fac_js = emitter.emit_expression(&def.fac_definition); + let prov_js = emitter.emit_expression(&def.prov_definition); + + assert!( + !fac_js.contains("ɵɵinject") && !fac_js.contains("inject("), + "ɵfac should not call inject — services resolve deps in the ctor body. Got: {fac_js}" + ); + assert!(prov_js.contains("ɵɵdefineService"), "ɵprov should use defineService. Got: {prov_js}"); + assert!(prov_js.contains("MyService.ɵfac"), "ɵprov should delegate to ɵfac. Got: {prov_js}"); + } +} diff --git a/crates/oxc_angular_compiler/src/service/metadata.rs b/crates/oxc_angular_compiler/src/service/metadata.rs new file mode 100644 index 000000000..6b490e22e --- /dev/null +++ b/crates/oxc_angular_compiler/src/service/metadata.rs @@ -0,0 +1,41 @@ +//! Service metadata structures. +//! +//! Ported from Angular's `service_compiler.ts:R3ServiceMetadata`. + +use oxc_str::Ident; + +use crate::output::ast::OutputExpression; + +/// Metadata needed to compile a `@Service` decorator (Angular v22+). +/// +/// Corresponds to Angular's `R3ServiceMetadata` interface in +/// `packages/compiler/src/service_compiler.ts`. Intentionally narrower than +/// `R3InjectableMetadata`: services don't support `providedIn` or the +/// `useClass`/`useFactory`/`useValue`/`useExisting` provider variants, and +/// constructor DI is resolved via `inject()` calls in the constructor body +/// rather than the ɵfac. +#[derive(Debug)] +pub struct R3ServiceMetadata<'a> { + /// Name of the service type. + pub name: Ident<'a>, + + /// An expression representing a reference to the service class. + pub r#type: OutputExpression<'a>, + + /// Number of generic type parameters of the type itself. + pub type_argument_count: u32, + + /// Whether the service is auto-provided in the root injector. + /// + /// `None` means the user didn't specify a value (defaults to `true` at + /// runtime). `Some(false)` is the only value that gets emitted to the + /// definition map — matching upstream's behavior of only emitting the + /// field when explicitly disabled. + pub auto_provided: Option, + + /// User-supplied factory expression from `@Service({factory: ...})`. + /// + /// `None` means default to `MyService.ɵfac`. `Some(expr)` produces an + /// arrow wrapper `() => expr()` per upstream `service_compiler.ts:35-42`. + pub factory: Option>, +} diff --git a/crates/oxc_angular_compiler/src/service/mod.rs b/crates/oxc_angular_compiler/src/service/mod.rs new file mode 100644 index 000000000..782bb5e22 --- /dev/null +++ b/crates/oxc_angular_compiler/src/service/mod.rs @@ -0,0 +1,26 @@ +//! Service compilation module (Angular v22+ `@Service` decorator). +//! +//! Ported from Angular's `service_compiler.ts`. The `@Service` decorator is a +//! lighter alternative to `@Injectable` for root-injector-provided services +//! whose dependencies are resolved via `inject()` calls in the constructor +//! body rather than constructor parameter injection. +//! +//! Unlike `@Injectable`, `@Service` does not support `providedIn` or the +//! `useClass`/`useFactory`/`useValue`/`useExisting` provider variants, and +//! its ɵfac never injects ctor params. + +mod compiler; +mod decorator; +mod definition; +mod metadata; + +pub use compiler::{ServiceCompileResult, compile_service}; +pub use decorator::{ + ServiceMetadata, extract_service_metadata, find_service_decorator, + find_service_decorator_span, +}; +pub use definition::{ + ServiceDefinition, generate_service_definition, + generate_service_definition_from_decorator, +}; +pub use metadata::R3ServiceMetadata; From 18d4b5fb99d55da2c123fb7715a38a7e50665d8f Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Fri, 29 May 2026 15:57:21 -0500 Subject: [PATCH 3/9] =?UTF-8?q?feat(service):=20emit=20=C9=B5fac/=C9=B5pro?= =?UTF-8?q?v=20for=20@Service=20classes=20in=20AOT=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a standalone-@Service branch to component/transform.rs alongside the existing standalone-@Injectable branch. A @Service-decorated class produces a static ɵfac (deps-less constructor factory) and a static ɵprov initialized by ɵɵdefineService. Three gates run before emission: 1. Import-map gate. `find_angular_service_decorator` only matches when the local `Service` identifier resolves to `@angular/core`. A bare `@Service()` from a third-party library is left untouched, mirroring the JIT path's behavior. 2. Version gate. Targeting Angular < 22 surfaces a diagnostic and skips emission, since the runtime lacks ɵɵdefineService. Unknown version defaults to "supports" (matches the implicit-standalone pattern). 3. Collision diagnostic. Per upstream service.ts:101-116, @Service may not coexist with another @angular/core decorator on the same class — `find_conflicting_angular_decorator` flags the offending sibling. setClassMetadata is emitted for TestBed support and a .d.ts declaration is produced via `generate_service_dts`. The .d.ts shape reuses ɵɵInjectableDeclaration (consistent with upstream service_compiler.ts:55), and the ɵfac ctor-deps tuple is always `never`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/component/transform.rs | 167 ++++++++++++++++++ crates/oxc_angular_compiler/src/dts.rs | 26 +++ 2 files changed, 193 insertions(+) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index b427264f1..f8c10f4c5 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -49,6 +49,10 @@ use crate::injectable::{ extract_injectable_metadata, find_injectable_decorator, find_injectable_decorator_span, generate_injectable_definition_from_decorator, }; +use crate::service::{ + extract_service_metadata, find_service_decorator_span, + generate_service_definition_from_decorator, +}; use crate::ng_module::{ extract_ng_module_metadata, find_ng_module_decorator, find_ng_module_decorator_span, generate_full_ng_module_definition, @@ -864,6 +868,79 @@ struct JitNonAngularMemberDecorator { decorator_texts: std::vec::Vec, } +/// Return the `@Service` decorator on a class iff the `Service` identifier +/// resolves to `@angular/core` via the import map. Returns `None` for a bare +/// `@Service()` from a third-party library. +/// +/// `Service` is a common export name in DI containers and web frameworks, so +/// matching by name alone would misclassify unrelated decorators. Mirrors +/// the gate in [`find_angular_decorator`]. +fn find_angular_service_decorator<'a>( + class: &'a oxc_ast::ast::Class<'a>, + import_map: &ImportMap<'a>, +) -> Option<&'a oxc_ast::ast::Decorator<'a>> { + for decorator in &class.decorators { + let Expression::CallExpression(call) = &decorator.expression else { continue }; + let Expression::Identifier(id) = &call.callee else { continue }; + if id.name != "Service" { + continue; + } + let from_angular_core = import_map + .get(&Ident::from(id.name.as_str())) + .map(|info| info.source_module.as_str() == "@angular/core") + .unwrap_or(false); + if from_angular_core { + return Some(decorator); + } + } + None +} + +/// Return the name of the first non-`Service` `@angular/core` decorator on +/// the class, if any. Used to enforce upstream's collision rule (see +/// `service.ts:101-116`): `@Service` cannot coexist with another Angular +/// decorator on the same class. +fn find_conflicting_angular_decorator<'a>( + class: &'a oxc_ast::ast::Class<'a>, + import_map: &ImportMap<'a>, +) -> Option<&'a str> { + const ANGULAR_DECORATORS: &[&str] = + &["Component", "Directive", "Pipe", "Injectable", "NgModule"]; + + for decorator in &class.decorators { + let name = match &decorator.expression { + Expression::CallExpression(call) => match &call.callee { + Expression::Identifier(id) => id.name.as_str(), + Expression::StaticMemberExpression(member) => member.property.name.as_str(), + _ => continue, + }, + _ => continue, + }; + if !ANGULAR_DECORATORS.contains(&name) { + continue; + } + // For Identifier-callees, verify the import resolves to @angular/core. + // Namespace-style (`@core.Component()`) is treated as Angular without + // a lookup, matching `find_angular_decorator`'s behavior. + let is_angular = if let Expression::CallExpression(call) = &decorator.expression { + match &call.callee { + Expression::Identifier(id) => import_map + .get(&Ident::from(id.name.as_str())) + .map(|info| info.source_module.as_str() == "@angular/core") + .unwrap_or(false), + Expression::StaticMemberExpression(_) => true, + _ => false, + } + } else { + false + }; + if is_angular { + return Some(name); + } + } + None +} + /// Find any Angular decorator on a class and return its kind and the decorator reference. /// /// For the `Service` identifier specifically, the import map is consulted so a @@ -2966,6 +3043,96 @@ pub fn transform_angular_file( (property_assignments, String::new(), external_decls), ); } + } else if let Some(service_decorator) = + find_angular_service_decorator(class, &import_map) + { + // Standalone @Service (Angular v22+). Mirrors the standalone-@Injectable + // branch below but emits ɵɵdefineService and a deps-less ɵfac. + let class_name_for_diag = + class.id.as_ref().map_or(String::new(), |id| id.name.to_string()); + + // Version gate: v22+ runtime introduced ɵɵdefineService. Unknown + // version defaults to "supports" (matches the JIT-side gate). + if !options + .angular_version + .map_or(true, |v| v.supports_service_decorator()) + { + result.diagnostics.push(OxcDiagnostic::error(format!( + "The @Service decorator on '{}' requires Angular v22 or later.", + class_name_for_diag + ))); + continue; + } + + // Collision diagnostic: upstream service.ts:101-116 rejects + // @Service co-located with any other @angular/core decorator. + if let Some(conflict_name) = + find_conflicting_angular_decorator(class, &import_map) + { + result.diagnostics.push(OxcDiagnostic::error(format!( + "Cannot apply more than one Angular decorator on an @Service class. \ + '{}' is also decorated with @{}.", + class_name_for_diag, conflict_name + ))); + continue; + } + + if let Some(service_metadata) = + extract_service_metadata(allocator, class, Some(source)) + { + // Track decorator span for removal + if let Some(span) = find_service_decorator_span(class) { + decorator_spans_to_remove.push(span); + } + // Even though @Service ɵfac doesn't inject ctor params, the + // user's constructor may still carry @Inject/@Optional/etc. + // decorators that need to be stripped from the output (the + // class metadata IIFE will pick them up). + collect_constructor_decorator_spans(class, &mut decorator_spans_to_remove); + + let type_argument_count = + class.type_parameters.as_ref().map_or(0, |tp| tp.params.len() as u32); + let definition = generate_service_definition_from_decorator( + allocator, + &service_metadata, + type_argument_count, + ); + + let emitter = JsEmitter::new(); + let class_name = service_metadata.class_name.to_string(); + + let property_assignments = format!( + "static ɵfac = {};\nstatic ɵprov = {};", + emitter.emit_expression(&definition.fac_definition), + emitter.emit_expression(&definition.prov_definition) + ); + + result + .dts_declarations + .push(dts::generate_service_dts(&service_metadata, type_argument_count)); + + let decls_after_class = build_set_class_metadata_decls( + allocator, + class, + &class_name, + service_decorator, + options, + source, + &string_consts, + &import_map, + &mut file_namespace_registry, + ); + + class_positions.push(( + class_name.clone(), + compute_effective_start(class, &decorator_spans_to_remove, stmt_start), + class.body.span.end, + )); + class_definitions.insert( + class_name, + (property_assignments, String::new(), decls_after_class), + ); + } } else if let Some(mut injectable_metadata) = extract_injectable_metadata(allocator, class, Some(source)) { diff --git a/crates/oxc_angular_compiler/src/dts.rs b/crates/oxc_angular_compiler/src/dts.rs index c543b60d2..c062e73fd 100644 --- a/crates/oxc_angular_compiler/src/dts.rs +++ b/crates/oxc_angular_compiler/src/dts.rs @@ -18,6 +18,7 @@ use crate::directive::{R3DirectiveMetadata, R3InputMetadata}; use crate::injectable::InjectableMetadata; use crate::ng_module::NgModuleMetadata; use crate::pipe::PipeMetadata; +use crate::service::ServiceMetadata; use oxc_str::Ident; /// A `.d.ts` type declaration for an Angular class. @@ -422,6 +423,31 @@ pub fn generate_injectable_dts( DtsDeclaration { class_name: class_name.to_string(), members } } +// ============================================================================= +// Service Declarations (Angular v22+ `@Service`) +// ============================================================================= + +/// Generate `.d.ts` declarations for a `@Service`-decorated class. +/// +/// The shape is identical to `@Injectable` (the `.d.ts` type is reused for +/// downstream consumers — see upstream `service_compiler.ts:55` which calls +/// `createInjectableType`). The ctor deps tuple is always `never` because +/// `@Service` ɵfac is generated with empty deps. +pub fn generate_service_dts( + metadata: &ServiceMetadata, + type_argument_count: u32, +) -> DtsDeclaration { + let class_name = metadata.class_name.as_str(); + let type_with_params = type_with_parameters(class_name, type_argument_count); + + let fac = format!("static ɵfac: i0.ɵɵFactoryDeclaration<{type_with_params}, never>;"); + let prov = format!("static ɵprov: i0.ɵɵInjectableDeclaration<{type_with_params}>;"); + + let members = format!("{fac}\n{prov}"); + + DtsDeclaration { class_name: class_name.to_string(), members } +} + // ============================================================================= // Helper Functions // ============================================================================= From ad213c1f60cb82d684242b6a0e603178c23c286a Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Fri, 29 May 2026 15:59:19 -0500 Subject: [PATCH 4/9] test(service): cover @Service AOT emission, version gate, and collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven integration tests: - aot_service_decorator_basic — bare @Service() emits static ɵfac/ɵprov with ɵɵdefineService and no autoProvided entry. - aot_service_decorator_auto_provided_false — explicit autoProvided: false surfaces in both ɵprov and setClassMetadata args. - aot_service_decorator_custom_factory — user-supplied factory is wrapped in an arrow that calls it, replacing the ɵfac delegation. - aot_service_decorator_version_gated — targeting v21 surfaces a diagnostic and emits nothing. - aot_service_decorator_collision_with_injectable — @Service alongside @Injectable surfaces the upstream collision diagnostic and emits nothing. - aot_non_angular_service_decorator_is_ignored — a third-party @Service is left untouched (no diagnostic, no emission). - dts_service — .d.ts emits ɵfac + ɵprov using the InjectableDeclaration type (matches createInjectableType upstream). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tests/integration_test.rs | 280 ++++++++++++++++++ ...service_decorator_auto_provided_false.snap | 20 ++ ...ion_test__aot_service_decorator_basic.snap | 19 ++ ..._aot_service_decorator_custom_factory.snap | 23 ++ 4 files changed, 342 insertions(+) create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__aot_service_decorator_auto_provided_false.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__aot_service_decorator_basic.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__aot_service_decorator_custom_factory.snap diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index b4ad0db32..99a4ddf6a 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -13380,3 +13380,283 @@ export class BadComponent {} result.diagnostics ); } + +// ============================================================================= +// AOT @Service decorator (Angular v22+) +// ============================================================================= + +#[test] +fn test_aot_service_decorator_basic() { + // A bare @Service() class should emit static ɵfac and static ɵprov. + // ɵprov uses ɵɵdefineService (not ɵɵdefineInjectable), and ɵfac is the + // deps-less constructor factory — @Service classes resolve DI via + // inject() calls inside the constructor body. + let allocator = Allocator::default(); + let source = r" +import { Service } from '@angular/core'; + +@Service() +export class CounterService {} +"; + + let result = + transform_angular_file(&allocator, "counter.service.ts", source, None, None); + assert!( + !result.has_errors(), + "Should compile without errors: {:?}", + result.diagnostics + ); + + assert!( + result.code.contains("ɵɵdefineService"), + "Should emit ɵɵdefineService. Got:\n{}", + result.code + ); + assert!( + result.code.contains("static ɵfac"), + "Should emit static ɵfac. Got:\n{}", + result.code + ); + assert!( + result.code.contains("static ɵprov"), + "Should emit static ɵprov. Got:\n{}", + result.code + ); + assert!( + !result.code.contains("@Service"), + "Should remove the @Service decorator from source. Got:\n{}", + result.code + ); + assert!( + !result.code.contains("autoProvided"), + "autoProvided is default-true, should not appear when not explicitly disabled. Got:\n{}", + result.code + ); + + insta::assert_snapshot!("aot_service_decorator_basic", result.code); +} + +#[test] +fn test_aot_service_decorator_auto_provided_false() { + let allocator = Allocator::default(); + let source = r" +import { Service } from '@angular/core'; + +@Service({ autoProvided: false }) +export class CounterService {} +"; + + let result = + transform_angular_file(&allocator, "counter.service.ts", source, None, None); + assert!( + !result.has_errors(), + "Should compile without errors: {:?}", + result.diagnostics + ); + + assert!( + result.code.contains("autoProvided:false") + || result.code.contains("autoProvided: false"), + "Should emit autoProvided: false. Got:\n{}", + result.code + ); + + insta::assert_snapshot!("aot_service_decorator_auto_provided_false", result.code); +} + +#[test] +fn test_aot_service_decorator_custom_factory() { + // A user-supplied factory: should be wrapped in an arrow `() => factory()`. + let allocator = Allocator::default(); + let source = r" +import { Service } from '@angular/core'; + +function makeCounter() { return { count: 0 }; } + +@Service({ factory: makeCounter }) +export class CounterService {} +"; + + let result = + transform_angular_file(&allocator, "counter.service.ts", source, None, None); + assert!( + !result.has_errors(), + "Should compile without errors: {:?}", + result.diagnostics + ); + + // The factory entry should be an arrow wrapper, not a ɵfac delegation. + assert!( + result.code.contains("makeCounter()"), + "Should call user-supplied factory inside arrow wrapper. Got:\n{}", + result.code + ); + assert!( + !result.code.contains("factory:CounterService.ɵfac") + && !result.code.contains("factory: CounterService.ɵfac"), + "ɵprov factory should not delegate to ɵfac when user supplied a custom factory. Got:\n{}", + result.code + ); + + insta::assert_snapshot!("aot_service_decorator_custom_factory", result.code); +} + +#[test] +fn test_aot_service_decorator_version_gated() { + // Targeting Angular < 22 should surface a diagnostic and leave the + // decorator unchanged (no ɵfac/ɵprov emitted). + let allocator = Allocator::default(); + let source = r" +import { Service } from '@angular/core'; + +@Service() +export class CounterService {} +"; + + let options = ComponentTransformOptions { + angular_version: Some(AngularVersion::new(21, 0, 0)), + ..Default::default() + }; + let result = transform_angular_file( + &allocator, + "counter.service.ts", + source, + Some(&options), + None, + ); + + assert!( + result.has_errors(), + "Targeting v21 with @Service should produce a diagnostic. Got none.\n{}", + result.code + ); + assert!( + result + .diagnostics + .iter() + .any(|d| d.to_string().contains("@Service") && d.to_string().contains("v22")), + "Diagnostic should mention @Service and v22. Got: {:?}", + result.diagnostics + ); + assert!( + !result.code.contains("ɵɵdefineService"), + "Should NOT emit ɵɵdefineService when version-gated out. Got:\n{}", + result.code + ); +} + +#[test] +fn test_aot_service_decorator_collision_with_injectable() { + // Upstream service.ts:101-116 rejects @Service co-located with any other + // @angular/core decorator. We surface a diagnostic and skip emission. + let allocator = Allocator::default(); + let source = r" +import { Service, Injectable } from '@angular/core'; + +@Service() +@Injectable() +export class CounterService {} +"; + + let result = + transform_angular_file(&allocator, "counter.service.ts", source, None, None); + + assert!( + result.has_errors(), + "Combining @Service with another Angular decorator should produce a diagnostic. Got none.\n{}", + result.code + ); + assert!( + result.diagnostics.iter().any(|d| { + let s = d.to_string(); + s.contains("@Service") && s.contains("Injectable") + }), + "Diagnostic should call out both @Service and the conflicting Injectable. Got: {:?}", + result.diagnostics + ); + assert!( + !result.code.contains("ɵɵdefineService"), + "Should NOT emit ɵɵdefineService when the collision rule fires. Got:\n{}", + result.code + ); +} + +#[test] +fn test_aot_non_angular_service_decorator_is_ignored() { + // A `@Service()` from a non-Angular library must not be transformed — + // no ɵfac/ɵprov emission, no version-gate diagnostic. + let allocator = Allocator::default(); + let source = r" +import { Service } from 'some-other-lib'; + +@Service() +export class CounterService {} +"; + + let options = ComponentTransformOptions { + angular_version: Some(AngularVersion::new(21, 0, 0)), + ..Default::default() + }; + let result = transform_angular_file( + &allocator, + "counter.service.ts", + source, + Some(&options), + None, + ); + + assert!( + !result + .diagnostics + .iter() + .any(|d| d.to_string().contains("@Service") && d.to_string().contains("v22")), + "Non-Angular @Service should not trigger the v22 diagnostic. Got: {:?}", + result.diagnostics + ); + assert!( + !result.code.contains("ɵɵdefineService"), + "Should NOT emit ɵɵdefineService for non-Angular @Service. Got:\n{}", + result.code + ); + assert!( + result.code.contains("@Service"), + "Source @Service decorator should be left intact when not from @angular/core. Got:\n{}", + result.code + ); +} + +#[test] +fn test_dts_service() { + let allocator = Allocator::default(); + let source = r" +import { Service } from '@angular/core'; + +@Service() +export class CounterService {} +"; + + let result = + transform_angular_file(&allocator, "counter.service.ts", source, None, None); + assert!( + !result.has_errors(), + "Should compile without errors: {:?}", + result.diagnostics + ); + + assert_eq!(result.dts_declarations.len(), 1); + let decl = &result.dts_declarations[0]; + assert_eq!(decl.class_name, "CounterService"); + + assert!( + decl.members.contains("static ɵfac: i0.ɵɵFactoryDeclaration;"), + "Should contain ɵfac. Got:\n{}", + decl.members + ); + // .d.ts reuses the InjectableDeclaration type per upstream + // service_compiler.ts:55 (createInjectableType). + assert!( + decl.members.contains("static ɵprov: i0.ɵɵInjectableDeclaration;"), + "Should contain ɵprov. Got:\n{}", + decl.members + ); +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__aot_service_decorator_auto_provided_false.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__aot_service_decorator_auto_provided_false.snap new file mode 100644 index 000000000..7780f262e --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__aot_service_decorator_auto_provided_false.snap @@ -0,0 +1,20 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +assertion_line: 13464 +expression: result.code +--- + +import { Service } from '@angular/core'; +import * as i0 from '@angular/core'; + +export class CounterService { +static ɵfac = function CounterService_Factory(__ngFactoryType__) { + return new (__ngFactoryType__ || CounterService)(); +}; +static ɵprov = /*@__PURE__*/ i0.ɵɵdefineService({token:CounterService,factory:CounterService.ɵfac, + autoProvided:false}); +} +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(CounterService, + [{type:Service,args:[{autoProvided:false}]}],null,null)); +})(); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__aot_service_decorator_basic.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__aot_service_decorator_basic.snap new file mode 100644 index 000000000..35d382087 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__aot_service_decorator_basic.snap @@ -0,0 +1,19 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +assertion_line: 13436 +expression: result.code +--- + +import { Service } from '@angular/core'; +import * as i0 from '@angular/core'; + +export class CounterService { +static ɵfac = function CounterService_Factory(__ngFactoryType__) { + return new (__ngFactoryType__ || CounterService)(); +}; +static ɵprov = /*@__PURE__*/ i0.ɵɵdefineService({token:CounterService,factory:CounterService.ɵfac}); +} +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(CounterService, + [{type:Service}],null,null)); +})(); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__aot_service_decorator_custom_factory.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__aot_service_decorator_custom_factory.snap new file mode 100644 index 000000000..84fbe6d90 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__aot_service_decorator_custom_factory.snap @@ -0,0 +1,23 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +assertion_line: 13501 +expression: result.code +--- + +import { Service } from '@angular/core'; +import * as i0 from '@angular/core'; + +function makeCounter() { return { count: 0 }; } + +export class CounterService { +static ɵfac = function CounterService_Factory(__ngFactoryType__) { + return new (__ngFactoryType__ || CounterService)(); +}; +static ɵprov = /*@__PURE__*/ i0.ɵɵdefineService({token:CounterService,factory:function() { + return makeCounter(); +}}); +} +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(CounterService, + [{type:Service,args:[{factory:makeCounter}]}],null,null)); +})(); From eff93bb3c6c66dd86b865f487840556bde0f31f7 Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Fri, 29 May 2026 16:03:49 -0500 Subject: [PATCH 5/9] feat(service): diagnose @Service classes with constructor params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-checked emission against the upstream ngtsc compliance goldens at packages/compiler-cli/test/compliance/test_cases/service_decorator/. Output for basic, autoProvided:false, custom-factory, and generic cases matches structurally (whitespace and i0/$r3$ namespace alias aside) plus the .d.ts shape. One missing diagnostic surfaced. Upstream service.ts:278-309 surfaces SERVICE_CONSTRUCTOR_DI when an @Service class declares its own constructor parameters, because the ɵfac is generated with empty deps — any constructor parameter would silently become `undefined` at runtime. `class_has_own_constructor_params` adds the local check (we skip upstream's base-class walk; that requires cross-file reflection that oxc doesn't perform, and upstream itself skips it in LOCAL mode). Three new integration tests cover this: - aot_service_decorator_constructor_di_diagnostic — own-ctor params trigger the diagnostic and emit nothing. - aot_service_decorator_explicit_auto_provided_true — matches upstream's explicitly_provided_service golden: autoProvided:true is omitted from ɵprov (matches the runtime default) but preserved in setClassMetadata args. - aot_service_decorator_inline_arrow_factory — matches upstream's service_with_factory golden: an inline arrow factory is double-wrapped (outer wrapper calls the user expression). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/component/transform.rs | 36 ++++++ .../tests/integration_test.rs | 117 ++++++++++++++++++ ...ervice_decorator_inline_arrow_factory.snap | 23 ++++ 3 files changed, 176 insertions(+) create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__aot_service_decorator_inline_arrow_factory.snap diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index f8c10f4c5..c28601103 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -868,6 +868,29 @@ struct JitNonAngularMemberDecorator { decorator_texts: std::vec::Vec, } +/// Whether the class declares its own constructor with at least one parameter. +/// +/// Used by the `@Service` handler to catch the common-but-broken pattern of +/// declaring constructor-based DI on a service: upstream service.ts:278-309 +/// surfaces a diagnostic because `@Service` ɵfac is generated with empty +/// deps, so those parameters would silently become `undefined` at runtime. +/// +/// Unlike upstream, we don't walk to base classes — that requires cross-file +/// resolution that oxc doesn't perform. Upstream's LOCAL compilation mode +/// also skips that walk, and our single-file transform is closer to LOCAL +/// mode than to a full reflector. +fn class_has_own_constructor_params(class: &oxc_ast::ast::Class<'_>) -> bool { + use oxc_ast::ast::{ClassElement, MethodDefinitionKind}; + class.body.body.iter().any(|element| { + if let ClassElement::MethodDefinition(method) = element { + method.kind == MethodDefinitionKind::Constructor + && !method.value.params.items.is_empty() + } else { + false + } + }) +} + /// Return the `@Service` decorator on a class iff the `Service` identifier /// resolves to `@angular/core` via the import map. Returns `None` for a bare /// `@Service()` from a third-party library. @@ -3077,6 +3100,19 @@ pub fn transform_angular_file( continue; } + // Constructor DI diagnostic: @Service ɵfac is generated with + // empty deps, so any constructor parameter would silently + // become `undefined` at runtime. Surface upstream's error + // (service.ts:312-318) instead of emitting broken code. + if class_has_own_constructor_params(class) { + result.diagnostics.push(OxcDiagnostic::error( + "@Service class cannot use constructor dependency injection. \ + Use the `inject` function instead." + .to_string(), + )); + continue; + } + if let Some(service_metadata) = extract_service_metadata(allocator, class, Some(source)) { diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 99a4ddf6a..a26cc2101 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -13501,6 +13501,39 @@ export class CounterService {} insta::assert_snapshot!("aot_service_decorator_custom_factory", result.code); } +#[test] +fn test_aot_service_decorator_inline_arrow_factory() { + // Upstream's service_with_factory compliance golden: an inline arrow + // factory becomes `factory: () => (() => new Alternate())()` — the user's + // arrow is preserved and called inside an outer wrapper. + let allocator = Allocator::default(); + let source = r" +import { Service } from '@angular/core'; + +class Alternate {} + +@Service({ factory: () => new Alternate() }) +export class CounterService {} +"; + + let result = + transform_angular_file(&allocator, "counter.service.ts", source, None, None); + assert!( + !result.has_errors(), + "Should compile without errors: {:?}", + result.diagnostics + ); + + // The user's arrow expression must appear inside the wrapper, called. + assert!( + result.code.contains("new Alternate()"), + "User's arrow body should appear in the emitted factory wrapper. Got:\n{}", + result.code + ); + + insta::assert_snapshot!("aot_service_decorator_inline_arrow_factory", result.code); +} + #[test] fn test_aot_service_decorator_version_gated() { // Targeting Angular < 22 should surface a diagnostic and leave the @@ -13625,6 +13658,90 @@ export class CounterService {} ); } +#[test] +fn test_aot_service_decorator_constructor_di_diagnostic() { + // Upstream service.ts:278-309 rejects @Service classes that declare + // constructor params, because the v22 @Service ɵfac is generated with + // empty deps — the params would silently be `undefined` at runtime. + let allocator = Allocator::default(); + let source = r" +import { Service, ApplicationRef } from '@angular/core'; + +@Service() +export class CounterService { + constructor(appRef: ApplicationRef) {} +} +"; + + let result = + transform_angular_file(&allocator, "counter.service.ts", source, None, None); + + assert!( + result.has_errors(), + "@Service with ctor params should surface a diagnostic. Got none.\n{}", + result.code + ); + assert!( + result.diagnostics.iter().any(|d| { + let s = d.to_string(); + s.contains("@Service") && s.contains("constructor") && s.contains("inject") + }), + "Diagnostic should match upstream's wording (mentions @Service, constructor, inject). Got: {:?}", + result.diagnostics + ); + assert!( + !result.code.contains("ɵɵdefineService"), + "Should NOT emit ɵɵdefineService when the ctor-DI rule fires. Got:\n{}", + result.code + ); +} + +#[test] +fn test_aot_service_decorator_explicit_auto_provided_true() { + // Upstream's explicitly_provided_service compliance golden: when the user + // writes @Service({autoProvided: true}), the ɵprov initializer must NOT + // contain `autoProvided` (it matches the runtime default), but the + // setClassMetadata `args` array must preserve the user's source value. + let allocator = Allocator::default(); + let source = r" +import { Service } from '@angular/core'; + +@Service({ autoProvided: true }) +export class CounterService {} +"; + + let result = + transform_angular_file(&allocator, "counter.service.ts", source, None, None); + assert!( + !result.has_errors(), + "Should compile without errors: {:?}", + result.diagnostics + ); + + // ɵprov must omit autoProvided when true (matches runtime default). + let prov_start = result + .code + .find("ɵɵdefineService") + .expect("should emit ɵɵdefineService"); + let prov_end = result.code[prov_start..] + .find("})") + .map(|p| prov_start + p) + .expect("should close the defineService call"); + let prov_chunk = &result.code[prov_start..=prov_end]; + assert!( + !prov_chunk.contains("autoProvided"), + "ɵprov should omit autoProvided when user passed true. Got: {prov_chunk}" + ); + + // setClassMetadata must preserve the user's decorator args. + assert!( + result.code.contains("autoProvided:true") + || result.code.contains("autoProvided: true"), + "setClassMetadata args should preserve user's autoProvided: true. Got:\n{}", + result.code + ); +} + #[test] fn test_dts_service() { let allocator = Allocator::default(); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__aot_service_decorator_inline_arrow_factory.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__aot_service_decorator_inline_arrow_factory.snap new file mode 100644 index 000000000..2f1db8962 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__aot_service_decorator_inline_arrow_factory.snap @@ -0,0 +1,23 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +assertion_line: 13534 +expression: result.code +--- + +import { Service } from '@angular/core'; +import * as i0 from '@angular/core'; + +class Alternate {} + +export class CounterService { +static ɵfac = function CounterService_Factory(__ngFactoryType__) { + return new (__ngFactoryType__ || CounterService)(); +}; +static ɵprov = /*@__PURE__*/ i0.ɵɵdefineService({token:CounterService,factory:function() { + return (() =>new Alternate())(); +}}); +} +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(CounterService, + [{type:Service,args:[{factory:() =>new Alternate()}]}],null,null)); +})(); From 021c69dd515f83ccd4acbaa929efb663de8b3f63 Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Fri, 29 May 2026 16:06:38 -0500 Subject: [PATCH 6/9] style(service): apply rustfmt Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/component/transform.rs | 20 ++-- .../src/service/compiler.rs | 5 +- .../src/service/decorator.rs | 8 +- .../src/service/definition.rs | 12 ++- .../oxc_angular_compiler/src/service/mod.rs | 6 +- .../tests/integration_test.rs | 95 +++++-------------- 6 files changed, 45 insertions(+), 101 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index c28601103..28e6d6519 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -49,10 +49,6 @@ use crate::injectable::{ extract_injectable_metadata, find_injectable_decorator, find_injectable_decorator_span, generate_injectable_definition_from_decorator, }; -use crate::service::{ - extract_service_metadata, find_service_decorator_span, - generate_service_definition_from_decorator, -}; use crate::ng_module::{ extract_ng_module_metadata, find_ng_module_decorator, find_ng_module_decorator_span, generate_full_ng_module_definition, @@ -77,6 +73,10 @@ use crate::pipeline::ingest::{ HostBindingInput, IngestOptions, ingest_component, ingest_component_with_options, ingest_host_binding_with_version, }; +use crate::service::{ + extract_service_metadata, find_service_decorator_span, + generate_service_definition_from_decorator, +}; use crate::transform::HtmlToR3Transform; use crate::transform::html_to_r3::TransformOptions as R3TransformOptions; @@ -3076,10 +3076,7 @@ pub fn transform_angular_file( // Version gate: v22+ runtime introduced ɵɵdefineService. Unknown // version defaults to "supports" (matches the JIT-side gate). - if !options - .angular_version - .map_or(true, |v| v.supports_service_decorator()) - { + if !options.angular_version.map_or(true, |v| v.supports_service_decorator()) { result.diagnostics.push(OxcDiagnostic::error(format!( "The @Service decorator on '{}' requires Angular v22 or later.", class_name_for_diag @@ -3143,9 +3140,10 @@ pub fn transform_angular_file( emitter.emit_expression(&definition.prov_definition) ); - result - .dts_declarations - .push(dts::generate_service_dts(&service_metadata, type_argument_count)); + result.dts_declarations.push(dts::generate_service_dts( + &service_metadata, + type_argument_count, + )); let decls_after_class = build_set_class_metadata_decls( allocator, diff --git a/crates/oxc_angular_compiler/src/service/compiler.rs b/crates/oxc_angular_compiler/src/service/compiler.rs index 87f95e94a..07c034d67 100644 --- a/crates/oxc_angular_compiler/src/service/compiler.rs +++ b/crates/oxc_angular_compiler/src/service/compiler.rs @@ -266,10 +266,7 @@ mod tests { let result = compile_service(&allocator, &metadata); let js = JsEmitter::new().emit_expression(&result.expression); - assert!( - js.contains("makeService()"), - "should call user factory inside arrow. Got: {js}" - ); + assert!(js.contains("makeService()"), "should call user factory inside arrow. Got: {js}"); assert!( !js.contains("MyService.ɵfac"), "should not delegate to ɵfac when custom factory supplied. Got: {js}" diff --git a/crates/oxc_angular_compiler/src/service/decorator.rs b/crates/oxc_angular_compiler/src/service/decorator.rs index fa4464ce0..17681ace3 100644 --- a/crates/oxc_angular_compiler/src/service/decorator.rs +++ b/crates/oxc_angular_compiler/src/service/decorator.rs @@ -12,9 +12,7 @@ //! upstream import-map gate that the AOT caller mirrors. use oxc_allocator::{Allocator, Box}; -use oxc_ast::ast::{ - Argument, Class, Decorator, Expression, ObjectPropertyKind, PropertyKey, -}; +use oxc_ast::ast::{Argument, Class, Decorator, Expression, ObjectPropertyKind, PropertyKey}; use oxc_span::{GetSpan, Span}; use oxc_str::Ident; @@ -35,9 +33,7 @@ pub struct ServiceMetadata<'a> { } /// Find the `@Service` decorator node on a class (by identifier name only). -pub fn find_service_decorator<'a>( - decorators: &'a [Decorator<'a>], -) -> Option<&'a Decorator<'a>> { +pub fn find_service_decorator<'a>(decorators: &'a [Decorator<'a>]) -> Option<&'a Decorator<'a>> { decorators.iter().find(|d| is_service_decorator(d)) } diff --git a/crates/oxc_angular_compiler/src/service/definition.rs b/crates/oxc_angular_compiler/src/service/definition.rs index aed9d3746..24a910ae9 100644 --- a/crates/oxc_angular_compiler/src/service/definition.rs +++ b/crates/oxc_angular_compiler/src/service/definition.rs @@ -75,7 +75,7 @@ fn generate_fac_definition<'a>( #[cfg(test)] mod tests { use super::*; - use crate::output::ast::{ReadVarExpr}; + use crate::output::ast::ReadVarExpr; use crate::output::emitter::JsEmitter; use oxc_allocator::Box; use oxc_str::Ident; @@ -104,7 +104,13 @@ mod tests { !fac_js.contains("ɵɵinject") && !fac_js.contains("inject("), "ɵfac should not call inject — services resolve deps in the ctor body. Got: {fac_js}" ); - assert!(prov_js.contains("ɵɵdefineService"), "ɵprov should use defineService. Got: {prov_js}"); - assert!(prov_js.contains("MyService.ɵfac"), "ɵprov should delegate to ɵfac. Got: {prov_js}"); + assert!( + prov_js.contains("ɵɵdefineService"), + "ɵprov should use defineService. Got: {prov_js}" + ); + assert!( + prov_js.contains("MyService.ɵfac"), + "ɵprov should delegate to ɵfac. Got: {prov_js}" + ); } } diff --git a/crates/oxc_angular_compiler/src/service/mod.rs b/crates/oxc_angular_compiler/src/service/mod.rs index 782bb5e22..5710ea7f7 100644 --- a/crates/oxc_angular_compiler/src/service/mod.rs +++ b/crates/oxc_angular_compiler/src/service/mod.rs @@ -16,11 +16,9 @@ mod metadata; pub use compiler::{ServiceCompileResult, compile_service}; pub use decorator::{ - ServiceMetadata, extract_service_metadata, find_service_decorator, - find_service_decorator_span, + ServiceMetadata, extract_service_metadata, find_service_decorator, find_service_decorator_span, }; pub use definition::{ - ServiceDefinition, generate_service_definition, - generate_service_definition_from_decorator, + ServiceDefinition, generate_service_definition, generate_service_definition_from_decorator, }; pub use metadata::R3ServiceMetadata; diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index a26cc2101..a87b71eaa 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -13399,24 +13399,15 @@ import { Service } from '@angular/core'; export class CounterService {} "; - let result = - transform_angular_file(&allocator, "counter.service.ts", source, None, None); - assert!( - !result.has_errors(), - "Should compile without errors: {:?}", - result.diagnostics - ); + let result = transform_angular_file(&allocator, "counter.service.ts", source, None, None); + assert!(!result.has_errors(), "Should compile without errors: {:?}", result.diagnostics); assert!( result.code.contains("ɵɵdefineService"), "Should emit ɵɵdefineService. Got:\n{}", result.code ); - assert!( - result.code.contains("static ɵfac"), - "Should emit static ɵfac. Got:\n{}", - result.code - ); + assert!(result.code.contains("static ɵfac"), "Should emit static ɵfac. Got:\n{}", result.code); assert!( result.code.contains("static ɵprov"), "Should emit static ɵprov. Got:\n{}", @@ -13446,17 +13437,11 @@ import { Service } from '@angular/core'; export class CounterService {} "; - let result = - transform_angular_file(&allocator, "counter.service.ts", source, None, None); - assert!( - !result.has_errors(), - "Should compile without errors: {:?}", - result.diagnostics - ); + let result = transform_angular_file(&allocator, "counter.service.ts", source, None, None); + assert!(!result.has_errors(), "Should compile without errors: {:?}", result.diagnostics); assert!( - result.code.contains("autoProvided:false") - || result.code.contains("autoProvided: false"), + result.code.contains("autoProvided:false") || result.code.contains("autoProvided: false"), "Should emit autoProvided: false. Got:\n{}", result.code ); @@ -13477,13 +13462,8 @@ function makeCounter() { return { count: 0 }; } export class CounterService {} "; - let result = - transform_angular_file(&allocator, "counter.service.ts", source, None, None); - assert!( - !result.has_errors(), - "Should compile without errors: {:?}", - result.diagnostics - ); + let result = transform_angular_file(&allocator, "counter.service.ts", source, None, None); + assert!(!result.has_errors(), "Should compile without errors: {:?}", result.diagnostics); // The factory entry should be an arrow wrapper, not a ɵfac delegation. assert!( @@ -13516,13 +13496,8 @@ class Alternate {} export class CounterService {} "; - let result = - transform_angular_file(&allocator, "counter.service.ts", source, None, None); - assert!( - !result.has_errors(), - "Should compile without errors: {:?}", - result.diagnostics - ); + let result = transform_angular_file(&allocator, "counter.service.ts", source, None, None); + assert!(!result.has_errors(), "Should compile without errors: {:?}", result.diagnostics); // The user's arrow expression must appear inside the wrapper, called. assert!( @@ -13550,13 +13525,8 @@ export class CounterService {} angular_version: Some(AngularVersion::new(21, 0, 0)), ..Default::default() }; - let result = transform_angular_file( - &allocator, - "counter.service.ts", - source, - Some(&options), - None, - ); + let result = + transform_angular_file(&allocator, "counter.service.ts", source, Some(&options), None); assert!( result.has_errors(), @@ -13591,8 +13561,7 @@ import { Service, Injectable } from '@angular/core'; export class CounterService {} "; - let result = - transform_angular_file(&allocator, "counter.service.ts", source, None, None); + let result = transform_angular_file(&allocator, "counter.service.ts", source, None, None); assert!( result.has_errors(), @@ -13630,13 +13599,8 @@ export class CounterService {} angular_version: Some(AngularVersion::new(21, 0, 0)), ..Default::default() }; - let result = transform_angular_file( - &allocator, - "counter.service.ts", - source, - Some(&options), - None, - ); + let result = + transform_angular_file(&allocator, "counter.service.ts", source, Some(&options), None); assert!( !result @@ -13673,8 +13637,7 @@ export class CounterService { } "; - let result = - transform_angular_file(&allocator, "counter.service.ts", source, None, None); + let result = transform_angular_file(&allocator, "counter.service.ts", source, None, None); assert!( result.has_errors(), @@ -13710,19 +13673,11 @@ import { Service } from '@angular/core'; export class CounterService {} "; - let result = - transform_angular_file(&allocator, "counter.service.ts", source, None, None); - assert!( - !result.has_errors(), - "Should compile without errors: {:?}", - result.diagnostics - ); + let result = transform_angular_file(&allocator, "counter.service.ts", source, None, None); + assert!(!result.has_errors(), "Should compile without errors: {:?}", result.diagnostics); // ɵprov must omit autoProvided when true (matches runtime default). - let prov_start = result - .code - .find("ɵɵdefineService") - .expect("should emit ɵɵdefineService"); + let prov_start = result.code.find("ɵɵdefineService").expect("should emit ɵɵdefineService"); let prov_end = result.code[prov_start..] .find("})") .map(|p| prov_start + p) @@ -13735,8 +13690,7 @@ export class CounterService {} // setClassMetadata must preserve the user's decorator args. assert!( - result.code.contains("autoProvided:true") - || result.code.contains("autoProvided: true"), + result.code.contains("autoProvided:true") || result.code.contains("autoProvided: true"), "setClassMetadata args should preserve user's autoProvided: true. Got:\n{}", result.code ); @@ -13752,13 +13706,8 @@ import { Service } from '@angular/core'; export class CounterService {} "; - let result = - transform_angular_file(&allocator, "counter.service.ts", source, None, None); - assert!( - !result.has_errors(), - "Should compile without errors: {:?}", - result.diagnostics - ); + let result = transform_angular_file(&allocator, "counter.service.ts", source, None, None); + assert!(!result.has_errors(), "Should compile without errors: {:?}", result.diagnostics); assert_eq!(result.dts_declarations.len(), 1); let decl = &result.dts_declarations[0]; From bae2019ff7adb986cf7b284d6abbed4fc345fce8 Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Fri, 29 May 2026 21:40:23 -0500 Subject: [PATCH 7/9] fix(service): aliased Service imports + cross-decorator collision Address two Codex review findings on the @Service AOT branch: 1. Aliased-import gate: the @Service classification matched on the local identifier name only, so `import { Injectable as Service } from '@angular/core'` was treated as the v22 decorator. Introduce `is_angular_core_export` to verify the import's original exported name and route both `find_angular_decorator` (JIT) and `find_angular_service_decorator` (AOT) through it. 2. Collision dispatch: the @Service-with-other-Angular-decorator collision check sat inside the standalone-@Service branch, which only runs after @Component/@Directive/@Pipe/@NgModule have failed to match. With @Service + @Component, the Component branch won the dispatch race and silently compiled the class. Hoist the collision check to a pre-flight ahead of the primary-decorator branches and drop the now-unreachable copy inside the Service branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/component/transform.rs | 72 ++++++++++------ .../tests/integration_test.rs | 86 +++++++++++++++++++ 2 files changed, 132 insertions(+), 26 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 28e6d6519..c75ebd1f9 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -905,20 +905,36 @@ fn find_angular_service_decorator<'a>( for decorator in &class.decorators { let Expression::CallExpression(call) = &decorator.expression else { continue }; let Expression::Identifier(id) = &call.callee else { continue }; - if id.name != "Service" { - continue; - } - let from_angular_core = import_map - .get(&Ident::from(id.name.as_str())) - .map(|info| info.source_module.as_str() == "@angular/core") - .unwrap_or(false); - if from_angular_core { + if is_angular_core_export(import_map, id.name.as_str(), "Service") { return Some(decorator); } } None } +/// Whether `local_name` resolves to the named `@angular/core` export. +/// +/// Matches when the import is from `@angular/core` AND the original exported +/// name equals `exported_name` — so `import { Injectable as Service }` does +/// not pass `is_angular_core_export(.., "Service", "Service")` even though +/// the local binding is `Service`. Bare `import { Service }` (no alias) +/// passes because `imported_name` is `None`, meaning the local name and the +/// exported name agree. +fn is_angular_core_export( + import_map: &ImportMap<'_>, + local_name: &str, + exported_name: &str, +) -> bool { + let Some(info) = import_map.get(&Ident::from(local_name)) else { return false }; + if info.source_module.as_str() != "@angular/core" { + return false; + } + match &info.imported_name { + Some(imported) => imported.as_str() == exported_name, + None => local_name == exported_name, + } +} + /// Return the name of the first non-`Service` `@angular/core` decorator on /// the class, if any. Used to enforce upstream's collision rule (see /// `service.ts:101-116`): `@Service` cannot coexist with another Angular @@ -999,11 +1015,7 @@ fn find_angular_decorator<'a>( if matches!(kind, Some(AngularDecoratorKind::Service)) { if let Expression::Identifier(id) = &call.callee { - let info = import_map.get(&Ident::from(id.name.as_str())); - let from_angular_core = info - .map(|info| info.source_module.as_str() == "@angular/core") - .unwrap_or(false); - if !from_angular_core { + if !is_angular_core_export(import_map, id.name.as_str(), "Service") { continue; } } @@ -2452,6 +2464,27 @@ pub fn transform_angular_file( }; if let Some(class) = class { + // Pre-flight: catch @Service co-located with another Angular + // decorator before the primary-decorator branches dispatch. + // Upstream service.ts:101-116 rejects this combination; without + // this early check, @Component / @Directive / @Pipe / @NgModule / + // @Injectable would win the branch race and compile the class + // (leaving the @Service decorator removed inconsistently) instead + // of producing the intended diagnostic. + if find_angular_service_decorator(class, &import_map).is_some() { + if let Some(conflict_name) = find_conflicting_angular_decorator(class, &import_map) + { + let class_name_for_diag = + class.id.as_ref().map_or(String::new(), |id| id.name.to_string()); + result.diagnostics.push(OxcDiagnostic::error(format!( + "Cannot apply more than one Angular decorator on an @Service class. \ + '{}' is also decorated with @{}.", + class_name_for_diag, conflict_name + ))); + continue; + } + } + // Compute implicit_standalone based on Angular version let implicit_standalone = options.implicit_standalone(); @@ -3084,19 +3117,6 @@ pub fn transform_angular_file( continue; } - // Collision diagnostic: upstream service.ts:101-116 rejects - // @Service co-located with any other @angular/core decorator. - if let Some(conflict_name) = - find_conflicting_angular_decorator(class, &import_map) - { - result.diagnostics.push(OxcDiagnostic::error(format!( - "Cannot apply more than one Angular decorator on an @Service class. \ - '{}' is also decorated with @{}.", - class_name_for_diag, conflict_name - ))); - continue; - } - // Constructor DI diagnostic: @Service ɵfac is generated with // empty deps, so any constructor parameter would silently // become `undefined` at runtime. Surface upstream's error diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index a87b71eaa..2b3d034dd 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -13583,6 +13583,92 @@ export class CounterService {} ); } +#[test] +fn test_aot_service_decorator_collision_with_component() { + // The collision check must fire even when the co-located decorator is a + // primary (Component/Directive/Pipe/NgModule) branch. Without the + // pre-flight gate, the Component branch would win the dispatch race and + // silently compile the class as a component — leaving the @Service + // decorator removed inconsistently instead of surfacing the diagnostic. + let allocator = Allocator::default(); + let source = r" +import { Service, Component } from '@angular/core'; + +@Service() +@Component({ selector: 'app-c', template: '' }) +export class CounterService {} +"; + + let result = transform_angular_file(&allocator, "counter.service.ts", source, None, None); + + assert!( + result.has_errors(), + "@Service + @Component should produce a collision diagnostic. Got none.\n{}", + result.code + ); + assert!( + result.diagnostics.iter().any(|d| { + let s = d.to_string(); + s.contains("@Service") && s.contains("Component") + }), + "Diagnostic should call out @Service and Component. Got: {:?}", + result.diagnostics + ); + assert!( + !result.code.contains("ɵɵdefineComponent"), + "Should NOT emit component definition when the collision rule fires. Got:\n{}", + result.code + ); + assert!( + !result.code.contains("ɵɵdefineService"), + "Should NOT emit service definition when the collision rule fires. Got:\n{}", + result.code + ); +} + +#[test] +fn test_aot_aliased_injectable_as_service_is_not_compiled_as_service() { + // `import { Injectable as Service }` aliases Injectable to the local + // name `Service`. The local-name-only gate would misclassify this as + // the v22 @Service decorator (and even emit `@Service requires v22` on + // pre-v22 targets). The fix consults the import's original exported + // name so only `import { Service }` from `@angular/core` qualifies. + // + // Note: this test asserts the negative claim only. Whether the class + // gets compiled as @Injectable via the aliased local name is a separate + // pre-existing concern of the Injectable branch. + let allocator = Allocator::default(); + let source = r" +import { Injectable as Service } from '@angular/core'; + +@Service() +export class CounterService {} +"; + + // Target Angular v21 so a false-positive @Service classification would + // trip the v22 version gate. + let options = ComponentTransformOptions { + angular_version: Some(AngularVersion::new(21, 0, 0)), + ..Default::default() + }; + let result = + transform_angular_file(&allocator, "counter.service.ts", source, Some(&options), None); + + assert!( + !result + .diagnostics + .iter() + .any(|d| d.to_string().contains("@Service") && d.to_string().contains("v22")), + "Aliased Injectable should not trip the @Service v22 gate. Got: {:?}", + result.diagnostics + ); + assert!( + !result.code.contains("ɵɵdefineService"), + "Aliased Injectable must not be compiled via the @Service path. Got:\n{}", + result.code + ); +} + #[test] fn test_aot_non_angular_service_decorator_is_ignored() { // A `@Service()` from a non-Angular library must not be transformed — From 01bd489d48ce4f75317acfd1d6e49d15c1dcf356 Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Fri, 29 May 2026 22:54:02 -0500 Subject: [PATCH 8/9] fix(service): aliased extraction + namespace Service gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from Codex's review of bae2019: 1. Aliased extraction asymmetry. The previous commit taught find_angular_service_decorator to accept aliased imports (e.g. `import { Service as NgService }; @NgService()`) via the import map, but extract_service_metadata still re-searched the class's decorators by literal name "Service" and returned None — so the dispatcher would accept the class and the extractor would silently drop it. Pass the already-resolved Decorator through to extract_service_metadata and use service_decorator.span directly for removal, so the dispatcher's gate is the only place the name/alias logic lives. 2. Namespace Service gate. find_angular_decorator's Service-only import-map check covered Identifier callees but fell through for StaticMemberExpression callees, classifying any `@third.Service()` from a third-party namespace as Angular's v22 decorator. Add an is_angular_core_namespace helper and extend the Service gate to verify namespace callees too. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/component/transform.rs | 48 +++++++++---- .../src/service/decorator.rs | 30 ++++---- .../tests/integration_test.rs | 72 +++++++++++++++++++ 3 files changed, 124 insertions(+), 26 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index c75ebd1f9..23c26ed29 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -73,10 +73,7 @@ use crate::pipeline::ingest::{ HostBindingInput, IngestOptions, ingest_component, ingest_component_with_options, ingest_host_binding_with_version, }; -use crate::service::{ - extract_service_metadata, find_service_decorator_span, - generate_service_definition_from_decorator, -}; +use crate::service::{extract_service_metadata, generate_service_definition_from_decorator}; use crate::transform::HtmlToR3Transform; use crate::transform::html_to_r3::TransformOptions as R3TransformOptions; @@ -935,6 +932,17 @@ fn is_angular_core_export( } } +/// Whether `local_name` is a namespace import (`import * as ns from ...`) +/// from `@angular/core`. Used to validate namespace-style decorator calls +/// like `@ns.Service()` — without this check, any third-party namespace +/// `Service` decorator would classify as the Angular v22 decorator. +fn is_angular_core_namespace(import_map: &ImportMap<'_>, local_name: &str) -> bool { + import_map + .get(&Ident::from(local_name)) + .map(|info| info.source_module.as_str() == "@angular/core" && !info.is_named_import) + .unwrap_or(false) +} + /// Return the name of the first non-`Service` `@angular/core` decorator on /// the class, if any. Used to enforce upstream's collision rule (see /// `service.ts:101-116`): `@Service` cannot coexist with another Angular @@ -1014,10 +1022,24 @@ fn find_angular_decorator<'a>( }; if matches!(kind, Some(AngularDecoratorKind::Service)) { - if let Expression::Identifier(id) = &call.callee { - if !is_angular_core_export(import_map, id.name.as_str(), "Service") { - continue; + let from_angular_core = match &call.callee { + Expression::Identifier(id) => { + is_angular_core_export(import_map, id.name.as_str(), "Service") } + // Namespace form `@ns.Service()`: verify `ns` is a + // namespace import from `@angular/core`. Without this, + // any `@third.Service()` from a third-party namespace + // import would classify as the v22 decorator. + Expression::StaticMemberExpression(member) => match &member.object { + Expression::Identifier(ns) => { + is_angular_core_namespace(import_map, ns.name.as_str()) + } + _ => false, + }, + _ => false, + }; + if !from_angular_core { + continue; } } @@ -3131,12 +3153,14 @@ pub fn transform_angular_file( } if let Some(service_metadata) = - extract_service_metadata(allocator, class, Some(source)) + extract_service_metadata(allocator, class, service_decorator, Some(source)) { - // Track decorator span for removal - if let Some(span) = find_service_decorator_span(class) { - decorator_spans_to_remove.push(span); - } + // Track decorator span for removal. Use the resolved + // decorator directly so aliased imports + // (`import { Service as NgService }`) still get + // stripped — re-searching by literal name would miss + // them. + decorator_spans_to_remove.push(service_decorator.span); // Even though @Service ɵfac doesn't inject ctor params, the // user's constructor may still carry @Inject/@Optional/etc. // decorators that need to be stripped from the output (the diff --git a/crates/oxc_angular_compiler/src/service/decorator.rs b/crates/oxc_angular_compiler/src/service/decorator.rs index 17681ace3..5347146b6 100644 --- a/crates/oxc_angular_compiler/src/service/decorator.rs +++ b/crates/oxc_angular_compiler/src/service/decorator.rs @@ -3,13 +3,14 @@ //! Extracts metadata from `@Service({...})` decorators on TypeScript class //! declarations. Ported from `packages/compiler-cli/src/ngtsc/annotations/src/service.ts`. //! -//! The caller is responsible for confirming that the `Service` identifier -//! resolves to `@angular/core` (via the file's import map) before invoking -//! `extract_service_metadata`. `Service` is a common library export name, so -//! name-only matching would misclassify unrelated decorators. The matching -//! helpers here intentionally only check the identifier name — see the JIT -//! pipeline at `component/transform.rs::find_angular_decorator` for the -//! upstream import-map gate that the AOT caller mirrors. +//! The caller is responsible for confirming that the decorator's local +//! identifier resolves to `Service` in `@angular/core` (via the file's +//! import map) before invoking `extract_service_metadata`. The extractor +//! takes the already-resolved `Decorator` reference rather than searching +//! the class by literal name, so aliased imports like +//! `import { Service as NgService }; @NgService()` flow through correctly. +//! See `component/transform.rs::find_angular_service_decorator` for the +//! import-map gate the AOT caller uses. use oxc_allocator::{Allocator, Box}; use oxc_ast::ast::{Argument, Class, Decorator, Expression, ObjectPropertyKind, PropertyKey}; @@ -42,22 +43,23 @@ pub fn find_service_decorator_span(class: &Class<'_>) -> Option { class.decorators.iter().find(|d| is_service_decorator(d)).map(|d| d.span) } -/// Extract `@Service` metadata from a class. +/// Extract `@Service` metadata from a class given the already-resolved +/// decorator node. /// -/// The caller must have already confirmed that the `Service` identifier -/// resolves to `@angular/core` — this function matches by name only and will -/// happily return metadata for an unrelated `@Service` from a third-party -/// library. +/// The caller (the AOT dispatcher) has already verified via the file's import +/// map that the decorator's local identifier resolves to `Service` in +/// `@angular/core`. Accepting the decorator by reference avoids re-searching +/// the class's decorators by literal name — which would skip aliased imports +/// such as `import { Service as NgService }; @NgService()`. pub fn extract_service_metadata<'a>( _allocator: &'a Allocator, class: &'a Class<'a>, + decorator: &'a Decorator<'a>, source_text: Option<&'a str>, ) -> Option> { let class_name: Ident<'a> = class.id.as_ref()?.name.clone().into(); let class_span = class.span; - let decorator = class.decorators.iter().find(|d| is_service_decorator(d))?; - let call_expr = match &decorator.expression { Expression::CallExpression(call) => call, _ => return None, diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 2b3d034dd..40f9b1c22 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -7011,6 +7011,45 @@ export class CounterService { ); } +#[test] +fn test_jit_third_party_namespace_service_decorator_is_ignored() { + // `@other.Service()` where `other` is a namespace import from a + // third-party library must NOT be classified as Angular's v22 + // @Service. The Service classification's import-map gate needs to + // walk through `StaticMemberExpression` callees and verify the + // namespace object resolves to `@angular/core` — otherwise any + // namespaced `Service` decorator trips the v22 version gate. + let allocator = Allocator::default(); + let source = r" +import * as other from 'some-other-lib'; + +@other.Service() +export class CounterService {} +"; + + let options = ComponentTransformOptions { + jit: true, + angular_version: Some(AngularVersion::new(21, 0, 0)), + ..Default::default() + }; + let result = + transform_angular_file(&allocator, "counter.service.ts", source, Some(&options), None); + + assert!( + !result + .diagnostics + .iter() + .any(|d| d.to_string().contains("@Service") && d.to_string().contains("v22")), + "Third-party namespace @Service should not trip the v22 diagnostic. Got: {:?}", + result.diagnostics + ); + assert!( + result.code.contains("other.Service"), + "Third-party namespace @Service decorator should be left intact. Got:\n{}", + result.code + ); +} + #[test] fn test_jit_full_component_example() { // Full example matching the issue #97 scenario @@ -13669,6 +13708,39 @@ export class CounterService {} ); } +#[test] +fn test_aot_aliased_service_import_still_emits_definition() { + // The mirror case of the prior test: `import { Service as NgService }` + // still resolves to Angular's v22 @Service. The AOT dispatcher accepts + // the aliased decorator via the import-map gate, and the extractor + // must consume the resolved decorator (rather than re-searching for a + // literal `Service` name) so ɵfac/ɵprov are still emitted. + let allocator = Allocator::default(); + let source = r" +import { Service as NgService } from '@angular/core'; + +@NgService() +export class CounterService {} +"; + + let result = transform_angular_file(&allocator, "counter.service.ts", source, None, None); + assert!( + !result.has_errors(), + "Aliased @Service should compile without errors. Got: {:?}", + result.diagnostics + ); + assert!( + result.code.contains("ɵɵdefineService"), + "Aliased @Service must still emit ɵɵdefineService. Got:\n{}", + result.code + ); + assert!( + result.code.contains("ɵfac") && result.code.contains("ɵprov"), + "Aliased @Service must emit both ɵfac and ɵprov. Got:\n{}", + result.code + ); +} + #[test] fn test_aot_non_angular_service_decorator_is_ignored() { // A `@Service()` from a non-Angular library must not be transformed — From 42680e8fdad626812adda1193ded9d01fa326121 Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Sat, 30 May 2026 09:24:44 -0500 Subject: [PATCH 9/9] fix(service): namespace @Service in AOT + collision false-positive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from Codex's review of 01bd489: 1. AOT namespace @Service. find_angular_service_decorator only accepted Identifier callees, so `import * as ng from '@angular/core'; @ng.Service()` entered the JIT branch (which already handles namespace callees) but silently skipped AOT — class emitted without ɵfac/ɵprov, decorator left in place. Extend the AOT helper to mirror the JIT-side namespace path via is_angular_core_namespace. 2. Namespace collision false-positive. find_conflicting_angular_decorator treated any `@ns.Component()`-shaped decorator as Angular based on property name alone. That meant an @Service class with an unrelated third-party namespaced decorator (`@thirdParty.Component()`) tripped the preflight collision diagnostic and never compiled. Verify the namespace object against the import map the same way @ns.Service() is verified. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/component/transform.rs | 36 +++++++++-- .../tests/integration_test.rs | 64 +++++++++++++++++++ 2 files changed, 94 insertions(+), 6 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 23c26ed29..cd4a0ba21 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -901,8 +901,26 @@ fn find_angular_service_decorator<'a>( ) -> Option<&'a oxc_ast::ast::Decorator<'a>> { for decorator in &class.decorators { let Expression::CallExpression(call) = &decorator.expression else { continue }; - let Expression::Identifier(id) = &call.callee else { continue }; - if is_angular_core_export(import_map, id.name.as_str(), "Service") { + let from_angular_core = match &call.callee { + Expression::Identifier(id) => { + is_angular_core_export(import_map, id.name.as_str(), "Service") + } + // Namespace form `@ns.Service()`: accept when `ns` is a + // namespace import from `@angular/core`. Without this, AOT + // would silently skip namespaced services that the JIT path + // already classifies correctly. + Expression::StaticMemberExpression(member) => { + member.property.name.as_str() == "Service" + && match &member.object { + Expression::Identifier(ns) => { + is_angular_core_namespace(import_map, ns.name.as_str()) + } + _ => false, + } + } + _ => false, + }; + if from_angular_core { return Some(decorator); } } @@ -966,16 +984,22 @@ fn find_conflicting_angular_decorator<'a>( if !ANGULAR_DECORATORS.contains(&name) { continue; } - // For Identifier-callees, verify the import resolves to @angular/core. - // Namespace-style (`@core.Component()`) is treated as Angular without - // a lookup, matching `find_angular_decorator`'s behavior. + // Verify the import resolves to @angular/core for both identifier + // and namespace callees. Without the namespace lookup, an unrelated + // `@thirdParty.Component()` on an @Service class would falsely + // trigger the collision preflight and block valid services. let is_angular = if let Expression::CallExpression(call) = &decorator.expression { match &call.callee { Expression::Identifier(id) => import_map .get(&Ident::from(id.name.as_str())) .map(|info| info.source_module.as_str() == "@angular/core") .unwrap_or(false), - Expression::StaticMemberExpression(_) => true, + Expression::StaticMemberExpression(member) => match &member.object { + Expression::Identifier(ns) => { + is_angular_core_namespace(import_map, ns.name.as_str()) + } + _ => false, + }, _ => false, } } else { diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 40f9b1c22..c4fe16045 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -13741,6 +13741,70 @@ export class CounterService {} ); } +#[test] +fn test_aot_namespace_service_import_emits_definition() { + // `import * as ng from '@angular/core'; @ng.Service()` is a valid v22 + // service in upstream Angular. The AOT dispatcher must accept the + // namespace form (the JIT classifier already does), otherwise the + // class silently skips the service branch and is emitted without + // ɵfac/ɵprov. + let allocator = Allocator::default(); + let source = r" +import * as ng from '@angular/core'; + +@ng.Service() +export class CounterService {} +"; + + let result = transform_angular_file(&allocator, "counter.service.ts", source, None, None); + assert!( + !result.has_errors(), + "Namespaced @ng.Service() should compile without errors. Got: {:?}", + result.diagnostics + ); + assert!( + result.code.contains("ɵɵdefineService"), + "Namespaced @ng.Service() must emit ɵɵdefineService. Got:\n{}", + result.code + ); +} + +#[test] +fn test_aot_service_namespace_collision_preflight_ignores_third_party() { + // The collision preflight must use the import map to decide whether a + // namespace decorator like `@thirdParty.Component()` is actually an + // Angular decorator. Without this, an @Service class with an + // unrelated namespaced decorator would falsely trip the collision + // diagnostic and never get compiled. + let allocator = Allocator::default(); + let source = r" +import { Service } from '@angular/core'; +import * as thirdParty from 'some-other-lib'; + +@Service() +@thirdParty.Component() +export class CounterService {} +"; + + let result = transform_angular_file(&allocator, "counter.service.ts", source, None, None); + + assert!( + !result.diagnostics.iter().any(|d| { + let s = d.to_string(); + s.contains("@Service") && s.contains("@Component") + }), + "Third-party namespace @Component must not trip the @Service collision diagnostic. \ + Got: {:?}", + result.diagnostics + ); + assert!( + result.code.contains("ɵɵdefineService"), + "Service compilation must still proceed despite the unrelated namespace decorator. \ + Got:\n{}", + result.code + ); +} + #[test] fn test_aot_non_angular_service_decorator_is_ignored() { // A `@Service()` from a non-Angular library must not be transformed —