diff --git a/Metano.slnx b/Metano.slnx
index 8765655..e5fbec1 100644
--- a/Metano.slnx
+++ b/Metano.slnx
@@ -11,6 +11,7 @@
+
diff --git a/docs/compiler-refactor-plan.md b/docs/compiler-refactor-plan.md
new file mode 100644
index 0000000..be1d757
--- /dev/null
+++ b/docs/compiler-refactor-plan.md
@@ -0,0 +1,487 @@
+# Compiler Refactor Plan for Multi-Target Backends
+
+## Goal
+
+Refactor the current TypeScript-focused compiler pipeline into a target-agnostic compiler architecture that can support multiple output languages without duplicating semantic lowering logic.
+
+The near-term constraint is critical:
+
+- Keep the current TypeScript backend working.
+- Preserve current observable behavior while refactoring.
+- Migrate incrementally.
+- Prepare the architecture for future targets beyond TypeScript.
+
+## Current Baseline
+
+The current implementation is centered in `src/Metano.Compiler.TypeScript/`.
+
+Observed characteristics:
+
+- Roslyn already provides parsing, binding, and semantic analysis.
+- The TypeScript backend currently contains both:
+ - semantic lowering decisions
+ - TypeScript-specific emission decisions
+- Large transformation hotspots include:
+ - `Transformation/TypeTransformer.cs`
+ - `Transformation/RecordClassTransformer.cs`
+ - `Transformation/ImportCollector.cs`
+ - `Transformation/TypeMapper.cs`
+ - `TypeScript/Printer.cs`
+- Current test baseline:
+ - `357` passing tests
+ - validated through the TUnit runner binary
+
+## Desired Target Architecture
+
+The compiler should evolve toward this pipeline:
+
+`Roslyn front-end -> semantic model extraction -> shared IR -> target-specific lowering/emission`
+
+### Layers
+
+1. **Front-end**
+ - Input: Roslyn `Compilation`, symbols, syntax, semantic models.
+ - Responsibility: discover transpilable program shape and normalize semantic facts.
+
+2. **Shared IR**
+ - A target-agnostic intermediate representation of:
+ - modules
+ - types
+ - members
+ - expressions
+ - statements
+ - type references
+ - runtime requirements
+ - diagnostics metadata
+
+3. **Common lowering**
+ - Converts Roslyn semantics to shared IR.
+ - Must contain language-independent rules such as:
+ - records/value semantics
+ - nullable handling
+ - async/task concepts
+ - extension methods
+ - overload dispatch intent
+ - pattern semantics
+
+4. **Target backends**
+ - Convert shared IR to a target-specific AST or textual output.
+ - Examples:
+ - TypeScript backend
+ - future Dart/Kotlin/Swift/etc.
+
+5. **Packaging/runtime integration**
+ - Per-target concerns:
+ - imports
+ - package/module layout
+ - runtime helper mapping
+ - dependency manifest generation
+
+## Design Rules
+
+These rules should guide every refactor step:
+
+- Do not break the TypeScript backend during architectural extraction.
+- Do not move TypeScript-specific syntax decisions into shared lowering.
+- Do not put semantic decisions into backend printers/emitters.
+- Eliminate static ambient compiler state from shared logic.
+- Each refactor step must leave the codebase in a releasable state.
+- Prefer adapters and parallel paths over big-bang rewrites.
+
+## Main Problems to Fix
+
+### 1. TypeScript backend owns too much semantic logic
+
+Today, backend classes decide both what the source means and how TypeScript should express it.
+
+Examples:
+
+- `RecordClassTransformer`
+- `ImportCollector`
+- `TypeMapper`
+
+This will not scale across multiple targets.
+
+### 2. Static state in `TypeMapper`
+
+`TypeMapper` currently uses thread-static mutable state for mappings and dependency tracking.
+
+Risks:
+
+- hidden coupling
+- difficult reasoning
+- poor composability
+- harder parallelization
+- harder testing of isolated steps
+
+### 3. Import/runtime decisions happen too late
+
+The current TypeScript import logic infers runtime needs by walking TypeScript AST output. That is fragile for multi-target support.
+
+Instead, runtime requirements should be modeled as explicit facts earlier in the pipeline.
+
+### 4. Large files combine multiple concerns
+
+Especially:
+
+- `TypeTransformer.cs`
+- `RecordClassTransformer.cs`
+- `ImportCollector.cs`
+
+These should be decomposed by responsibility, not only by size.
+
+## Phased Execution Plan
+
+## Phase 0 - Freeze the Baseline
+
+### Objective
+
+Create a stable safety net before moving architecture.
+
+### Tasks
+
+1. Record the current passing test baseline.
+2. Add a short contributor note describing how to run tests through the TUnit binary.
+3. Identify the highest-value golden/snapshot tests for TypeScript output.
+4. Add a benchmark harness for transform time on representative samples if lightweight enough.
+
+### Deliverables
+
+- documented baseline
+- test execution command documented
+- no behavior changes
+
+### Exit Criteria
+
+- all current tests still pass
+- no production code changed or only documentation/test harness changed
+
+## Phase 1 - Define the Shared Compiler Model
+
+### Objective
+
+Create the target-agnostic contracts without yet migrating the full pipeline.
+
+### Tasks
+
+1. Define compiler service contracts for:
+ - type resolution
+ - symbol lowering
+ - runtime dependency collection
+ - diagnostics reporting
+2. Define the first version of the shared IR:
+ - `IrModule`
+ - `IrTypeDecl`
+ - `IrMemberDecl`
+ - `IrMethodDecl`
+ - `IrPropertyDecl`
+ - `IrFieldDecl`
+ - `IrExpression`
+ - `IrStatement`
+ - `IrTypeRef`
+3. Add support for semantic annotations/capabilities in IR:
+ - nullable
+ - async
+ - generator
+ - extension method
+ - record/value semantics
+ - runtime helper requirements
+
+### Deliverables
+
+- initial IR model
+- initial compiler service interfaces
+- architecture note or ADR if needed
+
+### Exit Criteria
+
+- the IR compiles and is documented
+- no existing backend behavior is removed
+
+## Phase 2 - Remove Ambient State and Introduce Context Objects
+
+### Objective
+
+Make the current pipeline explicit and composable before rerouting it.
+
+### Tasks
+
+1. Replace thread-static `TypeMapper` state with an explicit per-compilation context/service.
+2. Move dependency tracking and cross-package origin tracking into owned context objects.
+3. Ensure current TypeScript transformation classes consume context explicitly.
+4. Keep public behavior unchanged.
+
+### Deliverables
+
+- `TypeMapper` no longer depends on ambient mutable state
+- dependency tracking lives in owned objects
+
+### Exit Criteria
+
+- all tests still pass
+- no thread-static state remains in the shared mapping path
+
+## Phase 3 - Extract Front-End Semantic Lowering
+
+### Objective
+
+Move language-independent analysis out of TypeScript-specific transformers.
+
+### Tasks
+
+1. Split `TypeTransformer` into:
+ - discovery/orchestration
+ - semantic lowering entrypoints
+ - backend dispatch
+2. Extract common semantic builders for:
+ - records/classes/interfaces/enums
+ - constructor model
+ - method model
+ - overload model
+ - nested types
+3. Introduce `Roslyn -> IR` builders alongside existing TypeScript logic.
+4. Start with easy shapes:
+ - enums
+ - interfaces
+ - type references
+ - simple classes
+
+### Deliverables
+
+- partial Roslyn-to-IR pipeline
+- backend still driven through adapters where necessary
+
+### Exit Criteria
+
+- at least one vertical slice reaches TypeScript through IR
+- tests still green
+
+## Phase 4 - Adapt TypeScript Backend to Consume IR
+
+### Objective
+
+Turn the current TypeScript backend into a true target backend.
+
+### Tasks
+
+1. Introduce `IR -> TypeScript AST` adapters.
+2. Keep the existing `TypeScript/AST` and `Printer` initially.
+3. Gradually replace direct Roslyn-driven lowering in TypeScript backend classes.
+4. Migrate TypeScript-specific concerns to backend-only services:
+ - naming/escaping
+ - module path calculation
+ - import rendering
+ - package.json generation
+
+### Deliverables
+
+- TypeScript backend consuming IR for selected features
+- reduced Roslyn coupling inside TS backend
+
+### Exit Criteria
+
+- core TypeScript features flow through IR
+- output parity preserved for migrated areas
+
+## Phase 5 - Model Runtime Requirements Explicitly
+
+### Objective
+
+Stop discovering runtime needs by reverse-walking backend-specific ASTs.
+
+### Tasks
+
+1. Represent runtime helper requirements in IR.
+2. Represent cross-package dependencies and external imports as semantic facts.
+3. Refactor `ImportCollector` into:
+ - semantic dependency collection
+ - TypeScript import rendering
+4. Ensure packaging logic reads backend facts instead of inferring from syntax trees.
+
+### Deliverables
+
+- explicit runtime/dependency facts in IR or backend-bound metadata
+- simpler TypeScript import assembly
+
+### Exit Criteria
+
+- import/runtime logic no longer depends primarily on heuristics over TS AST shape
+
+## Phase 6 - Migrate Complex Features
+
+### Objective
+
+Move high-complexity features after the architecture is proven.
+
+### Migration order
+
+1. records and value semantics
+2. overload dispatch
+3. extension methods and module lowering
+4. pattern matching / switch expressions
+5. JSON serializer context features
+6. operator overloading
+
+### Deliverables
+
+- migrated complex features through IR
+
+### Exit Criteria
+
+- TypeScript backend old/new paths substantially reduced
+- parity maintained by tests
+
+## Phase 7 - Pilot a Second Target
+
+### Objective
+
+Validate that the architecture is truly multi-target.
+
+### Tasks
+
+1. Choose one pilot backend.
+2. Implement a minimal feature slice:
+ - modules
+ - simple classes
+ - enums
+ - interfaces
+ - methods
+3. Compare pain points against TypeScript-specific assumptions still present in IR.
+4. Refine IR based on actual second-target pressure.
+
+### Deliverables
+
+- first non-TypeScript target prototype
+
+### Exit Criteria
+
+- the second target compiles a real sample
+- new target does not require semantic rules to be copied out of the TypeScript backend
+
+## Recommended Extraction Order in the Existing Codebase
+
+This is the suggested file-by-file order for the first implementation wave.
+
+### Wave 1
+
+- `Transformation/TypeMapper.cs`
+- `Transformation/TypeScriptTransformContext.cs`
+- `Transformation/PathNaming.cs`
+- `Transformation/ImportCollector.cs`
+
+Reason:
+
+- these are foundational dependency/context concerns
+- they unblock removal of hidden state
+- they reduce coupling before deeper transformations
+
+### Wave 2
+
+- `Transformation/TypeTransformer.cs`
+- `Transformation/InterfaceTransformer.cs`
+- `Transformation/EnumTransformer.cs`
+
+Reason:
+
+- these are good first vertical slices into shared IR
+
+### Wave 3
+
+- `Transformation/RecordClassTransformer.cs`
+- `Transformation/OverloadDispatcherBuilder.cs`
+- `Transformation/ModuleTransformer.cs`
+
+Reason:
+
+- these contain the most semantic complexity and should move after the framework is ready
+
+### Wave 4
+
+- `Transformation/ExpressionTransformer.cs`
+- child handlers under `Transformation/`
+
+Reason:
+
+- expression lowering should migrate after top-level declaration structure is stabilized
+
+## Test Strategy
+
+## Keep Existing Tests
+
+All current TypeScript behavior tests remain required.
+
+## Add Layered Tests
+
+Add tests at three levels:
+
+1. Roslyn -> IR tests
+2. IR -> TypeScript AST tests
+3. end-to-end Roslyn -> TypeScript output tests
+
+## Add Feature Matrix Coverage
+
+Track, per feature:
+
+- semantic support in IR
+- TypeScript backend support
+- next target support
+- runtime helper dependency
+- expected diagnostics
+
+## Risks
+
+### Risk: Big-bang rewrite
+
+Mitigation:
+
+- use adapters
+- migrate feature slices
+- preserve old path until parity is proven
+
+### Risk: IR becomes TypeScript-shaped
+
+Mitigation:
+
+- validate with a second target early
+- forbid TS syntax details in IR naming and constructs
+
+### Risk: Semantic duplication during migration
+
+Mitigation:
+
+- establish one canonical lowering path per migrated feature
+- deprecate old path immediately after parity is achieved
+
+### Risk: Test suite only validates TypeScript text
+
+Mitigation:
+
+- add IR-level tests before major feature migration
+
+## Suggested First Concrete Milestone
+
+The first milestone should be intentionally narrow:
+
+### Milestone A
+
+- eliminate ambient `TypeMapper` state
+- add initial IR model
+- route enums and interfaces through IR
+- keep TypeScript output unchanged
+
+Why this milestone:
+
+- high architectural leverage
+- relatively low feature risk
+- proves the new shape without touching the most fragile code first
+
+## Suggested Prompting Guidance for a Follow-Up Agent
+
+If another agent continues this work, it should:
+
+- preserve behavior first
+- avoid rewriting everything at once
+- prioritize extraction order
+- explicitly separate semantic lowering from TypeScript emission
+- produce small, reviewable patches
+
diff --git a/js/sample-issue-tracker/src/issues/application/issue-queries.ts b/js/sample-issue-tracker/src/issues/application/issue-queries.ts
index 60bf7c2..44e62de 100644
--- a/js/sample-issue-tracker/src/issues/application/issue-queries.ts
+++ b/js/sample-issue-tracker/src/issues/application/issue-queries.ts
@@ -1,5 +1,4 @@
-import { Enumerable } from "metano-runtime";
-import type { Grouping } from "metano-runtime";
+import { Enumerable, type Grouping } from "metano-runtime";
import { IssuePriority, IssueStatus, type Issue } from "#/issues/domain";
import type { UserId } from "#/shared-kernel";
diff --git a/js/sample-issue-tracker/src/issues/domain/issue-id.ts b/js/sample-issue-tracker/src/issues/domain/issue-id.ts
index f82c728..4a81b40 100644
--- a/js/sample-issue-tracker/src/issues/domain/issue-id.ts
+++ b/js/sample-issue-tracker/src/issues/domain/issue-id.ts
@@ -1,5 +1,4 @@
-import { HashCode } from "metano-runtime";
-import { UUID } from "metano-runtime";
+import { HashCode, UUID } from "metano-runtime";
export type IssueId = string & { readonly __brand: "IssueId" };
diff --git a/js/sample-issue-tracker/src/planning/domain/sprint.ts b/js/sample-issue-tracker/src/planning/domain/sprint.ts
index a58542b..d6a02f7 100644
--- a/js/sample-issue-tracker/src/planning/domain/sprint.ts
+++ b/js/sample-issue-tracker/src/planning/domain/sprint.ts
@@ -1,6 +1,5 @@
import { Temporal } from "@js-temporal/polyfill";
-import { dayNumber } from "metano-runtime";
-import { HashSet } from "metano-runtime";
+import { HashSet, dayNumber } from "metano-runtime";
import type { IssueId } from "#/issues/domain";
export class Sprint {
diff --git a/js/sample-issue-tracker/src/shared-kernel/user-id.ts b/js/sample-issue-tracker/src/shared-kernel/user-id.ts
index b285239..3b88502 100644
--- a/js/sample-issue-tracker/src/shared-kernel/user-id.ts
+++ b/js/sample-issue-tracker/src/shared-kernel/user-id.ts
@@ -1,5 +1,4 @@
-import { HashCode } from "metano-runtime";
-import { UUID } from "metano-runtime";
+import { HashCode, UUID } from "metano-runtime";
export type UserId = string & { readonly __brand: "UserId" };
diff --git a/js/sample-operator-overloading/package.json b/js/sample-operator-overloading/package.json
new file mode 100644
index 0000000..197fc6c
--- /dev/null
+++ b/js/sample-operator-overloading/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "sample-operator-overloading",
+ "type": "module",
+ "module": "./dist/program.js",
+ "private": true,
+ "sideEffects": false,
+ "imports": {
+ "#/*": {
+ "types": "./dist/*.d.ts",
+ "import": "./dist/*.js",
+ "default": "./src/*.ts"
+ },
+ "#": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "default": "./src/index.ts"
+ }
+ },
+ "dependencies": {
+ "metano-runtime": "workspace:*",
+ "decimal.js": "^10.6.0"
+ },
+ "devDependencies": {
+ "@types/bun": "latest",
+ "@typescript/native-preview": "^7.0.0-dev.20260225.1"
+ },
+ "scripts": {
+ "generate": "dotnet run --project ../../src/Metano.Compiler.TypeScript/Metano.Compiler.TypeScript.csproj -- -p ../../samples/SampleOperatorOverloading/SampleOperatorOverloading.csproj -o ./src --clean --time",
+ "build": "tsgo -b .",
+ "test": "bun test"
+ }
+}
diff --git a/js/sample-operator-overloading/src/currency.ts b/js/sample-operator-overloading/src/currency.ts
new file mode 100644
index 0000000..86ae747
--- /dev/null
+++ b/js/sample-operator-overloading/src/currency.ts
@@ -0,0 +1,5 @@
+export enum Currency {
+ Brl = 0,
+ Usd = 1,
+ Eur = 2,
+}
diff --git a/js/sample-operator-overloading/src/index.ts b/js/sample-operator-overloading/src/index.ts
new file mode 100644
index 0000000..4ce57c2
--- /dev/null
+++ b/js/sample-operator-overloading/src/index.ts
@@ -0,0 +1,3 @@
+export { Currency } from "./currency";
+export { Money } from "./money";
+export { NoSameMoneyCurrencyException } from "./no-same-money-currency-exception";
diff --git a/js/sample-operator-overloading/src/money.ts b/js/sample-operator-overloading/src/money.ts
new file mode 100644
index 0000000..7541b4d
--- /dev/null
+++ b/js/sample-operator-overloading/src/money.ts
@@ -0,0 +1,142 @@
+import { HashCode, isBigInt } from "metano-runtime";
+import { Decimal } from "decimal.js";
+import type { Currency } from "./currency";
+import { NoSameMoneyCurrencyException } from "./no-same-money-currency-exception";
+
+export class Money {
+ constructor(readonly cents: bigint, readonly currency: Currency) { }
+
+ static __add(left: Money, right: Money): Money {
+ Money.expectSameCurrency(left, right);
+
+ return left.with({ cents: left.cents + right.cents });
+ }
+
+ $add(right: Money): Money {
+ return Money.__add(this, right);
+ }
+
+ static __subtract(left: Money, right: Money): Money {
+ Money.expectSameCurrency(left, right);
+
+ return left.with({ cents: left.cents - right.cents });
+ }
+
+ $subtract(right: Money): Money {
+ return Money.__subtract(this, right);
+ }
+
+ private static __multiplyMoneyBigInteger(left: Money, factor: bigint): Money {
+ return left.with({ cents: left.cents * factor });
+ }
+
+ private static __multiplyMoneyMoney(left: Money, right: Money): Money {
+ Money.expectSameCurrency(left, right);
+
+ return left.with({ cents: left.cents * right.cents });
+ }
+
+ static __multiply(left: Money, factor: bigint): Money;
+ static __multiply(left: Money, right: Money): Money;
+ static __multiply(...args: unknown[]): Money {
+ if (args.length === 2 && args[0] instanceof Money && isBigInt(args[1])) {
+ return Money.__multiplyMoneyBigInteger(args[0] as Money, args[1] as bigint);
+ }
+
+ if (args.length === 2 && args[0] instanceof Money && args[1] instanceof Money) {
+ return Money.__multiplyMoneyMoney(args[0] as Money, args[1] as Money);
+ }
+
+ throw new Error("No matching overload for multiply");
+ }
+
+ $multiply(factor: bigint): Money;
+ $multiply(right: Money): Money;
+ $multiply(...args: unknown[]): Money {
+ if (args.length === 1 && isBigInt(args[0])) {
+ return Money.__multiplyMoneyBigInteger(this, args[0] as bigint);
+ }
+
+ if (args.length === 1 && args[0] instanceof Money) {
+ return Money.__multiplyMoneyMoney(this, args[0] as Money);
+ }
+
+ throw new Error("No matching overload for multiply");
+ }
+
+ private static __divideMoneyBigInteger(left: Money, divisor: bigint): Money {
+ return left.with({ cents: left.cents / divisor });
+ }
+
+ private static __divideMoneyMoney(left: Money, divisor: Money): Money {
+ Money.expectSameCurrency(left, divisor);
+
+ return left.with({ cents: left.cents / divisor.cents });
+ }
+
+ static __divide(left: Money, divisor: bigint): Money;
+ static __divide(left: Money, divisor: Money): Money;
+ static __divide(...args: unknown[]): Money {
+ if (args.length === 2 && args[0] instanceof Money && isBigInt(args[1])) {
+ return Money.__divideMoneyBigInteger(args[0] as Money, args[1] as bigint);
+ }
+
+ if (args.length === 2 && args[0] instanceof Money && args[1] instanceof Money) {
+ return Money.__divideMoneyMoney(args[0] as Money, args[1] as Money);
+ }
+
+ throw new Error("No matching overload for divide");
+ }
+
+ $divide(divisor: bigint): Money;
+ $divide(divisor: Money): Money;
+ $divide(...args: unknown[]): Money {
+ if (args.length === 1 && isBigInt(args[0])) {
+ return Money.__divideMoneyBigInteger(this, args[0] as bigint);
+ }
+
+ if (args.length === 1 && args[0] instanceof Money) {
+ return Money.__divideMoneyMoney(this, args[0] as Money);
+ }
+
+ throw new Error("No matching overload for divide");
+ }
+
+ toDecimal(): Decimal {
+ return new Decimal(this.cents.toString()).div(new Decimal("100"));
+ }
+
+ toString(): string {
+ return `${this.toDecimal()} ${this.currency.toString().toUpperCase()}`;
+ }
+
+ static fromCents(cents: bigint, currency: Currency): Money {
+ return new Money(cents, currency);
+ }
+
+ static fromValue(amount: Decimal, currency: Currency): Money {
+ return new Money(BigInt(amount.times(new Decimal("100")).round().toFixed(0)), currency);
+ }
+
+ private static expectSameCurrency(left: Money, right: Money): void {
+ if (left.currency !== right.currency) {
+ throw new NoSameMoneyCurrencyException(left.currency, right.currency);
+ }
+ }
+
+ equals(other: any): boolean {
+ return other instanceof Money && this.cents === other.cents && this.currency === other.currency;
+ }
+
+ hashCode(): number {
+ const hc = new HashCode();
+ hc.add(this.cents);
+ hc.add(this.currency);
+
+ return hc.toHashCode();
+ }
+
+ with(overrides?: Partial): Money {
+ return new Money(overrides?.cents ?? this.cents, overrides?.currency ?? this.currency);
+ }
+}
diff --git a/js/sample-operator-overloading/src/no-same-money-currency-exception.ts b/js/sample-operator-overloading/src/no-same-money-currency-exception.ts
new file mode 100644
index 0000000..d1ca47e
--- /dev/null
+++ b/js/sample-operator-overloading/src/no-same-money-currency-exception.ts
@@ -0,0 +1,7 @@
+import type { Currency } from "./currency";
+
+export class NoSameMoneyCurrencyException extends Error {
+ constructor(expected: Currency, provided: Currency) {
+ super(`Not same currency. Money has ${provided}, but expected ${expected}`);
+ }
+}
diff --git a/js/sample-operator-overloading/src/program.ts b/js/sample-operator-overloading/src/program.ts
new file mode 100644
index 0000000..1a6a982
--- /dev/null
+++ b/js/sample-operator-overloading/src/program.ts
@@ -0,0 +1,8 @@
+import { Currency } from "./currency";
+import { Money } from "./money";
+
+let poket = Money.fromCents(150n, Currency.Usd);
+
+poket = poket.$add(new Money(250n, Currency.Usd));
+
+console.log(poket);
diff --git a/js/sample-operator-overloading/test/money.test.ts b/js/sample-operator-overloading/test/money.test.ts
new file mode 100644
index 0000000..2fc37f0
--- /dev/null
+++ b/js/sample-operator-overloading/test/money.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, test } from "bun:test";
+import { Money } from "#/money";
+import { Currency } from "#/currency";
+import { NoSameMoneyCurrencyException } from "#/no-same-money-currency-exception";
+
+describe("Money operator overloads", () => {
+ const usd100 = new Money(10000n, Currency.Usd);
+ const usd50 = new Money(5000n, Currency.Usd);
+ const eur100 = new Money(10000n, Currency.Eur);
+
+ // ── Addition (+) ──
+
+ test("$add sums two Money values with same currency", () => {
+ const result = usd100.$add(usd50);
+ expect(result.cents).toBe(15000n);
+ expect(result.currency).toBe(Currency.Usd);
+ });
+
+ test("static __add sums two Money values", () => {
+ const result = Money.__add(usd100, usd50);
+ expect(result.cents).toBe(15000n);
+ });
+
+ test("$add throws on different currencies", () => {
+ expect(() => usd100.$add(eur100)).toThrow(NoSameMoneyCurrencyException);
+ });
+
+ // ── Subtraction (-) ──
+
+ test("$subtract subtracts two Money values", () => {
+ const result = usd100.$subtract(usd50);
+ expect(result.cents).toBe(5000n);
+ expect(result.currency).toBe(Currency.Usd);
+ });
+
+ test("$subtract throws on different currencies", () => {
+ expect(() => usd100.$subtract(eur100)).toThrow(NoSameMoneyCurrencyException);
+ });
+
+ // ── Multiplication (*) — overloaded: Money×bigint and Money×Money ──
+
+ test("$multiply scales by a bigint factor", () => {
+ const result = usd100.$multiply(3n);
+ expect(result.cents).toBe(30000n);
+ expect(result.currency).toBe(Currency.Usd);
+ });
+
+ test("$multiply multiplies two Money values", () => {
+ const usd3 = new Money(300n, Currency.Usd);
+ const result = usd100.$multiply(usd3);
+ expect(result.cents).toBe(10000n * 300n);
+ expect(result.currency).toBe(Currency.Usd);
+ });
+
+ test("$multiply Money×Money throws on different currencies", () => {
+ expect(() => usd100.$multiply(eur100)).toThrow(NoSameMoneyCurrencyException);
+ });
+
+ // ── Division (/) — overloaded: Money÷bigint and Money÷Money ──
+
+ test("$divide divides by a bigint divisor", () => {
+ const result = usd100.$divide(2n);
+ expect(result.cents).toBe(5000n);
+ expect(result.currency).toBe(Currency.Usd);
+ });
+
+ test("$divide divides two Money values", () => {
+ const usd2 = new Money(200n, Currency.Usd);
+ const result = usd100.$divide(usd2);
+ expect(result.cents).toBe(10000n / 200n);
+ expect(result.currency).toBe(Currency.Usd);
+ });
+
+ test("$divide Money÷Money throws on different currencies", () => {
+ expect(() => usd100.$divide(eur100)).toThrow(NoSameMoneyCurrencyException);
+ });
+
+ // ── Record helpers ──
+
+ test("with() creates a copy with overrides", () => {
+ const changed = usd100.with({ cents: 999n });
+ expect(changed.cents).toBe(999n);
+ expect(changed.currency).toBe(Currency.Usd);
+ });
+
+ test("equals() checks structural equality", () => {
+ const copy = new Money(10000n, Currency.Usd);
+ expect(usd100.equals(copy)).toBe(true);
+ expect(usd100.equals(usd50)).toBe(false);
+ expect(usd100.equals(eur100)).toBe(false);
+ });
+});
diff --git a/js/sample-operator-overloading/tsconfig.json b/js/sample-operator-overloading/tsconfig.json
new file mode 100644
index 0000000..2839955
--- /dev/null
+++ b/js/sample-operator-overloading/tsconfig.json
@@ -0,0 +1,39 @@
+{
+ "compilerOptions": {
+ // Environment setup & latest features
+ "lib": ["ESNext"],
+ "target": "ESNext",
+ "module": "Preserve",
+ "moduleDetection": "force",
+ "jsx": "react-jsx",
+ "allowJs": true,
+ "rootDir": "./src",
+ "outDir": "./dist",
+ "declaration": true,
+ "composite": true,
+ "types": ["bun"],
+
+ // Bundler mode
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "rewriteRelativeImportExtensions": true,
+ "verbatimModuleSyntax": true,
+
+ "paths": {
+ "#/*": ["./src/*"]
+ },
+
+ // Best practices
+ "strict": true,
+ "skipLibCheck": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+
+ // Some stricter flags (disabled by default)
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noPropertyAccessFromIndexSignature": false,
+ },
+ "include": ["src/**/*"]
+}
diff --git a/js/sample-todo-service/package.json b/js/sample-todo-service/package.json
index a20fda5..6e110db 100644
--- a/js/sample-todo-service/package.json
+++ b/js/sample-todo-service/package.json
@@ -9,6 +9,11 @@
"types": "./dist/*.d.ts",
"import": "./dist/*.js",
"default": "./src/*.ts"
+ },
+ "#": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "default": "./src/index.ts"
}
},
"dependencies": {
diff --git a/js/sample-todo-service/src/program.ts b/js/sample-todo-service/src/program.ts
index 6902231..ca7f611 100644
--- a/js/sample-todo-service/src/program.ts
+++ b/js/sample-todo-service/src/program.ts
@@ -1,5 +1,5 @@
import { Hono } from "hono";
-import { TodoStore } from "#";
+import { TodoStore } from "./todos";
const app = new Hono();
const store = new TodoStore();
diff --git a/js/sample-todo-service/src/todos.ts b/js/sample-todo-service/src/todos.ts
index 5f01f70..7a546ee 100644
--- a/js/sample-todo-service/src/todos.ts
+++ b/js/sample-todo-service/src/todos.ts
@@ -1,6 +1,4 @@
-import { Enumerable } from "metano-runtime";
-import { UUID } from "metano-runtime";
-import { listRemove } from "metano-runtime";
+import { Enumerable, UUID, listRemove } from "metano-runtime";
import { TodoItem, type Priority } from "sample-todo";
export interface StoredTodo {
diff --git a/js/sample-todo/src/todo-list.ts b/js/sample-todo/src/todo-list.ts
index a3306f5..3ace313 100644
--- a/js/sample-todo/src/todo-list.ts
+++ b/js/sample-todo/src/todo-list.ts
@@ -1,5 +1,4 @@
-import { isString } from "metano-runtime";
-import { Enumerable } from "metano-runtime";
+import { Enumerable, isString } from "metano-runtime";
import { Priority } from "./priority";
import { TodoItem } from "./todo-item";
diff --git a/samples/SampleOperatorOverloading/Currency.cs b/samples/SampleOperatorOverloading/Currency.cs
new file mode 100644
index 0000000..983d11d
--- /dev/null
+++ b/samples/SampleOperatorOverloading/Currency.cs
@@ -0,0 +1,8 @@
+namespace SampleOperatorOverloading;
+
+public enum Currency
+{
+ Brl,
+ Usd,
+ Eur,
+}
diff --git a/samples/SampleOperatorOverloading/Money.cs b/samples/SampleOperatorOverloading/Money.cs
new file mode 100644
index 0000000..699e896
--- /dev/null
+++ b/samples/SampleOperatorOverloading/Money.cs
@@ -0,0 +1,71 @@
+using System.Numerics;
+
+namespace SampleOperatorOverloading;
+
+public readonly record struct Money(BigInteger Cents, Currency Currency)
+{
+ public decimal ToDecimal() => (decimal)Cents / 100m;
+
+ public override string ToString() => $"{ToDecimal()} {Currency.ToString().ToUpper()}";
+
+ public static Money FromCents(BigInteger cents, Currency currency) => new(cents, currency);
+
+ public static Money FromValue(decimal amount, Currency currency) =>
+ new((BigInteger)Math.Round(amount * 100m), currency);
+
+ private static void ExpectSameCurrency(Money left, Money right)
+ {
+ if (left.Currency != right.Currency)
+ throw new NoSameMoneyCurrencyException(left.Currency, right.Currency);
+ }
+
+ public static Money operator +(Money left, Money right)
+ {
+ ExpectSameCurrency(left, right);
+
+ return left with
+ {
+ Cents = left.Cents + right.Cents,
+ };
+ }
+
+ public static Money operator -(Money left, Money right)
+ {
+ ExpectSameCurrency(left, right);
+
+ return left with
+ {
+ Cents = left.Cents - right.Cents,
+ };
+ }
+
+ public static Money operator *(Money left, BigInteger factor)
+ {
+ return left with { Cents = left.Cents * factor };
+ }
+
+ public static Money operator *(Money left, Money right)
+ {
+ ExpectSameCurrency(left, right);
+
+ return left with
+ {
+ Cents = left.Cents * right.Cents,
+ };
+ }
+
+ public static Money operator /(Money left, BigInteger divisor)
+ {
+ return left with { Cents = left.Cents / divisor };
+ }
+
+ public static Money operator /(Money left, Money divisor)
+ {
+ ExpectSameCurrency(left, divisor);
+
+ return left with
+ {
+ Cents = left.Cents / divisor.Cents,
+ };
+ }
+}
diff --git a/samples/SampleOperatorOverloading/NoSameMoneyCurrencyException.cs b/samples/SampleOperatorOverloading/NoSameMoneyCurrencyException.cs
new file mode 100644
index 0000000..7f663f3
--- /dev/null
+++ b/samples/SampleOperatorOverloading/NoSameMoneyCurrencyException.cs
@@ -0,0 +1,4 @@
+namespace SampleOperatorOverloading;
+
+public sealed class NoSameMoneyCurrencyException(Currency expected, Currency provided)
+ : Exception($"Not same currency. Money has {provided}, but expected {expected}");
diff --git a/samples/SampleOperatorOverloading/Program.cs b/samples/SampleOperatorOverloading/Program.cs
new file mode 100644
index 0000000..0bd1c94
--- /dev/null
+++ b/samples/SampleOperatorOverloading/Program.cs
@@ -0,0 +1,10 @@
+using Metano.Annotations;
+using SampleOperatorOverloading;
+
+[assembly: TranspileAssembly]
+
+var poket = Money.FromCents(150, Currency.Usd);
+
+poket += new Money(250, Currency.Usd);
+
+Console.WriteLine(poket);
diff --git a/samples/SampleOperatorOverloading/SampleOperatorOverloading.csproj b/samples/SampleOperatorOverloading/SampleOperatorOverloading.csproj
new file mode 100644
index 0000000..1dbfa8b
--- /dev/null
+++ b/samples/SampleOperatorOverloading/SampleOperatorOverloading.csproj
@@ -0,0 +1,18 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ false
+ ../../js/sample-operator-overloading/src
+ true
+ ../../src/Metano.Compiler.TypeScript/Metano.Compiler.TypeScript.csproj
+
+
+
+
+
+
+
+
diff --git a/spec/04-functional-requirements.md b/spec/04-functional-requirements.md
index c2f4753..57edb10 100644
--- a/spec/04-functional-requirements.md
+++ b/spec/04-functional-requirements.md
@@ -80,7 +80,16 @@ requirement uses a stable identifier in the format `FR-XXX`.
- **FR-029** The system shall generate barrels/index files consistent with
namespaces and product output conventions.
- **FR-030** The system shall generate or update the target package's
- `package.json` with dependencies required by the output.
+ `package.json` with correct `imports`, `exports`, and `dependencies` fields.
+ When the transpiler output directory is a subdirectory of the package's
+ TypeScript source root (e.g., `src/domain/` inside a package whose build
+ tool compiles from `src/`), the generated `imports` and `exports` paths
+ shall include the correct prefix so that dist paths mirror the source tree
+ structure. The system shall accept an explicit source-root parameter
+ (`--src-root` / `MetanoSrcRoot`, defaulting to the first path segment of
+ the output directory relative to the package root) to resolve ambiguity
+ when the relationship between source and dist directories cannot be
+ inferred.
- **FR-031** The system shall reflect cross-package dependencies between
transpiled assemblies through correct npm-based imports.
@@ -100,6 +109,10 @@ requirement uses a stable identifier in the format `FR-XXX`.
entry point.
- **FR-037** The system shall operate on real C# projects, using compilation and
semantic analysis as the basis for transformation.
+- **FR-046** The product shall support consumption of `Metano` and
+ `Metano.Build` as build-only dependencies in .NET projects, so adopting the
+ transpiler does not unnecessarily contribute runtime or transitive package
+ surface to downstream application outputs.
## 9. Diagnostic Requirements
@@ -126,3 +139,6 @@ requirement uses a stable identifier in the format `FR-XXX`.
supported language surface.
- **FR-045** The system shall detect cyclic local-package import chains between
generated TypeScript files and report them through diagnostics.
+- **FR-047** The system shall derive cross-package TypeScript import and export
+ subpaths from C# namespace structure and declared package identity, not from
+ incidental file placement details in the generated output tree.
diff --git a/spec/08-feature-support-matrix.md b/spec/08-feature-support-matrix.md
index 6012092..565fbf1 100644
--- a/spec/08-feature-support-matrix.md
+++ b/spec/08-feature-support-matrix.md
@@ -59,6 +59,9 @@ Statuses are intentionally high-level:
| --- | --- | --- | --- |
| Output | Namespace-based imports and barrels | Implemented | Guided by ADR-0006 and ADR-0007. |
| Packaging | `[EmitPackage]` cross-package support | Implemented | Includes `package.json` dependency propagation. |
+| Packaging | `Metano` and `Metano.Build` as build-only consumer dependencies | Planned/Partial | Intended to avoid unnecessary runtime/transitive contribution in consuming .NET projects. |
+| Packaging | Subdirectory-aware `package.json` imports/exports (`--src-root`) | Planned | When output targets a subdirectory of the source tree, dist paths and export subpaths must include the correct prefix (FR-030). |
+| Cross-package | Import/export subpaths derived from namespace instead of file layout | Planned/Correction | Spec-mandated behavior; current implementation requires correction. |
| Serialization | `JsonSerializerContext` transpilation | Implemented | JSON names resolved at transpile time. |
| Validation | Generated type guards | Implemented | Via `[GenerateGuard]`. |
| Diagnostics | Stable `MS0001`-`MS0008` catalog | Implemented | See diagnostic catalog. |
diff --git a/src/Metano.Compiler.TypeScript/PackageJsonWriter.cs b/src/Metano.Compiler.TypeScript/PackageJsonWriter.cs
index c6521b1..f48c54c 100644
--- a/src/Metano.Compiler.TypeScript/PackageJsonWriter.cs
+++ b/src/Metano.Compiler.TypeScript/PackageJsonWriter.cs
@@ -112,8 +112,14 @@ public static IReadOnlyList UpdateOrCreate(
var exports = isExecutable
? null
: BuildExports(files, distDirRelativeToPackageRoot, outputPrefix);
- var rootExportKey = outputPrefix.Length > 0 ? $"./{outputPrefix}" : ".";
- var hasRootIndex = exports?.ContainsKey(rootExportKey) ?? false;
+ // Check for a root barrel from the file list — executables skip exports
+ // but may still need the "#" import alias for internal barrel imports.
+ // When outputPrefix is set, the root barrel is at {prefix}/index.ts.
+ var rootIndexName = outputPrefix.Length > 0 ? $"{outputPrefix}/index.ts" : "index.ts";
+ var hasRootIndex = files.Any(f =>
+ NormalizePath(f.FileName).Equals(rootIndexName, StringComparison.Ordinal)
+ || NormalizePath(f.FileName).Equals("index.ts", StringComparison.Ordinal)
+ );
var imports = BuildImports(
srcRelative,
distDirRelativeToPackageRoot,
diff --git a/src/Metano.Compiler.TypeScript/Transformation/ExpressionTransformer.cs b/src/Metano.Compiler.TypeScript/Transformation/ExpressionTransformer.cs
index dba1782..5c295d7 100644
--- a/src/Metano.Compiler.TypeScript/Transformation/ExpressionTransformer.cs
+++ b/src/Metano.Compiler.TypeScript/Transformation/ExpressionTransformer.cs
@@ -146,7 +146,7 @@ public TsExpression TransformExpression(ExpressionSyntax expression)
TransformExpression(cond.WhenFalse)
),
- CastExpressionSyntax cast => TransformExpression(cast.Expression),
+ CastExpressionSyntax cast => TransformCast(cast),
WithExpressionSyntax withExpr => ObjectCreation.TransformWithExpression(withExpr),
@@ -197,6 +197,78 @@ public TsExpression TransformExpression(ExpressionSyntax expression)
};
}
+ ///
+ /// Lowers a C# explicit cast expression. Most casts are erased (JS types are the
+ /// same at runtime), but numeric type conversions that change representation need
+ /// explicit code:
+ ///
+ /// - (decimal)bigIntVar → new Decimal(bigIntVar.toString())
+ /// - (BigInteger)decimalExpr → BigInt(expr.toFixed(0))
+ /// - (int)decimalVar / (long)decimalVar → decimalVar.toNumber()
+ ///
+ ///
+ private TsExpression TransformCast(CastExpressionSyntax cast)
+ {
+ var inner = TransformExpression(cast.Expression);
+ var sourceInfo = Model.GetTypeInfo(cast.Expression);
+ var sourceType = sourceInfo.Type;
+ var targetType = Model.GetTypeInfo(cast).Type;
+
+ if (sourceType is null || targetType is null)
+ return inner;
+
+ var sourceFull = sourceType.ToDisplayString();
+ var targetFull = targetType.ToDisplayString();
+
+ // BigInteger → decimal: new Decimal(value.toString())
+ if (sourceFull == "System.Numerics.BigInteger" && targetFull == "decimal")
+ {
+ return new TsNewExpression(
+ new TsIdentifier("Decimal"),
+ [new TsCallExpression(new TsPropertyAccess(inner, "toString"), [])]
+ );
+ }
+
+ // decimal → BigInteger: BigInt(value.toFixed(0))
+ if (sourceFull == "decimal" && targetFull == "System.Numerics.BigInteger")
+ {
+ return new TsCallExpression(
+ new TsIdentifier("BigInt"),
+ [new TsCallExpression(new TsPropertyAccess(inner, "toFixed"), [new TsLiteral("0")])]
+ );
+ }
+
+ // decimal → int/long/short/byte (any integer): value.toNumber()
+ if (
+ sourceFull == "decimal"
+ && targetType.SpecialType
+ is SpecialType.System_Int32
+ or SpecialType.System_Int64
+ or SpecialType.System_Int16
+ or SpecialType.System_Byte
+ or SpecialType.System_UInt32
+ or SpecialType.System_UInt64
+ )
+ {
+ return new TsCallExpression(new TsPropertyAccess(inner, "toNumber"), []);
+ }
+
+ // int/long → BigInteger: BigInt(value)
+ if (
+ targetFull == "System.Numerics.BigInteger"
+ && sourceType.SpecialType
+ is SpecialType.System_Int32
+ or SpecialType.System_Int64
+ or SpecialType.System_Decimal
+ )
+ {
+ return new TsCallExpression(new TsIdentifier("BigInt"), [inner]);
+ }
+
+ // Default: erase the cast (same-width numeric, reference casts, etc.)
+ return inner;
+ }
+
///
/// Lowers a C# element access expression. Arrays / lists keep the bracket form
/// (arr[i] stays valid in JS). Dictionary-family receivers — which lower
diff --git a/src/Metano.Compiler.TypeScript/Transformation/ImportCollector.cs b/src/Metano.Compiler.TypeScript/Transformation/ImportCollector.cs
index fab7434..22716ee 100644
--- a/src/Metano.Compiler.TypeScript/Transformation/ImportCollector.cs
+++ b/src/Metano.Compiler.TypeScript/Transformation/ImportCollector.cs
@@ -68,7 +68,12 @@ List statements
referencedTypes.Remove($"is{tsTypeName}"); // own guard — don't import
var imports = new List();
+ // For compiler-synthesized types (top-level statements), the namespace is empty.
+ // Use the project's root namespace so same-namespace imports produce relative paths
+ // instead of barrel aliases.
var currentNs = PathNaming.GetNamespace(currentType);
+ if (currentNs.Length == 0 && _pathNaming.RootNamespace.Length > 0)
+ currentNs = _pathNaming.RootNamespace;
// The current file's "key" — file name + namespace — used to elide self-imports
// for types co-located via [EmitInFile]. Without this, a multi-type file would
// try to import its sibling types from their individual paths (which don't
@@ -388,7 +393,90 @@ bclEntry.ExportedName is not null
);
}
- return imports;
+ return MergeImportsByPath(imports);
+ }
+
+ ///
+ /// Consolidates multiple entries that share the same path into
+ /// a single import line. Default imports are kept separate. Type-only qualification is
+ /// preserved: a name is type-only only if ALL contributing imports marked it as such.
+ ///
+ private static IReadOnlyList MergeImportsByPath(List imports)
+ {
+ var merged = new List();
+ var grouped = imports.GroupBy(i => (i.From, i.IsDefault)).ToList();
+
+ foreach (var group in grouped)
+ {
+ var items = group.ToList();
+ if (items.Count == 1)
+ {
+ merged.Add(items[0]);
+ continue;
+ }
+
+ // Default imports can't be merged (only one name per default import).
+ if (group.Key.IsDefault)
+ {
+ merged.AddRange(items);
+ continue;
+ }
+
+ // Merge all names, preserving type-only where every contributor agrees.
+ var allNames = new List();
+ var typeOnlyNames = new HashSet(StringComparer.Ordinal);
+ var allTypeOnly = true;
+
+ foreach (var item in items)
+ {
+ foreach (var name in item.Names)
+ {
+ if (allNames.Contains(name))
+ continue;
+ allNames.Add(name);
+
+ var isTypeOnly =
+ item.TypeOnly
+ || (item.TypeOnlyNames is not null && item.TypeOnlyNames.Contains(name));
+ if (isTypeOnly)
+ typeOnlyNames.Add(name);
+ }
+
+ if (!item.TypeOnly)
+ allTypeOnly = false;
+ }
+
+ // Names that appear as value in ANY import are not type-only.
+ if (!allTypeOnly)
+ {
+ foreach (var item in items)
+ {
+ if (item.TypeOnly)
+ continue;
+ foreach (var name in item.Names)
+ {
+ if (item.TypeOnlyNames is null || !item.TypeOnlyNames.Contains(name))
+ typeOnlyNames.Remove(name);
+ }
+ }
+ }
+
+ var finalAllTypeOnly = allTypeOnly || typeOnlyNames.Count == allNames.Count;
+ SortValuesFirst(allNames, typeOnlyNames);
+
+ merged.Add(
+ new TsImport(
+ allNames.ToArray(),
+ group.Key.From,
+ TypeOnly: finalAllTypeOnly,
+ TypeOnlyNames: !finalAllTypeOnly && typeOnlyNames.Count > 0
+ ? typeOnlyNames
+ : null
+ )
+ );
+ }
+
+ return merged;
}
///
diff --git a/src/Metano.Compiler.TypeScript/Transformation/InvocationHandler.cs b/src/Metano.Compiler.TypeScript/Transformation/InvocationHandler.cs
index 9cdafc4..7ac345e 100644
--- a/src/Metano.Compiler.TypeScript/Transformation/InvocationHandler.cs
+++ b/src/Metano.Compiler.TypeScript/Transformation/InvocationHandler.cs
@@ -46,6 +46,13 @@ public TsExpression Transform(InvocationExpressionSyntax invocation)
return ExpandEmit(emit, emitArgs);
}
+ // Math.Round/Floor/Ceiling with a decimal argument → decimal.js instance method
+ // (e.g., `Math.Round(amount)` → `amount.round()`). The standard Math.round is
+ // for number operands; decimal.js has its own round/floor/ceil.
+ var mathDecimalResult = TryRewriteMathDecimal(methodSymbol, invocation);
+ if (mathDecimalResult is not null)
+ return mathDecimalResult;
+
var mapped = BclMapper.TryMapMethod(methodSymbol, invocation, _parent);
if (mapped is not null)
return mapped;
@@ -84,6 +91,46 @@ public TsExpression Transform(InvocationExpressionSyntax invocation)
return new TsCallExpression(callee, args);
}
+ ///
+ /// Rewrites Math.Round, Math.Floor, Math.Ceiling, and Math.Abs
+ /// calls when the first argument is System.Decimal. The JS Math.round only
+ /// works on number; decimal.js instances have their own instance methods.
+ /// Only rewrites single-argument overloads — multi-argument overloads (e.g.,
+ /// Math.Round(decimal, int)) are left for the standard BCL mapping.
+ ///
+ private TsExpression? TryRewriteMathDecimal(
+ IMethodSymbol method,
+ InvocationExpressionSyntax invocation
+ )
+ {
+ if (
+ method.ContainingType?.ToDisplayString() != "System.Math"
+ || invocation.ArgumentList.Arguments.Count != 1
+ )
+ return null;
+
+ var firstParamType = _parent
+ .Model.GetTypeInfo(invocation.ArgumentList.Arguments[0].Expression)
+ .Type;
+ if (firstParamType?.SpecialType != SpecialType.System_Decimal)
+ return null;
+
+ var jsMethodName = method.Name switch
+ {
+ "Round" => "round",
+ "Floor" => "floor",
+ "Ceiling" => "ceil",
+ "Abs" => "abs",
+ _ => null,
+ };
+
+ if (jsMethodName is null)
+ return null;
+
+ var arg = _parent.TransformExpression(invocation.ArgumentList.Arguments[0].Expression);
+ return new TsCallExpression(new TsPropertyAccess(arg, jsMethodName), []);
+ }
+
///
/// Expands an [Emit] template, replacing $0, $1, … with the textual
/// form of each transformed argument. Delegates to
diff --git a/src/Metano.Compiler.TypeScript/Transformation/LiteralHandler.cs b/src/Metano.Compiler.TypeScript/Transformation/LiteralHandler.cs
index 9b9456e..4aec521 100644
--- a/src/Metano.Compiler.TypeScript/Transformation/LiteralHandler.cs
+++ b/src/Metano.Compiler.TypeScript/Transformation/LiteralHandler.cs
@@ -40,19 +40,28 @@ public static TsExpression Transform(LiteralExpressionSyntax lit, SemanticModel?
private static TsExpression TransformNumeric(LiteralExpressionSyntax lit, SemanticModel? model)
{
- // For decimal literals (1.5m), wrap the literal in `new Decimal("…")` so that
- // decimal.js preserves the exact value. We pass the value as a string because
- // converting through a JS number first would already lose precision.
- if (
- model is not null
- && model.GetTypeInfo(lit).Type?.SpecialType == SpecialType.System_Decimal
- )
+ if (model is not null)
{
- return new TsNewExpression(
- new TsIdentifier("Decimal"),
- [new TsStringLiteral(lit.Token.ValueText)]
- );
+ var info = model.GetTypeInfo(lit);
+ // ConvertedType captures implicit conversions (e.g., int → BigInteger).
+ var effectiveType = info.ConvertedType ?? info.Type;
+
+ // decimal literals (1.5m) → new Decimal("…") for decimal.js
+ if (effectiveType?.SpecialType == SpecialType.System_Decimal)
+ {
+ return new TsNewExpression(
+ new TsIdentifier("Decimal"),
+ [new TsStringLiteral(lit.Token.ValueText)]
+ );
+ }
+
+ // BigInteger targets → bigint literal with n suffix (150 → 150n)
+ if (effectiveType?.ToDisplayString() == "System.Numerics.BigInteger")
+ {
+ return new TsLiteral($"{lit.Token.ValueText}n");
+ }
}
+
return new TsLiteral(lit.Token.ValueText);
}
}
diff --git a/src/Metano.Compiler.TypeScript/Transformation/OperatorHandler.cs b/src/Metano.Compiler.TypeScript/Transformation/OperatorHandler.cs
index 9d94e4f..8f00566 100644
--- a/src/Metano.Compiler.TypeScript/Transformation/OperatorHandler.cs
+++ b/src/Metano.Compiler.TypeScript/Transformation/OperatorHandler.cs
@@ -108,6 +108,11 @@ public TsExpression TransformAssignment(AssignmentExpressionSyntax assign)
}
}
+ // User-defined compound assignment: x += y → x = x.$add(y) when the compound
+ // operator resolves to a user-defined operator method.
+ if (TryLowerCompoundOperatorAssignment(assign) is { } lowered)
+ return lowered;
+
return new TsBinaryExpression(
_parent.TransformExpression(assign.Left),
MapAssignmentOperator(assign.OperatorToken.Text),
@@ -184,6 +189,49 @@ private static string MapBinaryOperator(string op) =>
_ => op,
};
+ ///
+ /// Lowers a compound assignment (x += y, x -= y, x *= y, etc.)
+ /// when the underlying operator is a user-defined operator method. The C# semantic
+ /// model resolves compound assignments to their operator method — if it's user-defined,
+ /// we rewrite to x = x.$add(y).
+ ///
+ private TsExpression? TryLowerCompoundOperatorAssignment(AssignmentExpressionSyntax assign)
+ {
+ // The semantic model exposes the operator method for compound assignments
+ // via GetSymbolInfo on the assignment expression itself.
+ var symbolInfo = _parent.Model.GetSymbolInfo(assign);
+ if (
+ symbolInfo.Symbol is not IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator } op
+ )
+ return null;
+
+ var opToken = assign.OperatorToken.Text.TrimEnd('='); // "+=" → "+"
+ var opName = MapCompoundOperatorToken(opToken);
+ if (opName is null)
+ return null;
+
+ var left = _parent.TransformExpression(assign.Left);
+ var right = _parent.TransformExpression(assign.Right);
+
+ // x = x.$add(y) — reuse `left` to avoid evaluating the LHS twice.
+ return new TsBinaryExpression(
+ left,
+ "=",
+ new TsCallExpression(new TsPropertyAccess(left, $"${opName}"), [right])
+ );
+ }
+
+ private static string? MapCompoundOperatorToken(string token) =>
+ token switch
+ {
+ "+" => "add",
+ "-" => "subtract",
+ "*" => "multiply",
+ "/" => "divide",
+ "%" => "modulo",
+ _ => null,
+ };
+
private static string MapAssignmentOperator(string op) =>
op switch
{
diff --git a/src/Metano.Compiler.TypeScript/Transformation/RecordClassTransformer.cs b/src/Metano.Compiler.TypeScript/Transformation/RecordClassTransformer.cs
index 8c23911..37901ec 100644
--- a/src/Metano.Compiler.TypeScript/Transformation/RecordClassTransformer.cs
+++ b/src/Metano.Compiler.TypeScript/Transformation/RecordClassTransformer.cs
@@ -149,6 +149,7 @@ type.BaseType is not null
// Fields, properties, operators
var ordinaryMethods = new List();
+ var operatorMethods = new List<(string Name, IMethodSymbol Method)>();
foreach (var member in type.GetMembers())
{
@@ -179,8 +180,21 @@ is not (Accessibility.Internal or Accessibility.NotApplicable)
break;
case IMethodSymbol method when method.MethodKind == MethodKind.UserDefinedOperator:
- classMembers.AddRange(TransformClassOperator(type, method));
+ {
+ var opSyntax =
+ method.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax()
+ as OperatorDeclarationSyntax;
+ var opName =
+ SymbolHelper.GetNameOverride(method)
+ ?? (
+ opSyntax is not null
+ ? MapOperatorTokenToName(opSyntax.OperatorToken.Text)
+ : null
+ );
+ if (opName is not null)
+ operatorMethods.Add((opName, method));
break;
+ }
case IEventSymbol evt:
classMembers.AddRange(TransformEvent(evt));
@@ -188,6 +202,24 @@ is not (Accessibility.Internal or Accessibility.NotApplicable)
}
}
+ // Process operators — group by derived name, dispatch when overloaded
+ foreach (var opGroup in operatorMethods.GroupBy(o => o.Name))
+ {
+ var ops = opGroup.ToList();
+ if (ops.Count == 1)
+ {
+ classMembers.AddRange(TransformClassOperator(type, ops[0].Method, ops[0].Name));
+ }
+ else
+ {
+ // Multiple overloads for the same operator token — use the dispatcher.
+ // Wrap each operator as a named method so the existing dispatcher can handle it.
+ classMembers.AddRange(
+ BuildOperatorDispatcher(type, ops[0].Name, ops.Select(o => o.Method).ToList())
+ );
+ }
+ }
+
// Process ordinary methods — detect overloads (same name, different signatures)
var methodGroups = ordinaryMethods.GroupBy(m => m.Name).ToList();
@@ -780,13 +812,10 @@ TsAccessibility accessibility
private IReadOnlyList TransformClassOperator(
INamedTypeSymbol containingType,
- IMethodSymbol method
+ IMethodSymbol method,
+ string operatorName
)
{
- var nameOverride = SymbolHelper.GetNameOverride(method);
- if (nameOverride is null)
- return [];
-
var syntax =
method.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax()
as OperatorDeclarationSyntax;
@@ -806,7 +835,7 @@ IMethodSymbol method
var exprTransformer = _context.CreateExpressionTransformer(semanticModel);
var body = exprTransformer.TransformBody(syntax.Body, syntax.ExpressionBody);
- var staticName = $"__{nameOverride}";
+ var staticName = $"__{operatorName}";
var isUnary = method.Parameters.Length == 1;
var results = new List();
@@ -826,7 +855,7 @@ IMethodSymbol method
[new TsIdentifier("this")]
)
);
- results.Add(new TsMethodMember($"${nameOverride}", [], returnType, [helperBody]));
+ results.Add(new TsMethodMember($"${operatorName}", [], returnType, [helperBody]));
}
else
{
@@ -842,13 +871,269 @@ [new TsIdentifier("this")]
)
);
results.Add(
- new TsMethodMember($"${nameOverride}", [rightParam], returnType, [helperBody])
+ new TsMethodMember($"${operatorName}", [rightParam], returnType, [helperBody])
);
}
return results;
}
+ ///
+ /// Maps a C# operator token to a conventional TypeScript method name.
+ /// Returns null for unsupported operators.
+ ///
+ private static string? MapOperatorTokenToName(string token) =>
+ token switch
+ {
+ "+" => "add",
+ "-" => "subtract",
+ "*" => "multiply",
+ "/" => "divide",
+ "%" => "modulo",
+ "==" => "equals",
+ "!=" => "notEquals",
+ "<" => "lessThan",
+ ">" => "greaterThan",
+ "<=" => "lessThanOrEqual",
+ ">=" => "greaterThanOrEqual",
+ "!" => "not",
+ "~" => "bitwiseNot",
+ "&" => "bitwiseAnd",
+ "|" => "bitwiseOr",
+ "^" => "xor",
+ "<<" => "shiftLeft",
+ ">>" => "shiftRight",
+ _ => null,
+ };
+
+ ///
+ /// Generates overload-dispatching plumbing for operators that share the same derived
+ /// name (e.g., two operator * overloads with different parameter types).
+ /// Produces a static dispatcher (__multiply(...args)) plus fast-path specializations,
+ /// and a single instance helper ($multiply) that delegates to the static dispatcher.
+ ///
+ private IReadOnlyList BuildOperatorDispatcher(
+ INamedTypeSymbol containingType,
+ string operatorName,
+ List overloads
+ )
+ {
+ var sorted = overloads.OrderByDescending(m => m.Parameters.Length).ToList();
+ var staticName = $"__{operatorName}";
+ var returnType = TypeMapper.Map(sorted[0].ReturnType);
+ var className = TypeTransformer.GetTsTypeName(containingType);
+ var members = new List();
+
+ // Generate overload signatures for the static dispatcher
+ var overloadSigs = sorted
+ .Select(m =>
+ {
+ var @params = m
+ .Parameters.Select(p => new TsParameter(
+ TypeScriptNaming.ToCamelCase(p.Name),
+ TypeMapper.Map(p.Type)
+ ))
+ .ToList();
+ return new TsMethodOverload(@params, TypeMapper.Map(m.ReturnType));
+ })
+ .ToList();
+
+ // Generate fast-path names using same strategy as OverloadDispatcherBuilder
+ var fastPathNames = sorted
+ .Select(m =>
+ staticName
+ + string.Concat(m.Parameters.Select(p => Capitalize(SimpleTypeName(p.Type))))
+ )
+ .ToList();
+
+ // Generate fast-path methods (private, one per overload)
+ for (var i = 0; i < sorted.Count; i++)
+ {
+ var method = sorted[i];
+ var syntax =
+ method.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax()
+ as OperatorDeclarationSyntax;
+ if (syntax is null)
+ continue;
+
+ var parameters = method
+ .Parameters.Select(p => new TsParameter(
+ TypeScriptNaming.ToCamelCase(p.Name),
+ TypeMapper.Map(p.Type)
+ ))
+ .ToList();
+ var semanticModel = _context.Compilation.GetSemanticModel(syntax.SyntaxTree);
+ var exprTransformer = _context.CreateExpressionTransformer(semanticModel);
+ var body = exprTransformer.TransformBody(syntax.Body, syntax.ExpressionBody);
+
+ members.Add(
+ new TsMethodMember(
+ fastPathNames[i],
+ parameters,
+ TypeMapper.Map(method.ReturnType),
+ body,
+ Static: true,
+ Accessibility: TsAccessibility.Private
+ )
+ );
+ }
+
+ // Generate dispatcher body that delegates to fast-paths
+ var dispatcherBody = new List();
+ for (var i = 0; i < sorted.Count; i++)
+ {
+ var method = sorted[i];
+ var paramCount = method.Parameters.Length;
+
+ TsExpression condition = new TsBinaryExpression(
+ new TsPropertyAccess(new TsIdentifier("args"), "length"),
+ "===",
+ new TsLiteral(paramCount.ToString())
+ );
+
+ for (var j = 0; j < paramCount; j++)
+ {
+ var check = TypeCheckGenerator.GenerateForParam(
+ method.Parameters[j].Type,
+ j,
+ _context.AssemblyWideTranspile,
+ _context.CurrentAssembly
+ );
+ condition = new TsBinaryExpression(condition, "&&", check);
+ }
+
+ var callArgs = new List();
+ for (var j = 0; j < paramCount; j++)
+ {
+ var paramType = TypeMapper.Map(method.Parameters[j].Type);
+ callArgs.Add(new TsCastExpression(new TsIdentifier($"args[{j}]"), paramType));
+ }
+
+ var delegateCall = new TsCallExpression(
+ new TsPropertyAccess(new TsIdentifier(className), fastPathNames[i]),
+ callArgs
+ );
+
+ dispatcherBody.Add(new TsIfStatement(condition, [new TsReturnStatement(delegateCall)]));
+ }
+
+ dispatcherBody.Add(
+ new TsThrowStatement(
+ new TsNewExpression(
+ new TsIdentifier("Error"),
+ [new TsStringLiteral($"No matching overload for {operatorName}")]
+ )
+ )
+ );
+
+ // Static dispatcher: static __multiply(...args: unknown[]): ReturnType
+ members.Add(
+ new TsMethodMember(
+ staticName,
+ [new TsParameter("...args", new TsNamedType("unknown[]"))],
+ returnType,
+ dispatcherBody,
+ Static: true,
+ Overloads: overloadSigs
+ )
+ );
+
+ // Instance helper: $multiply(...args: unknown[]): ReturnType
+ // Dispatches to static fast-paths with `this` as first argument.
+ var instanceDispatchBody = new List();
+ for (var i = 0; i < sorted.Count; i++)
+ {
+ var method = sorted[i];
+ // Instance args start at index 1 (skip `this`/`left`)
+ var instanceArgCount = method.Parameters.Length - 1;
+
+ TsExpression instCondition = new TsBinaryExpression(
+ new TsPropertyAccess(new TsIdentifier("args"), "length"),
+ "===",
+ new TsLiteral(instanceArgCount.ToString())
+ );
+
+ for (var j = 0; j < instanceArgCount; j++)
+ {
+ var check = TypeCheckGenerator.GenerateForParam(
+ method.Parameters[j + 1].Type, // skip first param (self)
+ j,
+ _context.AssemblyWideTranspile,
+ _context.CurrentAssembly
+ );
+ instCondition = new TsBinaryExpression(instCondition, "&&", check);
+ }
+
+ var instCallArgs = new List { new TsIdentifier("this") };
+ for (var j = 0; j < instanceArgCount; j++)
+ {
+ var paramType = TypeMapper.Map(method.Parameters[j + 1].Type);
+ instCallArgs.Add(new TsCastExpression(new TsIdentifier($"args[{j}]"), paramType));
+ }
+
+ var instCall = new TsCallExpression(
+ new TsPropertyAccess(new TsIdentifier(className), fastPathNames[i]),
+ instCallArgs
+ );
+
+ instanceDispatchBody.Add(
+ new TsIfStatement(instCondition, [new TsReturnStatement(instCall)])
+ );
+ }
+
+ instanceDispatchBody.Add(
+ new TsThrowStatement(
+ new TsNewExpression(
+ new TsIdentifier("Error"),
+ [new TsStringLiteral($"No matching overload for {operatorName}")]
+ )
+ )
+ );
+
+ // Instance overload signatures (skip first param — it becomes `this`)
+ var instanceOverloads = sorted
+ .Select(m =>
+ {
+ var @params = m
+ .Parameters.Skip(1)
+ .Select(p => new TsParameter(
+ TypeScriptNaming.ToCamelCase(p.Name),
+ TypeMapper.Map(p.Type)
+ ))
+ .ToList();
+ return new TsMethodOverload(@params, TypeMapper.Map(m.ReturnType));
+ })
+ .ToList();
+
+ members.Add(
+ new TsMethodMember(
+ $"${operatorName}",
+ [new TsParameter("...args", new TsNamedType("unknown[]"))],
+ returnType,
+ instanceDispatchBody,
+ Overloads: instanceOverloads
+ )
+ );
+
+ return members;
+ }
+
+ private static string Capitalize(string s) =>
+ string.IsNullOrEmpty(s) ? s : char.ToUpperInvariant(s[0]) + s[1..];
+
+ private static string SimpleTypeName(ITypeSymbol type) =>
+ type.SpecialType switch
+ {
+ SpecialType.System_Int32 => "Int",
+ SpecialType.System_Int64 => "Long",
+ SpecialType.System_String => "String",
+ SpecialType.System_Boolean => "Bool",
+ SpecialType.System_Double => "Double",
+ SpecialType.System_Single => "Float",
+ SpecialType.System_Decimal => "Decimal",
+ _ => type.Name,
+ };
+
// ─── Captured primary-ctor params (DI-style) ──────────────
///
diff --git a/src/Metano.Compiler.TypeScript/Transformation/StatementHandler.cs b/src/Metano.Compiler.TypeScript/Transformation/StatementHandler.cs
index 3a84a7d..7c9efe9 100644
--- a/src/Metano.Compiler.TypeScript/Transformation/StatementHandler.cs
+++ b/src/Metano.Compiler.TypeScript/Transformation/StatementHandler.cs
@@ -220,8 +220,15 @@ private bool IsLocalMutated(VariableDeclaratorSyntax variable)
// Walking the entire enclosing block is sufficient because C# locals can't
// escape it, and we re-resolve symbols through the SemanticModel so shadowing
// by an inner scope (different ILocalSymbol) is naturally excluded.
+ //
+ // For top-level statements (C# 9+), each statement lives in its own
+ // GlobalStatementSyntax which inherits from MemberDeclarationSyntax. Walking
+ // only that node would miss mutations from sibling statements — use the
+ // CompilationUnit as scope so all top-level statements are visible.
SyntaxNode? scope = variable.FirstAncestorOrSelf();
- scope ??= variable.FirstAncestorOrSelf();
+ scope ??= variable.FirstAncestorOrSelf() is not null
+ ? variable.FirstAncestorOrSelf()
+ : variable.FirstAncestorOrSelf();
if (scope is null)
return true;
diff --git a/tests/Metano.Tests/OperatorTranspileTests.cs b/tests/Metano.Tests/OperatorTranspileTests.cs
index b283f47..8386bf4 100644
--- a/tests/Metano.Tests/OperatorTranspileTests.cs
+++ b/tests/Metano.Tests/OperatorTranspileTests.cs
@@ -66,7 +66,7 @@ public readonly record struct Num(int Value)
}
[Test]
- public async Task Operator_WithoutNameAttribute_IsSkipped()
+ public async Task Operator_WithoutNameAttribute_AutoDerivesName()
{
var result = TranspileHelper.Transpile(
"""
@@ -79,7 +79,73 @@ public readonly record struct Num(int Value)
);
var output = result["num.ts"];
- await Assert.That(output).DoesNotContain("__");
- await Assert.That(output).DoesNotContain("$");
+ // Auto-derived: + → "add"
+ await Assert.That(output).Contains("static __add(");
+ await Assert.That(output).Contains("$add(b: Num): Num");
+ }
+
+ [Test]
+ public async Task Operator_NameAttributeOverridesAutoName()
+ {
+ var result = TranspileHelper.Transpile(
+ """
+ [Transpile]
+ public readonly record struct Num(int Value)
+ {
+ [Name("plus")]
+ public static Num operator +(Num a, Num b) => new(a.Value + b.Value);
+ }
+ """
+ );
+
+ var output = result["num.ts"];
+ // [Name("plus")] overrides the default "add"
+ await Assert.That(output).Contains("static __plus(");
+ await Assert.That(output).Contains("$plus(b: Num): Num");
+ }
+
+ [Test]
+ public async Task Operator_OnPlainClass_AutoDerivesName()
+ {
+ var result = TranspileHelper.Transpile(
+ """
+ [Transpile]
+ public class Money
+ {
+ public decimal Amount { get; }
+ public Money(decimal amount) { Amount = amount; }
+
+ public static Money operator +(Money a, Money b) =>
+ new(a.Amount + b.Amount);
+
+ public static Money operator -(Money a, Money b) =>
+ new(a.Amount - b.Amount);
+ }
+ """
+ );
+
+ var output = result["money.ts"];
+ await Assert.That(output).Contains("static __add(");
+ await Assert.That(output).Contains("$add(b: Money): Money");
+ await Assert.That(output).Contains("static __subtract(");
+ await Assert.That(output).Contains("$subtract(b: Money): Money");
+ }
+
+ [Test]
+ public async Task UnaryOperator_WithoutNameAttribute_AutoDerivesName()
+ {
+ var result = TranspileHelper.Transpile(
+ """
+ [Transpile]
+ public readonly record struct Vec2(int X, int Y)
+ {
+ public static Vec2 operator -(Vec2 v) => new(-v.X, -v.Y);
+ }
+ """
+ );
+
+ var output = result["vec2.ts"];
+ await Assert.That(output).Contains("static __subtract(");
+ await Assert.That(output).Contains("$subtract(): Vec2");
}
}
diff --git a/tests/Metano.Tests/TypeMappingTranspileTests.cs b/tests/Metano.Tests/TypeMappingTranspileTests.cs
index 004009c..a8ddfaf 100644
--- a/tests/Metano.Tests/TypeMappingTranspileTests.cs
+++ b/tests/Metano.Tests/TypeMappingTranspileTests.cs
@@ -105,7 +105,7 @@ public record Entity(Guid Id, string Name);
var output = result["entity.ts"];
// Guid → branded UUID type from metano-runtime (not plain string).
await Assert.That(output).Contains("id: UUID");
- await Assert.That(output).Contains("import { UUID } from \"metano-runtime\"");
+ await Assert.That(output).Matches("import \\{[^}]*UUID[^}]*\\} from \"metano-runtime\"");
}
[Test]
@@ -123,7 +123,7 @@ public static class IdFactory
var output = result["id-factory.ts"];
await Assert.That(output).Contains("UUID.newUuid()");
- await Assert.That(output).Contains("import { UUID } from \"metano-runtime\"");
+ await Assert.That(output).Matches("import \\{[^}]*UUID[^}]*\\} from \"metano-runtime\"");
}
[Test]
@@ -159,7 +159,7 @@ public static class IdFactory
var output = result["id-factory.ts"];
await Assert.That(output).Contains("UUID.empty");
- await Assert.That(output).Contains("import { UUID } from \"metano-runtime\"");
+ await Assert.That(output).Matches("import \\{[^}]*UUID[^}]*\\} from \"metano-runtime\"");
}
// ─── Dictionary → Map ───────────────────────────────────