From d8f029617df9ee6330d3c6dfce815811b152c11d Mon Sep 17 00:00:00 2001 From: mforce Date: Mon, 5 Oct 2026 14:50:43 +0000 Subject: [PATCH 1/6] test(arch): copy the module owners and edges into Domain assembly attributes ZzMapParity proves the reflected attributes equal RealModuleLedger.Owners and Edges, in order (local run; a one-character reason edit turns it red). Refs #859 --- .../Common/Architecture/ModuleEdges.cs | 141 +++++++++ .../Architecture/ModuleMapAttributes.cs | 46 +++ .../Common/Architecture/ModuleOwners.cs | 292 ++++++++++++++++++ .../Architecture/ZzMapParity.cs | 23 ++ 4 files changed, 502 insertions(+) create mode 100644 src/Cluckwork.Domain/Common/Architecture/ModuleEdges.cs create mode 100644 src/Cluckwork.Domain/Common/Architecture/ModuleMapAttributes.cs create mode 100644 src/Cluckwork.Domain/Common/Architecture/ModuleOwners.cs create mode 100644 tests/Cluckwork.Application.Tests/Architecture/ZzMapParity.cs diff --git a/src/Cluckwork.Domain/Common/Architecture/ModuleEdges.cs b/src/Cluckwork.Domain/Common/Architecture/ModuleEdges.cs new file mode 100644 index 000000000..134bae899 --- /dev/null +++ b/src/Cluckwork.Domain/Common/Architecture/ModuleEdges.cs @@ -0,0 +1,141 @@ +using Cluckwork.Domain.Common.Architecture; + +[assembly: ModuleEdge( + "Access", "Commerce", "W", + "W because AccountProvisioner inserts EggUnitConversion.Defaults for a new farm inside its provisioning transaction; the rest of the cell reads. Identity carries the per-user stepper-unit preference: ApplicationUser stores Domain.Catalog.EggUnit, IIdentityProvider, the Identity port Access claims, takes it in SetStepperUnitAsync, IdentityProvider reads and writes it, SetStepperUnitHandler resolves it through Commerce's IEggUnitConversionLookup port (#854) and rejects inactive conversions, and SetStepperUnitValidator validates only the enum name. Nothing prevents a stored preference from pointing at a conversion later deactivated through UpdateEggUnitConversionHandler. Design 3.4 shows Access -> Commerce as none; this is live coupling the target design has still to remove.", + "Cluckwork.Application.Common.IIdentityProvider", + "Cluckwork.Application.Features.Users.SetStepperUnit.SetStepperUnitHandler", + "Cluckwork.Application.Features.Users.SetStepperUnit.SetStepperUnitValidator", + "Cluckwork.Infrastructure.Identity.AccountProvisioner", + "Cluckwork.Infrastructure.Identity.ApplicationUser", + "Cluckwork.Infrastructure.Identity.IdentityProvider")] +[assembly: ModuleEdge( + "Access", "EggOperations", "W", + "W because AccountProvisioner inserts a new farm's default grades, Domain.Eggs.EggGrade.Defaults(accountId, SeedDefaults.FarmId), inside its provisioning transaction, so farm provisioning writes Egg Operations' table and depends on its grade shape. Design 3.4 shows Access -> Egg Ops as none; this is live coupling the target design has still to remove.", + "Cluckwork.Infrastructure.Identity.AccountProvisioner")] +[assembly: ModuleEdge( + "Access", "Farm", "W", + "W because AccountProvisioner inserts the new Account inside its provisioning transaction and AccountRenameService calls Account.Rename and saves; the rest of the cell reads. Identity is where a farm is created, renamed, suspended and recovered. AccountProvisioner, AccountRenameService, AccountSuspensionService, AdminRecoveryService, FirstRunAdminService and FirstRunStatusService load Domain.Accounts.Account or inject Farm's IAccountRepository, and IdentityProvider takes that repository by fully qualified name for the account-scoped login (#532). CredentialEpochVerifier reads Account.IsActive in the same fresh per-request query as the user's credential epoch, so a suspended farm's credentials stop working at once (#364, #579, #857). The user validators read Domain.Accounts.Roles for the assignable role set, and IUserRoleAssignmentRepository returns Domain.Accounts.UserRoleAssignment rows, which AccessModule reads to list a user's flock assignments. IAccessLookup returns Domain.Accounts.EffectiveAccountRole, and AccessLookup resolves it with Roles.ResolveEffective in its private effective-role routine (#612, #857). Design 3.4 row Access -> Farm = R. AccessFixture (#858) creates the simulation fixture's UserRoleAssignment through the scoped context, and AccessSeedLookup checks the fixture's Owner role by Roles.Owner.", + "Cluckwork.Application.Features.Users.AssignFlock.AssignFlockHandler", + "Cluckwork.Application.Features.Users.AssignFlock.UnassignFlockHandler", + "Cluckwork.Application.Features.Users.ChangeUserRole.ChangeUserRoleValidator", + "Cluckwork.Application.Features.Users.CreateUser.CreateUserValidator", + "Cluckwork.Application.Features.Users.IAccessLookup", + "Cluckwork.Application.Features.Users.IUserRoleAssignmentRepository", + "Cluckwork.Infrastructure.Identity.AccessFixture", + "Cluckwork.Infrastructure.Identity.AccessLookup", + "Cluckwork.Infrastructure.Identity.AccessModule", + "Cluckwork.Infrastructure.Identity.AccessSeedLookup", + "Cluckwork.Infrastructure.Identity.AccountProvisioner", + "Cluckwork.Infrastructure.Identity.AccountRenameService", + "Cluckwork.Infrastructure.Identity.AccountSuspensionService", + "Cluckwork.Infrastructure.Identity.AdminRecoveryService", + "Cluckwork.Infrastructure.Identity.CredentialEpochVerifier", + "Cluckwork.Infrastructure.Identity.FirstRunAdminService", + "Cluckwork.Infrastructure.Identity.FirstRunStatusService", + "Cluckwork.Infrastructure.Identity.IdentityProvider")] +[assembly: ModuleEdge( + "Access", "FlockManagement", "R", + "AssignFlockHandler injects Flock Management's IFlockLookup port to prove the flock exists before narrowing a worker to it and to name it in the audit row (#103, spec 5.2/5.3). Design 3.4 row Access -> Flock = R. AccessFixture (#858) reads the same port for the simulation fixture's assignment and audit row. AccessModule names a user's flock assignments through IFlockLookup.GetDisplayNamesAsync, one bounded read whose filters decide which names the caller sees (#613, #859).", + "Cluckwork.Application.Features.Users.AssignFlock.AssignFlockHandler", + "Cluckwork.Infrastructure.Identity.AccessFixture", + "Cluckwork.Infrastructure.Identity.AccessModule")] +[assembly: ModuleEdge( + "Commerce", "Access", "R", + "ConfirmSaleHandler reads through Access's IAccessLookup port (GetEffectiveRoleAsync and GetAssignedFlocksAsync, #857) inside the confirm transaction, so a plain Worker's committed flock assignments and the discount ceiling bound to their effective role are read at the moment of confirmation rather than from a request-start snapshot (#727). Design 3.4 shows Commerce -> Access as none; this is live coupling the target design has still to remove.", + "Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleHandler")] +[assembly: ModuleEdge( + "Commerce", "EggOperations", "W", + "FIFO egg stock. ConfirmSaleHandler and VoidSaleHandler lock, draw from and return egg lots through Egg Operations' IEggStock port, which adds the lot changes and Sale or Void movements to the confirm and void transactions and never saves (#854); Commerce sees plans and lot ids, never Domain.Eggs.EggLot. Products, order lines and confirm refusals read grades through IEggGradeLookup in CreateProductHandler, UpdateProductHandler, AddOrderItemHandler and ConfirmSaleHandler. Design 3.4 row Commerce -> Egg Ops = W.", + "Cluckwork.Application.Features.Catalog.CreateProduct.CreateProductHandler", + "Cluckwork.Application.Features.Catalog.UpdateProduct.UpdateProductHandler", + "Cluckwork.Application.Features.Sales.AddOrderItem.AddOrderItemHandler", + "Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleHandler", + "Cluckwork.Application.Features.Sales.VoidSale.VoidSaleHandler")] +[assembly: ModuleEdge( + "Commerce", "Farm", "R", + "Orders and products snapshot the farm's currency through Farm's IAccountRepository (CreateSalesOrderHandler, CreateProductHandler, UpdateProductHandler), with the sales-order path holding the FOR SHARE lock on the account row (#162) so an order cannot land in a denomination the farm is mid-flight out of; ConfirmSaleHandler also reads the farm's discount ceiling and worker sale-allocation policy off Account. Design 3.4 row Commerce -> Farm = R.", + "Cluckwork.Application.Features.Catalog.CreateProduct.CreateProductHandler", + "Cluckwork.Application.Features.Catalog.UpdateProduct.UpdateProductHandler", + "Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleHandler", + "Cluckwork.Application.Features.Sales.CreateSalesOrder.CreateSalesOrderHandler")] +[assembly: ModuleEdge( + "EggOperations", "Farm", "R", + "CreateEggGradeHandler attaches a new grade to Domain.Accounts.SeedDefaults.FarmId, the single-farm stand-in for the farm the grade belongs to; EggGradeFloorPolicy resolves the caller's effective role through Domain.Accounts.Roles to decide whether a grade's low-stock floor may move (#911, Owner-only per #729). Design 3.4 row Egg Ops -> Farm = R.", + "Cluckwork.Application.Features.EggGrades.CreateEggGrade.CreateEggGradeHandler", + "Cluckwork.Application.Features.EggGrades.EggGradeFloorPolicy")] +[assembly: ModuleEdge( + "EggOperations", "FlockManagement", "W", + "Daily entry is the mortality writer: SubmitDailyEntryHandler, AdjustDailyEntryHandler and VoidDailyEntryHandler append bird-movement rows through Flock Management's IMortalityLedger port, which adds the row to the caller's unit of work and never saves, so it commits with the entry itself (#54, #69, #852). Every daily-entry handler reads the flock through the IFlockLookup port to check CanRecordProductionOn for the entry's date. Design 3.4 row Egg Ops -> Flock = W.", + "Cluckwork.Application.Features.DailyEntries.AdjustDailyEntry.AdjustDailyEntryHandler", + "Cluckwork.Application.Features.DailyEntries.RecordDailyEntry.RecordDailyEntryHandler", + "Cluckwork.Application.Features.DailyEntries.SubmitDailyEntry.SubmitDailyEntryHandler", + "Cluckwork.Application.Features.DailyEntries.VoidDailyEntry.VoidDailyEntryHandler")] +[assembly: ModuleEdge( + "Farm", "Commerce", "R", + "Farm settings own three Commerce-shaped values. Domain.Accounts.Account holds Domain.Catalog.EggUnit as the farm's default stepper unit and exposes Domain.Sales.DiscountCeiling, while UpdateFarmSettingsHandler resolves the stepper unit through Commerce's IEggUnitConversionLookup port (#854) and UpdateFarmSettingsValidator parses the ceiling with DiscountCeiling.TryParsePercent (#727). The Farm contract's FarmSettingsDetails, declared beside IFarmModule, carries the same Domain.Catalog.EggUnit stepper unit out to its callers (#851), and FarmModule fills its discount ceiling from DiscountCeiling.Percent. Design 3.4 shows Farm -> Commerce as none; this is live coupling the target design has still to remove.", + "Cluckwork.Application.Features.Accounts.FarmModule", + "Cluckwork.Application.Features.Accounts.FarmSettingsDetails", + "Cluckwork.Application.Features.Accounts.UpdateFarmSettings.UpdateFarmSettingsHandler", + "Cluckwork.Application.Features.Accounts.UpdateFarmSettings.UpdateFarmSettingsValidator", + "Cluckwork.Domain.Accounts.Account")] +[assembly: ModuleEdge( + "Finance", "Farm", "R", + "CreateExpenseHandler injects Farm's IAccountRepository for the lock-aware currency snapshot an expense binds to (#162, FOR SHARE on the account row), and it and CreateExpenseCategoryHandler attach their rows to Domain.Accounts.SeedDefaults.FarmId, whose active categories FinanceModule lists. Design 3.4 classes this cell W, but the code reads Farm state under a shared row lock and mutates nothing there, the same shape 3.4 classes R on Commerce -> Farm and Inventory -> Farm; the ledger records what the code does and notes the disagreement here so #848 regenerates the matrix from this reading.", + "Cluckwork.Application.Features.Expenses.CreateExpense.CreateExpenseHandler", + "Cluckwork.Application.Features.Expenses.CreateExpenseCategory.CreateExpenseCategoryHandler", + "Cluckwork.Application.Features.Expenses.FinanceModule")] +[assembly: ModuleEdge( + "Finance", "FlockManagement", "R", + "CreateExpenseHandler and AdjustExpenseHandler inject Flock Management's IFlockLookup port to validate the optional flock an expense is attributed to. Design 3.4 row Finance -> Flock = R.", + "Cluckwork.Application.Features.Expenses.AdjustExpense.AdjustExpenseHandler", + "Cluckwork.Application.Features.Expenses.CreateExpense.CreateExpenseHandler")] +[assembly: ModuleEdge( + "FlockManagement", "Farm", "R", + "CreateFlockHandler places a new flock on Domain.Accounts.SeedDefaults.FarmId and SeedDefaults.HouseId, the single-farm stand-ins. Design 3.4 row Flock -> Farm = R.", + "Cluckwork.Application.Features.Flocks.CreateFlock.CreateFlockHandler")] +[assembly: ModuleEdge( + "GeneralInventory", "EggOperations", "R", + "RecordFeedUsageHandler and RecordWaterUsageHandler inject Egg Operations' IDailyEntryLookup port by fully qualified name and call FindIdForFlockScopedWriteAsync (#853), so a feed or water record carries the day's daily-entry provenance. Design 3.4 shows Inventory -> Egg Ops as none; this is live coupling the target design has still to remove.", + "Cluckwork.Application.Features.Inventory.RecordFeedUsage.RecordFeedUsageHandler", + "Cluckwork.Application.Features.Inventory.RecordWaterUsage.RecordWaterUsageHandler")] +[assembly: ModuleEdge( + "GeneralInventory", "Farm", "R", + "CreateInventoryItemHandler, RecordPurchaseHandler and UpdateInventoryItemHandler inject Farm's IAccountRepository for the farm currency a priced item or a purchase snapshots, under the same FOR SHARE account-row lock as the sales path (#162), and CreateInventoryItemHandler attaches the item to Domain.Accounts.SeedDefaults.FarmId. Design 3.4 row Inventory -> Farm = R.", + "Cluckwork.Application.Features.Inventory.CreateInventoryItem.CreateInventoryItemHandler", + "Cluckwork.Application.Features.Inventory.RecordPurchase.RecordPurchaseHandler", + "Cluckwork.Application.Features.Inventory.UpdateInventoryItem.UpdateInventoryItemHandler")] +[assembly: ModuleEdge( + "GeneralInventory", "FlockManagement", "R", + "RecordFeedUsageHandler, RecordWaterUsageHandler and UpdateWaterUsageHandler inject Flock Management's IFlockLookup port to prove the flock being fed or watered exists and is eligible for the usage date. Design 3.4 row Inventory -> Flock = R.", + "Cluckwork.Application.Features.Inventory.RecordFeedUsage.RecordFeedUsageHandler", + "Cluckwork.Application.Features.Inventory.RecordWaterUsage.RecordWaterUsageHandler", + "Cluckwork.Application.Features.Inventory.UpdateWaterUsage.UpdateWaterUsageHandler")] +[assembly: ModuleEdge( + "Insights", "Commerce", "R", + "ReportQueries reads confirmed SalesOrders and Payments for sales and profit totals; ExportQueries streams Customers, SalesOrders, SalesOrderItems, SalesOrderAllocations and Payments. Reads compose in C# and never mutate Commerce rows.", + "Cluckwork.Infrastructure.Insights.ExportQueries", + "Cluckwork.Infrastructure.Insights.ReportQueries")] +[assembly: ModuleEdge( + "Insights", "EggOperations", "R", + "ReportQueries reads DailyEntries, DailyEntryGrades and EggGrades for production and grading totals; ExportQueries streams those records, EggLots and EggInventoryMovements with their existing filters and ordering.", + "Cluckwork.Infrastructure.Insights.ExportQueries", + "Cluckwork.Infrastructure.Insights.ReportQueries")] +[assembly: ModuleEdge( + "Insights", "Farm", "R", + "ReportQueries.AccountCurrencyAsync reads the current Account currency for report DTOs. The account query remains tenant-filtered and AsNoTracking.", + "Cluckwork.Infrastructure.Insights.ReportQueries")] +[assembly: ModuleEdge( + "Insights", "Finance", "R", + "ReportQueries aggregates Expenses for expense and profit totals; ExportQueries streams ExpenseCategories and Expenses as typed CSV rows. Neither query writes Finance data.", + "Cluckwork.Infrastructure.Insights.ExportQueries", + "Cluckwork.Infrastructure.Insights.ReportQueries")] +[assembly: ModuleEdge( + "Insights", "FlockManagement", "R", + "ExportQueries streams Flock and BirdMovement records; ReportQueries reads flock lifecycle and bird movements to calculate hen-day exposure. These are read-only owner-table queries composed in C#.", + "Cluckwork.Infrastructure.Insights.ExportQueries", + "Cluckwork.Infrastructure.Insights.ReportQueries")] +[assembly: ModuleEdge( + "Insights", "GeneralInventory", "R", + "ExportQueries streams InventoryItems, InventoryLots, InventoryMovements and FeedUsages as typed CSV rows. No inventory mutation or cross-owner SQL join is introduced.", + "Cluckwork.Infrastructure.Insights.ExportQueries")] diff --git a/src/Cluckwork.Domain/Common/Architecture/ModuleMapAttributes.cs b/src/Cluckwork.Domain/Common/Architecture/ModuleMapAttributes.cs new file mode 100644 index 000000000..9858c09dc --- /dev/null +++ b/src/Cluckwork.Domain/Common/Architecture/ModuleMapAttributes.cs @@ -0,0 +1,46 @@ +namespace Cluckwork.Domain.Common.Architecture; + +// The module map (#514, #842, #859): one owner row per module and one edge cell per cross-owner dependency, +// declared in ModuleOwners.cs and ModuleEdges.cs. Domain is the one assembly every module compilation +// references, so the module-edge analyzer reads these rows from source or metadata, and the architecture tests +// read them by reflection through RealModuleLedger. Owner names stay strings (#859). + +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] +public sealed class ModuleOwnerAttribute(string name, string kind) : Attribute +{ + public string Name { get; } = name; + + public string Kind { get; } = kind; + + public string[] Namespaces { get; set; } = []; + + public string[] ExactNamespaces { get; set; } = []; + + // #849: when non-empty, adapters may reach this owner only through these types. + public string[] Contract { get; set; } = []; + + // #850: types outside the module's namespaces trusted to read its tables. + public string[] Implementations { get; set; } = []; + + // #1023: non-contract types peer modules may still reach. Adapters may not, and the contract walk skips + // them, because a seam can carry an aggregate on purpose (#851's account seam). + public string[] Seam { get; set; } = []; + + // #1023: types this owner claims inside a Platform namespace. + public string[] Types { get; set; } = []; +} + +// Symbols are the top-level from-side types realising the edge. +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] +public sealed class ModuleEdgeAttribute(string from, string to, string kind, string reason, params string[] symbols) : Attribute +{ + public string From { get; } = from; + + public string To { get; } = to; + + public string Kind { get; } = kind; + + public string Reason { get; } = reason; + + public string[] Symbols { get; } = symbols; +} diff --git a/src/Cluckwork.Domain/Common/Architecture/ModuleOwners.cs b/src/Cluckwork.Domain/Common/Architecture/ModuleOwners.cs new file mode 100644 index 000000000..47d25b4ba --- /dev/null +++ b/src/Cluckwork.Domain/Common/Architecture/ModuleOwners.cs @@ -0,0 +1,292 @@ +using Cluckwork.Domain.Common.Architecture; + +[assembly: ModuleOwner("Access", "module", + Namespaces = [ + "Cluckwork.Application.Features.Users", + "Cluckwork.Infrastructure.Identity", + ], + Contract = [ + "Cluckwork.Application.Features.Users.IAccessModule", + "Cluckwork.Application.Features.Users.IAccessLookup", + "Cluckwork.Application.Features.Users.ICredentialEpochVerifier", + "Cluckwork.Application.Features.Users.IRefreshTokenPurge", + "Cluckwork.Application.Features.Users.IAccessOperations", + "Cluckwork.Application.Features.Users.IAccessAccountLifecycle", + "Cluckwork.Application.Features.Users.IAccessSeedLookup", + "Cluckwork.Application.Features.Users.IAccessFixture", + "Cluckwork.Application.Features.Users.CredentialVerdict", + "Cluckwork.Application.Features.Users.UserFlockAssignment", + "Cluckwork.Application.Features.Users.AccessActor", + "Cluckwork.Application.Features.Users.AccessUserSummary", + "Cluckwork.Application.Features.Users.FirstRunAdminOutcome", + "Cluckwork.Application.Features.Users.AdminRecoveryResult", + "Cluckwork.Application.Features.Users.AccountProvisionOutcome", + "Cluckwork.Application.Features.Users.AccountLifecycleOutcome", + "Cluckwork.Application.Features.Users.AccountRenameOutcome", + "Cluckwork.Application.Features.Users.CreateUser.CreateUserCommand", + "Cluckwork.Application.Features.Users.UpdateUser.UpdateUserCommand", + "Cluckwork.Application.Features.Users.SetUserPassword.SetUserPasswordCommand", + "Cluckwork.Application.Features.Users.ChangeUserRole.ChangeUserRoleCommand", + "Cluckwork.Application.Features.Users.ChangeUserEmail.ChangeUserEmailCommand", + "Cluckwork.Application.Features.Users.DisableUser.DisableUserCommand", + "Cluckwork.Application.Features.Users.EnableUser.EnableUserCommand", + "Cluckwork.Application.Features.Users.AssignFlock.AssignFlockCommand", + "Cluckwork.Application.Features.Users.AssignFlock.UnassignFlockCommand", + "Cluckwork.Application.Features.Users.SetLanguage.SetLanguageCommand", + "Cluckwork.Application.Features.Users.SetStepperUnit.SetStepperUnitCommand", + "Cluckwork.Application.Features.Users.ChangeOwnPassword.ChangeOwnPasswordCommand", + ], + Implementations = [ + "Cluckwork.Infrastructure.Repositories.UserRoleAssignmentRepository", + ], + Types = [ + "Cluckwork.Application.Common.IIdentityProvider", + "Cluckwork.Application.Common.IStepUpGrantService", + ])] +[assembly: ModuleOwner("Farm", "module", + Namespaces = [ + "Cluckwork.Domain.Accounts", + "Cluckwork.Domain.Media", + "Cluckwork.Application.Features.Accounts", + ], + Contract = [ + "Cluckwork.Application.Features.Accounts.IFarmModule", + "Cluckwork.Application.Features.Accounts.IFarmFixture", + "Cluckwork.Application.Features.Accounts.FarmSettingsDetails", + "Cluckwork.Application.Features.Accounts.FarmBrandingHashes", + "Cluckwork.Application.Features.Accounts.FarmLogoMetadata", + "Cluckwork.Application.Features.Accounts.FarmLogoContent", + "Cluckwork.Application.Features.Accounts.UpdateFarmSettings.UpdateFarmSettingsCommand", + "Cluckwork.Application.Features.Accounts.IFarmDirectory", + "Cluckwork.Application.Features.Accounts.FarmTimeZone", + "Cluckwork.Application.Features.Accounts.FarmListing", + ], + Implementations = [ + "Cluckwork.Infrastructure.Repositories.AccountRepository", + "Cluckwork.Infrastructure.Repositories.FarmFixture", + "Cluckwork.Infrastructure.Repositories.FarmLogoRepository", + ], + Seam = [ + "Cluckwork.Application.Features.Accounts.IAccountRepository", + "Cluckwork.Domain.Accounts.Account", + "Cluckwork.Domain.Accounts.UserRoleAssignment", + ])] +[assembly: ModuleOwner("FlockManagement", "module", + Namespaces = [ + "Cluckwork.Domain.Flocks", + "Cluckwork.Application.Features.Flocks", + ], + Contract = [ + "Cluckwork.Application.Features.Flocks.IFlockModule", + "Cluckwork.Application.Features.Flocks.IFlockFixture", + "Cluckwork.Application.Features.Flocks.FlockFixtureCounts", + "Cluckwork.Application.Features.Flocks.IFlockLookup", + "Cluckwork.Application.Features.Flocks.IMortalityLedger", + "Cluckwork.Application.Features.Flocks.FlockDetails", + "Cluckwork.Application.Features.Flocks.FlockReference", + "Cluckwork.Application.Features.Flocks.FlockNameResolution", + "Cluckwork.Application.Features.Flocks.BirdMovementDetails", + "Cluckwork.Application.Features.Flocks.FlockEligibility", + "Cluckwork.Application.Features.Flocks.CreateFlock.CreateFlockCommand", + "Cluckwork.Application.Features.Flocks.UpdateFlock.UpdateFlockCommand", + "Cluckwork.Application.Features.Flocks.RecordBirdMovement.RecordBirdMovementCommand", + "Cluckwork.Domain.Flocks.FlockStatus", + "Cluckwork.Domain.Flocks.BirdMovementType", + ], + Implementations = [ + "Cluckwork.Infrastructure.Repositories.BirdMovementRepository", + "Cluckwork.Infrastructure.Repositories.FlockFixture", + "Cluckwork.Infrastructure.Repositories.FlockRepository", + ])] +[assembly: ModuleOwner("EggOperations", "module", + Namespaces = [ + "Cluckwork.Domain.Eggs", + "Cluckwork.Application.Features.DailyEntries", + "Cluckwork.Application.Features.EggGrades", + "Cluckwork.Application.Features.EggLots", + "Cluckwork.Application.Features.Eggs", + ], + Contract = [ + "Cluckwork.Application.Features.Eggs.IEggOperationsModule", + "Cluckwork.Application.Features.Eggs.IEggOperationsFixture", + "Cluckwork.Application.Features.Eggs.EggOperationsFixtureCounts", + "Cluckwork.Application.Features.EggGrades.IEggGradeLookup", + "Cluckwork.Application.Features.EggLots.IEggStock", + "Cluckwork.Application.Features.EggLots.IEggStockReservation", + "Cluckwork.Application.Features.EggLots.SaleDemandLine", + "Cluckwork.Application.Features.EggLots.PlannedEggLotDraw", + "Cluckwork.Application.Features.EggLots.SaleAllocationPlan", + "Cluckwork.Application.Features.DailyEntries.IDailyEntryLookup", + "Cluckwork.Application.Features.Eggs.DailyEntryDetails", + "Cluckwork.Application.Features.Eggs.EggGradeDetails", + "Cluckwork.Application.Features.Eggs.EggLotDetails", + "Cluckwork.Application.Features.Eggs.EggLotMovementDetails", + "Cluckwork.Application.Features.EggLots.StockByGrade", + "Cluckwork.Application.Features.DailyEntries.LockDueDailyEntries.DailyEntryLockPass", + "Cluckwork.Application.Features.DailyEntries.LockDueDailyEntries.LockedDailyEntry", + "Cluckwork.Application.Features.DailyEntries.LockDueDailyEntries.RefusedDailyEntryLock", + "Cluckwork.Application.Features.DailyEntries.RecordDailyEntry.RecordDailyEntryCommand", + "Cluckwork.Application.Features.DailyEntries.RecordDailyEntry.GradeQuantityDto", + "Cluckwork.Application.Features.DailyEntries.SubmitDailyEntry.SubmitDailyEntryResponse", + "Cluckwork.Application.Features.DailyEntries.AdjustDailyEntry.AdjustDailyEntryCommand", + "Cluckwork.Application.Features.DailyEntries.AdjustDailyEntry.AdjustDailyEntryResponse", + "Cluckwork.Application.Features.DailyEntries.VoidDailyEntry.VoidDailyEntryCommand", + "Cluckwork.Application.Features.DailyEntries.VoidDailyEntry.VoidDailyEntryResponse", + "Cluckwork.Application.Features.EggGrades.CreateEggGrade.CreateEggGradeCommand", + "Cluckwork.Application.Features.EggGrades.UpdateEggGrade.UpdateEggGradeCommand", + "Cluckwork.Application.Features.EggLots.RecordEggLotMovement.RecordEggLotMovementCommand", + "Cluckwork.Application.Features.EggLots.RecordEggLotMovement.RecordEggLotMovementResult", + "Cluckwork.Domain.Eggs.DailyEntryStatus", + "Cluckwork.Domain.Eggs.DailyEntryKind", + "Cluckwork.Domain.Eggs.EggGradeType", + "Cluckwork.Domain.Eggs.EggMovementType", + ], + Implementations = [ + "Cluckwork.Infrastructure.Repositories.EggOperationsFixture", + "Cluckwork.Infrastructure.Repositories.DailyEntryRepository", + "Cluckwork.Infrastructure.Repositories.EggGradeRepository", + "Cluckwork.Infrastructure.Repositories.EggInventoryMovementRepository", + "Cluckwork.Infrastructure.Repositories.EggLotRepository", + ])] +[assembly: ModuleOwner("Commerce", "module", + Namespaces = [ + "Cluckwork.Domain.Catalog", + "Cluckwork.Domain.Sales", + "Cluckwork.Application.Features.Catalog", + "Cluckwork.Application.Features.Customers", + "Cluckwork.Application.Features.Sales", + ], + Contract = [ + "Cluckwork.Application.Features.Sales.ICommerceModule", + "Cluckwork.Application.Features.Sales.ICommerceFixture", + "Cluckwork.Application.Features.Sales.CommerceFixtureCounts", + "Cluckwork.Application.Features.Catalog.IEggUnitConversionLookup", + "Cluckwork.Application.Features.Sales.ProductDetails", + "Cluckwork.Application.Features.Catalog.EggUnitConversionDetails", + "Cluckwork.Application.Features.Sales.CustomerDetails", + "Cluckwork.Application.Features.Customers.CustomerReference", + "Cluckwork.Application.Features.Sales.SalesOrderDetails", + "Cluckwork.Application.Features.Sales.SalesOrderItemDetails", + "Cluckwork.Application.Features.Sales.SalesOrderListItem", + "Cluckwork.Application.Features.Sales.SalesOrderListFilter", + "Cluckwork.Application.Features.Sales.SettlementScope", + "Cluckwork.Application.Features.Sales.PaymentDetails", + "Cluckwork.Application.Features.Sales.CustomerBalance", + "Cluckwork.Application.Features.Catalog.CreateProduct.CreateProductCommand", + "Cluckwork.Application.Features.Catalog.UpdateProduct.UpdateProductCommand", + "Cluckwork.Application.Features.Catalog.UpdateEggUnitConversion.UpdateEggUnitConversionCommand", + "Cluckwork.Application.Features.Customers.CreateCustomer.CreateCustomerCommand", + "Cluckwork.Application.Features.Customers.UpdateCustomer.UpdateCustomerCommand", + "Cluckwork.Application.Features.Sales.CreateSalesOrder.CreateSalesOrderCommand", + "Cluckwork.Application.Features.Sales.AddOrderItem.AddOrderItemCommand", + "Cluckwork.Application.Features.Sales.UpdateOrderItem.UpdateOrderItemCommand", + "Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleCommand", + "Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleResponse", + "Cluckwork.Application.Features.Sales.VoidSale.VoidSaleCommand", + "Cluckwork.Application.Features.Sales.VoidSale.VoidSaleResponse", + "Cluckwork.Application.Features.Sales.RecordPayment.RecordPaymentCommand", + "Cluckwork.Application.Features.Sales.VoidPayment.VoidPaymentCommand", + "Cluckwork.Domain.Catalog.ProductType", + "Cluckwork.Domain.Catalog.ProductUnit", + "Cluckwork.Domain.Catalog.EggUnit", + "Cluckwork.Domain.Sales.SalesOrderStatus", + "Cluckwork.Domain.Sales.DiscountReasonCode", + "Cluckwork.Domain.Sales.ListPriceBasis", + "Cluckwork.Domain.Sales.PaymentMethod", + ], + Implementations = [ + "Cluckwork.Infrastructure.Repositories.CustomerRepository", + "Cluckwork.Infrastructure.Repositories.CommerceFixture", + "Cluckwork.Infrastructure.Repositories.EggUnitConversionRepository", + "Cluckwork.Infrastructure.Repositories.PaymentRepository", + "Cluckwork.Infrastructure.Repositories.ProductRepository", + "Cluckwork.Infrastructure.Repositories.SalesOrderAllocationRepository", + "Cluckwork.Infrastructure.Repositories.SalesOrderRepository", + ])] +[assembly: ModuleOwner("GeneralInventory", "module", + Namespaces = [ + "Cluckwork.Domain.Inventory", + "Cluckwork.Application.Features.Inventory", + ], + Contract = [ + "Cluckwork.Application.Features.Inventory.IInventoryModule", + "Cluckwork.Application.Features.Inventory.IInventoryFixture", + "Cluckwork.Application.Features.Inventory.InventoryFixtureCounts", + "Cluckwork.Application.Features.Inventory.InventoryItemDetails", + "Cluckwork.Application.Features.Inventory.InventoryLotDetails", + "Cluckwork.Application.Features.Inventory.InventoryMovementDetails", + "Cluckwork.Application.Features.Inventory.FeedUsageDetails", + "Cluckwork.Application.Features.Inventory.WaterUsageDetails", + "Cluckwork.Application.Features.Inventory.CreateInventoryItem.CreateInventoryItemCommand", + "Cluckwork.Application.Features.Inventory.UpdateInventoryItem.UpdateInventoryItemCommand", + "Cluckwork.Application.Features.Inventory.RecordPurchase.RecordPurchaseCommand", + "Cluckwork.Application.Features.Inventory.RecordAdjustment.RecordAdjustmentCommand", + "Cluckwork.Application.Features.Inventory.RecordFeedUsage.RecordFeedUsageCommand", + "Cluckwork.Application.Features.Inventory.RecordFeedUsage.RecordFeedUsageResponse", + "Cluckwork.Application.Features.Inventory.RecordWaterUsage.RecordWaterUsageCommand", + "Cluckwork.Application.Features.Inventory.UpdateWaterUsage.UpdateWaterUsageCommand", + ], + Implementations = [ + "Cluckwork.Infrastructure.Repositories.FeedUsageRepository", + "Cluckwork.Infrastructure.Repositories.InventoryFixture", + "Cluckwork.Infrastructure.Repositories.InventoryItemRepository", + "Cluckwork.Infrastructure.Repositories.InventoryLotRepository", + "Cluckwork.Infrastructure.Repositories.InventoryMovementRepository", + "Cluckwork.Infrastructure.Repositories.WaterUsageRepository", + ])] +[assembly: ModuleOwner("Finance", "module", + Namespaces = [ + "Cluckwork.Domain.Expenses", + "Cluckwork.Application.Features.Expenses", + ], + Contract = [ + "Cluckwork.Application.Features.Expenses.IFinanceModule", + "Cluckwork.Application.Features.Expenses.IFinanceFixture", + "Cluckwork.Application.Features.Expenses.FinanceFixtureCounts", + "Cluckwork.Application.Features.Expenses.ExpenseCategoryDetails", + "Cluckwork.Application.Features.Expenses.ExpenseDetails", + "Cluckwork.Application.Features.Expenses.ExpenseListPage", + "Cluckwork.Application.Features.Expenses.CreateExpenseCategory.CreateExpenseCategoryCommand", + "Cluckwork.Application.Features.Expenses.UpdateExpenseCategory.UpdateExpenseCategoryCommand", + "Cluckwork.Application.Features.Expenses.CreateExpense.CreateExpenseCommand", + "Cluckwork.Application.Features.Expenses.AdjustExpense.AdjustExpenseCommand", + ], + Implementations = [ + "Cluckwork.Infrastructure.Repositories.ExpenseCategoryRepository", + "Cluckwork.Infrastructure.Repositories.ExpenseRepository", + "Cluckwork.Infrastructure.Repositories.FinanceFixture", + ])] +[assembly: ModuleOwner("Insights", "module", + Namespaces = [ + "Cluckwork.Application.Features.Audit", + "Cluckwork.Application.Features.Reports", + "Cluckwork.Application.Features.Export", + "Cluckwork.Application.Features.Insights", + "Cluckwork.Infrastructure.Insights", + ], + Contract = [ + "Cluckwork.Application.Features.Insights.IInsightsModule", + "Cluckwork.Application.Features.Insights.AuditEventRead", + "Cluckwork.Application.Features.Audit.EntityProvenance", + "Cluckwork.Application.Features.Export.ExportDataset", + "Cluckwork.Application.Features.Reports.ProductionReport", + "Cluckwork.Application.Features.Reports.ProductionDay", + "Cluckwork.Application.Features.Reports.GradeTotal", + "Cluckwork.Application.Features.Reports.SalesSummary", + "Cluckwork.Application.Features.Reports.ExpenseSummary", + "Cluckwork.Application.Features.Reports.ExpenseCategoryTotal", + "Cluckwork.Application.Features.Reports.ProfitReport", + ])] +[assembly: ModuleOwner("Platform", "platform", + Namespaces = [ + "Cluckwork.Domain.Common", + "Cluckwork.Domain.Auditing", + "Cluckwork.Application.Common", + "Cluckwork.Infrastructure", + "Cluckwork.Api", + "Cluckwork.AppHost", + ], + ExactNamespaces = [ + "Cluckwork.Domain", + "Cluckwork.Application", + ])] diff --git a/tests/Cluckwork.Application.Tests/Architecture/ZzMapParity.cs b/tests/Cluckwork.Application.Tests/Architecture/ZzMapParity.cs new file mode 100644 index 000000000..becb62338 --- /dev/null +++ b/tests/Cluckwork.Application.Tests/Architecture/ZzMapParity.cs @@ -0,0 +1,23 @@ +using System.Reflection; +using System.Text.Json; +using Cluckwork.Domain.Common.Architecture; + +namespace Cluckwork.Application.Tests.Architecture; + +public sealed class ZzMapParity +{ + [Fact] + public void AttributesEqualRows() + { + var domain = typeof(ModuleOwnerAttribute).Assembly; + var owners = domain.GetCustomAttributes().Select(o => + new OwnerDefinition(o.Name, o.Kind, o.Namespaces, o.ExactNamespaces) + { Contract = o.Contract, Implementations = o.Implementations, Seam = o.Seam, Types = o.Types }).ToArray(); + var edges = domain.GetCustomAttributes().Select(e => + new EdgeCell(e.From, e.To, e.Kind, e.Reason, e.Symbols)).ToArray(); + Assert.Equal(JsonSerializer.Serialize(RealModuleLedger.Owners), JsonSerializer.Serialize(owners)); + Assert.Equal(JsonSerializer.Serialize(RealModuleLedger.Edges), JsonSerializer.Serialize(edges)); + Assert.Equal(9, owners.Length); + Assert.Equal(RealModuleLedger.Edges.Length, edges.Length); + } +} From 64f44085614010394f707fbfbd1fe3ea3920ab97 Mon Sep 17 00:00:00 2001 From: mforce Date: Mon, 5 Oct 2026 14:51:49 +0000 Subject: [PATCH 2/6] test(arch): read the module owners and edges from Domain's attributes RealModuleLedger.Owners and Edges now reflect src/Cluckwork.Domain/Common/ Architecture, so the rows exist once. The undeclared-edge fix prints the attribute row to paste there. Refs #859 --- .../Features/Accounts/IFarmModule.cs | 2 +- .../Features/Eggs/IEggOperationsModule.cs | 2 +- .../Features/Expenses/IFinanceModule.cs | 2 +- .../Features/Flocks/IFlockModule.cs | 2 +- .../Features/Inventory/IInventoryModule.cs | 2 +- .../Features/Sales/ICommerceModule.cs | 2 +- .../Architecture/ModuleLedgerScanner.cs | 6 +- .../Architecture/ModuleLedgerTests.cs | 4 +- .../Architecture/RealModuleLedger.Edges.cs | 159 --------- .../Architecture/RealModuleLedger.Owners.cs | 302 ------------------ .../Architecture/RealModuleLedger.cs | 19 +- .../Architecture/ZzMapParity.cs | 23 -- 12 files changed, 29 insertions(+), 496 deletions(-) delete mode 100644 tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.Edges.cs delete mode 100644 tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.Owners.cs delete mode 100644 tests/Cluckwork.Application.Tests/Architecture/ZzMapParity.cs diff --git a/src/Cluckwork.Application/Features/Accounts/IFarmModule.cs b/src/Cluckwork.Application/Features/Accounts/IFarmModule.cs index ce1b2a7b1..83c9f177c 100644 --- a/src/Cluckwork.Application/Features/Accounts/IFarmModule.cs +++ b/src/Cluckwork.Application/Features/Accounts/IFarmModule.cs @@ -8,7 +8,7 @@ namespace Cluckwork.Application.Features.Accounts; // #851: the Farm module's contract. Adapters reach Farm only through the types -// RealModuleLedger.Owners lists in the Farm row's Contract. Every member reads or +// ModuleOwners.cs lists in the Farm row's Contract. Every member reads or // writes the current tenant's farm, so it runs only after TenantContext is // resolved and never establishes identity: sign-in resolves its farm code // through IIdentityProvider instead. diff --git a/src/Cluckwork.Application/Features/Eggs/IEggOperationsModule.cs b/src/Cluckwork.Application/Features/Eggs/IEggOperationsModule.cs index 56e5789df..38a643abf 100644 --- a/src/Cluckwork.Application/Features/Eggs/IEggOperationsModule.cs +++ b/src/Cluckwork.Application/Features/Eggs/IEggOperationsModule.cs @@ -12,7 +12,7 @@ namespace Cluckwork.Application.Features.Eggs; // #853: the Egg Operations contract for adapters. Adapters reach Egg Operations -// only through the types RealModuleLedger.Owners lists in +// only through the types ModuleOwners.cs lists in // the EggOperations row's Contract; peer modules use the narrower IEggGradeLookup // and IDailyEntryLookup ports. public interface IEggOperationsModule diff --git a/src/Cluckwork.Application/Features/Expenses/IFinanceModule.cs b/src/Cluckwork.Application/Features/Expenses/IFinanceModule.cs index 78de09c0f..07af9a848 100644 --- a/src/Cluckwork.Application/Features/Expenses/IFinanceModule.cs +++ b/src/Cluckwork.Application/Features/Expenses/IFinanceModule.cs @@ -8,7 +8,7 @@ namespace Cluckwork.Application.Features.Expenses; // #849: the Finance module's contract. Adapters reach Finance only through the -// types RealModuleLedger.Owners lists in the Finance row's Contract. +// types ModuleOwners.cs lists in the Finance row's Contract. public interface IFinanceModule { // The EntityType Finance writes on an expense's audit rows; provenance reads key by it. diff --git a/src/Cluckwork.Application/Features/Flocks/IFlockModule.cs b/src/Cluckwork.Application/Features/Flocks/IFlockModule.cs index 0e1bcce81..d88ab5231 100644 --- a/src/Cluckwork.Application/Features/Flocks/IFlockModule.cs +++ b/src/Cluckwork.Application/Features/Flocks/IFlockModule.cs @@ -7,7 +7,7 @@ namespace Cluckwork.Application.Features.Flocks; // #852: the Flock Management contract for adapters. Adapters reach Flock -// Management only through the types RealModuleLedger.Owners lists in +// Management only through the types ModuleOwners.cs lists in // the FlockManagement row's Contract; peer modules use the narrower IFlockLookup // and IMortalityLedger ports. public interface IFlockModule diff --git a/src/Cluckwork.Application/Features/Inventory/IInventoryModule.cs b/src/Cluckwork.Application/Features/Inventory/IInventoryModule.cs index 3e23fc0af..987f146a7 100644 --- a/src/Cluckwork.Application/Features/Inventory/IInventoryModule.cs +++ b/src/Cluckwork.Application/Features/Inventory/IInventoryModule.cs @@ -11,7 +11,7 @@ namespace Cluckwork.Application.Features.Inventory; // #855: the General Inventory contract. Adapters reach General Inventory only -// through the types RealModuleLedger.Owners lists in +// through the types ModuleOwners.cs lists in // the GeneralInventory row's Contract. public interface IInventoryModule { diff --git a/src/Cluckwork.Application/Features/Sales/ICommerceModule.cs b/src/Cluckwork.Application/Features/Sales/ICommerceModule.cs index 84809c4a1..7eb61d73f 100644 --- a/src/Cluckwork.Application/Features/Sales/ICommerceModule.cs +++ b/src/Cluckwork.Application/Features/Sales/ICommerceModule.cs @@ -18,7 +18,7 @@ namespace Cluckwork.Application.Features.Sales; // #854: the Commerce contract for adapters. Adapters reach Commerce only -// through the types RealModuleLedger.Owners lists in the Commerce row's Contract; +// through the types ModuleOwners.cs lists in the Commerce row's Contract; // peer modules use the narrower IEggUnitConversionLookup port. public interface ICommerceModule { diff --git a/tests/Cluckwork.Application.Tests/Architecture/ModuleLedgerScanner.cs b/tests/Cluckwork.Application.Tests/Architecture/ModuleLedgerScanner.cs index 3cc98d486..a43acd256 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/ModuleLedgerScanner.cs +++ b/tests/Cluckwork.Application.Tests/Architecture/ModuleLedgerScanner.cs @@ -256,7 +256,7 @@ public static IReadOnlyList Evaluate(ModuleLedgerReport report) failures.Add( $"undeclared cross-owner edge {edge.From} -> {edge.To} from {edge.Symbol} " + $"(references {string.Join(", ", edge.ReferencedNamespaces)}) at {edge.File}:{edge.Line} — " + - $"add this row to RealModuleLedger.Edges, with a reason naming the port or type it calls:\n{RenderEdges([edge])}"); + $"add this row to src/Cluckwork.Domain/Common/Architecture/ModuleEdges.cs, with a reason naming the port or type it calls:\n{RenderEdges([edge])}"); } foreach (var row in report.StaleSymbols) @@ -609,8 +609,8 @@ private static string RenderEdges(IReadOnlyList edges) => string .GroupBy(e => (e.From, e.To)) .OrderBy(g => g.Key.From, StringComparer.Ordinal) .ThenBy(g => g.Key.To, StringComparer.Ordinal) - .Select(cell => $"new({RealModuleLedger.Quote(cell.Key.From)}, {RealModuleLedger.Quote(cell.Key.To)}, \"R\", \"\", " + - $"{RealModuleLedger.List(cell.Select(e => e.Symbol).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal))}),")); + .Select(cell => $"[assembly: ModuleEdge({RealModuleLedger.Quote(cell.Key.From)}, {RealModuleLedger.Quote(cell.Key.To)}, \"R\", \"\", " + + $"{string.Join(", ", cell.Select(e => e.Symbol).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).Select(RealModuleLedger.Quote))})]")); private static bool IsTypeDeclaration(SyntaxNode node) => node is BaseTypeDeclarationSyntax or DelegateDeclarationSyntax; diff --git a/tests/Cluckwork.Application.Tests/Architecture/ModuleLedgerTests.cs b/tests/Cluckwork.Application.Tests/Architecture/ModuleLedgerTests.cs index 587068781..e7fb1cf95 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/ModuleLedgerTests.cs +++ b/tests/Cluckwork.Application.Tests/Architecture/ModuleLedgerTests.cs @@ -62,8 +62,8 @@ public class R { public string Go() => B.Name; } Assert.Contains("Red -> Blue", failure!); Assert.Contains("Cluckwork.Temp.Red.R", failure!); Assert.Contains("Cluckwork.Temp.Blue", failure!); - Assert.Contains("add this row to RealModuleLedger.Edges", failure!); - Assert.EndsWith("\nnew(\"Red\", \"Blue\", \"R\", \"\", [\"Cluckwork.Temp.Red.R\"]),", failure!); + Assert.Contains("add this row to src/Cluckwork.Domain/Common/Architecture/ModuleEdges.cs", failure!); + Assert.EndsWith("\n[assembly: ModuleEdge(\"Red\", \"Blue\", \"R\", \"\", \"Cluckwork.Temp.Red.R\")]", failure!); } [Fact] diff --git a/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.Edges.cs b/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.Edges.cs deleted file mode 100644 index 0b4cc34c4..000000000 --- a/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.Edges.cs +++ /dev/null @@ -1,159 +0,0 @@ -namespace Cluckwork.Application.Tests.Architecture; - -internal static partial class RealModuleLedger -{ - internal static readonly EdgeCell[] Edges = - [ - new("Access", "Commerce", "W", - "W because AccountProvisioner inserts EggUnitConversion.Defaults for a new farm inside its provisioning transaction; the rest of the cell reads. Identity carries the per-user stepper-unit preference: ApplicationUser stores Domain.Catalog.EggUnit, IIdentityProvider, the Identity port Access claims, takes it in SetStepperUnitAsync, IdentityProvider reads and writes it, SetStepperUnitHandler resolves it through Commerce's IEggUnitConversionLookup port (#854) and rejects inactive conversions, and SetStepperUnitValidator validates only the enum name. Nothing prevents a stored preference from pointing at a conversion later deactivated through UpdateEggUnitConversionHandler. Design 3.4 shows Access -> Commerce as none; this is live coupling the target design has still to remove.", - [ - "Cluckwork.Application.Common.IIdentityProvider", - "Cluckwork.Application.Features.Users.SetStepperUnit.SetStepperUnitHandler", - "Cluckwork.Application.Features.Users.SetStepperUnit.SetStepperUnitValidator", - "Cluckwork.Infrastructure.Identity.AccountProvisioner", - "Cluckwork.Infrastructure.Identity.ApplicationUser", - "Cluckwork.Infrastructure.Identity.IdentityProvider", - ]), - new("Access", "EggOperations", "W", - "W because AccountProvisioner inserts a new farm's default grades, Domain.Eggs.EggGrade.Defaults(accountId, SeedDefaults.FarmId), inside its provisioning transaction, so farm provisioning writes Egg Operations' table and depends on its grade shape. Design 3.4 shows Access -> Egg Ops as none; this is live coupling the target design has still to remove.", - ["Cluckwork.Infrastructure.Identity.AccountProvisioner"]), - new("Access", "Farm", "W", - "W because AccountProvisioner inserts the new Account inside its provisioning transaction and AccountRenameService calls Account.Rename and saves; the rest of the cell reads. Identity is where a farm is created, renamed, suspended and recovered. AccountProvisioner, AccountRenameService, AccountSuspensionService, AdminRecoveryService, FirstRunAdminService and FirstRunStatusService load Domain.Accounts.Account or inject Farm's IAccountRepository, and IdentityProvider takes that repository by fully qualified name for the account-scoped login (#532). CredentialEpochVerifier reads Account.IsActive in the same fresh per-request query as the user's credential epoch, so a suspended farm's credentials stop working at once (#364, #579, #857). The user validators read Domain.Accounts.Roles for the assignable role set, and IUserRoleAssignmentRepository returns Domain.Accounts.UserRoleAssignment rows, which AccessModule reads to list a user's flock assignments. IAccessLookup returns Domain.Accounts.EffectiveAccountRole, and AccessLookup resolves it with Roles.ResolveEffective in its private effective-role routine (#612, #857). Design 3.4 row Access -> Farm = R. AccessFixture (#858) creates the simulation fixture's UserRoleAssignment through the scoped context, and AccessSeedLookup checks the fixture's Owner role by Roles.Owner.", - [ - "Cluckwork.Application.Features.Users.AssignFlock.AssignFlockHandler", - "Cluckwork.Application.Features.Users.AssignFlock.UnassignFlockHandler", - "Cluckwork.Application.Features.Users.ChangeUserRole.ChangeUserRoleValidator", - "Cluckwork.Application.Features.Users.CreateUser.CreateUserValidator", - "Cluckwork.Application.Features.Users.IAccessLookup", - "Cluckwork.Application.Features.Users.IUserRoleAssignmentRepository", - "Cluckwork.Infrastructure.Identity.AccessFixture", - "Cluckwork.Infrastructure.Identity.AccessLookup", - "Cluckwork.Infrastructure.Identity.AccessModule", - "Cluckwork.Infrastructure.Identity.AccessSeedLookup", - "Cluckwork.Infrastructure.Identity.AccountProvisioner", - "Cluckwork.Infrastructure.Identity.AccountRenameService", - "Cluckwork.Infrastructure.Identity.AccountSuspensionService", - "Cluckwork.Infrastructure.Identity.AdminRecoveryService", - "Cluckwork.Infrastructure.Identity.CredentialEpochVerifier", - "Cluckwork.Infrastructure.Identity.FirstRunAdminService", - "Cluckwork.Infrastructure.Identity.FirstRunStatusService", - "Cluckwork.Infrastructure.Identity.IdentityProvider", - ]), - new("Access", "FlockManagement", "R", - "AssignFlockHandler injects Flock Management's IFlockLookup port to prove the flock exists before narrowing a worker to it and to name it in the audit row (#103, spec 5.2/5.3). Design 3.4 row Access -> Flock = R. AccessFixture (#858) reads the same port for the simulation fixture's assignment and audit row. AccessModule names a user's flock assignments through IFlockLookup.GetDisplayNamesAsync, one bounded read whose filters decide which names the caller sees (#613, #859).", - [ - "Cluckwork.Application.Features.Users.AssignFlock.AssignFlockHandler", - "Cluckwork.Infrastructure.Identity.AccessFixture", - "Cluckwork.Infrastructure.Identity.AccessModule", - ]), - new("Commerce", "Access", "R", - "ConfirmSaleHandler reads through Access's IAccessLookup port (GetEffectiveRoleAsync and GetAssignedFlocksAsync, #857) inside the confirm transaction, so a plain Worker's committed flock assignments and the discount ceiling bound to their effective role are read at the moment of confirmation rather than from a request-start snapshot (#727). Design 3.4 shows Commerce -> Access as none; this is live coupling the target design has still to remove.", - ["Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleHandler"]), - new("Commerce", "EggOperations", "W", - "FIFO egg stock. ConfirmSaleHandler and VoidSaleHandler lock, draw from and return egg lots through Egg Operations' IEggStock port, which adds the lot changes and Sale or Void movements to the confirm and void transactions and never saves (#854); Commerce sees plans and lot ids, never Domain.Eggs.EggLot. Products, order lines and confirm refusals read grades through IEggGradeLookup in CreateProductHandler, UpdateProductHandler, AddOrderItemHandler and ConfirmSaleHandler. Design 3.4 row Commerce -> Egg Ops = W.", - [ - "Cluckwork.Application.Features.Catalog.CreateProduct.CreateProductHandler", - "Cluckwork.Application.Features.Catalog.UpdateProduct.UpdateProductHandler", - "Cluckwork.Application.Features.Sales.AddOrderItem.AddOrderItemHandler", - "Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleHandler", - "Cluckwork.Application.Features.Sales.VoidSale.VoidSaleHandler", - ]), - new("Commerce", "Farm", "R", - "Orders and products snapshot the farm's currency through Farm's IAccountRepository (CreateSalesOrderHandler, CreateProductHandler, UpdateProductHandler), with the sales-order path holding the FOR SHARE lock on the account row (#162) so an order cannot land in a denomination the farm is mid-flight out of; ConfirmSaleHandler also reads the farm's discount ceiling and worker sale-allocation policy off Account. Design 3.4 row Commerce -> Farm = R.", - [ - "Cluckwork.Application.Features.Catalog.CreateProduct.CreateProductHandler", - "Cluckwork.Application.Features.Catalog.UpdateProduct.UpdateProductHandler", - "Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleHandler", - "Cluckwork.Application.Features.Sales.CreateSalesOrder.CreateSalesOrderHandler", - ]), - new("EggOperations", "Farm", "R", - "CreateEggGradeHandler attaches a new grade to Domain.Accounts.SeedDefaults.FarmId, the single-farm stand-in for the farm the grade belongs to; EggGradeFloorPolicy resolves the caller's effective role through Domain.Accounts.Roles to decide whether a grade's low-stock floor may move (#911, Owner-only per #729). Design 3.4 row Egg Ops -> Farm = R.", - [ - "Cluckwork.Application.Features.EggGrades.CreateEggGrade.CreateEggGradeHandler", - "Cluckwork.Application.Features.EggGrades.EggGradeFloorPolicy", - ]), - new("EggOperations", "FlockManagement", "W", - "Daily entry is the mortality writer: SubmitDailyEntryHandler, AdjustDailyEntryHandler and VoidDailyEntryHandler append bird-movement rows through Flock Management's IMortalityLedger port, which adds the row to the caller's unit of work and never saves, so it commits with the entry itself (#54, #69, #852). Every daily-entry handler reads the flock through the IFlockLookup port to check CanRecordProductionOn for the entry's date. Design 3.4 row Egg Ops -> Flock = W.", - [ - "Cluckwork.Application.Features.DailyEntries.AdjustDailyEntry.AdjustDailyEntryHandler", - "Cluckwork.Application.Features.DailyEntries.RecordDailyEntry.RecordDailyEntryHandler", - "Cluckwork.Application.Features.DailyEntries.SubmitDailyEntry.SubmitDailyEntryHandler", - "Cluckwork.Application.Features.DailyEntries.VoidDailyEntry.VoidDailyEntryHandler", - ]), - new("Farm", "Commerce", "R", - "Farm settings own three Commerce-shaped values. Domain.Accounts.Account holds Domain.Catalog.EggUnit as the farm's default stepper unit and exposes Domain.Sales.DiscountCeiling, while UpdateFarmSettingsHandler resolves the stepper unit through Commerce's IEggUnitConversionLookup port (#854) and UpdateFarmSettingsValidator parses the ceiling with DiscountCeiling.TryParsePercent (#727). The Farm contract's FarmSettingsDetails, declared beside IFarmModule, carries the same Domain.Catalog.EggUnit stepper unit out to its callers (#851), and FarmModule fills its discount ceiling from DiscountCeiling.Percent. Design 3.4 shows Farm -> Commerce as none; this is live coupling the target design has still to remove.", - [ - "Cluckwork.Application.Features.Accounts.FarmModule", - "Cluckwork.Application.Features.Accounts.FarmSettingsDetails", - "Cluckwork.Application.Features.Accounts.UpdateFarmSettings.UpdateFarmSettingsHandler", - "Cluckwork.Application.Features.Accounts.UpdateFarmSettings.UpdateFarmSettingsValidator", - "Cluckwork.Domain.Accounts.Account", - ]), - new("Finance", "Farm", "R", - "CreateExpenseHandler injects Farm's IAccountRepository for the lock-aware currency snapshot an expense binds to (#162, FOR SHARE on the account row), and it and CreateExpenseCategoryHandler attach their rows to Domain.Accounts.SeedDefaults.FarmId, whose active categories FinanceModule lists. Design 3.4 classes this cell W, but the code reads Farm state under a shared row lock and mutates nothing there, the same shape 3.4 classes R on Commerce -> Farm and Inventory -> Farm; the ledger records what the code does and notes the disagreement here so #848 regenerates the matrix from this reading.", - [ - "Cluckwork.Application.Features.Expenses.CreateExpense.CreateExpenseHandler", - "Cluckwork.Application.Features.Expenses.CreateExpenseCategory.CreateExpenseCategoryHandler", - "Cluckwork.Application.Features.Expenses.FinanceModule", - ]), - new("Finance", "FlockManagement", "R", - "CreateExpenseHandler and AdjustExpenseHandler inject Flock Management's IFlockLookup port to validate the optional flock an expense is attributed to. Design 3.4 row Finance -> Flock = R.", - [ - "Cluckwork.Application.Features.Expenses.AdjustExpense.AdjustExpenseHandler", - "Cluckwork.Application.Features.Expenses.CreateExpense.CreateExpenseHandler", - ]), - new("FlockManagement", "Farm", "R", - "CreateFlockHandler places a new flock on Domain.Accounts.SeedDefaults.FarmId and SeedDefaults.HouseId, the single-farm stand-ins. Design 3.4 row Flock -> Farm = R.", - ["Cluckwork.Application.Features.Flocks.CreateFlock.CreateFlockHandler"]), - new("GeneralInventory", "EggOperations", "R", - "RecordFeedUsageHandler and RecordWaterUsageHandler inject Egg Operations' IDailyEntryLookup port by fully qualified name and call FindIdForFlockScopedWriteAsync (#853), so a feed or water record carries the day's daily-entry provenance. Design 3.4 shows Inventory -> Egg Ops as none; this is live coupling the target design has still to remove.", - [ - "Cluckwork.Application.Features.Inventory.RecordFeedUsage.RecordFeedUsageHandler", - "Cluckwork.Application.Features.Inventory.RecordWaterUsage.RecordWaterUsageHandler", - ]), - new("GeneralInventory", "Farm", "R", - "CreateInventoryItemHandler, RecordPurchaseHandler and UpdateInventoryItemHandler inject Farm's IAccountRepository for the farm currency a priced item or a purchase snapshots, under the same FOR SHARE account-row lock as the sales path (#162), and CreateInventoryItemHandler attaches the item to Domain.Accounts.SeedDefaults.FarmId. Design 3.4 row Inventory -> Farm = R.", - [ - "Cluckwork.Application.Features.Inventory.CreateInventoryItem.CreateInventoryItemHandler", - "Cluckwork.Application.Features.Inventory.RecordPurchase.RecordPurchaseHandler", - "Cluckwork.Application.Features.Inventory.UpdateInventoryItem.UpdateInventoryItemHandler", - ]), - new("GeneralInventory", "FlockManagement", "R", - "RecordFeedUsageHandler, RecordWaterUsageHandler and UpdateWaterUsageHandler inject Flock Management's IFlockLookup port to prove the flock being fed or watered exists and is eligible for the usage date. Design 3.4 row Inventory -> Flock = R.", - [ - "Cluckwork.Application.Features.Inventory.RecordFeedUsage.RecordFeedUsageHandler", - "Cluckwork.Application.Features.Inventory.RecordWaterUsage.RecordWaterUsageHandler", - "Cluckwork.Application.Features.Inventory.UpdateWaterUsage.UpdateWaterUsageHandler", - ]), - new("Insights", "Commerce", "R", - "ReportQueries reads confirmed SalesOrders and Payments for sales and profit totals; ExportQueries streams Customers, SalesOrders, SalesOrderItems, SalesOrderAllocations and Payments. Reads compose in C# and never mutate Commerce rows.", - [ - "Cluckwork.Infrastructure.Insights.ExportQueries", - "Cluckwork.Infrastructure.Insights.ReportQueries", - ]), - new("Insights", "EggOperations", "R", - "ReportQueries reads DailyEntries, DailyEntryGrades and EggGrades for production and grading totals; ExportQueries streams those records, EggLots and EggInventoryMovements with their existing filters and ordering.", - [ - "Cluckwork.Infrastructure.Insights.ExportQueries", - "Cluckwork.Infrastructure.Insights.ReportQueries", - ]), - new("Insights", "Farm", "R", - "ReportQueries.AccountCurrencyAsync reads the current Account currency for report DTOs. The account query remains tenant-filtered and AsNoTracking.", - ["Cluckwork.Infrastructure.Insights.ReportQueries"]), - new("Insights", "Finance", "R", - "ReportQueries aggregates Expenses for expense and profit totals; ExportQueries streams ExpenseCategories and Expenses as typed CSV rows. Neither query writes Finance data.", - [ - "Cluckwork.Infrastructure.Insights.ExportQueries", - "Cluckwork.Infrastructure.Insights.ReportQueries", - ]), - new("Insights", "FlockManagement", "R", - "ExportQueries streams Flock and BirdMovement records; ReportQueries reads flock lifecycle and bird movements to calculate hen-day exposure. These are read-only owner-table queries composed in C#.", - [ - "Cluckwork.Infrastructure.Insights.ExportQueries", - "Cluckwork.Infrastructure.Insights.ReportQueries", - ]), - new("Insights", "GeneralInventory", "R", - "ExportQueries streams InventoryItems, InventoryLots, InventoryMovements and FeedUsages as typed CSV rows. No inventory mutation or cross-owner SQL join is introduced.", - ["Cluckwork.Infrastructure.Insights.ExportQueries"]), - ]; -} diff --git a/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.Owners.cs b/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.Owners.cs deleted file mode 100644 index 6470c8cb7..000000000 --- a/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.Owners.cs +++ /dev/null @@ -1,302 +0,0 @@ -namespace Cluckwork.Application.Tests.Architecture; - -internal static partial class RealModuleLedger -{ - internal static readonly OwnerDefinition[] Owners = - [ - new("Access", "module", [ - "Cluckwork.Application.Features.Users", - "Cluckwork.Infrastructure.Identity", - ], []) - { - Contract = [ - "Cluckwork.Application.Features.Users.IAccessModule", - "Cluckwork.Application.Features.Users.IAccessLookup", - "Cluckwork.Application.Features.Users.ICredentialEpochVerifier", - "Cluckwork.Application.Features.Users.IRefreshTokenPurge", - "Cluckwork.Application.Features.Users.IAccessOperations", - "Cluckwork.Application.Features.Users.IAccessAccountLifecycle", - "Cluckwork.Application.Features.Users.IAccessSeedLookup", - "Cluckwork.Application.Features.Users.IAccessFixture", - "Cluckwork.Application.Features.Users.CredentialVerdict", - "Cluckwork.Application.Features.Users.UserFlockAssignment", - "Cluckwork.Application.Features.Users.AccessActor", - "Cluckwork.Application.Features.Users.AccessUserSummary", - "Cluckwork.Application.Features.Users.FirstRunAdminOutcome", - "Cluckwork.Application.Features.Users.AdminRecoveryResult", - "Cluckwork.Application.Features.Users.AccountProvisionOutcome", - "Cluckwork.Application.Features.Users.AccountLifecycleOutcome", - "Cluckwork.Application.Features.Users.AccountRenameOutcome", - "Cluckwork.Application.Features.Users.CreateUser.CreateUserCommand", - "Cluckwork.Application.Features.Users.UpdateUser.UpdateUserCommand", - "Cluckwork.Application.Features.Users.SetUserPassword.SetUserPasswordCommand", - "Cluckwork.Application.Features.Users.ChangeUserRole.ChangeUserRoleCommand", - "Cluckwork.Application.Features.Users.ChangeUserEmail.ChangeUserEmailCommand", - "Cluckwork.Application.Features.Users.DisableUser.DisableUserCommand", - "Cluckwork.Application.Features.Users.EnableUser.EnableUserCommand", - "Cluckwork.Application.Features.Users.AssignFlock.AssignFlockCommand", - "Cluckwork.Application.Features.Users.AssignFlock.UnassignFlockCommand", - "Cluckwork.Application.Features.Users.SetLanguage.SetLanguageCommand", - "Cluckwork.Application.Features.Users.SetStepperUnit.SetStepperUnitCommand", - "Cluckwork.Application.Features.Users.ChangeOwnPassword.ChangeOwnPasswordCommand", - ], - Implementations = ["Cluckwork.Infrastructure.Repositories.UserRoleAssignmentRepository"], - Types = [ - "Cluckwork.Application.Common.IIdentityProvider", - "Cluckwork.Application.Common.IStepUpGrantService", - ], - }, - new("Farm", "module", [ - "Cluckwork.Domain.Accounts", - "Cluckwork.Domain.Media", - "Cluckwork.Application.Features.Accounts", - ], []) - { - Contract = [ - "Cluckwork.Application.Features.Accounts.IFarmModule", - "Cluckwork.Application.Features.Accounts.IFarmFixture", - "Cluckwork.Application.Features.Accounts.FarmSettingsDetails", - "Cluckwork.Application.Features.Accounts.FarmBrandingHashes", - "Cluckwork.Application.Features.Accounts.FarmLogoMetadata", - "Cluckwork.Application.Features.Accounts.FarmLogoContent", - "Cluckwork.Application.Features.Accounts.UpdateFarmSettings.UpdateFarmSettingsCommand", - "Cluckwork.Application.Features.Accounts.IFarmDirectory", - "Cluckwork.Application.Features.Accounts.FarmTimeZone", - "Cluckwork.Application.Features.Accounts.FarmListing", - ], - Implementations = [ - "Cluckwork.Infrastructure.Repositories.AccountRepository", - "Cluckwork.Infrastructure.Repositories.FarmFixture", - "Cluckwork.Infrastructure.Repositories.FarmLogoRepository", - ], - Seam = [ - "Cluckwork.Application.Features.Accounts.IAccountRepository", - "Cluckwork.Domain.Accounts.Account", - "Cluckwork.Domain.Accounts.UserRoleAssignment", - ], - }, - new("FlockManagement", "module", [ - "Cluckwork.Domain.Flocks", - "Cluckwork.Application.Features.Flocks", - ], []) - { - Contract = [ - "Cluckwork.Application.Features.Flocks.IFlockModule", - "Cluckwork.Application.Features.Flocks.IFlockFixture", - "Cluckwork.Application.Features.Flocks.FlockFixtureCounts", - "Cluckwork.Application.Features.Flocks.IFlockLookup", - "Cluckwork.Application.Features.Flocks.IMortalityLedger", - "Cluckwork.Application.Features.Flocks.FlockDetails", - "Cluckwork.Application.Features.Flocks.FlockReference", - "Cluckwork.Application.Features.Flocks.FlockNameResolution", - "Cluckwork.Application.Features.Flocks.BirdMovementDetails", - "Cluckwork.Application.Features.Flocks.FlockEligibility", - "Cluckwork.Application.Features.Flocks.CreateFlock.CreateFlockCommand", - "Cluckwork.Application.Features.Flocks.UpdateFlock.UpdateFlockCommand", - "Cluckwork.Application.Features.Flocks.RecordBirdMovement.RecordBirdMovementCommand", - "Cluckwork.Domain.Flocks.FlockStatus", - "Cluckwork.Domain.Flocks.BirdMovementType", - ], - Implementations = [ - "Cluckwork.Infrastructure.Repositories.BirdMovementRepository", - "Cluckwork.Infrastructure.Repositories.FlockFixture", - "Cluckwork.Infrastructure.Repositories.FlockRepository", - ], - }, - new("EggOperations", "module", [ - "Cluckwork.Domain.Eggs", - "Cluckwork.Application.Features.DailyEntries", - "Cluckwork.Application.Features.EggGrades", - "Cluckwork.Application.Features.EggLots", - "Cluckwork.Application.Features.Eggs", - ], []) - { - Contract = [ - "Cluckwork.Application.Features.Eggs.IEggOperationsModule", - "Cluckwork.Application.Features.Eggs.IEggOperationsFixture", - "Cluckwork.Application.Features.Eggs.EggOperationsFixtureCounts", - "Cluckwork.Application.Features.EggGrades.IEggGradeLookup", - "Cluckwork.Application.Features.EggLots.IEggStock", - "Cluckwork.Application.Features.EggLots.IEggStockReservation", - "Cluckwork.Application.Features.EggLots.SaleDemandLine", - "Cluckwork.Application.Features.EggLots.PlannedEggLotDraw", - "Cluckwork.Application.Features.EggLots.SaleAllocationPlan", - "Cluckwork.Application.Features.DailyEntries.IDailyEntryLookup", - "Cluckwork.Application.Features.Eggs.DailyEntryDetails", - "Cluckwork.Application.Features.Eggs.EggGradeDetails", - "Cluckwork.Application.Features.Eggs.EggLotDetails", - "Cluckwork.Application.Features.Eggs.EggLotMovementDetails", - "Cluckwork.Application.Features.EggLots.StockByGrade", - "Cluckwork.Application.Features.DailyEntries.LockDueDailyEntries.DailyEntryLockPass", - "Cluckwork.Application.Features.DailyEntries.LockDueDailyEntries.LockedDailyEntry", - "Cluckwork.Application.Features.DailyEntries.LockDueDailyEntries.RefusedDailyEntryLock", - "Cluckwork.Application.Features.DailyEntries.RecordDailyEntry.RecordDailyEntryCommand", - "Cluckwork.Application.Features.DailyEntries.RecordDailyEntry.GradeQuantityDto", - "Cluckwork.Application.Features.DailyEntries.SubmitDailyEntry.SubmitDailyEntryResponse", - "Cluckwork.Application.Features.DailyEntries.AdjustDailyEntry.AdjustDailyEntryCommand", - "Cluckwork.Application.Features.DailyEntries.AdjustDailyEntry.AdjustDailyEntryResponse", - "Cluckwork.Application.Features.DailyEntries.VoidDailyEntry.VoidDailyEntryCommand", - "Cluckwork.Application.Features.DailyEntries.VoidDailyEntry.VoidDailyEntryResponse", - "Cluckwork.Application.Features.EggGrades.CreateEggGrade.CreateEggGradeCommand", - "Cluckwork.Application.Features.EggGrades.UpdateEggGrade.UpdateEggGradeCommand", - "Cluckwork.Application.Features.EggLots.RecordEggLotMovement.RecordEggLotMovementCommand", - "Cluckwork.Application.Features.EggLots.RecordEggLotMovement.RecordEggLotMovementResult", - "Cluckwork.Domain.Eggs.DailyEntryStatus", - "Cluckwork.Domain.Eggs.DailyEntryKind", - "Cluckwork.Domain.Eggs.EggGradeType", - "Cluckwork.Domain.Eggs.EggMovementType", - ], - Implementations = [ - "Cluckwork.Infrastructure.Repositories.EggOperationsFixture", - "Cluckwork.Infrastructure.Repositories.DailyEntryRepository", - "Cluckwork.Infrastructure.Repositories.EggGradeRepository", - "Cluckwork.Infrastructure.Repositories.EggInventoryMovementRepository", - "Cluckwork.Infrastructure.Repositories.EggLotRepository", - ], - }, - new("Commerce", "module", [ - "Cluckwork.Domain.Catalog", - "Cluckwork.Domain.Sales", - "Cluckwork.Application.Features.Catalog", - "Cluckwork.Application.Features.Customers", - "Cluckwork.Application.Features.Sales", - ], []) - { - Contract = [ - "Cluckwork.Application.Features.Sales.ICommerceModule", - "Cluckwork.Application.Features.Sales.ICommerceFixture", - "Cluckwork.Application.Features.Sales.CommerceFixtureCounts", - "Cluckwork.Application.Features.Catalog.IEggUnitConversionLookup", - "Cluckwork.Application.Features.Sales.ProductDetails", - "Cluckwork.Application.Features.Catalog.EggUnitConversionDetails", - "Cluckwork.Application.Features.Sales.CustomerDetails", - "Cluckwork.Application.Features.Customers.CustomerReference", - "Cluckwork.Application.Features.Sales.SalesOrderDetails", - "Cluckwork.Application.Features.Sales.SalesOrderItemDetails", - "Cluckwork.Application.Features.Sales.SalesOrderListItem", - "Cluckwork.Application.Features.Sales.SalesOrderListFilter", - "Cluckwork.Application.Features.Sales.SettlementScope", - "Cluckwork.Application.Features.Sales.PaymentDetails", - "Cluckwork.Application.Features.Sales.CustomerBalance", - "Cluckwork.Application.Features.Catalog.CreateProduct.CreateProductCommand", - "Cluckwork.Application.Features.Catalog.UpdateProduct.UpdateProductCommand", - "Cluckwork.Application.Features.Catalog.UpdateEggUnitConversion.UpdateEggUnitConversionCommand", - "Cluckwork.Application.Features.Customers.CreateCustomer.CreateCustomerCommand", - "Cluckwork.Application.Features.Customers.UpdateCustomer.UpdateCustomerCommand", - "Cluckwork.Application.Features.Sales.CreateSalesOrder.CreateSalesOrderCommand", - "Cluckwork.Application.Features.Sales.AddOrderItem.AddOrderItemCommand", - "Cluckwork.Application.Features.Sales.UpdateOrderItem.UpdateOrderItemCommand", - "Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleCommand", - "Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleResponse", - "Cluckwork.Application.Features.Sales.VoidSale.VoidSaleCommand", - "Cluckwork.Application.Features.Sales.VoidSale.VoidSaleResponse", - "Cluckwork.Application.Features.Sales.RecordPayment.RecordPaymentCommand", - "Cluckwork.Application.Features.Sales.VoidPayment.VoidPaymentCommand", - "Cluckwork.Domain.Catalog.ProductType", - "Cluckwork.Domain.Catalog.ProductUnit", - "Cluckwork.Domain.Catalog.EggUnit", - "Cluckwork.Domain.Sales.SalesOrderStatus", - "Cluckwork.Domain.Sales.DiscountReasonCode", - "Cluckwork.Domain.Sales.ListPriceBasis", - "Cluckwork.Domain.Sales.PaymentMethod", - ], - Implementations = [ - "Cluckwork.Infrastructure.Repositories.CustomerRepository", - "Cluckwork.Infrastructure.Repositories.CommerceFixture", - "Cluckwork.Infrastructure.Repositories.EggUnitConversionRepository", - "Cluckwork.Infrastructure.Repositories.PaymentRepository", - "Cluckwork.Infrastructure.Repositories.ProductRepository", - "Cluckwork.Infrastructure.Repositories.SalesOrderAllocationRepository", - "Cluckwork.Infrastructure.Repositories.SalesOrderRepository", - ], - }, - new("GeneralInventory", "module", [ - "Cluckwork.Domain.Inventory", - "Cluckwork.Application.Features.Inventory", - ], []) - { - Contract = [ - "Cluckwork.Application.Features.Inventory.IInventoryModule", - "Cluckwork.Application.Features.Inventory.IInventoryFixture", - "Cluckwork.Application.Features.Inventory.InventoryFixtureCounts", - "Cluckwork.Application.Features.Inventory.InventoryItemDetails", - "Cluckwork.Application.Features.Inventory.InventoryLotDetails", - "Cluckwork.Application.Features.Inventory.InventoryMovementDetails", - "Cluckwork.Application.Features.Inventory.FeedUsageDetails", - "Cluckwork.Application.Features.Inventory.WaterUsageDetails", - "Cluckwork.Application.Features.Inventory.CreateInventoryItem.CreateInventoryItemCommand", - "Cluckwork.Application.Features.Inventory.UpdateInventoryItem.UpdateInventoryItemCommand", - "Cluckwork.Application.Features.Inventory.RecordPurchase.RecordPurchaseCommand", - "Cluckwork.Application.Features.Inventory.RecordAdjustment.RecordAdjustmentCommand", - "Cluckwork.Application.Features.Inventory.RecordFeedUsage.RecordFeedUsageCommand", - "Cluckwork.Application.Features.Inventory.RecordFeedUsage.RecordFeedUsageResponse", - "Cluckwork.Application.Features.Inventory.RecordWaterUsage.RecordWaterUsageCommand", - "Cluckwork.Application.Features.Inventory.UpdateWaterUsage.UpdateWaterUsageCommand", - ], - Implementations = [ - "Cluckwork.Infrastructure.Repositories.FeedUsageRepository", - "Cluckwork.Infrastructure.Repositories.InventoryFixture", - "Cluckwork.Infrastructure.Repositories.InventoryItemRepository", - "Cluckwork.Infrastructure.Repositories.InventoryLotRepository", - "Cluckwork.Infrastructure.Repositories.InventoryMovementRepository", - "Cluckwork.Infrastructure.Repositories.WaterUsageRepository", - ], - }, - new("Finance", "module", [ - "Cluckwork.Domain.Expenses", - "Cluckwork.Application.Features.Expenses", - ], []) - { - Contract = [ - "Cluckwork.Application.Features.Expenses.IFinanceModule", - "Cluckwork.Application.Features.Expenses.IFinanceFixture", - "Cluckwork.Application.Features.Expenses.FinanceFixtureCounts", - "Cluckwork.Application.Features.Expenses.ExpenseCategoryDetails", - "Cluckwork.Application.Features.Expenses.ExpenseDetails", - "Cluckwork.Application.Features.Expenses.ExpenseListPage", - "Cluckwork.Application.Features.Expenses.CreateExpenseCategory.CreateExpenseCategoryCommand", - "Cluckwork.Application.Features.Expenses.UpdateExpenseCategory.UpdateExpenseCategoryCommand", - "Cluckwork.Application.Features.Expenses.CreateExpense.CreateExpenseCommand", - "Cluckwork.Application.Features.Expenses.AdjustExpense.AdjustExpenseCommand", - ], - Implementations = [ - "Cluckwork.Infrastructure.Repositories.ExpenseCategoryRepository", - "Cluckwork.Infrastructure.Repositories.ExpenseRepository", - "Cluckwork.Infrastructure.Repositories.FinanceFixture", - ], - }, - new("Insights", "module", [ - "Cluckwork.Application.Features.Audit", - "Cluckwork.Application.Features.Reports", - "Cluckwork.Application.Features.Export", - "Cluckwork.Application.Features.Insights", - "Cluckwork.Infrastructure.Insights", - ], []) - { - Contract = [ - "Cluckwork.Application.Features.Insights.IInsightsModule", - "Cluckwork.Application.Features.Insights.AuditEventRead", - "Cluckwork.Application.Features.Audit.EntityProvenance", - "Cluckwork.Application.Features.Export.ExportDataset", - "Cluckwork.Application.Features.Reports.ProductionReport", - "Cluckwork.Application.Features.Reports.ProductionDay", - "Cluckwork.Application.Features.Reports.GradeTotal", - "Cluckwork.Application.Features.Reports.SalesSummary", - "Cluckwork.Application.Features.Reports.ExpenseSummary", - "Cluckwork.Application.Features.Reports.ExpenseCategoryTotal", - "Cluckwork.Application.Features.Reports.ProfitReport", - ], - }, - new("Platform", "platform", [ - "Cluckwork.Domain.Common", - "Cluckwork.Domain.Auditing", - "Cluckwork.Application.Common", - "Cluckwork.Infrastructure", - "Cluckwork.Api", - "Cluckwork.AppHost", - ], [ - "Cluckwork.Domain", - "Cluckwork.Application", - ]), - ]; -} diff --git a/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.cs b/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.cs index 09022401c..4ecbb445d 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.cs +++ b/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.cs @@ -1,10 +1,27 @@ +using System.Reflection; +using Cluckwork.Domain.Common.Architecture; using Microsoft.CodeAnalysis.CSharp; namespace Cluckwork.Application.Tests.Architecture; -// The one module ledger every real-tree test reads (#859). Its rows are the RealModuleLedger.*.cs files. +// The one module ledger every real-tree test reads (#859). Its rows are the RealModuleLedger.*.cs files, plus the +// owner and edge rows in src/Cluckwork.Domain/Common/Architecture, which the module-edge analyzer reads too. internal static partial class RealModuleLedger { + internal static readonly OwnerDefinition[] Owners = [.. typeof(ModuleOwnerAttribute).Assembly + .GetCustomAttributes() + .Select(o => new OwnerDefinition(o.Name, o.Kind, o.Namespaces, o.ExactNamespaces) + { + Contract = o.Contract, + Implementations = o.Implementations, + Seam = o.Seam, + Types = o.Types, + })]; + + internal static readonly EdgeCell[] Edges = [.. typeof(ModuleEdgeAttribute).Assembly + .GetCustomAttributes() + .Select(e => new EdgeCell(e.From, e.To, e.Kind, e.Reason, e.Symbols))]; + // Lazy, because the order of static field initializers across partial files is not defined; Build runs on // first access, after every row array is set. private static readonly Lazy Built = new(Build); diff --git a/tests/Cluckwork.Application.Tests/Architecture/ZzMapParity.cs b/tests/Cluckwork.Application.Tests/Architecture/ZzMapParity.cs deleted file mode 100644 index becb62338..000000000 --- a/tests/Cluckwork.Application.Tests/Architecture/ZzMapParity.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Reflection; -using System.Text.Json; -using Cluckwork.Domain.Common.Architecture; - -namespace Cluckwork.Application.Tests.Architecture; - -public sealed class ZzMapParity -{ - [Fact] - public void AttributesEqualRows() - { - var domain = typeof(ModuleOwnerAttribute).Assembly; - var owners = domain.GetCustomAttributes().Select(o => - new OwnerDefinition(o.Name, o.Kind, o.Namespaces, o.ExactNamespaces) - { Contract = o.Contract, Implementations = o.Implementations, Seam = o.Seam, Types = o.Types }).ToArray(); - var edges = domain.GetCustomAttributes().Select(e => - new EdgeCell(e.From, e.To, e.Kind, e.Reason, e.Symbols)).ToArray(); - Assert.Equal(JsonSerializer.Serialize(RealModuleLedger.Owners), JsonSerializer.Serialize(owners)); - Assert.Equal(JsonSerializer.Serialize(RealModuleLedger.Edges), JsonSerializer.Serialize(edges)); - Assert.Equal(9, owners.Length); - Assert.Equal(RealModuleLedger.Edges.Length, edges.Length); - } -} From 0151cde69ec18f024baf101b5a893c905a2a761f Mon Sep 17 00:00:00 2001 From: mforce Date: Mon, 5 Oct 2026 14:54:32 +0000 Subject: [PATCH 3/6] build(arch): report module edges and namespace ownership at compile time Refs #859 --- Cluckwork.sln | 6 + Directory.Packages.props | 5 +- .../Cluckwork.Analyzers.csproj | 18 ++ src/Cluckwork.Analyzers/ModuleEdgeAnalyzer.cs | 284 ++++++++++++++++++ src/Cluckwork.Analyzers/ModuleMap.cs | 107 +++++++ src/Cluckwork.Analyzers/packages.lock.json | 120 ++++++++ src/Cluckwork.Api/Cluckwork.Api.csproj | 2 + src/Cluckwork.Api/packages.lock.json | 2 +- .../Cluckwork.Application.csproj | 2 + src/Cluckwork.Domain/Cluckwork.Domain.csproj | 5 + .../Common/Architecture/ModuleOwners.cs | 1 + .../Cluckwork.Infrastructure.csproj | 2 + .../packages.lock.json | 2 +- .../packages.lock.json | 20 +- .../packages.lock.json | 20 +- 15 files changed, 573 insertions(+), 23 deletions(-) create mode 100644 src/Cluckwork.Analyzers/Cluckwork.Analyzers.csproj create mode 100644 src/Cluckwork.Analyzers/ModuleEdgeAnalyzer.cs create mode 100644 src/Cluckwork.Analyzers/ModuleMap.cs create mode 100644 src/Cluckwork.Analyzers/packages.lock.json diff --git a/Cluckwork.sln b/Cluckwork.sln index 6879b425f..cabd52877 100644 --- a/Cluckwork.sln +++ b/Cluckwork.sln @@ -13,6 +13,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cluckwork.Api", "src/Cluckw EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cluckwork.AppHost", "src/Cluckwork.AppHost/Cluckwork.AppHost.csproj", "{10000001-0000-0000-0000-000000000008}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cluckwork.Analyzers", "src/Cluckwork.Analyzers/Cluckwork.Analyzers.csproj", "{10000001-0000-0000-0000-00000000000A}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cluckwork.Domain.Tests", "tests/Cluckwork.Domain.Tests/Cluckwork.Domain.Tests.csproj", "{10000001-0000-0000-0000-000000000005}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cluckwork.Application.Tests", "tests/Cluckwork.Application.Tests/Cluckwork.Application.Tests.csproj", "{10000001-0000-0000-0000-000000000006}" @@ -47,6 +49,10 @@ Global {10000001-0000-0000-0000-000000000008}.Debug|Any CPU.Build.0 = Debug|Any CPU {10000001-0000-0000-0000-000000000008}.Release|Any CPU.ActiveCfg = Release|Any CPU {10000001-0000-0000-0000-000000000008}.Release|Any CPU.Build.0 = Release|Any CPU + {10000001-0000-0000-0000-00000000000A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {10000001-0000-0000-0000-00000000000A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {10000001-0000-0000-0000-00000000000A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {10000001-0000-0000-0000-00000000000A}.Release|Any CPU.Build.0 = Release|Any CPU {10000001-0000-0000-0000-000000000005}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {10000001-0000-0000-0000-000000000005}.Debug|Any CPU.Build.0 = Debug|Any CPU {10000001-0000-0000-0000-000000000005}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/Directory.Packages.props b/Directory.Packages.props index 8850cd493..9eb151fd5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,7 +24,10 @@ - + + diff --git a/src/Cluckwork.Analyzers/Cluckwork.Analyzers.csproj b/src/Cluckwork.Analyzers/Cluckwork.Analyzers.csproj new file mode 100644 index 000000000..70575dc34 --- /dev/null +++ b/src/Cluckwork.Analyzers/Cluckwork.Analyzers.csproj @@ -0,0 +1,18 @@ + + + + + netstandard2.0 + latest + true + true + + $(NoWarn);RS2008 + + + + + + + diff --git a/src/Cluckwork.Analyzers/ModuleEdgeAnalyzer.cs b/src/Cluckwork.Analyzers/ModuleEdgeAnalyzer.cs new file mode 100644 index 000000000..5b4ad591d --- /dev/null +++ b/src/Cluckwork.Analyzers/ModuleEdgeAnalyzer.cs @@ -0,0 +1,284 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Cluckwork.Analyzers; + +// #859: ModuleLedgerScanner's undeclared-edge, stale-row and unowned-namespace rules (#842, semantic since #1071), +// reported by the compiler while editing and building. ModuleLedgerRealTreeTests stays the CI authority; this +// analyzer only reports earlier, so nothing here guards against suppressing it. +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ModuleEdgeAnalyzer : DiagnosticAnalyzer +{ + // The compilation that references every other module assembly, so it alone can tell a row naming a type + // that exists nowhere. + private const string RootAssembly = "Cluckwork.Api"; + + internal static readonly DiagnosticDescriptor MapMissing = new( + "CW1000", "Module map unavailable", + "{0} runs the module-edge analyzer but reads no [assembly: ModuleOwner] rows from Cluckwork.Domain", + "Architecture", DiagnosticSeverity.Error, isEnabledByDefault: true, customTags: WellKnownDiagnosticTags.CompilationEnd); + + internal static readonly DiagnosticDescriptor Undeclared = new( + "CW1001", "Undeclared cross-module edge", + "undeclared cross-owner edge {0} -> {1} from {2} (references {3}); {4}", + "Architecture", DiagnosticSeverity.Error, isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor Stale = new( + "CW1002", "Stale module edge", + "stale module edge {0} -> {1} :: {2}; {3}", + "Architecture", DiagnosticSeverity.Error, isEnabledByDefault: true, customTags: WellKnownDiagnosticTags.CompilationEnd); + + internal static readonly DiagnosticDescriptor Unowned = new( + "CW1003", "Unowned namespace", + "unowned namespace '{0}'; every namespace in src/ must be claimed by exactly one owner in src/Cluckwork.Domain/Common/Architecture/ModuleOwners.cs", + "Architecture", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public override ImmutableArray SupportedDiagnostics => [MapMissing, Undeclared, Stale, Unowned]; + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + // ModuleLedgerScanner reads every src/*.cs file, generated header or not; Walk skips generator output. + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); + context.RegisterCompilationStartAction(Start); + } + + private static void Start(CompilationStartAnalysisContext start) + { + var compilation = start.Compilation; + var assembly = compilation.AssemblyName ?? ""; + if (ModuleMap.Read(compilation) is not { } map) + { + start.RegisterCompilationEndAction(end => end.ReportDiagnostic(Diagnostic.Create(MapMissing, Location.None, assembly))); + return; + } + + var realised = new ConcurrentDictionary<(string From, string To, string Symbol), byte>(); + var declaredHere = new ConcurrentDictionary(StringComparer.Ordinal); + + start.RegisterSemanticModelAction(model => Walk(model, map, assembly, realised, declaredHere)); + start.RegisterCompilationEndAction(end => + { + foreach (var (from, to, symbols) in map.Edges) + { + if (symbols.IsEmpty && assembly == RootAssembly) + { + end.ReportDiagnostic(Diagnostic.Create(Stale, Location.None, from, to, "", + "the cell lists no symbols, so it excuses nothing and can never go stale; delete it")); + } + + foreach (var symbol in symbols) + { + var stale = declaredHere.ContainsKey(symbol) + ? !realised.ContainsKey((from, to, symbol)) + : assembly == RootAssembly && !ExistsAnywhere(end.Compilation, symbol); + if (stale) + { + end.ReportDiagnostic(Diagnostic.Create(Stale, Location.None, from, to, symbol, + $"no reference in src/ realises this edge; delete the symbol from its ModuleEdge row in {ModuleMap.EdgesFile}, or restore the dependency it was written for")); + } + } + } + }); + } + + private static bool ExistsAnywhere(Compilation compilation, string symbol) + { + // File-scoped symbols are checked only by the compilation holding their file. + if (symbol.StartsWith(":", StringComparison.Ordinal) || symbol.IndexOf('@') >= 0) + { + return true; + } + + var lt = symbol.IndexOf('<'); + var metadataName = lt < 0 ? symbol : $"{symbol.Substring(0, lt)}`{symbol.Length - lt - 1}"; + return compilation.GetTypeByMetadataName(metadataName) is not null; + } + + private static void Walk( + SemanticModelAnalysisContext context, + ModuleMap map, + string assembly, + ConcurrentDictionary<(string, string, string), byte> realised, + ConcurrentDictionary declaredHere) + { + var model = context.SemanticModel; + var path = model.SyntaxTree.FilePath.Replace('\\', '/'); + // Generator output has a relative hint path, or sits under obj/; ModuleLedgerScanner never reads either. + var rooted = path.StartsWith("/", StringComparison.Ordinal) || (path.Length > 2 && path[1] == ':'); + if (!rooted || path.Contains("/obj/")) + { + return; + } + + var root = (CompilationUnitSyntax)model.SyntaxTree.GetRoot(context.CancellationToken); + var attributions = Attribute(context, root, path, assembly, map); + foreach (var attribution in attributions) + { + declaredHere.TryAdd(attribution.Symbol, 0); + } + + if (!attributions.Any(a => a.Owner is { } owner && !map.IsPlatform(owner))) + { + return; + } + + var reported = new HashSet<(string, string, string)>(); + foreach (var node in root.DescendantNodes(n => n is not UsingDirectiveSyntax)) + { + var enclosing = node.Ancestors().LastOrDefault(IsTypeDeclaration); + var from = attributions.FirstOrDefault(a => a.Scope == enclosing) ?? attributions[0]; + if (from.Owner is null || map.IsPlatform(from.Owner)) + { + continue; + } + + foreach (var type in BoundSymbols(model, node).SelectMany(ReferencedTypes)) + { + var outermost = type.OriginalDefinition; + while (outermost.ContainingType is { } parent) + { + outermost = parent; + } + + if (outermost.TypeKind == TypeKind.Error || outermost.ContainingNamespace.IsGlobalNamespace + || map.Resolve($"{outermost.ContainingNamespace.ToDisplayString()}.{outermost.Name}", declared: false) is not { } to + || to.Owner == from.Owner || map.IsPlatform(to.Owner)) + { + continue; + } + + var key = (from.Owner, to.Owner, from.Symbol); + realised.TryAdd(key, 0); + if (!map.IsDeclared(from.Owner, to.Owner, from.Symbol) && reported.Add(key)) + { + context.ReportDiagnostic(Diagnostic.Create(Undeclared, node.GetLocation(), + from.Owner, to.Owner, from.Symbol, to.Namespace, map.Fix(from.Owner, to.Owner, from.Symbol))); + } + } + } + } + + // ModuleLedgerScanner.ScanFile's attribution: each top-level type is charged to its namespace owner, or to the + // owner claiming it (#1023); a file without types is charged as a whole. + private static List Attribute( + SemanticModelAnalysisContext context, CompilationUnitSyntax root, string path, string assembly, ModuleMap map) + { + var attributions = new List(); + foreach (var type in root.DescendantNodes().Where(IsTypeDeclaration).Where(n => !n.Ancestors().Any(IsTypeDeclaration))) + { + var declared = NamespaceOf(type, assembly); + var owner = map.Resolve(declared, declared: true)?.Owner; + if (owner is null) + { + context.ReportDiagnostic(Diagnostic.Create(Unowned, IdentifierLocation(type), declared)); + } + + var symbol = IsFileLocal(type) ? $"{declared}.{Identifier(type)}@{Relative(path)}" : $"{declared}.{Identifier(type)}"; + attributions.Add(new Attribution(type, symbol, map.Claimant(symbol) ?? owner)); + } + + if (attributions.Count == 0) + { + var declaration = root.DescendantNodes().OfType().FirstOrDefault(); + var fileNamespace = declaration?.Name.ToString() ?? assembly; + var owner = map.Resolve(fileNamespace, declared: true)?.Owner; + if (owner is null) + { + context.ReportDiagnostic(Diagnostic.Create(Unowned, + declaration?.Name.GetLocation() ?? Location.Create(root.SyntaxTree, default), fileNamespace)); + } + + attributions.Add(new Attribution(null, $":{Relative(path)}", owner)); + } + + return attributions; + } + + // GetSymbolInfo names what the source spells; the compiler also calls conversion operators, collection-initializer + // Adds and the foreach enumerator pattern on its own. + private static IEnumerable BoundSymbols(SemanticModel model, SyntaxNode node) => node switch + { + ExpressionSyntax expression => + [ + model.GetSymbolInfo(expression).Symbol, + model.GetConversion(expression).MethodSymbol, + expression.Parent.IsKind(SyntaxKind.CollectionInitializerExpression) + ? model.GetCollectionInitializerSymbolInfo(expression).Symbol + : null, + ], + CommonForEachStatementSyntax loop when model.GetForEachStatementInfo(loop) is var info => + [info.GetEnumeratorMethod, info.MoveNextMethod, info.CurrentProperty, info.DisposeMethod, info.ElementConversion.MethodSymbol], + _ => [], + }; + + private static IEnumerable ReferencedTypes(ISymbol? symbol) => symbol switch + { + ITypeSymbol type => TypesIn(type), + IMethodSymbol or IPropertySymbol or IFieldSymbol or IEventSymbol => TypesIn(symbol.ContainingType), + _ => [], + }; + + private static IEnumerable TypesIn(ITypeSymbol type) => type switch + { + INamedTypeSymbol named => named.TypeArguments.SelectMany(TypesIn).Prepend(named), + IArrayTypeSymbol array => TypesIn(array.ElementType), + IPointerTypeSymbol pointer => TypesIn(pointer.PointedAtType), + _ => [], + }; + + private sealed class Attribution(SyntaxNode? scope, string symbol, string? owner) + { + public SyntaxNode? Scope { get; } = scope; + + public string Symbol { get; } = symbol; + + public string? Owner { get; } = owner; + } + + private static bool IsTypeDeclaration(SyntaxNode node) => node is BaseTypeDeclarationSyntax or DelegateDeclarationSyntax; + + private static Location IdentifierLocation(SyntaxNode node) => node switch + { + BaseTypeDeclarationSyntax type => type.Identifier.GetLocation(), + DelegateDeclarationSyntax d => d.Identifier.GetLocation(), + _ => node.GetLocation(), + }; + + private static string Identifier(SyntaxNode node) => node switch + { + TypeDeclarationSyntax type => GenericIdentifier(type.Identifier.ValueText, type.TypeParameterList), + BaseTypeDeclarationSyntax type => type.Identifier.ValueText, + DelegateDeclarationSyntax d => GenericIdentifier(d.Identifier.ValueText, d.TypeParameterList), + _ => throw new InvalidOperationException($"{node.Kind()} is not a type declaration."), + }; + + // Arity only: type parameter names are not part of a generic type's identity. + private static string GenericIdentifier(string identifier, TypeParameterListSyntax? parameters) => + parameters is null ? identifier : $"{identifier}<{new string(',', parameters.Parameters.Count - 1)}>"; + + private static bool IsFileLocal(SyntaxNode node) => node switch + { + BaseTypeDeclarationSyntax type => type.Modifiers.Any(SyntaxKind.FileKeyword), + DelegateDeclarationSyntax d => d.Modifiers.Any(SyntaxKind.FileKeyword), + _ => false, + }; + + private static string NamespaceOf(SyntaxNode node, string assembly) + { + var parts = node.Ancestors().OfType().Select(n => n.Name.ToString()).Reverse().ToList(); + return parts.Count == 0 ? assembly : string.Join(".", parts); + } + + // ponytail: repo-relative by the last "/src/" segment, as ModuleLedgerScanner prints it; a checkout path without + // one keeps the absolute path, which only file-scoped symbols would notice. + private static string Relative(string path) + { + var cut = path.LastIndexOf("/src/", StringComparison.Ordinal); + return cut < 0 ? path : path.Substring(cut + 1); + } +} diff --git a/src/Cluckwork.Analyzers/ModuleMap.cs b/src/Cluckwork.Analyzers/ModuleMap.cs new file mode 100644 index 000000000..8663fe594 --- /dev/null +++ b/src/Cluckwork.Analyzers/ModuleMap.cs @@ -0,0 +1,107 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace Cluckwork.Analyzers; + +// The [assembly: ModuleOwner] and [assembly: ModuleEdge] rows of Cluckwork.Domain, read from source when compiling +// Domain and from metadata everywhere else, so an edited row takes effect without rebuilding the analyzer. +internal sealed class ModuleMap +{ + internal const string EdgesFile = "src/Cluckwork.Domain/Common/Architecture/ModuleEdges.cs"; + + private const string Namespace = "Cluckwork.Domain.Common.Architecture."; + + // ModuleLedgerScanner.BuildNamespaceIndex: a namespace claim covers its subtree; an exact namespace and a + // claimed type (#1023) cover their own name only. + private readonly Dictionary _claims = new(StringComparer.Ordinal); + private readonly HashSet _platform = new(StringComparer.Ordinal); + private readonly HashSet<(string From, string To, string Symbol)> _declared = []; + + internal ImmutableArray<(string From, string To, ImmutableArray Symbols)> Edges { get; private set; } + + internal static ModuleMap? Read(Compilation compilation) + { + var domain = compilation.Assembly.Name == "Cluckwork.Domain" + ? compilation.Assembly + : compilation.SourceModule.ReferencedAssemblySymbols.FirstOrDefault(a => a.Name == "Cluckwork.Domain"); + if (domain is null) + { + return null; + } + + var map = new ModuleMap(); + var edges = ImmutableArray.CreateBuilder<(string, string, ImmutableArray)>(); + foreach (var attribute in domain.GetAttributes()) + { + var args = attribute.ConstructorArguments; + switch (attribute.AttributeClass?.ToDisplayString()) + { + case Namespace + "ModuleOwnerAttribute": + var owner = (string)args[0].Value!; + if ((string?)args[1].Value == "platform") + { + map._platform.Add(owner); + } + + foreach (var named in attribute.NamedArguments) + { + if (named.Key is "Namespaces" or "ExactNamespaces" or "Types") + { + foreach (var claimed in named.Value.Values) + { + map._claims[(string)claimed.Value!] = (owner, named.Key == "Namespaces"); + } + } + } + + break; + case Namespace + "ModuleEdgeAttribute": + var (from, to) = ((string)args[0].Value!, (string)args[1].Value!); + var symbols = args[4].Values.Select(v => (string)v.Value!).ToImmutableArray(); + edges.Add((from, to, symbols)); + foreach (var symbol in symbols) + { + map._declared.Add((from, to, symbol)); + } + + break; + } + } + + map.Edges = edges.ToImmutable(); + return map._claims.Count == 0 ? null : map; + } + + internal bool IsPlatform(string owner) => _platform.Contains(owner); + + internal bool IsDeclared(string from, string to, string symbol) => _declared.Contains((from, to, symbol)); + + internal string? Claimant(string symbol) => _claims.TryGetValue(symbol, out var claim) ? claim.Owner : null; + + // ModuleLedgerScanner.Resolve: the longest claimed prefix. Resolving a declared namespace, an exact claim covers + // only its own name. + internal (string Owner, string Namespace)? Resolve(string dotted, bool declared) + { + var probe = dotted; + while (true) + { + if (_claims.TryGetValue(probe, out var claim) && (claim.Subtree || !declared || probe == dotted)) + { + return (claim.Owner, probe); + } + + var cut = probe.LastIndexOf('.'); + if (cut < 0) + { + return null; + } + + probe = probe.Substring(0, cut); + } + } + + internal string Fix(string from, string to, string symbol) => + Edges.Any(cell => cell.From == from && cell.To == to) + ? $"add \"{symbol}\" to the ModuleEdge(\"{from}\", \"{to}\", ...) row in {EdgesFile}, and extend its reason" + : $"add this row to {EdgesFile}, with a reason naming the port or type it calls: [assembly: ModuleEdge(\"{from}\", \"{to}\", \"R\", \"\", \"{symbol}\")]"; +} diff --git a/src/Cluckwork.Analyzers/packages.lock.json b/src/Cluckwork.Analyzers/packages.lock.json new file mode 100644 index 000000000..9b0a600f1 --- /dev/null +++ b/src/Cluckwork.Analyzers/packages.lock.json @@ -0,0 +1,120 @@ +{ + "version": 2, + "dependencies": { + ".NETStandard,Version=v2.0": { + "Microsoft.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[5.0.0, )", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "System.Buffers": "4.6.0", + "System.Collections.Immutable": "9.0.0", + "System.Memory": "4.6.0", + "System.Numerics.Vectors": "4.6.0", + "System.Reflection.Metadata": "9.0.0", + "System.Runtime.CompilerServices.Unsafe": "6.1.0", + "System.Text.Encoding.CodePages": "8.0.0", + "System.Threading.Tasks.Extensions": "4.6.0" + } + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "System.Buffers": "4.6.0", + "System.Collections.Immutable": "9.0.0", + "System.Memory": "4.6.0", + "System.Numerics.Vectors": "4.6.0", + "System.Reflection.Metadata": "9.0.0", + "System.Runtime.CompilerServices.Unsafe": "6.1.0", + "System.Text.Encoding.CodePages": "8.0.0", + "System.Threading.Tasks.Extensions": "4.6.0" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.0", + "contentHash": "lN6tZi7Q46zFzAbRYXTIvfXcyvQQgxnY7Xm6C6xQ9784dEL1amjM6S6Iw4ZpsvesAKnRVsM4scrDQaDqSClkjA==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "QhkXUl2gNrQtvPmtBTQHb0YsUrDiDQ2QS09YbtTTiSjGcf7NBqtYbrG/BE06zcBPCKEwQGzIv13IVdXNOSub2w==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.0", + "contentHash": "OEkbBQoklHngJ8UD8ez2AERSk2g+/qpAaSWWCBFbpH727HxDq5ydVkuncBaKcKfwRqXGWx64dS6G1SUScMsitg==", + "dependencies": { + "System.Buffers": "4.6.0", + "System.Numerics.Vectors": "4.6.0", + "System.Runtime.CompilerServices.Unsafe": "6.1.0" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.0", + "contentHash": "t+SoieZsRuEyiw/J+qXUbolyO219tKQQI0+2/YI+Qv7YdGValA6WiuokrNKqjrTNsy5ABWU11bdKOzUdheteXg==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "ANiqLu3DxW9kol/hMmTWbt3414t9ftdIuiIU7j80okq2YzAueo120M442xk1kDJWtmZTqWQn7wHDvMRipVOEOQ==", + "dependencies": { + "System.Collections.Immutable": "9.0.0", + "System.Memory": "4.5.5" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.0", + "contentHash": "5o/HZxx6RVqYlhKSq8/zronDkALJZUT2Vz0hx43f0gwe8mwlM0y2nYlqdBwLMzr262Bwvpikeb/yEwkAa5PADg==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "OZIsVplFGaVY90G2SbpgU7EnCoOO5pw1t4ic21dBF3/1omrJFpAGoNAVpPyMVOC90/hvgkGG3VFqR13YgZMQfg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.6.0", + "contentHash": "I5G6Y8jb0xRtGUC9Lahy7FUvlYlnGMMkbuKAQBy8Jb7Y6Yn8OlBEiUOY0PqZ0hy6Ua8poVA1ui1tAIiXNxGdsg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.1.0" + } + } + } + } +} \ No newline at end of file diff --git a/src/Cluckwork.Api/Cluckwork.Api.csproj b/src/Cluckwork.Api/Cluckwork.Api.csproj index d1dadccc5..026ad0841 100644 --- a/src/Cluckwork.Api/Cluckwork.Api.csproj +++ b/src/Cluckwork.Api/Cluckwork.Api.csproj @@ -34,6 +34,8 @@ + + diff --git a/src/Cluckwork.Api/packages.lock.json b/src/Cluckwork.Api/packages.lock.json index 5c4c855b6..7a8b5e31f 100644 --- a/src/Cluckwork.Api/packages.lock.json +++ b/src/Cluckwork.Api/packages.lock.json @@ -444,7 +444,7 @@ }, "Microsoft.CodeAnalysis.CSharp": { "type": "CentralTransitive", - "requested": "[5.9.0, )", + "requested": "[5.0.0, )", "resolved": "5.0.0", "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", "dependencies": { diff --git a/src/Cluckwork.Application/Cluckwork.Application.csproj b/src/Cluckwork.Application/Cluckwork.Application.csproj index b8dcec579..4b82ed4bc 100644 --- a/src/Cluckwork.Application/Cluckwork.Application.csproj +++ b/src/Cluckwork.Application/Cluckwork.Application.csproj @@ -8,6 +8,8 @@ + + diff --git a/src/Cluckwork.Domain/Cluckwork.Domain.csproj b/src/Cluckwork.Domain/Cluckwork.Domain.csproj index 61718a1f8..fb7aaeeac 100644 --- a/src/Cluckwork.Domain/Cluckwork.Domain.csproj +++ b/src/Cluckwork.Domain/Cluckwork.Domain.csproj @@ -1,3 +1,8 @@ + + + + + diff --git a/src/Cluckwork.Domain/Common/Architecture/ModuleOwners.cs b/src/Cluckwork.Domain/Common/Architecture/ModuleOwners.cs index 47d25b4ba..ebf68b9f5 100644 --- a/src/Cluckwork.Domain/Common/Architecture/ModuleOwners.cs +++ b/src/Cluckwork.Domain/Common/Architecture/ModuleOwners.cs @@ -285,6 +285,7 @@ "Cluckwork.Infrastructure", "Cluckwork.Api", "Cluckwork.AppHost", + "Cluckwork.Analyzers", ], ExactNamespaces = [ "Cluckwork.Domain", diff --git a/src/Cluckwork.Infrastructure/Cluckwork.Infrastructure.csproj b/src/Cluckwork.Infrastructure/Cluckwork.Infrastructure.csproj index 5454005d4..c34721789 100644 --- a/src/Cluckwork.Infrastructure/Cluckwork.Infrastructure.csproj +++ b/src/Cluckwork.Infrastructure/Cluckwork.Infrastructure.csproj @@ -25,6 +25,8 @@ + + diff --git a/src/Cluckwork.Infrastructure/packages.lock.json b/src/Cluckwork.Infrastructure/packages.lock.json index a7dd3e9c8..f328c1102 100644 --- a/src/Cluckwork.Infrastructure/packages.lock.json +++ b/src/Cluckwork.Infrastructure/packages.lock.json @@ -307,7 +307,7 @@ }, "Microsoft.CodeAnalysis.CSharp": { "type": "CentralTransitive", - "requested": "[5.9.0, )", + "requested": "[5.0.0, )", "resolved": "5.0.0", "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", "dependencies": { diff --git a/tests/Cluckwork.Api.IntegrationTests/packages.lock.json b/tests/Cluckwork.Api.IntegrationTests/packages.lock.json index 591a15c18..3366b0828 100644 --- a/tests/Cluckwork.Api.IntegrationTests/packages.lock.json +++ b/tests/Cluckwork.Api.IntegrationTests/packages.lock.json @@ -26,12 +26,12 @@ }, "Microsoft.CodeAnalysis.CSharp": { "type": "Direct", - "requested": "[5.9.0, )", - "resolved": "5.9.0", - "contentHash": "7JGDA0UT1+h7k9ZcA3rF4eFC8+QPq1xyYaXxag4p8r/zzPurEJxvdi7aM+MRL/SfP7XADXpWF/pl/eUYXOq/ww==", + "requested": "[5.0.0, )", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", - "Microsoft.CodeAnalysis.Common": "[5.9.0]" + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]" } }, "Microsoft.NET.Test.Sdk": { @@ -153,15 +153,15 @@ }, "Microsoft.CodeAnalysis.Analyzers": { "type": "Transitive", - "resolved": "5.9.0-1.26328.17", - "contentHash": "HP9NNk8ZjOSI2hgOyXnQg+kv7/X837Vr2nAlXiGAtqtYnYKjRRa1UmQFr8KFs5ynGYKqfbb8zB9APoWjiAGdMg==" + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" }, "Microsoft.CodeAnalysis.Common": { "type": "Transitive", - "resolved": "5.9.0", - "contentHash": "IYaIaUWdIx539AReKZOBEqTskFusZfCh/wFSPilDvCn5Say8MegLw2LONcSIcVy+v3Gzv53qYBspgvBGSErfbQ==", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17" + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" } }, "Microsoft.CodeCoverage": { diff --git a/tests/Cluckwork.Application.Tests/packages.lock.json b/tests/Cluckwork.Application.Tests/packages.lock.json index b4c0262e3..223a0124f 100644 --- a/tests/Cluckwork.Application.Tests/packages.lock.json +++ b/tests/Cluckwork.Application.Tests/packages.lock.json @@ -10,12 +10,12 @@ }, "Microsoft.CodeAnalysis.CSharp": { "type": "Direct", - "requested": "[5.9.0, )", - "resolved": "5.9.0", - "contentHash": "7JGDA0UT1+h7k9ZcA3rF4eFC8+QPq1xyYaXxag4p8r/zzPurEJxvdi7aM+MRL/SfP7XADXpWF/pl/eUYXOq/ww==", + "requested": "[5.0.0, )", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", - "Microsoft.CodeAnalysis.Common": "[5.9.0]" + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]" } }, "Microsoft.NET.Test.Sdk": { @@ -65,15 +65,15 @@ }, "Microsoft.CodeAnalysis.Analyzers": { "type": "Transitive", - "resolved": "5.9.0-1.26328.17", - "contentHash": "HP9NNk8ZjOSI2hgOyXnQg+kv7/X837Vr2nAlXiGAtqtYnYKjRRa1UmQFr8KFs5ynGYKqfbb8zB9APoWjiAGdMg==" + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" }, "Microsoft.CodeAnalysis.Common": { "type": "Transitive", - "resolved": "5.9.0", - "contentHash": "IYaIaUWdIx539AReKZOBEqTskFusZfCh/wFSPilDvCn5Say8MegLw2LONcSIcVy+v3Gzv53qYBspgvBGSErfbQ==", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17" + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" } }, "Microsoft.CodeCoverage": { From 672d65716bff8aadbf1c9bb0f9d59ae2f4d9532a Mon Sep 17 00:00:00 2001 From: mforce Date: Mon, 5 Oct 2026 15:01:06 +0000 Subject: [PATCH 4/6] test(arch): unit-test the module-edge analyzer Refs #859 --- Directory.Packages.props | 3 + src/Cluckwork.Api/packages.lock.json | 27 +-- .../packages.lock.json | 27 +-- .../Architecture/ModuleEdgeAnalyzerTests.cs | 114 ++++++++++ .../Cluckwork.Application.Tests.csproj | 3 + .../packages.lock.json | 215 ++++++++++++++++++ 6 files changed, 363 insertions(+), 26 deletions(-) create mode 100644 tests/Cluckwork.Application.Tests/Architecture/ModuleEdgeAnalyzerTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 9eb151fd5..ea08eb4b7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -28,6 +28,9 @@ built against a Roslyn no newer than itself, and 5.0.0 ships with SDK 10.0.1xx, the oldest band in use; raising this fails every build on that band with CS9057. --> + + + diff --git a/src/Cluckwork.Api/packages.lock.json b/src/Cluckwork.Api/packages.lock.json index 7a8b5e31f..7be0ec0a6 100644 --- a/src/Cluckwork.Api/packages.lock.json +++ b/src/Cluckwork.Api/packages.lock.json @@ -146,19 +146,6 @@ "Microsoft.CodeAnalysis.Analyzers": "3.11.0" } }, - "Microsoft.CodeAnalysis.CSharp.Workspaces": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", - "dependencies": { - "Humanizer.Core": "2.14.1", - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", - "Microsoft.CodeAnalysis.Common": "[5.0.0]", - "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", - "System.Composition": "9.0.0" - } - }, "Microsoft.CodeAnalysis.Workspaces.Common": { "type": "Transitive", "resolved": "5.0.0", @@ -452,6 +439,20 @@ "Microsoft.CodeAnalysis.Common": "[5.0.0]" } }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "CentralTransitive", + "requested": "[5.0.0, )", + "resolved": "5.0.0", + "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, "Microsoft.EntityFrameworkCore": { "type": "CentralTransitive", "requested": "[10.*, )", diff --git a/src/Cluckwork.Infrastructure/packages.lock.json b/src/Cluckwork.Infrastructure/packages.lock.json index f328c1102..1e382447e 100644 --- a/src/Cluckwork.Infrastructure/packages.lock.json +++ b/src/Cluckwork.Infrastructure/packages.lock.json @@ -112,19 +112,6 @@ "Microsoft.CodeAnalysis.Analyzers": "3.11.0" } }, - "Microsoft.CodeAnalysis.CSharp.Workspaces": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", - "dependencies": { - "Humanizer.Core": "2.14.1", - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", - "Microsoft.CodeAnalysis.Common": "[5.0.0]", - "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", - "System.Composition": "9.0.0" - } - }, "Microsoft.CodeAnalysis.Workspaces.Common": { "type": "Transitive", "resolved": "5.0.0", @@ -315,6 +302,20 @@ "Microsoft.CodeAnalysis.Common": "[5.0.0]" } }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "CentralTransitive", + "requested": "[5.0.0, )", + "resolved": "5.0.0", + "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, "Riok.Mapperly": { "type": "CentralTransitive", "requested": "[4.*, )", diff --git a/tests/Cluckwork.Application.Tests/Architecture/ModuleEdgeAnalyzerTests.cs b/tests/Cluckwork.Application.Tests/Architecture/ModuleEdgeAnalyzerTests.cs new file mode 100644 index 000000000..7c4d27432 --- /dev/null +++ b/tests/Cluckwork.Application.Tests/Architecture/ModuleEdgeAnalyzerTests.cs @@ -0,0 +1,114 @@ +using Cluckwork.Analyzers; +using Cluckwork.Application.Tests.TenantBypass; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; + +namespace Cluckwork.Application.Tests.Architecture; + +// #859: the analyzer over a two-project fixture. The fixture's Cluckwork.Domain compiles the real +// ModuleMapAttributes.cs with a map of Red, Blue and Platform; the code under test is a second project referencing it. +public sealed class ModuleEdgeAnalyzerTests +{ + private const string BlueSource = """ + namespace Cluckwork.Domain.Blue; + public sealed class B { public string Name => ""; } + """; + + private const string HubSource = """ + namespace Cluckwork.Domain.Common; + public sealed class Hub { public Cluckwork.Domain.Blue.B Blue { get; } = new(); } + """; + + [Fact] + public Task UndeclaredEdge_IsCW1001() => + Fixture(edges: "", """ + namespace Cluckwork.Application.Red; + public sealed class R { public object Go() => {|CW1001:new Cluckwork.Domain.Blue.B()|}; } + """).RunAsync(); + + [Fact] + public Task DeclaredEdge_IsClean() => + Fixture(Edge("Cluckwork.Application.Red.R"), """ + namespace Cluckwork.Application.Red; + public sealed class R { public object Go() => new Cluckwork.Domain.Blue.B(); } + """).RunAsync(); + + // The type is never named; binding Name still charges its declaring type (#1071). + [Fact] + public Task MemberAccessOnlyReference_IsAnEdge() => + Fixture(edges: "", """ + namespace Cluckwork.Application.Red; + public sealed class R { public string Go(Cluckwork.Domain.Common.Hub hub) => {|CW1001:hub.Blue.Name|}; } + """).RunAsync(); + + [Fact] + public Task StaleRow_IsCW1002() + { + var test = Fixture(Edge("Cluckwork.Application.Red.R"), """ + namespace Cluckwork.Application.Red; + public sealed class R; + """); + test.ExpectedDiagnostics.Add(new DiagnosticResult("CW1002", DiagnosticSeverity.Error).WithArguments( + "Red", "Blue", "Cluckwork.Application.Red.R", + "no reference in src/ realises this edge; delete the symbol from its ModuleEdge row in src/Cluckwork.Domain/Common/Architecture/ModuleEdges.cs, or restore the dependency it was written for")); + return test.RunAsync(); + } + + [Fact] + public Task UnownedNamespace_IsCW1003() => + Fixture(edges: "", """ + namespace Cluckwork.Application.Green; + public sealed class {|CW1003:G|}; + """).RunAsync(); + + [Fact] + public Task UsingWithNoUse_IsClean() => + Fixture(edges: "", """ + using Cluckwork.Domain.Blue; + using static Cluckwork.Domain.Blue.B; + namespace Cluckwork.Application.Red; + public sealed class R; + """).RunAsync(); + + private static string Edge(string symbol) => + $"[assembly: ModuleEdge(\"Red\", \"Blue\", \"R\", \"Red reads Blue.\", \"{symbol}\")]"; + + private static CSharpAnalyzerTest Fixture(string edges, string source) + { + var attributes = File.ReadAllText(Path.Combine( + GuardScanner.FindRepoRoot(AppContext.BaseDirectory) ?? throw new InvalidOperationException("repo root not found"), + "src/Cluckwork.Domain/Common/Architecture/ModuleMapAttributes.cs")); + var map = $""" + using Cluckwork.Domain.Common.Architecture; + [assembly: ModuleOwner("Red", "module", Namespaces = ["Cluckwork.Application.Red"])] + [assembly: ModuleOwner("Blue", "module", Namespaces = ["Cluckwork.Domain.Blue"])] + [assembly: ModuleOwner("Platform", "platform", Namespaces = ["Cluckwork.Domain.Common"], ExactNamespaces = ["Cluckwork.Domain", "Cluckwork.Application"])] + {edges} + """; + + var domain = new ProjectState("Cluckwork.Domain", LanguageNames.CSharp, "/domain/", "cs"); + domain.Sources.Add(("/domain/ModuleMapAttributes.cs", "using System;\n" + attributes)); + domain.Sources.Add(("/domain/ModuleMap.cs", map)); + domain.Sources.Add(("/domain/B.cs", BlueSource)); + domain.Sources.Add(("/domain/Hub.cs", HubSource)); + + var test = new CSharpAnalyzerTest + { + // The running runtime's assemblies, so the test never downloads a reference pack. + ReferenceAssemblies = new ReferenceAssemblies("net10.0"), + TestCode = source, + }; + test.TestState.AdditionalProjects.Add("Cluckwork.Domain", domain); + test.TestState.AdditionalProjectReferences.Add("Cluckwork.Domain"); + test.SolutionTransforms.Add((solution, _) => solution.Projects.Aggregate(solution, (s, project) => + s.AddMetadataReferences(project.Id, RuntimeReferences.Value))); + return test; + } + + private static readonly Lazy RuntimeReferences = new(() => + ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator) + .Where(path => Path.GetFileName(path) is "System.Private.CoreLib.dll" or "System.Runtime.dll") + .Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)) + .ToArray()); +} diff --git a/tests/Cluckwork.Application.Tests/Cluckwork.Application.Tests.csproj b/tests/Cluckwork.Application.Tests/Cluckwork.Application.Tests.csproj index 785ea2bf1..19a7318ee 100644 --- a/tests/Cluckwork.Application.Tests/Cluckwork.Application.Tests.csproj +++ b/tests/Cluckwork.Application.Tests/Cluckwork.Application.Tests.csproj @@ -7,6 +7,8 @@ + + @@ -17,6 +19,7 @@ +