Extensio.Core 0.2.0

dotnet add package Extensio.Core --version 0.2.0
                    
NuGet\Install-Package Extensio.Core -Version 0.2.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Extensio.Core" Version="0.2.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Extensio.Core" Version="0.2.0" />
                    
Directory.Packages.props
<PackageReference Include="Extensio.Core" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Extensio.Core --version 0.2.0
                    
#r "nuget: Extensio.Core, 0.2.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Extensio.Core@0.2.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Extensio.Core&version=0.2.0
                    
Install as a Cake Addin
#tool nuget:?package=Extensio.Core&version=0.2.0
                    
Install as a Cake Tool

Extensio

Biblioteca C#/.NET (BCL puro) que permite a uma aplicação base ser customizada por extensões externas que interceptam e envolvem seus métodos sem modificar o código base — trazendo para C# o modelo de extensibilidade do Microsoft Dynamics 365 F&O / X++ (Chain of Command, event handlers, triggers). Além de extensões C# compiladas no próprio assembly, o Extensio suporta scripting opcional em duas linguagens sobre a mesma pipeline: Lua (sandboxado, para o usuário final personalizar o sistema — estilo pontos de entrada do ADVPL/Protheus) e C# hot-reloadable (código pleno, para consultor/parceiro, com estado preservado entre reloads e debug nativo).

Status: publicado no nuget.org (0.2.0). Núcleo BCL + DI + Castle + source-generator AOT-safe + pontos de entrada nomeados + scripting em Lua e C# hot-reloadable + 5 samples + 107 testes (verdes). Arquitetura completa em docs/DESIGN_BLUEPRINT.md.

dotnet add package Extensio.Core                   # só o núcleo (BCL puro, zero dependências)
dotnet test                                        # 107/107 (núcleo, Castle, source-gen, Lua, scripts C#)
dotnet run --project samples/Extensio.Sample.Erp   # demo CoC + Pre + Post + Trigger + veto

Ideia

Você constrói um sistema C# normal e expõe certos métodos como pontos de extensão. Terceiros (ou outros módulos) registram handlers que envolvem, observam ou vetam esses métodos — ordenados por prioridade, descobertos por atributos e/ou registrados via DI — sem tocar no código original.

Mapeamento X++ → Extensio

Conceito X++ (D365 F&O) Construção Extensio (C#)
Chain of Command — wrapper que chama next(args) handler Around + await next(ctx) (cebola de camadas)
[ExtensionOf("Target")] [ExtensionOf(typeof(T))] / por string (late binding)
SubscribesTo Pre / Post handlers Pre / Post (observam; não envolvem a cadeia)
XppEventArgs.Handled / Cancel ctx.Handled (para Post seguintes) / short-circuit
Triggers de tabela (insert/update/delete/validateWrite) eventos de lifecycle de entidade (EntityLifecycle.*) + veto
XppEventBroker.InvokeChainNext kernel + PipelineExecutor (original capturado por-chamada)
Ponto de entrada por nome (ExecBlock/ExistBlock) [EntryPoint("Nome")] + kernel.InvokeEntryPointAsync / HasEntryPoint

Arquitetura (resumo)

Duas superfícies, um pipeline:

  • Proxy transparente via System.Reflection.DispatchProxy para serviços por interface — a base não muda.
  • Kernel explícito para hook points nomeados — AOT-friendly, depurável.

Ambas passam pelo mesmo PipelineExecutor (PRE → cebola AROUND → POST), com suporte a sync + async (Task/ValueTask), short-circuit, mutação de args/retorno e propagação de exceção.

Crescimento: IInterceptionProvider mantém o núcleo agnóstico ao mecanismo de proxy:

  • Extensio.Castle (opcional) — Castle DynamicProxy para classes concretas, métodos virtuais e ref/out.
  • Extensio.SourceGen (opcional) — source-generator que emite proxies de interface em tempo de compilação (anote a interface com [ExtensioProxy]), roteando pelo kernel sem gerar IL em runtime: é o caminho AOT-safe (sem [RequiresDynamicCode]), e ainda propaga ref/out.
  • Extensio.Lua (opcional) — pontos de entrada em Lua para o usuário final (estilo PEs do ADVPL/Protheus): scripts .lua registram extensio.around/pre/post/trigger sobre a mesma pipeline e extensio.entrypoint nos pontos nomeados do núcleo (os mesmos que o C# usa). Allowlist do que o sistema expõe, sandbox, hot-reload (troca atômica; erro mantém a versão anterior), debug no VS Code (breakpoints nos .lua via MoonSharp Debug) e geração dos artefatos do Lua Language Server (anotações LuaCATS + .luarc.json + recomendações do VS Code — abrir a pasta de scripts já funciona). next(ctx) funciona sobre métodos async reais (ponte por corrotina — sem sync-over-async).
  • Extensio.Scripting (opcional, net10) — extensões em C# hot-reloadable via Engine.Scripting: o autor escreve a MESMA extensão C# dos assemblies compilados ([ExtensionOf]/[Around]/[Pre]/[Post]/[Trigger]) num .cs que recompila e troca em runtime (Roslyn incremental + ALC coletável com unload verificado), com estado preservado entre gerações ([HotReloadState] em classes IReloadableScript), erro de compilação mantendo a geração anterior, modo produção com DLL pré-compilada (sem Roslyn no deploy) e debug nativo (PDB com fonte embutido — breakpoints no VS Code/VS/Rider). Divisão de papéis: Lua = sandbox p/ usuário final; C# script = poder total p/ consultor/parceiro — ambos na mesma pipeline.

Castle e SourceGen plugam na mesma IInterceptionProvider; Lua e Scripting registram handlers na mesma pipeline e nos mesmos pontos de entrada — nenhum deles exige mexer no núcleo nem no código do usuário.

Quickstart

// 1. Serviço base — INTOCADO
public interface ISalesOrderService { Task<Receipt> PlaceAsync(Order order, CancellationToken ct = default); }

// 2. Extensão externa (Chain of Command): aplica desconto sem tocar o serviço
[ExtensionOf(typeof(ISalesOrderService))]
public sealed class SalesDiscount
{
    [Around(nameof(ISalesOrderService.PlaceAsync))]
    public async ValueTask Apply(InvocationContext ctx, InvocationDelegate next)
    {
        await next(ctx);                                  // chama o original
        var r = (Receipt)ctx.ReturnValue!;
        ctx.ReturnValue = r with { Total = r.Total * 0.9m }; // 10% off no retorno
    }
}

// 3. Composição (BCL puro)
var registry = new ExtensionRegistryBuilder().Scan(typeof(SalesDiscount).Assembly).Build();
var kernel   = new ExtensionKernel(registry);
var svc = (ISalesOrderService)new DispatchProxyInterceptionProvider()
    .CreateProxy(typeof(ISalesOrderService), new SalesOrderService(...), kernel);

var receipt = await svc.PlaceAsync(order); // passa por Pre → Around → original → Post

Com DI (Extensio.Extensions.DependencyInjection):

services.AddExtensio(o => o.ScanAssemblyContaining<SalesDiscount>());
services.AddExtensibleService<ISalesOrderService, SalesOrderService>();
// Consumidores resolvem ISalesOrderService normalmente — recebem o proxy de forma transparente.

// Lifetimes: use Scoped quando a implementação depende de serviços scoped (ex.: DbContext)
services.AddExtensibleService<IOrderRepository, EfOrderRepository>(ServiceLifetime.Scoped);

// Providers em cadeia (proxy gerado AOT-safe → DispatchProxy); Castle opt-in:
services.AddExtensioProvider<CastleInterceptionProvider>(); // antes do AddExtensio = preferido; depois = fallback

Scripting: Lua ou C# hot-reloadable

Além das extensões C# compiladas acima, dois pacotes opcionais plugam scripts na mesma pipeline (mesmo [ExtensionOf]/[Around]/[Pre]/[Post]/[Trigger], mesma ordenação, mesmo veto):

Extensio.Lua Extensio.Scripting
Linguagem Lua (via MoonSharp) C# — a mesma extensão dos assemblies compilados
Público usuário final consultor / parceiro / equipe
Segurança sandbox + allowlist (ExposeService/ExposeEntity) código pleno — confie como um assembly
Hot-reload troca de estado do script Roslyn incremental + ALC coletável, [HotReloadState] preservado entre gerações
Debug MoonSharp Debug (breakpoints no VS Code) nativo — PDB com fonte embutido (VS/VS Code/Rider)
Produção fonte .lua interpretado DLL pré-compilada, sem Roslyn no deploy
Pontos de entrada extensio.entrypoint("Nome", fn) [EntryPoint("Nome")] — ambos nos mesmos pontos do núcleo
Extras artefatos do Lua Language Server (LuaCATS) + debug MoonSharp modo fonte + modo imagem via Engine.Scripting

Exemplos executáveis: Extensio.Sample.Lua e Extensio.Sample.CSharpScripts.

Exemplos

Sample Estilo O que mostra
Extensio.Sample.ScanOnly Atributos (Scan + DI) A mesma app abaixo com composição de ~6 linhas: [ExtensionOf]/[Around]/[Pre]/[Post], [Trigger] de classe com entidade inferida + dependência via container, [Lifecycle] na base
Extensio.Sample.Fluent Fluente (manual, BCL puro) A mesma app com zero atributos e zero DI — toda a composição explícita no builder
Extensio.Sample.Erp Misto Demo completa: CoC + Pre + Post + triggers de estoque + veto + ExplainTriggers
Extensio.Sample.Lua Lua (usuário final) Pontos de entrada em scripts .lua (desconto, veto de limite, auditoria) + artefatos do Lua Language Server
Extensio.Sample.CSharpScripts C# hot-reloadable (net10) A mesma extensão C# em .cs recarregável: hot-reload com [HotReloadState] preservado + modo produção com DLL pré-compilada

Os dois primeiros implementam a mesma aplicação com a mesma saída — o diff entre eles documenta os dois estilos de registro.

Pontos de entrada nomeados (análogo ao PE por nome do ADVPL/Protheus)

Além de interceptar métodos existentes, o código base pode perguntar num ponto nomeado e quem quiser responde — sem handler algum, a chamada é um no-op:

// Código base: pergunta e segue a vida (não sabe nem se alguém respondeu)
var frete = await kernel.InvokeEntryPointAsync<decimal>("CalculaFrete", pedido);

if (kernel.HasEntryPoint("CalculaFrete")) { /* análogo ao ExistBlock do Protheus */ }

Handlers vêm de qualquer origem, no mesmo ponto — o kernel resolve a ordem (módulo → order → registro) e cada um enxerga o Result do anterior, então o último a atribuir vence:

// 1) C# compilado, por atributo (descoberto pelo Scan) — pode ser static
[EntryPoint("CalculaFrete")]
public ValueTask Calcular(EntryPointContext ep)
{
    ep.Result = ep.Arg<Pedido>(0)!.Total >= 100m ? 0m : 15m;
    return default;
}

// 2) C# compilado, fluente
builder.EntryPoint("CalculaFrete", ep => { /* ... */ return default; }, module: "politica", moduleOrder: 900);
-- 3) Lua (usuário final) — Arg é 1-based, como manda a convenção da linguagem
extensio.entrypoint("CalculaFrete", function(ep)
    if ep:Arg(1).Total >= 100 then ep.Result = 0 else ep.Result = 15 end
end)
// 4) script C# hot-reloadable — mesma sintaxe do compilado, recarregável em runtime
[EntryPoint("CalculaFrete")]
public ValueTask Calcular(EntryPointContext ep) { /* ... */ return default; }

Ao compor sobre o resultado de outro handler, use ep.ResultAs<T>(), nunca ep.Result is T — números vindos do Lua chegam como double, e um teste direto por decimal falharia em silêncio:

if (ep.ResultAs<decimal>() is decimal v && v > 10m) ep.Result = 10m;   // teto sobre o que o Lua decidiu

kernel.ExplainEntryPoints("CalculaFrete") lista quem respondeu, de qual módulo e em que ordem.

Triggers de ciclo de vida (análogo a insert/update/delete/validateWrite)

Triggers reagem à escrita de entidades (não a métodos): ValidatingInserting/Updating/Deleting (pré-escrita, podem vetar) → Inserted/Updated/Deleted (pós-escrita, sempre rodam — finally-style, Cancel é ignorado). Eles não são um mecanismo paralelo: um EntityLifecycleAround<T> registrado no método de escrita do repositório os despacha pelo mesmo pipeline.

// 1. Ligação: envolve IOrderRepository.AddAsync com o ciclo de vida de Order.
//    Lifecycle() liga o dispatcher sozinho no Build() — sem cerimônia de TriggerHub.
var registry = new ExtensionRegistryBuilder()
    .Trigger<Order>(EntityLifecycle.Validating, new StockGuardTrigger(products)) // pode vetar
    .Trigger<Order>(EntityLifecycle.Inserted,   new StockTrigger(products))      // reage após a escrita
    .ExtensionOf<IOrderRepository>(x => x.Lifecycle<Order>(
        r => r.AddAsync(default!, default), EntityOperation.Insert,
        new TriggerOptions { ThrowOnVeto = true }))        // opcional: veto vira exceção com os motivos
    .Build();

// 2. Um trigger que veta (validateWrite == false)
public sealed class StockGuardTrigger : IEntityTrigger<Order>
{
    public ValueTask OnLifecycleAsync(TriggerContext<Order> t)
    {
        if (t.Operation != EntityOperation.Insert) return default;   // sabe O QUE está validando
        if (SemEstoque(t.Entity)) t.ValidationErrors.Add("Estoque insuficiente."); // qualquer erro veta
        return default;
    }
}

O que o TriggerContext<T> oferece:

Membro Para quê
Phase / Operation fase atual + operação (Insert/Update/Delete) — um Validating distingue o que valida
Entity a entidade (mutável nas fases *-ing)
Original pré-imagem (análoga a orig()) em Update/Delete — forneça readOriginal ao EntityLifecycleAround
Cancel / ValidationErrors veto sem exceção nas fases pré-escrita; os erros chegam ao chamador via EntityValidationException.Errors (ThrowOnVeto) ou via InvocationContext.Items[TriggerKeys.ValidationErrors] (lido por handlers Post)
Items rascunho compartilhado entre todas as fases da mesma operação (calcule em Inserting, leia em Inserted)

Também dá para registrar tudo por atributos (descoberto pelo Scan) — inclusive em métodos/classes static; para handlers de instância, a classe precisa de construtor sem parâmetros ou de estar registrada no container (o Scan do DI resolve via GetService):

// Trigger no nível da CLASSE — entidade inferida de IEntityTrigger<T>:
[Trigger(EntityLifecycle.Validating)]
public sealed class StockGuardTrigger : IEntityTrigger<Order> { /* pode ter deps via DI */ }

// Trigger de método — entidade inferida do TriggerContext<T>:
public sealed class OrderAuditTriggers
{
    [Trigger(EntityLifecycle.Inserted)]
    public ValueTask PrintAudit(TriggerContext<Order> t) { /* ... */ return default; }
}

// E o CÓDIGO BASE pode expor o ponto de ciclo de vida com [Lifecycle] —
// o Scan liga o EntityLifecycleAround sozinho (equivale ao Lifecycle() fluente):
public interface IOrderRepository
{
    [Lifecycle(typeof(Order), EntityOperation.Insert, ThrowOnVeto = true)]
    Task AddAsync(Order order, CancellationToken ct = default);
}

Com DI, o fluxo completo fica 100% em services.* — sem new, sem CreateProxy, sem builder:

services.AddEntityTrigger<Order, StockGuardTrigger>(EntityLifecycle.Validating); // deps injetadas
services.AddEntityTrigger<Order, StockTrigger>(EntityLifecycle.Inserted);
services.AddEntityLifecycle<Order, IOrderRepository>(                            // liga o ciclo de vida
    r => r.AddAsync(default!, default), EntityOperation.Insert);
services.AddExtensibleService<IOrderRepository, InMemoryOrderRepository>();      // expõe como proxy

// Qualquer consumidor que injete IOrderRepository no construtor recebe o PROXY
// transparentemente — os triggers disparam sem o consumidor saber que existem.

Armadilha clássica: triggers só disparam se o repositório for proxied com o EntityLifecycleAround. Se nada dispara, pergunte ao kernel: kernel.ExplainTriggers(typeof(Order)) lista os triggers registrados na ordem efetiva por fase — lista vazia = não registrou; lista cheia sem disparo = repo não-proxied.

Layout

src/
  Extensio.Core/                            # núcleo, somente BCL, zero dependências
  Extensio.Extensions.DependencyInjection/  # integração MS.DI (opcional)
  Extensio.Castle/                          # adaptador Castle: classes concretas, virtuais, ref/out (opcional)
  Extensio.SourceGen/                       # source-generator: proxies de interface AOT-safe (opcional)
  Extensio.Lua/                             # pontos de entrada em Lua p/ usuário final + defs do Lua LS (opcional)
  Extensio.Scripting/                       # extensões C# hot-reloadable via Engine.Scripting (opcional, net10)
samples/
  Extensio.Sample.Erp/                      # mini-ERP: CoC + Pre + Post + Trigger end-to-end
  Extensio.Sample.ScanOnly/                 # registro 100% por atributos (Scan + DI)
  Extensio.Sample.Fluent/                   # registro 100% fluente/manual (BCL puro)
  Extensio.Sample.Lua/                      # personalizações do usuário final em scripts .lua
  Extensio.Sample.CSharpScripts/            # extensões C# hot-reloadable + produção pré-compilada (net10)
tests/
  Extensio.Tests/                           # xUnit (núcleo)
  Extensio.Castle.Tests/                    # xUnit (Castle)
  Extensio.SourceGen.Tests/                 # xUnit E2E (gerador plugado como analyzer)
  Extensio.Lua.Tests/                       # xUnit (host Lua: corrotina/next, veto, sandbox, defs)
  Extensio.Scripting.Tests/                 # xUnit E2E (C# hot-reload: swap, erro, estado preservado)

Pacotes NuGet

Pacote O que traz Dependências
Extensio.Core núcleo (kernel, pipeline, triggers, DispatchProxy) nenhuma — BCL puro
Extensio.Extensions.DependencyInjection AddExtensio, AddExtensibleService, AddEntityTrigger Core + Microsoft.Extensions.DependencyInjection.Abstractions
Extensio.Castle classes concretas, métodos virtuais, ref/out Core + Castle.Core
Extensio.SourceGen analyzer: proxies de interface AOT-safe ([ExtensioProxy]) — (instale junto do Core)
Extensio.Lua scripts Lua sandboxados + defs do Lua Language Server Core + MoonSharp
Extensio.Scripting extensões C# hot-reloadable (net10) Core + Engine.Scripting.*

Todos em 0.2.0, exceto o Extensio.Lua, publicado como prerelease (0.2.0-beta.1) enquanto depender do MoonSharp 3.0.0-beta.1 — o único MoonSharp estável é o 2.0.0 de 2016, sem variáveis estruturadas no debugger. O sufixo vive no csproj e é sobreposto quando a própria tag já é prerelease.

dotnet pack Extensio.Packages.slnx -c Release -o artifacts/packages   # os 6 pacotes

Nota sobre o Extensio.Scripting: a referência ao Engine.Scripting é condicional — com o repositório irmão ao lado usa ProjectReference (editar lá reflete aqui na hora); sem ele (CI, clone isolado, consumo via NuGet) resolve os pacotes de $(EngineScriptingVersion). Force -p:UseEngineScriptingSource=false para empacotar sempre pelos pacotes.

CI/CD

Workflow Quando O que faz
ci.yml push/PR em main restore → build → 107 testes → smoke dos samples (ScanOnly e Fluent devem imprimir a mesma saída) → pack de validação, com os .nupkg como artifact
release.yml tag v* (ou manual) valida a versão (SemVer) → build → testes como gatepack versionado → publica no nuget.org → cria o GitHub Release com os pacotes

Ambos usam a solution portável Extensio.Packages.slnx — os 6 projetos publicáveis e seus testes, sem depender de nenhum repositório vizinho (o Extensio.Scripting entra porque cai para os pacotes do Engine.Scripting quando o irmão não está ao lado). A Extensio.slnx completa acrescenta apenas os samples.

Para publicar:

  1. Trusted Publishing: a publicação usa OIDC — o workflow troca o token do GitHub Actions por uma API key efêmera via NuGet/login, sem secret de longa duração. Cadastre a policy em nuget.org → Trusted Publishing apontando para este repositório e release.yml (cada repositório precisa da sua). O usuário fica em env.NUGET_USER.
  2. git tag v0.2.0 && git push origin v0.2.0 — a tag é a fonte da verdade da versão (o VersionPrefix do src/Directory.Build.props serve a builds locais). Uma tag estável publica os 6 pacotes, com o Lua recebendo o sufixo -beta.1 do próprio csproj.
  3. Para ensaiar sem publicar: Actions → Release → Run workflow com dry_run marcado.

Quando o Extensio.Scripting precisar de uma versão nova do Engine.Scripting, publique o irmão primeiro (ele tem o mesmo par de workflows) e atualize $(EngineScriptingVersion) no csproj.

Os pacotes levam símbolos e fontes embarcados (DebugType=embedded + EmbedAllSources): o consumidor depura dentro da biblioteca sem symbol server nem SourceLink.

Licença

MIT.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net8.0

    • No dependencies.

NuGet packages (4)

Showing the top 4 NuGet packages that depend on Extensio.Core:

Package Downloads
Extensio.Scripting

Pontos de entrada em C# hot-reloadable para o Extensio, via Engine.Scripting: o usuário escreve a MESMA extensão C# ([ExtensionOf]/[Around]/[Pre]/[Post]/[Trigger]) num .cs recarregável em runtime, com estado preservado ([HotReloadState]) e debug nativo.

Extensio.Castle

Adaptador opcional do Extensio sobre Castle DynamicProxy: intercepta classes concretas, métodos virtuais e parâmetros ref/out.

Extensio.Extensions.DependencyInjection

Integração opcional do Extensio com Microsoft.Extensions.DependencyInjection.

Extensio.Lua

Pontos de entrada em Lua para o Extensio (estilo PEs do Protheus): usuários finais personalizam o sistema com scripts .lua sobre a mesma pipeline (Pre/Around/Post/Trigger), com artefatos para o Lua Language Server.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.2.0 133 7/25/2026
0.1.0 132 7/25/2026