From 9303dac49ead78214ad5e1173df8371477ec92a5 Mon Sep 17 00:00:00 2001 From: Marcin Wojas Date: Wed, 20 May 2026 22:19:31 +0200 Subject: [PATCH] scan-workers.csx --- soneta-programming/SKILL.md | 4 +- soneta-programming/references/scan-workers.md | 242 +++++++++ .../references/worker-extender.md | 84 ++- soneta-programming/scripts/scan-workers.csx | 513 ++++++++++++++++++ 4 files changed, 841 insertions(+), 2 deletions(-) create mode 100644 soneta-programming/references/scan-workers.md create mode 100644 soneta-programming/scripts/scan-workers.csx diff --git a/soneta-programming/SKILL.md b/soneta-programming/SKILL.md index 401712e..a42df88 100644 --- a/soneta-programming/SKILL.md +++ b/soneta-programming/SKILL.md @@ -43,6 +43,7 @@ SKILL.md zawiera "duży obraz" - hierarchię klas, thread-safety, kanoniczne wzo | **Zasady bezpiecznego kodu biznesowego — checklist do review i refaktoringu** | [references/safe-code.md](references/safe-code.md) | | Skanowanie pól obiektu biznesowego z DLL (Roslyn MetadataReference) | [references/scan-props.md](references/scan-props.md) | | Inwentaryzacja modułów i tabel (`*Module` / `*Row` / `*Table`) z DLL | [references/scan-modules.md](references/scan-modules.md) | +| Inwentaryzacja workerów i extenderów (`[Worker<…>]`) z DLL | [references/scan-workers.md](references/scan-workers.md) | ## Architektura warstw @@ -326,10 +327,11 @@ Więcej wzorców (kasowanie, obsługa błędów, pełny import end-to-end) - pat ## Narzędzia pomocnicze -Skill udostępnia dwa skrypty `dotnet script` (`scripts/`) do statycznej inwentaryzacji bibliotek Soneta — bez ładowania IL do CLR (Roslyn `MetadataReference.CreateFromFile`): +Skill udostępnia trzy skrypty `dotnet script` (`scripts/`) do statycznej inwentaryzacji bibliotek Soneta — bez ładowania IL do CLR (Roslyn `MetadataReference.CreateFromFile`): - `scan-modules.csx` — listuje moduły (`*Module`) i ich tabele (`*Row`/`*Table`) z Caption/Description. Dobre na start. Szczegóły, parametry i przykłady uruchomienia: [references/scan-modules.md](references/scan-modules.md). - `scan-props.csx` — wypisuje pola i właściwości kalkulowane konkretnej klasy biznesowej, rekurencyjnie po polach typu subrow. Sięgnij po niego, gdy znasz już tabelę i potrzebujesz jej kontraktu. Szczegóły: [references/scan-props.md](references/scan-props.md). +- `scan-workers.csx` — wypisuje na stdout **JSON** z workerami i extenderami zarejestrowanymi atrybutem assembly `[Worker<…>]`, pogrupowanymi wg `DataType`. Dla każdej klasy: parametry inicjowane z `Context` (ctor + `[Context]`, z rozwinięciem pod-property dla typów dziedziczących z `ContextBase`), property do bindowania, akcje menu Czynności. Opcjonalny drugi argument filtruje wynik do konkretnego typu danych (np. `DokumentHandlowy`) — w praktyce konieczny, bo pełne skanowanie zwraca tysiące rejestracji. Wynik łatwo przetwarzać `jq`. Szczegóły: [references/scan-workers.md](references/scan-workers.md). ## Konwencje nazewnicze diff --git a/soneta-programming/references/scan-workers.md b/soneta-programming/references/scan-workers.md new file mode 100644 index 0000000..9a88038 --- /dev/null +++ b/soneta-programming/references/scan-workers.md @@ -0,0 +1,242 @@ +# Skanowanie workerów i extenderów z DLL (Roslyn MetadataReference) + +Narzędzie do wylistowania wszystkich klas `*Worker` / `*Extender` zarejestrowanych w bibliotekach +dodatków enova365 / Soneta przez atrybut `WorkerAttribute` (assembly). Czyta metadane skompilowanych +bibliotek dodatku — nie wymaga źródeł. + +## Cel + +W modelu Soneta workery i extendery są rejestrowane atrybutem assembly: + +```csharp +[assembly: Worker] // worker przypięty do typu danych +[assembly: Worker] // extender (bez typu danych) +``` + +Skrypt wyciąga wszystkie takie rejestracje, grupuje workery wg typu danych oraz wypisuje dla każdej +klasy: parametry inicjowane z `Context`, property dostępne do bindowania/odczytu oraz pozycje +menu Czynności (metody z atrybutem `[Action]`). + +Używaj tego narzędzia, gdy: +- robisz inwentaryzację rozszerzeń (workery / extendery) w dodatku innej osoby albo w całej aplikacji; +- chcesz znaleźć dostępne workery dla danego typu danych zanim napiszesz form.xml (`{Workers..}`); +- sprawdzasz, jakie pozycje menu Czynności są dostępne na danym obiekcie; +- weryfikujesz, że Twój nowy worker / extender został poprawnie zarejestrowany i jest widoczny dla platformy; +- przygotowujesz raport dla code review (dodatek powinien mieć spójną listę workerów / akcji). + +Po komplementarne dane — patrz `scan-modules.csx` (lista tabel) i `scan-props.csx` (pola tabeli). + +## Mechanizm + +Skrypt używa **Roslyn** (`Microsoft.CodeAnalysis.CSharp`) i `MetadataReference.CreateFromFile`, +czyli metadane są czytane bez ładowania IL do CLR — bezpiecznie, bez ryzyka konfliktów wersji. + +Algorytm: +1. Zbierz wszystkie `*.dll` z podanego katalogu, dodaj jako `MetadataReference`. Dołącz biblioteki + runtime'u .NET (TPA — `AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")`) — bez tego Roslyn + nie rozwiązuje atrybutów ramowych i zwraca pustą `ConstructorArguments`. +2. Zbuduj `CSharpCompilation` z tymi referencjami. +3. Dla każdego `IAssemblySymbol` przejdź po atrybutach assembly (`asm.GetAttributes()`) + i odfiltruj te, których klasa to `WorkerAttribute` z namespace zaczynającego się od `Soneta` + (chroni przed kolizją z atrybutami o tej samej nazwie z innych bibliotek). +4. Wyciągnij dane rejestracji w zależności od wariantu atrybutu: + - **wariant generyczny** (`[Worker]`, `[Worker]`) — typy biorę z + `AttributeClass.TypeArguments[0]` (worker) oraz `TypeArguments[1]` (data, opcjonalnie). + W metadanych klasa atrybutu ma backtick (`WorkerAttribute\`1`, `WorkerAttribute\`2`), ale + `INamedTypeSymbol.Name` zwraca `"WorkerAttribute"` bez backticka — wystarczy porównanie po nazwie. + - **wariant z parametrami** (`[Worker(typeof(TWorker), typeof(TData))]`) — typy biorę + z `ConstructorArguments` (`TypedConstantKind.Type`). Dodatkowy string z konstruktora lub + `NamedArgument` `Name` traktuję jako alias bindingu (nadpisuje nazwę domyślną). +5. Pogrupuj rejestracje wg `DataType` (workery) i posortuj alfabetycznie. Rejestracje bez + `DataType` trafiają pod osobny klucz `__extenders__` w JSON-ie. +6. Dla każdej klasy workera / extendera odczytaj: + - **Alias bindingu** (pole `name`) — jawnie podany `Name` z atrybutu albo nazwa klasy + bez sufiksu `Worker` / pełna nazwa dla extendera. Binding na UI: + - worker (lista): `{Workers..}` + - extender (formularz): `{new .}` + - **Konstruktor inicjowany z Context** — parametry pierwszego publicznego konstruktora + z parametrami trafiają do `params` z `kind: "ctor"`. + - **Property z `[Context]`** — publiczne instancyjne property z atrybutem `ContextAttribute`, + inicjowane z Context — trafiają do `params` bez pola `kind`. + - **Pod-parametry typu `ContextBase`** — gdy typ parametru dziedziczy z + `Soneta.Business.ContextBase`, wpis dostaje zagnieżdżone `props` z publicznymi property + tej klasy (pomija property samego `ContextBase`). + - **Property do bindowania / odczytu** (`props` workera) — pozostałe publiczne instancyjne + property z getterem (bez `[Context]`). + - **Akcje menu Czynności** (`actions`) — publiczne instancyjne metody z atrybutem + `ActionAttribute`; każda akcja ma `name` (tytuł), `method` (nazwa metody), `result` + (typ wyniku, `void` dla `void`). +7. Wypisz JSON na stdout (sformatowany, z polskimi znakami w czystej formie). Sekcje + `params` / `actions` / `props` są pomijane, gdy są puste. + +## Wymagania + +- .NET SDK (8.0+) +- `dotnet-script`: + ```bash + dotnet tool install -g dotnet-script + ``` + +## Uruchomienie + +```bash +dotnet script ~/.claude/skills/soneta-programming/scripts/scan-workers.csx \ + -- [] [--related] +``` + +Drugi argument (opcjonalny) ogranicza wynik do workerów przypiętych do wskazanego typu danych — +pełne skanowanie bibliotek Soneta zwraca tysiące rejestracji, więc filtr jest praktycznie +niezbędny w codziennej pracy. Dopasowanie po: +- **prostej nazwie** klasy (np. `DokumentHandlowy`), albo +- **pełnej nazwie** z namespace (np. `Soneta.Handel.DokumentHandlowy`). + +Gdy filtr jest podany, extendery (rejestracje bez `DataType`) są pomijane — ich nie da się +przypisać do typu danych. + +### Flaga `--related` — typy powiązane + +`--related` rozszerza filtr o typy powiązane z podanym typem. Pozwala jednym wywołaniem zebrać +workery z całej „rodziny" obiektu (rekord, tabela, historia), bez konieczności trzech osobnych +uruchomień. Reguły rozpoznawania powiązań (po metadanych typu): + +| Typ wejściowy | Powiązany typ | Sposób odczytu | +|---|---|---| +| Klasa dziedzicząca z `Soneta.Business.Row` (np. `Pracownik`, `DokumentHandlowy`) | Klasa tabeli (`Pracownicy`, `DokHandlowe`) | property `Table` w klasie `Row` (lub klas bazowych) | +| Klasa dziedzicząca z `Soneta.Business.Table` (np. `Pracownicy`, `DokHandlowe`) | Klasa rekordu (`Pracownik`, `DokumentHandlowy`) | indekser `this[int]` — typ zwracany | +| Typ implementujący `IRowWithHistory` (np. `Pracownik`) | Typ rekordu historycznego (`PracHistoria`) | indekser `this[Soneta.Types.Date]` — typ zwracany | + +Reguły działają łącznie — np. dla `Pracownik` (Row + IRowWithHistory) zebrane są workery z trzech +typów naraz: `Pracownik`, `Pracownicy`, `PracHistoria`. Informacje o znalezionych typach +powiązanych skrypt wypisuje na stderr (`# Typ podstawowy: …`, `# Typ powiązany: …`), żeby nie +zaśmiecać JSON-a na stdout. + +Gdy `--related` jest podany, ale typu z `` nie da się znaleźć w referencjach +(np. literówka), skrypt loguje ostrzeżenie na stderr i wraca do prostego dopasowania po nazwie. + +### Przykłady + +Pełna inwentaryzacja: + +```bash +dotnet script ~/.claude/skills/soneta-programming/scripts/scan-workers.csx \ + -- ./bin/Debug/net8.0 +``` + +Tylko workery przypięte do `DokumentHandlowy`: + +```bash +dotnet script ~/.claude/skills/soneta-programming/scripts/scan-workers.csx \ + -- ./bin/Debug/net8.0 DokumentHandlowy +``` + +### Format wyjścia: JSON + +Skrypt **zawsze** wypisuje JSON na stdout — nadaje się do dalszego przetwarzania +(`jq`, skrypty, narzędzia). Markdown został usunięty, żeby utrzymać jedno, stabilne +źródło danych dla automatów i klientów. + +Struktura JSON: + +```json +{ + "description": "Workery przypięte do typu `DokumentHandlowy` (Soneta)", + "Soneta.Handel.DokumentHandlowy": [ + { + "workerAssembly": "Soneta.Zadania", + "workerType": "Soneta.Zadania.Smsing.WyslijSmsWorker", + "name": "WyslijSms", + "params": [ + { "name": "ConstructorParam", "type": "Soneta.X.Y", "kind": "ctor" }, + { "name": "PropWithContextAttr", "type": "Soneta.X.Y" }, + { + "name": "Pars", + "type": "Soneta.X.SomeWorker.Params", + "props": [ + { "name": "DataOd", "type": "Soneta.Types.Date" }, + { "name": "DataDo", "type": "Soneta.Types.Date" } + ] + } + ], + "actions": [ + { "name": "Wyślij SMS", "method": "WyslijSmsa", "result": "object" } + ], + "props": [ + { "name": "PublicPropWithoutContextAttr", "type": "Soneta.X.Y" } + ] + } + ] +} +``` + +- Klucze top-level: `description` + jeden klucz na każdy `DataType` (pełna nazwa z namespace). +- `params` łączy parametry konstruktora (z `kind: "ctor"`) oraz property z atrybutem `[Context]` + (bez pola `kind`) — wszystko, co Soneta inicjuje z `Context` przy tworzeniu workera. +- Gdy typ parametru **dziedziczy z `Soneta.Business.ContextBase`** (klasa parametrów workera — + zwykle nested `Params` w klasie workera), wpis zawiera dodatkowo `props` z listą publicznych, + instancyjnych property tej klasy. To pod-parametry, które użytkownik widzi w oknie parametrów + workera. Property samego `ContextBase` (np. `Context`) są pomijane. +- `actions` — metody z atrybutem `[Action]`. `name` to tytuł z atrybutu, `method` to nazwa + metody w C#, `result` to deklarowany typ wyniku (`void` dla metod bez wartości). +- `props` — publiczne, instancyjne property z getterem, bez `[Context]` — kandydaci do + bindowania w `form.xml` przez `{Workers..}`. +- Sekcje puste (`params`/`actions`/`props`) są pomijane, żeby JSON pozostał zwięzły. +- Extendery (rejestracje bez `DataType`) trafiają — wyłącznie w trybie bez filtra typu — + pod klucz `__extenders__`. + +### Przykłady filtrowania `jq` + +```bash +# Lista workerów dla typu: +jq '."Soneta.Handel.DokumentHandlowy"[] | .workerType' /tmp/out.json + +# Tylko z akcjami menu Czynności: +jq '."Soneta.Handel.DokumentHandlowy"[] | select(.actions)' /tmp/out.json + +# Konkretny worker po aliasie: +jq '."Soneta.Handel.DokumentHandlowy"[] | select(.name=="KSeFWyslij")' /tmp/out.json + +# Workery, których parametr `Params` ma pole `Magazyn`: +jq '.[] | arrays | .[] | select((.params // []) + | map(.props // []) | flatten | map(.name) | index("Magazyn"))' /tmp/out.json +``` + +## Kody wyjścia + +| Kod | Znaczenie | +|-----|-----------| +| `0` | OK — wypisano listę workerów i extenderów | +| `1` | Błąd argumentów / nie istnieje katalog / brak DLL | + +## Ograniczenia + +- Skanuje tylko górny poziom katalogu (`SearchOption.TopDirectoryOnly`) — jeśli DLL są + rozproszone, skopiuj je do jednego katalogu. +- Filtruje atrybut `WorkerAttribute` po nazwie i namespace `Soneta*`. Jeśli inny dodatek + zarejestruje atrybut o tej samej nazwie w innym namespace, nie zostanie ujęty. +- Skrypt wypisuje **publiczne instancyjne** property/metody. Property prywatne lub statyczne + są pomijane (nie biorą udziału w bindowaniu / akcjach). +- Property z modyfikatorem `internal` nie są ujęte — Soneta wymaga publicznych członków + do bindowania UI. +- Pierwsze uruchomienie pobiera pakiet NuGet `Microsoft.CodeAnalysis.CSharp` — wymaga + połączenia internetowego (kolejne odpalenia działają offline). + +## Typowy workflow + +1. **Inwentaryzacja workerów** — uruchom `scan-workers.csx`, znajdź wszystkie workery + zarejestrowane dla interesującego Cię typu danych (np. `DokumentHandlowy`). +2. **Wybór aliasu do bindingu** — z sekcji workera odczytaj `Alias` i `Property do bindowania` + — to bezpośrednio wartości do podstawienia w `form.xml`: + `{Workers..}` (worker) lub `{new .}` (extender). +3. **Lista akcji** — kolumna „Menu Czynności" pokazuje, które pozycje pojawią się + w menu Czynności dla danego obiektu. +4. **Code review** — porównaj listę z oczekiwaną zawartością dodatku (wszystkie spodziewane + rejestracje są obecne, aliasy się nie pokrywają, akcje mają komplet metod sterujących). + +## Powiązania + +- [worker-extender.md](./worker-extender.md) — semantyka workerów/extenderów, atrybut `[Context]`, + `[Action]`, metody sterujące `IsVisibleXxx` / `IsEnabledXxx` / `GetNameXxx` / `IsCheckedXxx`, + bindowanie w form.xml. +- [context.md](./context.md) — jak działa `Context` i co może być źródłem parametrów workera. +- [scan-modules.md](./scan-modules.md) — lista modułów i tabel platformy (komplementarne do scan-workers). +- [scan-props.md](./scan-props.md) — pola konkretnego rekordu (do których workery doklejają property kalkulowane). diff --git a/soneta-programming/references/worker-extender.md b/soneta-programming/references/worker-extender.md index 9a55d9e..63671c2 100644 --- a/soneta-programming/references/worker-extender.md +++ b/soneta-programming/references/worker-extender.md @@ -7,11 +7,13 @@ Oba korzystają z [Context](context.md) do pobierania parametrów. ## Obiekty Worker Worker dorzuca do obiektu danych dodatkowe properties wyliczane (do użycia w bindowaniu) oraz pozycje w menu Czynności. +Worker można też **utworzyć i wywołać ręcznie z kodu** — wystarczy zainstancjonować klasę, ustawić jej pola/properties +i wywoływać metody (patrz [Programowe użycie workera](#programowe-użycie-workera)). * Przypisuj worker do konkretnego obiektu danych — worker zawsze działa w kontekście jednego typu. * Dodawaj do nazwy klasy sufiks `Worker` (np. `WyliczenieStanMagazynuWorker`). * Wybieraj nazwę klasy opisującą działanie, nie technikę. -* Inicjuj parametry z kontekstu przez `[Context]`. +* Inicjuj parametry z kontekstu przez `[Context]` lub przez konstruktor (jego parametry również pobierane są z `Context`). * Rejestruj przez generyczny atrybut `[assembly: Worker]` — to wersja zalecana. ### Rejestracja worker @@ -26,6 +28,20 @@ Worker dorzuca do obiektu danych dodatkowe properties wyliczane (do użycia w bi [assembly: Worker(typeof(NazwaKlasyWorker), typeof(DataType))] ``` +#### Opcjonalny alias `name` + +Atrybut `Worker` przyjmuje dodatkowy, opcjonalny parametr `name` — alternatywną nazwę używaną +przy bindowaniu w `form.xml` (`{Workers..}`). Standardowo aliasem jest nazwa klasy +workera **bez sufiksu `Worker`** (`WyliczenieStanMagazynuWorker` → `WyliczenieStanMagazynu`). +Parametr `name` ma sens tylko wtedy, gdy chcesz zbindować worker pod inną nazwą niż domyślna — +np. dla zachowania kompatybilności po refaktoringu klasy. + +```csharp +[assembly: Worker("WyliczenieStanMagazynu")] +// W form.xml dalej używamy starego aliasu: +// EditValue="{Workers.WyliczenieStanMagazynu.StanMagazynu}" +``` + ### Deklaracja klasy worker ```csharp @@ -276,6 +292,72 @@ public class StanTowaruWorker } ``` +## Konstruktor inicjowany z Context + +Jeśli klasa workera (lub extendera) ma **konstruktor publiczny z parametrami**, jego parametry są +inicjowane z `Context` po typie — analogicznie jak property z atrybutem `[Context]`. Pozwala to +trzymać pola jako `readonly` i wymusza komplet zależności w momencie tworzenia obiektu. + +```csharp +[assembly: Worker] + +public class WyliczenieStanMagazynuWorker +{ + private readonly Towar towar; + private readonly Magazyn magazyn; + + // Parametry konstruktora są pobierane z Context (po typie) w momencie tworzenia workera. + public WyliczenieStanMagazynuWorker(Towar towar, Magazyn magazyn) + { + this.towar = towar; + this.magazyn = magazyn; + } + + public decimal StanMagazynu => + magazyn != null ? towar.GetStan(magazyn) : towar.GetStanCalkowity(); +} +``` + +Reguły: +* Jeśli jest więcej niż jeden konstruktor publiczny, platforma wybiera ten, dla którego potrafi + rozwiązać komplet parametrów z `Context`. +* Konstruktor i property z `[Context]` można łączyć w jednej klasie. +* Brak wymaganej zależności w `Context` skutkuje błędem / oknem parametrów (analogicznie jak + brakujące `[Context]`). + +## Programowe użycie workera + +Workera można utworzyć i wywołać bez pośrednictwa UI — ręcznie z kodu biznesowego. Wystarczy +zainstancjonować klasę, ustawić pola/properties (lub przekazać je przez konstruktor) i wywołać +metody. + +```csharp +using (var session = login.CreateSession(readOnly: true, config: false, name: "PoliczStan")) +{ + var towar = session.GetTowary().Towary.WgKodu["NOWY001"]; + var magazyn = session.GetMagazyny().Magazyny.WgKodu["MAG-A"]; + + var worker = new WyliczenieStanMagazynuWorker + { + Towar = towar, + Magazyn = magazyn, + }; + + decimal stan = worker.StanMagazynu; +} +``` + +Kiedy worker wymaga konstruktora — przekaż zależności jako parametry konstruktora zamiast property: + +```csharp +var worker = new WyliczenieStanMagazynuWorker(towar, magazyn); +decimal stan = worker.StanMagazynu; +``` + +Taki sposób użycia jest przydatny w testach jednostkowych, w workerach wywoływanych z innych +workerów oraz w kodzie biznesowym, który chce skorzystać z logiki zamkniętej w workerze bez +przechodzenia przez UI. + ## Dobre praktyki 1. **Używaj [Context]** w obiektach worker i extender dla parametrów inicjowanych z context diff --git a/soneta-programming/scripts/scan-workers.csx b/soneta-programming/scripts/scan-workers.csx new file mode 100644 index 0000000..36fca66 --- /dev/null +++ b/soneta-programming/scripts/scan-workers.csx @@ -0,0 +1,513 @@ +#r "nuget: Microsoft.CodeAnalysis.CSharp, 4.11.0" + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Encodings.Web; +using System.Text.Unicode; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +// Argumenty pozycyjne + flagi (np. --related). +var positional = Args.Where(a => !a.StartsWith("--", StringComparison.Ordinal)).ToList(); +var includeRelated = Args.Any(a => a == "--related"); + +if (positional.Count < 1) +{ + Console.Error.WriteLine("Użycie: dotnet script scan-workers.csx -- [] [--related]"); + Console.Error.WriteLine("Przykład: dotnet script scan-workers.csx -- ./bin/Debug/net8.0"); + Console.Error.WriteLine("Przykład: dotnet script scan-workers.csx -- ./bin/Debug/net8.0 DokumentHandlowy"); + Console.Error.WriteLine("Przykład: dotnet script scan-workers.csx -- ./bin/Debug/net8.0 Pracownik --related"); + return 1; +} + +var dllDir = Path.GetFullPath(positional[0]); +var typeFilter = positional.Count >= 2 ? positional[1] : null; +if (!Directory.Exists(dllDir)) +{ + Console.Error.WriteLine($"Katalog nie istnieje: {dllDir}"); + return 1; +} + +var dllPaths = Directory.EnumerateFiles(dllDir, "*.dll", SearchOption.TopDirectoryOnly).ToList(); +if (dllPaths.Count == 0) +{ + Console.Error.WriteLine($"Brak plików *.dll w katalogu: {dllDir}"); + return 1; +} + +var refs = new List(); +var addedPaths = new HashSet(StringComparer.OrdinalIgnoreCase); +foreach (var p in dllPaths) +{ + try + { + refs.Add(MetadataReference.CreateFromFile(p)); + addedPaths.Add(Path.GetFileName(p)); + } + catch (Exception ex) { Console.Error.WriteLine($"# Pominięto {Path.GetFileName(p)}: {ex.Message}"); } +} + +var tpa = (AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string ?? "") + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); +foreach (var path in tpa) +{ + var name = Path.GetFileName(path); + if (addedPaths.Contains(name)) continue; + try { refs.Add(MetadataReference.CreateFromFile(path)); addedPaths.Add(name); } + catch { /* pomiń */ } +} + +var compilation = CSharpCompilation.Create("ScanWorkers") + .WithOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) + .AddReferences(refs); + +// Rekord opisujący pojedynczą rejestrację Worker/Extender (jednej klasie może +// odpowiadać wiele rejestracji — np. ten sam worker przypięty do różnych typów danych). +var registrations = new List(); + +foreach (var asmRef in compilation.References) +{ + if (compilation.GetAssemblyOrModuleSymbol(asmRef) is not IAssemblySymbol asm) continue; + foreach (var a in asm.GetAttributes()) + { + var ac = a.AttributeClass; + if (ac == null) continue; + if (!IsWorkerAttribute(ac)) continue; + + INamedTypeSymbol workerType = null; + INamedTypeSymbol dataType = null; + string alias = null; + + // Wariant generyczny: [Worker] lub [Worker] + if (ac.IsGenericType && ac.TypeArguments.Length >= 1) + { + workerType = ac.TypeArguments[0] as INamedTypeSymbol; + if (ac.TypeArguments.Length >= 2) + dataType = ac.TypeArguments[1] as INamedTypeSymbol; + + // Opcjonalny string z konstruktora = alias (Name) + foreach (var arg in a.ConstructorArguments) + { + if (arg.Kind == TypedConstantKind.Primitive && arg.Value is string s) + { + alias = s; + break; + } + } + } + // Wariant z parametrami: [Worker(typeof(TWorker))] / [Worker(typeof(TWorker), typeof(TData))] + // ewentualnie z dodatkowym name jako string. + else + { + var ca = a.ConstructorArguments; + int typeIdx = 0; + foreach (var arg in ca) + { + if (arg.Kind == TypedConstantKind.Type && arg.Value is INamedTypeSymbol nt) + { + if (typeIdx == 0) workerType = nt; + else if (typeIdx == 1) dataType = nt; + typeIdx++; + } + else if (arg.Kind == TypedConstantKind.Primitive && arg.Value is string s && alias == null) + { + alias = s; + } + } + } + + // NamedArgument "Name" ma priorytet, gdy jest jawnie podany. + foreach (var na in a.NamedArguments) + { + if (na.Key == "Name" && na.Value.Value is string s) alias = s; + } + + if (workerType == null) continue; + registrations.Add(new WorkerRegistration(workerType, dataType, alias, asm.Name)); + } +} + +// Filtr po nazwie typu danych — gdy podany drugi argument, ograniczamy do workerów +// przypiętych do tego typu (po prostej nazwie lub po pełnej nazwie z namespace). +// Z flagą --related dorzucamy typy powiązane: Row→Table (przez property `Table`), +// Table→Row (przez indekser this[int]), oraz history-row (gdy DataType implementuje +// IRowWithHistory — przez indekser this[Date]). +// Extendery (rejestracje bez DataType) są w trybie filtra pomijane. + +var allowedDataTypes = new HashSet(SymbolEqualityComparer.Default); +INamedTypeSymbol primaryFilterType = null; +if (typeFilter != null && includeRelated) +{ + primaryFilterType = FindTypeByName(compilation, typeFilter); + if (primaryFilterType != null) + { + allowedDataTypes.Add(primaryFilterType); + var related = ResolveRelatedTypes(primaryFilterType).ToList(); + foreach (var r in related) allowedDataTypes.Add(r); + + Console.Error.WriteLine($"# Typ podstawowy: {primaryFilterType.ToDisplayString()}"); + foreach (var r in related) + Console.Error.WriteLine($"# Typ powiązany: {r.ToDisplayString()}"); + } + else + { + Console.Error.WriteLine($"# Nie znaleziono typu `{typeFilter}` w referencjach — --related wyłączony."); + } +} + +bool MatchesTypeFilter(INamedTypeSymbol dt) +{ + if (typeFilter == null || dt == null) return typeFilter == null; + if (allowedDataTypes.Count > 0) return allowedDataTypes.Contains(dt); + return string.Equals(dt.Name, typeFilter, StringComparison.Ordinal) + || string.Equals(dt.ToDisplayString(), typeFilter, StringComparison.Ordinal); +} + +var filtered = typeFilter != null + ? registrations.Where(r => r.DataType != null && MatchesTypeFilter(r.DataType)).ToList() + : registrations; + +// Sortowanie: najpierw workery z dataType (po nazwie dataType), potem extendery (bez dataType). +var byData = filtered + .Where(r => r.DataType != null) + .GroupBy(r => r.DataType, SymbolEqualityComparer.Default) + .OrderBy(g => ((INamedTypeSymbol)g.Key).ToDisplayString(), StringComparer.Ordinal) + .ToList(); + +var extenders = typeFilter == null + ? registrations.Where(r => r.DataType == null) + .OrderBy(r => r.WorkerType.ToDisplayString(), StringComparer.Ordinal) + .ToList() + : new List(); + +WriteJson(byData, extenders, typeFilter); +return 0; + +static bool IsWorkerAttribute(INamedTypeSymbol attrClass) +{ + // Nazwa klasy atrybutu w metadanych może mieć backtick dla wariantu generycznego + // (WorkerAttribute, WorkerAttribute`1, WorkerAttribute`2). Symbol.Name zwraca "WorkerAttribute" + // bez backticka, więc wystarczy porównać po nazwie i — dla bezpieczeństwa — sprawdzić namespace. + if (attrClass.Name != "WorkerAttribute") return false; + var ns = attrClass.ContainingNamespace?.ToDisplayString() ?? ""; + return ns.StartsWith("Soneta", StringComparison.Ordinal); +} + +static bool HasAttribute(ISymbol s, string attributeTypeName) +{ + var shortName = attributeTypeName.EndsWith("Attribute") + ? attributeTypeName.Substring(0, attributeTypeName.Length - "Attribute".Length) + : attributeTypeName; + foreach (var a in s.GetAttributes()) + { + var n = a.AttributeClass?.Name; + if (n == attributeTypeName || n == shortName) return true; + } + return false; +} + +static string GetActionTitle(IMethodSymbol m) +{ + foreach (var a in m.GetAttributes()) + { + var n = a.AttributeClass?.Name; + if (n != "ActionAttribute" && n != "Action") continue; + foreach (var arg in a.ConstructorArguments) + { + if (arg.Kind == TypedConstantKind.Primitive && arg.Value is string s) + return s; + } + return ""; // [Action] bez tytułu — i tak licz jako akcja + } + return null; +} + +static string StripSuffix(string name, string suffix) +{ + return name.EndsWith(suffix, StringComparison.Ordinal) + ? name.Substring(0, name.Length - suffix.Length) + : name; +} + +static void WriteJson( + List> byData, + List extenders, + string typeFilter) +{ + // Dictionary z zachowaną kolejnością wstawiania — opis na początku, potem klucze typów. + var root = new Dictionary(); + root["description"] = typeFilter != null + ? $"Workery przypięte do typu `{typeFilter}` (Soneta)" + : "Workery i extendery (Soneta)"; + + foreach (var g in byData) + { + var dt = (INamedTypeSymbol)g.Key; + root[dt.ToDisplayString()] = g + .OrderBy(r => r.WorkerType.Name, StringComparer.Ordinal) + .Select(BuildWorkerJson) + .ToList(); + } + if (typeFilter == null && extenders.Count > 0) + { + root["__extenders__"] = extenders.Select(BuildWorkerJson).ToList(); + } + + var opts = new JsonSerializerOptions + { + WriteIndented = true, + Encoder = JavaScriptEncoder.Create(UnicodeRanges.All), + }; + Console.WriteLine(JsonSerializer.Serialize(root, opts)); +} + +static Dictionary BuildWorkerJson(WorkerRegistration reg) +{ + var w = reg.WorkerType; + var hasWorkerSuffix = w.Name.EndsWith("Worker", StringComparison.Ordinal); + var defaultAlias = hasWorkerSuffix + ? StripSuffix(w.Name, "Worker") + : w.Name; + var aliasShown = !string.IsNullOrEmpty(reg.Alias) ? reg.Alias : defaultAlias; + + var paramsList = new List>(); + + // Parametry konstruktora (kind=ctor) — wybieramy pierwszy publiczny konstruktor z parametrami. + var ctor = w.InstanceConstructors + .FirstOrDefault(c => c.DeclaredAccessibility == Accessibility.Public && c.Parameters.Length > 0); + if (ctor != null) + { + foreach (var p in ctor.Parameters) + { + var entry = new Dictionary + { + ["name"] = p.Name, + ["type"] = p.Type.ToDisplayString(), + ["kind"] = "ctor", + }; + AttachContextBaseProps(entry, p.Type); + paramsList.Add(entry); + } + } + + // Property z [Context] — inicjowane z Context. + foreach (var p in w.GetMembers().OfType() + .Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic && !p.IsIndexer) + .Where(p => HasAttribute(p, "ContextAttribute")) + .OrderBy(p => p.Name, StringComparer.Ordinal)) + { + var entry = new Dictionary + { + ["name"] = p.Name, + ["type"] = p.Type.ToDisplayString(), + }; + AttachContextBaseProps(entry, p.Type); + paramsList.Add(entry); + } + + // Akcje [Action] — metoda + tytuł + typ wyniku. + var actions = new List>(); + foreach (var m in w.GetMembers().OfType() + .Where(m => m.MethodKind == MethodKind.Ordinary + && m.DeclaredAccessibility == Accessibility.Public + && !m.IsStatic) + .OrderBy(m => m.Name, StringComparer.Ordinal)) + { + var title = GetActionTitle(m); + if (title == null) continue; + actions.Add(new Dictionary + { + ["name"] = title, + ["method"] = m.Name, + ["result"] = m.ReturnsVoid ? "void" : m.ReturnType.ToDisplayString(), + }); + } + + // Pozostałe public property z getterem (bez [Context]) — do bindowania / odczytu. + var props = new List>(); + foreach (var p in w.GetMembers().OfType() + .Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic && !p.IsIndexer) + .Where(p => p.GetMethod != null) + .Where(p => !HasAttribute(p, "ContextAttribute")) + .OrderBy(p => p.Name, StringComparer.Ordinal)) + { + props.Add(new Dictionary + { + ["name"] = p.Name, + ["type"] = p.Type.ToDisplayString(), + }); + } + + var obj = new Dictionary + { + ["workerAssembly"] = reg.AssemblyName, + ["workerType"] = w.ToDisplayString(), + ["name"] = aliasShown, + }; + if (paramsList.Count > 0) obj["params"] = paramsList; + if (actions.Count > 0) obj["actions"] = actions; + if (props.Count > 0) obj["props"] = props; + return obj; +} + +// Dla typu parametru workera dziedziczącego z ContextBase doczepia listę publicznych +// instancyjnych property — to są pod-parametry, które użytkownik widzi w oknie parametrów workera. +static void AttachContextBaseProps(Dictionary entry, ITypeSymbol type) +{ + if (type is not INamedTypeSymbol nt) return; + if (!InheritsFromContextBase(nt)) return; + + var props = new List>(); + var seen = new HashSet(StringComparer.Ordinal); + for (var t = nt; t != null && t.SpecialType != SpecialType.System_Object; t = t.BaseType) + { + if (t.Name == "ContextBase") break; // property bazowe ContextBase pomijamy (Context itp.) + foreach (var p in t.GetMembers().OfType()) + { + if (p.DeclaredAccessibility != Accessibility.Public || p.IsStatic || p.IsIndexer) continue; + if (p.GetMethod == null) continue; + if (!seen.Add(p.Name)) continue; + props.Add(new Dictionary + { + ["name"] = p.Name, + ["type"] = p.Type.ToDisplayString(), + }); + } + } + if (props.Count > 0) + { + props = props.OrderBy(d => (string)d["name"], StringComparer.Ordinal).ToList(); + entry["props"] = props; + } +} + +static bool InheritsFromContextBase(INamedTypeSymbol type) +{ + for (var t = type.BaseType; t != null && t.SpecialType != SpecialType.System_Object; t = t.BaseType) + { + if (t.Name == "ContextBase" + && (t.ContainingNamespace?.ToDisplayString() ?? "").StartsWith("Soneta", StringComparison.Ordinal)) + return true; + } + return false; +} + +// Szuka typu po prostej nazwie ("DokumentHandlowy") lub pełnej z namespace +// ("Soneta.Handel.DokumentHandlowy"). Zwraca pierwsze trafienie. +static INamedTypeSymbol FindTypeByName(CSharpCompilation compilation, string nameOrFullName) +{ + foreach (var asmRef in compilation.References) + { + if (compilation.GetAssemblyOrModuleSymbol(asmRef) is not IAssemblySymbol asm) continue; + foreach (var t in EnumerateAllTypes(asm.GlobalNamespace)) + { + if (t.DeclaredAccessibility != Accessibility.Public) continue; + if (string.Equals(t.Name, nameOrFullName, StringComparison.Ordinal) + || string.Equals(t.ToDisplayString(), nameOrFullName, StringComparison.Ordinal)) + return t; + } + } + return null; +} + +static IEnumerable EnumerateAllTypes(INamespaceSymbol ns) +{ + foreach (var t in ns.GetTypeMembers()) yield return t; + foreach (var sub in ns.GetNamespaceMembers()) + foreach (var t in EnumerateAllTypes(sub)) yield return t; +} + +// Zbiór typów powiązanych z `primary` (przechodnio): +// - jeśli typ dziedziczy z `Soneta.Business.Row` → typ z property `Table` (klasa tabeli); +// - jeśli typ dziedziczy z `Soneta.Business.Table` → typ zwracany przez indekser `this[int]` +// (klasa rekordu); +// - jeśli typ implementuje interfejs `IRowWithHistory` → typ zwracany przez indekser +// przyjmujący `Soneta.Types.Date` (historyczny rekord). +// Reguły aplikowane są w pętli — np. dla `Pracownik` (Row + IRowWithHistory) najpierw +// dostajemy `Pracownicy` i `PracHistoria`, a `PracHistoria` (kolejny Row) dorzuca własną +// tabelę `PracHistorie`. Pętle są zabezpieczone zbiorem już odwiedzonych typów. +static IEnumerable ResolveRelatedTypes(INamedTypeSymbol primary) +{ + var result = new HashSet(SymbolEqualityComparer.Default); + var queue = new Queue(); + queue.Enqueue(primary); + var visited = new HashSet(SymbolEqualityComparer.Default) { primary }; + + while (queue.Count > 0) + { + var t = queue.Dequeue(); + foreach (var related in DirectRelatedTypes(t)) + { + if (visited.Add(related)) + { + result.Add(related); + queue.Enqueue(related); + } + } + } + return result; +} + +static IEnumerable DirectRelatedTypes(INamedTypeSymbol t) +{ + if (InheritsFromNamed(t, "Row", "Soneta.Business")) + { + if (FindMemberInherited(t, m => m is IPropertySymbol p && !p.IsIndexer && p.Name == "Table") + is IPropertySymbol tableProp + && tableProp.Type is INamedTypeSymbol tableType) + yield return tableType; + } + + if (InheritsFromNamed(t, "Table", "Soneta.Business")) + { + if (FindMemberInherited(t, m => m is IPropertySymbol p + && p.IsIndexer && p.Parameters.Length == 1 + && p.Parameters[0].Type.SpecialType == SpecialType.System_Int32) + is IPropertySymbol rowIndexer + && rowIndexer.Type is INamedTypeSymbol rowType) + yield return rowType; + } + + if (ImplementsInterface(t, "IRowWithHistory")) + { + if (FindMemberInherited(t, m => m is IPropertySymbol p + && p.IsIndexer && p.Parameters.Length == 1 + && p.Parameters[0].Type is INamedTypeSymbol pt + && pt.Name == "Date" + && (pt.ContainingNamespace?.ToDisplayString() ?? "").StartsWith("Soneta", StringComparison.Ordinal)) + is IPropertySymbol dateIndexer + && dateIndexer.Type is INamedTypeSymbol histType) + yield return histType; + } +} + +static bool InheritsFromNamed(INamedTypeSymbol type, string name, string nsPrefix) +{ + for (var t = type.BaseType; t != null && t.SpecialType != SpecialType.System_Object; t = t.BaseType) + { + if (t.Name == name + && (t.ContainingNamespace?.ToDisplayString() ?? "").StartsWith(nsPrefix, StringComparison.Ordinal)) + return true; + } + return false; +} + +static bool ImplementsInterface(INamedTypeSymbol type, string ifaceName) +{ + return type.AllInterfaces.Any(i => i.Name == ifaceName); +} + +static ISymbol FindMemberInherited(INamedTypeSymbol type, Func predicate) +{ + for (var t = type; t != null && t.SpecialType != SpecialType.System_Object; t = t.BaseType) + { + var m = t.GetMembers().FirstOrDefault(predicate); + if (m != null) return m; + } + return null; +} + +record WorkerRegistration(INamedTypeSymbol WorkerType, INamedTypeSymbol DataType, string Alias, string AssemblyName);