Extensio.SourceGen
0.2.0
dotnet add package Extensio.SourceGen --version 0.2.0
NuGet\Install-Package Extensio.SourceGen -Version 0.2.0
<PackageReference Include="Extensio.SourceGen" Version="0.2.0" />
<PackageVersion Include="Extensio.SourceGen" Version="0.2.0" />
<PackageReference Include="Extensio.SourceGen" />
paket add Extensio.SourceGen --version 0.2.0
#r "nuget: Extensio.SourceGen, 0.2.0"
#:package Extensio.SourceGen@0.2.0
#addin nuget:?package=Extensio.SourceGen&version=0.2.0
#tool nuget:?package=Extensio.SourceGen&version=0.2.0
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.DispatchProxypara 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 eref/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 propagaref/out.Extensio.Lua(opcional) — pontos de entrada em Lua para o usuário final (estilo PEs do ADVPL/Protheus): scripts.luaregistramextensio.around/pre/post/triggersobre a mesma pipeline eextensio.entrypointnos 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.luavia 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étodosasyncreais (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.csque recompila e troca em runtime (Roslyn incremental + ALC coletável com unload verificado), com estado preservado entre gerações ([HotReloadState]em classesIReloadableScript), 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): Validating → Inserting/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 usaProjectReference(editar lá reflete aqui na hora); sem ele (CI, clone isolado, consumo via NuGet) resolve os pacotes de$(EngineScriptingVersion). Force-p:UseEngineScriptingSource=falsepara 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 gate → pack 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:
- 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 erelease.yml(cada repositório precisa da sua). O usuário fica emenv.NUGET_USER. git tag v0.2.0 && git push origin v0.2.0— a tag é a fonte da verdade da versão (oVersionPrefixdosrc/Directory.Build.propsserve a builds locais). Uma tag estável publica os 6 pacotes, com oLuarecebendo o sufixo-beta.1do próprio csproj.- Para ensaiar sem publicar: Actions → Release → Run workflow com
dry_runmarcado.
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.
Learn more about Target Frameworks and .NET Standard.
This package has no dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.