Extensio.Lua 0.2.0-beta.1

This is a prerelease version of Extensio.Lua.
dotnet add package Extensio.Lua --version 0.2.0-beta.1
                    
NuGet\Install-Package Extensio.Lua -Version 0.2.0-beta.1
                    
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.Lua" Version="0.2.0-beta.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Extensio.Lua" Version="0.2.0-beta.1" />
                    
Directory.Packages.props
<PackageReference Include="Extensio.Lua" />
                    
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.Lua --version 0.2.0-beta.1
                    
#r "nuget: Extensio.Lua, 0.2.0-beta.1"
                    
#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.Lua@0.2.0-beta.1
                    
#: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.Lua&version=0.2.0-beta.1&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Extensio.Lua&version=0.2.0-beta.1&prerelease
                    
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.

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