Extensio.SourceGen
0.1.0
See the version list below for details.
dotnet add package Extensio.SourceGen --version 0.1.0
NuGet\Install-Package Extensio.SourceGen -Version 0.1.0
<PackageReference Include="Extensio.SourceGen" Version="0.1.0" />
<PackageVersion Include="Extensio.SourceGen" Version="0.1.0" />
<PackageReference Include="Extensio.SourceGen" />
paket add Extensio.SourceGen --version 0.1.0
#r "nuget: Extensio.SourceGen, 0.1.0"
#:package Extensio.SourceGen@0.1.0
#addin nuget:?package=Extensio.SourceGen&version=0.1.0
#tool nuget:?package=Extensio.SourceGen&version=0.1.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: v1 + pós-v1 (4/4) implementados. Núcleo BCL + DI + Castle + source-generator AOT-safe + scripting em Lua e C# hot-reloadable + sample ERP + 93 testes (verdes). Arquitetura completa em docs/DESIGN_BLUEPRINT.md.
dotnet test # 93/93 testes (core + Castle + source-gen)
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) |
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.entrypointem pontos nomeados que o código base invoca. 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.
Ambos plugam na mesma IInterceptionProvider e na mesma pipeline, sem 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 |
| Extras | pontos de entrada nomeados (extensio.entrypoint), artefatos do Lua Language Server (LuaCATS) |
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.
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 |
pontos de entrada em Lua + defs do Lua Language Server | Core + MoonSharp (3.0.0-beta.1) |
Extensio.Scripting ¹ |
extensões C# hot-reloadable | Core + Engine.Scripting.* |
Extensio.Lua é publicado como prerelease (0.1.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.
Os demais saem estáveis na mesma tag; o sufixo está no csproj e é sobreposto quando a própria tag
já é prerelease.
dotnet pack Extensio.Packages.slnx -c Release -o artifacts/packages # os 5 pacotes principais
¹
Extensio.Scriptingdepende do repositório irmão Engine.Scripting, que precisa estar publicado no nuget.org antes. A referência é condicional: com o repo irmão ao lado usaProjectReference(editar lá reflete aqui na hora); sem ele — CI, clone isolado, publicação — cai paraPackageReferencena versão de$(EngineScriptingVersion). Para empacotar, force-p:UseEngineScriptingSource=false(é o que o inputpublish_scriptingdo release faz).
CI/CD
| Workflow | Quando | O que faz |
|---|---|---|
ci.yml |
push/PR em main |
restore → build → 87 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 — só os projetos
sem dependência cross-repo (a Extensio.slnx completa inclui o Extensio.Scripting, que precisa do
repo irmão ao lado e não existe num runner).
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.1.0 && git push origin v0.1.0— a tag é a fonte da verdade da versão (oVersionPrefixdosrc/Directory.Build.propsserve a builds locais). Uma tag estável publicaCore/DI/Castle/SourceGenem0.1.0e oLuaem0.1.0-beta.1.- Para ensaiar sem publicar: Actions → Release → Run workflow com
dry_runmarcado. - Para incluir o
Extensio.Scripting, marquepublish_scripting— só depois de o Engine.Scripting estar no nuget.org (ele tem o mesmo par de workflows; publique-o primeiro por tag).
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.