Extensio.Lua
0.2.0-beta.1
dotnet add package Extensio.Lua --version 0.2.0-beta.1
NuGet\Install-Package Extensio.Lua -Version 0.2.0-beta.1
<PackageReference Include="Extensio.Lua" Version="0.2.0-beta.1" />
<PackageVersion Include="Extensio.Lua" Version="0.2.0-beta.1" />
<PackageReference Include="Extensio.Lua" />
paket add Extensio.Lua --version 0.2.0-beta.1
#r "nuget: Extensio.Lua, 0.2.0-beta.1"
#:package Extensio.Lua@0.2.0-beta.1
#addin nuget:?package=Extensio.Lua&version=0.2.0-beta.1&prerelease
#tool nuget:?package=Extensio.Lua&version=0.2.0-beta.1&prerelease
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.
| Product | Versions 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. |
-
net8.0
- Extensio.Core (>= 0.2.0)
- MoonSharp (>= 3.0.0-beta.1)
- MoonSharp.Debugger.VsCode (>= 3.0.0-beta.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.2.0-beta.1 | 44 | 7/25/2026 |
| 0.1.0-beta.1 | 43 | 7/25/2026 |