diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index a11ec41..828b573 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -24,9 +24,34 @@ By default the index stores object/method *metadata* only — method bodies (the | `index history` | Show per-model extraction run history | | `index export` | Dump the index to a portable archive | | `index import` | Restore from an archive | +| `index cross-check` | Report where this tool's catalogs are narrower than the installation | | `index optimize` | Run `VACUUM` + `ANALYZE` to compact and re-plan | | `doctor` | End-to-end health check: paths, schema version, object counts | +### `index cross-check` + +Every catalog this tool answers from — the form-pattern registry, the object-type registry, the +DataContract catalog — is generated from one platform version and then committed. That makes it +right when it was made and silent about drift afterwards. This asks the installation instead. + +```sh +d365fo index cross-check # gaps only +d365fo index cross-check --show-uncovered # plus families the tool does not cover +``` + +Two findings, deliberately kept apart: + +- **Gaps** are where the tool will be *wrong* — something the installation uses that a catalog + claims to cover and does not. A form pattern in the wild that the registry has never heard of + means `generate form`, `form-pattern validate` and `form-pattern repair` cannot judge those + forms, and will say so in the confident voice of a tool that has a catalog. Exit code 2. +- **Uncovered** families are where the tool is merely *narrow* — an AOT folder it was never built + to handle. On a real installation that is dozens of entries (40 of 83 on the box this was + written against), so it is off by default and never fails the command. + +The fix for a gap is to regenerate the named catalog on the installation that produced it +(`scripts/emit-form-patterns.ps1`, `scripts/emit-metadata-contracts.ps1`), not to hand-add an entry. + --- ## Search & Discovery @@ -203,6 +228,7 @@ All scaffolders write atomically (`.tmp` + move, `.bak` on overwrite). Pass `--i | `generate security-policy` | `AxSecurityPolicy` (XDS row-level security), including the nested `--constrained` table tree | | `generate systest` | `SysTestCase` skeleton — `[SysTestMethod]` Arrange/Act/Assert stub, optional `[SysTestCaseDataDependency]` and `--atl` `AtlDataRootNode` wiring (ATL-ready MVP, no test-logic generation) | | `generate migration-script` | Data-fix `Runnable` class with `ttsbegin`/`ttscommit` batching | +| `generate form-clone` | Copy of an existing `AxForm` under a new name, datasources optionally re-bound | | `generate simple-list` | Alias for `generate form --pattern SimpleList` | | `modify method` | Replace an existing method's body on a live class/table/edt/form via D365FO.Bridge (`IMetadataProvider`, structured `XDocument` replace — no CDATA string surgery, no on-disk fallback). Reference/BP validation always blocks on error-severity findings. | | `modify property` | Set a property (`Label`, `ConfigurationKey`, `TableGroup`, …) on a live object. | @@ -225,6 +251,32 @@ same object already uses, so a model does not accumulate `CustTable.Fleet` next Every `modify` write (including `modify method`) records its exact pre-image in the modification journal — revert with `d365fo undo`. +#### Cloning a reference form + +A Microsoft form that already has the pattern, the control tree and the wiring right is a better +starting point than any template, and cloning one is what a developer does by hand anyway. + +```sh +d365fo generate form-clone ConVehicleGroup --from CustGroup --rebind CustGroup=ConVehicleGroupTable --out ConVehicleGroup.xml +``` + +`--from` takes a form name (resolved through the index) or a path to the AxForm XML. `--rebind` +moves a datasource onto another table, renames the datasource when it was named after the old +table, and follows that rename into every control that referenced it — including the datasource +entry under ``, where override methods live. + +The edits are string-level and narrow. An `AxForm` is a V6 contract whose Design subtree is +written in the empty namespace with `i:type` on every control, so loading it into an `XDocument` +and writing it back rewrites namespace declarations nobody asked to change — which is also why +`FormPatternTemplates` renders forms as strings. Verified against a shipped 16 KB `CustGroup` +form: the clone differs on exactly the intended lines and is byte-identical everywhere else. + +What it deliberately does *not* do is a blind replace of the old form name. Form names are short +and appear inside unrelated identifiers (`CustGroup` inside `Grid_CustGroupId`), so only the root +``, the class declaration and `formStr()` self-references move. Everything it cannot reach — +menu items, privileges, extensions, callers elsewhere in the AOT — comes back as a warning, as +does the fact that a rebind does not check the new table actually has the bound fields. + #### The grounding gate Every `generate` subcommand runs the same gate before it writes anything — not just the diff --git a/src/D365FO.Cli/CliApp.cs b/src/D365FO.Cli/CliApp.cs index 65301c5..b79a99a 100644 --- a/src/D365FO.Cli/CliApp.cs +++ b/src/D365FO.Cli/CliApp.cs @@ -216,6 +216,7 @@ public static CommandApp Build(Spectre.Console.IAnsiConsole? console = null) b.AddCommand("extract").WithDescription("Walk PACKAGES_PATH and ingest AOT metadata."); b.AddCommand("refresh").WithDescription("Incremental extract — skip models whose XMLs haven't changed since last extract."); b.AddCommand("history").WithDescription("Show recent ExtractionRuns (per-model timings persisted across runs)."); + b.AddCommand("cross-check").WithDescription("Report where this tool's catalogs are narrower than the installation."); b.AddCommand("optimize").WithDescription("VACUUM + ANALYZE the index (reclaim space, refresh query-planner stats)."); b.AddCommand("export").WithDescription("Export index as a GZip-compressed snapshot for sharing or CI caching."); b.AddCommand("import").WithDescription("Import a GZip-compressed index snapshot."); @@ -238,6 +239,7 @@ public static CommandApp Build(Spectre.Console.IAnsiConsole? console = null) b.AddCommand("form").WithDescription("Create an AxForm with a chosen pattern (SimpleList, DetailsMaster, DetailsTransaction, Dialog, Lookup, ListPage, Workspace, …)."); b.AddCommand("datasource-method").WithDescription("Add/override a method on a form datasource (form-level SourceCode). Omit --method to list overridable methods."); b.AddCommand("control-method").WithDescription("Add/override a method on a form control (form-level SourceCode). Omit --method to list overridable methods."); + b.AddCommand("form-clone").WithDescription("Clone an existing AxForm under a new name, optionally re-binding its datasources."); b.AddCommand("simple-list").WithDescription("(Deprecated) Alias for `generate form --pattern SimpleList`."); b.AddCommand("entity").WithDescription("Create an AxDataEntityView over a table."); b.AddCommand("extension").WithDescription("Create a Table/Form/Edt/Enum extension."); diff --git a/src/D365FO.Cli/Commands/Generate/GenerateFormCloneCommand.cs b/src/D365FO.Cli/Commands/Generate/GenerateFormCloneCommand.cs new file mode 100644 index 0000000..f862b03 --- /dev/null +++ b/src/D365FO.Cli/Commands/Generate/GenerateFormCloneCommand.cs @@ -0,0 +1,161 @@ +using D365FO.Core; +using D365FO.Core.FormPatterns; +using D365FO.Core.Scaffolding; +using Spectre.Console.Cli; + +using static D365FO.Core.ObjectTypes.ObjectTypeRegistry; + +namespace D365FO.Cli.Commands.Generate; + +/// +/// Clone an existing form under a new name, optionally re-binding its datasources. +/// +/// +/// Issue #164 / R5. A Microsoft form that already has the pattern, the control tree and the +/// wiring right is a better starting point than any template, and cloning one is what a developer +/// does by hand anyway. The edits are string-level and narrow — see for +/// why a round-trip through XDocument would return a form that differs from the original +/// in ways nobody asked for. +/// +public sealed class GenerateFormCloneCommand : Command +{ + public sealed class Settings : GenerateSettings + { + [CommandArgument(0, "")] + [System.ComponentModel.Description("Name for the clone.")] + public string Name { get; init; } = ""; + + [CommandOption("--from
")] + [System.ComponentModel.Description("Reference form: a form name resolved through the index, or a path to its AxForm XML.")] + public string? From { get; init; } + + [CommandOption("--rebind ")] + [System.ComponentModel.Description("Repeatable: =. Moves the datasource, its name when it matched the table, and every control that references it.")] + public string[] Rebind { get; init; } = Array.Empty(); + } + + public override int Execute(CommandContext ctx, Settings settings) + { + var kind = OutputMode.Resolve(settings.Output); + + if (string.IsNullOrWhiteSpace(settings.Name)) + return RenderHelpers.Render(kind, ToolResult.Fail(D365FoErrorCodes.BadInput, "Clone name required.")); + if (string.IsNullOrWhiteSpace(settings.From)) + return RenderHelpers.Render(kind, ToolResult.Fail(D365FoErrorCodes.BadInput, "--from required.")); + + var (sourceXml, readError) = ReadSourceForm(settings.From!); + if (readError is not null) + return RenderHelpers.Render(kind, ToolResult.Fail(D365FoErrorCodes.SourceUnreadable, readError)); + + if (!TryParseRebinds(settings.Rebind, out var rebinds, out var rebindError)) + return RenderHelpers.Render(kind, ToolResult.Fail(D365FoErrorCodes.BadInput, rebindError!)); + + var hasInstall = !string.IsNullOrWhiteSpace(settings.InstallTo); + var hasOut = !string.IsNullOrWhiteSpace(settings.Out); + if (!hasInstall && !hasOut) + return RenderHelpers.Render(kind, ToolResult.Fail(D365FoErrorCodes.BadInput, "--out or --install-to is required.")); + + var outPath = settings.Out; + if (hasInstall && !hasOut) + { + outPath = GenerateInstaller.ResolveInstallPath(kind, Folders.Form, settings.Name, settings.InstallTo!, out var fail); + if (fail.HasValue) return fail.Value; + } + + FormCloneResult clone; + try { clone = FormCloner.Clone(sourceXml!, settings.Name, rebinds); } + catch (FormCloneException ex) + { + return RenderHelpers.Render(kind, ToolResult.Fail("CLONE_FAILED", ex.Message)); + } + + // The clone claims the tables it was rebound onto exist — that is the one thing here the + // index can prove, and the reason this goes through the gate like every other generate. + var gate = GenerateInstaller.Gate( + settings, settings.Name, doc: null, + requiredSymbols: rebinds.Values); + if (gate.Failure is not null) return RenderHelpers.Render(kind, gate.Failure); + + var warnings = gate.Warnings; + warnings.AddRange(clone.Warnings); + + try + { + var res = GenerateInstaller.Write(gate, clone.Xml, outPath!, settings.Overwrite); + return RenderHelpers.Render(kind, ToolResult.Success(new + { + kind = "AxForm", + role = "Clone", + name = settings.Name, + from = settings.From, + rebound = clone.Rebound, + renamedDataSources = clone.RenamedDataSources, + path = res.Path, + bytes = res.Bytes, + backup = res.BackupPath, + model = settings.InstallTo, + grounding = gate.Grounding, + }, warnings)); + } + catch (Exception ex) + { + return RenderHelpers.Render(kind, ToolResult.Fail(D365FoErrorCodes.WriteFailed, ex.Message)); + } + } + + /// + /// Read the reference form, from a path or by name through the index. + /// + /// + /// A path wins when it exists, so a developer can clone a form they have in front of them + /// without an index. The name route reads SourcePath off the index and then reads the + /// file — the index stores metadata, not the document. + /// + private static (string? Xml, string? Error) ReadSourceForm(string from) + { + if (File.Exists(from)) + { + try { return (File.ReadAllText(from), null); } + catch (Exception ex) { return (null, $"Could not read '{from}': {ex.Message}"); } + } + + try + { + var details = RepoFactory.Create().GetForm(from); + if (details is null) + return (null, $"Form '{from}' is not in the index, and no file exists at that path. " + + "Run `d365fo index extract`, or pass the path to the AxForm XML."); + + var path = details.Form.SourcePath; + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + return (null, $"The index knows form '{from}' but its source file is not at '{path}'. " + + "The index is a cache and is never invalidated on delete — re-run `d365fo index refresh`."); + + return (File.ReadAllText(path), null); + } + catch (Exception ex) + { + return (null, $"Could not resolve form '{from}': {ex.Message}"); + } + } + + private static bool TryParseRebinds( + string[] raw, out Dictionary rebinds, out string? error) + { + rebinds = new Dictionary(StringComparer.OrdinalIgnoreCase); + error = null; + + foreach (var spec in raw.Where(r => !string.IsNullOrWhiteSpace(r))) + { + var parts = spec.Split('=', 2, StringSplitOptions.TrimEntries); + if (parts.Length != 2 || parts[0].Length == 0 || parts[1].Length == 0) + { + error = $"Invalid --rebind '{spec}'. Expected =."; + return false; + } + rebinds[parts[0]] = parts[1]; + } + + return true; + } +} diff --git a/src/D365FO.Cli/Commands/Index/IndexCrossCheckCommand.cs b/src/D365FO.Cli/Commands/Index/IndexCrossCheckCommand.cs new file mode 100644 index 0000000..23beeb9 --- /dev/null +++ b/src/D365FO.Cli/Commands/Index/IndexCrossCheckCommand.cs @@ -0,0 +1,81 @@ +using D365FO.Core; +using D365FO.Core.Analysis; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace D365FO.Cli.Commands.Index; + +/// +/// Reports where this tool's catalogs are narrower than the installation in front of you. +/// +/// +/// Issue #164 / R5. Every catalog the tool answers from — the form-pattern registry, the object-type +/// registry, the DataContract catalog — is generated from one platform version and then committed. +/// That makes it right when it was made and silent about drift afterwards. This command asks the +/// installation instead, and reports what the catalogs do not cover. +/// Exit codes: 0 = no gaps (or skipped), 1 = command failure, 2 = gaps found. +/// +public sealed class IndexCrossCheckCommand : Command +{ + public sealed class Settings : D365OutputSettings + { + [CommandOption("--packages ")] + [System.ComponentModel.Description("Packages root to sweep for AOT folders. Defaults to D365FO_PACKAGES_PATH; the form-pattern half runs without it.")] + public string? PackagesPath { get; init; } + + [CommandOption("--show-uncovered")] + [System.ComponentModel.Description("Also list AOT families this tool does not cover. Off by default — on a real installation it is dozens of entries and none of them is a defect.")] + public bool ShowUncovered { get; init; } + } + + public override int Execute(CommandContext ctx, Settings settings) + { + var kind = OutputMode.Resolve(settings.Output); + var cfg = D365FoSettings.FromEnvironment(); + + CrossCheckReport report; + try + { + var repo = RepoFactory.Create(); + report = CatalogCrossCheck.Run(repo, settings.PackagesPath ?? cfg.PackagesPath); + } + catch (Exception ex) + { + return RenderHelpers.Render(kind, ToolResult.Fail("NO_INDEX", + $"Cross-check needs the SQLite index: {ex.Message}", + "Run `d365fo index build` then `d365fo index extract` first.")); + } + + var result = ToolResult.Success(new + { + clean = report.Clean, + objectsConsidered = report.ObjectsConsidered, + gaps = report.Gaps.Select(g => new { catalog = g.Catalog, item = g.Item, observed = g.Observed, detail = g.Detail }), + uncoveredCount = report.Uncovered.Count, + uncovered = settings.ShowUncovered + ? report.Uncovered.Select(u => new { folder = u.Folder, models = u.Models }) + : null, + unusedCount = report.Unused.Count, + verdict = report.Clean + ? "Every pattern and family the installation uses is covered by a catalog that claims to cover it." + : $"{report.Gaps.Count} catalog gap(s) — the tool will answer wrongly about these. Regenerate the catalog named in each.", + }); + + var rc = RenderHelpers.Render(kind, result, _ => + { + foreach (var g in report.Gaps) + { + AnsiConsole.MarkupLine($"[red]{RenderHelpers.Escape(g.Catalog)}[/] {RenderHelpers.Escape(g.Item)} [grey]({g.Observed})[/]"); + AnsiConsole.MarkupLine($" [grey]{RenderHelpers.Escape(g.Detail)}[/]"); + } + if (settings.ShowUncovered) + foreach (var u in report.Uncovered) + AnsiConsole.MarkupLine($"[yellow]uncovered[/] {RenderHelpers.Escape(u.Folder)} [grey]({u.Models} model(s))[/]"); + AnsiConsole.MarkupLine(report.Clean + ? $"[green]no catalog gaps[/] ({report.Uncovered.Count} uncovered famil(ies), {report.Unused.Count} unused entr(ies))" + : $"[red]{report.Gaps.Count} catalog gap(s)[/], {report.Uncovered.Count} uncovered famil(ies)"); + }); + + return rc != 0 ? rc : report.Clean ? 0 : 2; + } +} diff --git a/src/D365FO.Core/Analysis/CatalogCrossCheck.cs b/src/D365FO.Core/Analysis/CatalogCrossCheck.cs new file mode 100644 index 0000000..2a95840 --- /dev/null +++ b/src/D365FO.Core/Analysis/CatalogCrossCheck.cs @@ -0,0 +1,248 @@ +using D365FO.Core.FormPatterns; +using D365FO.Core.Index; +using D365FO.Core.Metadata; +using D365FO.Core.ObjectTypes; + +namespace D365FO.Core.Analysis; + +/// Something the installation uses that a catalog in this repo does not know about. +/// Which catalog is short. +/// The thing that was observed. +/// How many indexed objects use it. +/// What the gap means for anyone relying on that catalog. +public sealed record CatalogGap(string Catalog, string Item, long Observed, string Detail); + +/// A catalog entry no indexed object uses. Informational, never a defect on its own. +/// Which catalog it belongs to. +/// The unused entry. +public sealed record UnusedCatalogEntry(string Catalog, string Item); + +/// An AOT family present on the installation that this tool does not cover. +/// The AOT folder name. +/// How many model folders contain it. +public sealed record UncoveredFamily(string Folder, long Models); + +/// The result of one cross-check pass. +/// +/// The three lists are deliberately separate, and only one of them is a verdict. +/// is where the tool will be wrong: something the installation uses +/// that a catalog claims to cover and does not. is where the tool is +/// merely narrow — an AOT family it was never built to handle — which on a real +/// installation is dozens of entries and would drown the first list if the two were mixed. +/// is the opposite direction and is evidence of nothing on its own. +/// +public sealed record CrossCheckReport( + IReadOnlyList Gaps, + IReadOnlyList Uncovered, + IReadOnlyList Unused, + long ObjectsConsidered) +{ + /// Nothing the installation uses is missing from a catalog that claims to cover it. + public bool Clean => Gaps.Count == 0; +} + +/// +/// Compares what the index actually observed in a real installation against the catalogs this +/// repo ships, and reports what the catalogs are missing. +/// +/// +/// +/// Issue #164 / R5's crossCheck. Every catalog here is derived from a Microsoft assembly +/// or ground-truthed against shipped files, which makes them right at the moment they were +/// generated and says nothing about the installation in front of you. A platform update adds a +/// form pattern, a model introduces an AOT folder, an ISV ships an object whose root type the +/// contract catalog predates — and the tool keeps answering confidently from a catalog that no +/// longer covers what is on disk. +/// +/// +/// Worth running after every index extract: the form-pattern half is one query, and the +/// AOT-folder half is a two-level directory sweep of the packages root — the same walk the +/// extractor already does, without reading a file. +/// +/// +/// Severity is split rather than ranked, because the two findings mean different things. A +/// is where the tool will be wrong. An +/// is where it is merely narrow — and on a real +/// installation that is dozens of entries (40 of the 83 AOT folders present on the box this was +/// written against), which would bury the first list entirely if the two were mixed. +/// +/// +public static class CatalogCrossCheck +{ + /// + /// <Pattern> values that mean "this form has no pattern", not a pattern the + /// registry should know. + /// + /// + /// (none) is the index's own placeholder for a missing element. Custom is the + /// AOT's, and it is the one that matters: it is the fourth most common value on a real + /// installation, so treating it as a pattern reports the largest catalog gap in the report + /// and it is not a gap at all. Ground-truthed — of 143 sampled forms with + /// Pattern=Custom, every single one has no PatternVersion, which is what a + /// real pattern always carries. + /// + private static readonly IReadOnlySet NonPatterns = + new HashSet(StringComparer.OrdinalIgnoreCase) { "(none)", "Custom" }; + + public const string FormPatternCatalog = "form-patterns"; + public const string ObjectTypeCatalog = "object-types"; + public const string ContractCatalog = "metadata-contracts"; + + /// Run every cross-check. + /// The index, for what the extractor actually saw. + /// + /// A packages root to sweep for AOT folders. Skipped when null or absent — the form-pattern + /// half still runs, because it needs only the index. + /// + public static CrossCheckReport Run(MetadataRepository repo, string? packagesPath = null) + { + ArgumentNullException.ThrowIfNull(repo); + + var gaps = new List(); + var uncovered = new List(); + var unused = new List(); + long considered = 0; + + considered += CheckFormPatterns(repo, gaps, unused); + considered += CheckAotFolders(packagesPath, gaps, uncovered); + + return new CrossCheckReport(gaps, uncovered, unused, considered); + } + + /// + /// Every form pattern the installation uses has to be one the registry knows. + /// + /// + /// This is the check the predecessor's crossCheck existed for, and it is the one with teeth: + /// generate form, form-pattern validate and form-pattern repair all + /// answer from the registry, so a pattern in the wild that it has never heard of is a form + /// this tool cannot judge — and it will say so in the confident voice of a tool that has a + /// catalog. The registry is derived from + /// Microsoft.Dynamics.AX.Metadata.Patterns.dll by scripts/emit-form-patterns.ps1, + /// so the fix for a gap is to regenerate it on the installation that produced it, not to + /// hand-add an entry. + /// + private static long CheckFormPatterns( + MetadataRepository repo, List gaps, List unused) + { + List observed; + try { observed = repo.SummarizeFormPatterns().ToList(); } + catch (Exception) { return 0; } // no Forms table yet — nothing observed, nothing to say + + long considered = 0; + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var row in observed) + { + considered += row.Count; + if (string.IsNullOrWhiteSpace(row.Pattern) || NonPatterns.Contains(row.Pattern)) + continue; + + seen.Add(row.Pattern); + + // Name-level, not name+version: a version the registry has not caught up with is a + // far weaker signal than a pattern it has never heard of, and reporting both at + // once buries the one that matters. + if (FormPatternRegistry.VersionsOf(row.Pattern).Count > 0) continue; + + gaps.Add(new CatalogGap( + FormPatternCatalog, row.Pattern, row.Count, + $"{row.Count} indexed form(s) use the '{row.Pattern}' pattern and the registry has no " + + "entry for it, so `generate form`, `form-pattern validate` and `form-pattern repair` " + + "cannot judge them. Regenerate with scripts/emit-form-patterns.ps1 on this installation.")); + } + + foreach (var known in FormPatternRegistry.All.Where(p => p.Active).Select(p => p.Name).Distinct(StringComparer.OrdinalIgnoreCase)) + if (!seen.Contains(known)) + unused.Add(new UnusedCatalogEntry(FormPatternCatalog, known)); + + return considered; + } + + /// + /// Every AOT folder present on disk has to be one the registry names, and its root type one + /// the contract catalog declares. + /// + /// + /// + /// This is where drift actually bites. The extractor only walks folders + /// names, so a folder it does not know is not indexed at + /// all — it is simply invisible, and every "not found in the index" answer about an object + /// living there is wrong in the most convincing possible way. A platform update that adds an + /// AOT family produces exactly that, silently. + /// + /// + /// The contract half is the other direction of the same staleness: a known family whose root + /// type the catalog does not declare gets no XML007/XML008 and no contract-order + /// canonicalisation, because both stand aside for a type they do not recognise. That is a + /// whole family of objects quietly exempt from the checks the rest get. + /// + /// + private static long CheckAotFolders( + string? packagesPath, List gaps, List uncovered) + { + if (string.IsNullOrWhiteSpace(packagesPath) || !Directory.Exists(packagesPath)) return 0; + + Dictionary folders; + try { folders = SweepAotFolders(packagesPath!); } + catch (Exception) { return 0; } // an unreadable packages root is not this check's business + + long considered = 0; + foreach (var (folder, count) in folders.OrderByDescending(f => f.Value)) + { + considered += count; + + var type = ObjectTypeRegistry.Find(folder); + if (type is null || !string.Equals(type.AotSubfolder, folder, StringComparison.OrdinalIgnoreCase)) + { + // Not a defect: the registry covers what this tool supports, and a real + // installation has dozens of families it does not. Reported so the narrowness is + // visible and can be triaged, not so it can be treated as a failure. + uncovered.Add(new UncoveredFamily(folder, count)); + continue; + } + + if (MetadataContracts.Find(type.RootElement) is null) + { + gaps.Add(new CatalogGap( + ContractCatalog, type.RootElement, count, + $"'{folder}' holds <{type.RootElement}> objects, which the contract catalog does not " + + "declare — XML007/XML008 and the contract-order canonicaliser stand aside for the " + + "whole family. Regenerate with scripts/emit-metadata-contracts.ps1 on this installation.")); + } + } + + return considered; + } + + /// + /// Distinct Ax* folder names under a packages root, and how many models hold each. + /// + /// + /// Two levels down (<packages>\<Package>\<Model>\Ax*) because that is + /// the layout a real installation has, and enumerated rather than globbed so an unreadable + /// package does not abort the sweep — a cross-check that stops at the first permission error + /// reports "no gaps" for a directory it never finished reading. + /// + private static Dictionary SweepAotFolders(string packagesPath) + { + var counts = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var package in SafeDirectories(packagesPath)) + foreach (var model in SafeDirectories(package)) + foreach (var aot in SafeDirectories(model)) + { + var name = Path.GetFileName(aot); + if (!name.StartsWith("Ax", StringComparison.Ordinal)) continue; + counts[name] = counts.TryGetValue(name, out var n) ? n + 1 : 1; + } + + return counts; + } + + private static IEnumerable SafeDirectories(string path) + { + try { return Directory.EnumerateDirectories(path); } + catch (Exception) { return Array.Empty(); } + } +} diff --git a/src/D365FO.Core/FormPatterns/FormCloner.cs b/src/D365FO.Core/FormPatterns/FormCloner.cs new file mode 100644 index 0000000..5114124 --- /dev/null +++ b/src/D365FO.Core/FormPatterns/FormCloner.cs @@ -0,0 +1,195 @@ +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace D365FO.Core.FormPatterns; + +/// The outcome of cloning a form. +/// The cloned document. +/// Datasources whose name changed because their table did. +/// Table rebinds that were actually applied. +/// Things the caller has to finish by hand. +public sealed record FormCloneResult( + string Xml, + IReadOnlyList RenamedDataSources, + IReadOnlyList Rebound, + IReadOnlyList Warnings); + +/// Raised when the source is not a form, or the clone would produce a broken one. +public sealed class FormCloneException(string message) : Exception(message); + +/// +/// Clones a reference form under a new name, optionally re-binding its datasources to other +/// tables. +/// +/// +/// +/// Issue #164 / R5's formCloner. Starting from a Microsoft form that already has the +/// pattern, the control tree and the wiring right is a far better starting point than a template, +/// and it is what a developer does by hand anyway. +/// +/// +/// String-level by design, and this repo has the evidence for it. An AxForm is a V6 +/// contract whose Design subtree is written in the empty namespace, carries +/// i:type discriminators on every control and <FormControlExtension i:nil="true" /> +/// on all of them. Loading that into an and writing it back reorders +/// namespace declarations and rewrites prefixes, which is why FormPatternTemplates renders +/// forms as strings rather than building them as documents. A cloner that round-tripped would +/// return a form that differs from the original in ways nobody asked for, on every clone. +/// +/// +/// So every edit here is anchored and narrow. The one thing this deliberately does not do is a +/// blind replace of the old form name: form names are short and appear inside unrelated +/// identifiers (CustTable inside CustTableListPage), and a global replace would +/// quietly corrupt references to other objects. What it changes is the root +/// <Name>, the class declaration, and formStr() self-references — anything +/// else it finds, it reports rather than touches. +/// +/// +public static class FormCloner +{ + /// + /// Clone as . + /// + /// The reference form's XML, exactly as on disk. + /// Name for the clone. + /// + /// Old table name → new table name. A datasource whose name matched its old table is renamed + /// with it, and every control bound to that datasource follows. + /// + public static FormCloneResult Clone( + string sourceXml, string newName, IReadOnlyDictionary? tableRebinds = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sourceXml); + ArgumentException.ThrowIfNullOrWhiteSpace(newName); + + var sourceName = ReadFormName(sourceXml) + ?? throw new FormCloneException("Source is not an AxForm document: no root found."); + + if (string.Equals(sourceName, newName, StringComparison.Ordinal)) + throw new FormCloneException($"The clone's name is the same as the source's ('{sourceName}')."); + + var warnings = new List(); + var xml = sourceXml; + + // 1. The root . Anchored to the first one in the document, which is the form's own: + // every nested Name belongs to a datasource, a control or a method. + xml = ReplaceFirst(xml, $"{Regex.Escape(sourceName)}", $"{newName}") + ?? throw new FormCloneException($"Could not rewrite the root of '{sourceName}'."); + + // 2. The X++ class declaration. A form's class is named after the form, and a clone whose + // declaration still names the original does not compile. + xml = Regex.Replace( + xml, + $@"(?<=\bclass\s+){Regex.Escape(sourceName)}(?=\s+extends\b)", + newName); + + // 3. formStr() self-references — a form that hands its own name to the framework. + xml = Regex.Replace( + xml, + $@"(?<=\bformStr\s*\(\s*){Regex.Escape(sourceName)}(?=\s*\))", + newName); + + var (rebound, renamed) = RebindTables(ref xml, tableRebinds, warnings); + + // Menu items, security privileges and display-menu references point at the source by + // name and live outside this document; nothing here can fix them. + warnings.Add( + $"'{newName}' is a copy of '{sourceName}'. Anything outside the form that referenced " + + $"'{sourceName}' — menu items, privileges, extensions, callers using formStr — still " + + "points at the original."); + + EnsureStillWellFormed(xml, newName); + + return new FormCloneResult(xml, renamed, rebound, warnings); + } + + /// The form's own name, or null when the document is not a form. + public static string? ReadFormName(string xml) + { + var match = Regex.Match(xml, @"(?[^<]+)"); + return match.Success ? match.Groups["n"].Value.Trim() : null; + } + + /// + /// Point the clone's datasources at different tables. + /// + /// + /// A datasource is conventionally named after its table, and controls refer to it by that + /// name — so rebinding the table without renaming the datasource leaves a form whose + /// datasource is called CustTable and reads VendTable, which compiles and is + /// a lie. When the names do not match, the datasource keeps its name and only the table + /// moves, because the name was chosen deliberately. + /// + private static (List Rebound, List Renamed) RebindTables( + ref string xml, IReadOnlyDictionary? rebinds, List warnings) + { + var rebound = new List(); + var renamed = new List(); + if (rebinds is null || rebinds.Count == 0) return (rebound, renamed); + + foreach (var (oldTable, newTable) in rebinds) + { + if (string.IsNullOrWhiteSpace(oldTable) || string.IsNullOrWhiteSpace(newTable)) continue; + + var tableTag = $"{Regex.Escape(oldTable)}
"; + if (!Regex.IsMatch(xml, tableTag)) + { + warnings.Add($"No datasource is bound to '{oldTable}', so that rebind did nothing."); + continue; + } + + xml = Regex.Replace(xml, tableTag, $"{newTable}
"); + rebound.Add($"{oldTable} -> {newTable}"); + + // The datasource element that carried the table, and every control pointing at it. + var dsName = $"{Regex.Escape(oldTable)}"; + if (Regex.IsMatch(xml, dsName)) + { + xml = Regex.Replace(xml, dsName, $"{newTable}"); + xml = Regex.Replace(xml, $"{Regex.Escape(oldTable)}", $"{newTable}"); + renamed.Add($"{oldTable} -> {newTable}"); + } + + warnings.Add( + $"Fields bound through '{oldTable}' were not checked against '{newTable}'. A " + + " naming a column the new table does not have is a form that compiles " + + "and fails at runtime — run `d365fo validate references` over the result."); + } + + return (rebound, renamed); + } + + /// + /// The clone has to still be a parseable form carrying its new name. + /// + /// + /// The edits are regex-driven over a document this code does not own, so the cheap + /// structural assertion is worth its cost: returning a corrupted form would be worse than + /// refusing to clone. + /// + private static void EnsureStillWellFormed(string xml, string newName) + { + XDocument doc; + try { doc = XDocument.Parse(xml); } + catch (System.Xml.XmlException ex) + { + throw new FormCloneException($"The clone is not well-formed XML: {ex.Message}"); + } + + var root = doc.Root ?? throw new FormCloneException("The clone has no root element."); + if (root.Name.LocalName != "AxForm") + throw new FormCloneException($"Expected an root, got <{root.Name.LocalName}>."); + + var name = root.Elements().FirstOrDefault(e => e.Name.LocalName == "Name")?.Value; + if (!string.Equals(name, newName, StringComparison.Ordinal)) + throw new FormCloneException($"The clone's root is '{name}', expected '{newName}'."); + } + + private static string? ReplaceFirst(string haystack, string pattern, string replacement) + { + var match = Regex.Match(haystack, pattern); + return match.Success + ? haystack[..match.Index] + replacement + haystack[(match.Index + match.Length)..] + : null; + } +} diff --git a/src/D365FO.Core/ObjectTypes/GenerateSurface.cs b/src/D365FO.Core/ObjectTypes/GenerateSurface.cs index 721787b..111535e 100644 --- a/src/D365FO.Core/ObjectTypes/GenerateSurface.cs +++ b/src/D365FO.Core/ObjectTypes/GenerateSurface.cs @@ -44,6 +44,7 @@ public static class GenerateSurface new("form", "AxForm in one of nine patterns", [Root.Form]), new("datasource-method", "Method override on a form datasource", [Root.Form]), new("control-method", "Method override on a form control", [Root.Form]), + new("form-clone", "Copy of an existing AxForm under a new name, datasources optionally re-bound", [Root.Form]), new("simple-list", "Alias for `form --pattern SimpleList`", [Root.Form], Deprecated: true), new("entity", "AxDataEntityView over a table", [Root.DataEntityView]), new("extension", "Table/Form/Edt/Enum/View/Query/Entity/Duty/Role extension", diff --git a/src/D365FO.Core/Scaffolding/PropertyHonesty.cs b/src/D365FO.Core/Scaffolding/PropertyHonesty.cs index a3c6d23..92595eb 100644 --- a/src/D365FO.Core/Scaffolding/PropertyHonesty.cs +++ b/src/D365FO.Core/Scaffolding/PropertyHonesty.cs @@ -66,6 +66,7 @@ public static IReadOnlyList Reconcile( foreach (var (option, value) in requested) { if (string.IsNullOrWhiteSpace(value)) continue; + if (LooksLikeAPath(value)) continue; foreach (var part in Parts(value)) { @@ -113,6 +114,36 @@ private static string Haystack(string writtenXml) /// splitting them is what made --constrained Header/Line report a gap for a policy /// that had both tables, because no single element holds the path as written. /// + /// + /// Whether a requested value names a file rather than a property of the generated object. + /// + /// + /// Not every option a command declares describes the object it produces. --from names + /// a reference form to clone, --add-to and --into-role name documents to merge + /// into, and the --out-* family names where companion artefacts go. None of those + /// values can appear inside the AOT XML, so reconciling them reports one gap per path + /// segment — AosService, PackagesLocalDirectory, CustGroup.xml — and + /// buries the findings that mean something. + /// + /// Rooted-or-has-an-extension rather than "contains a separator", because / is a + /// meaningful separator in real option values: --constrained Header/Line nests a + /// policy's constrained-table tree and every segment of it genuinely has to reach the + /// document. + /// + /// + private static bool LooksLikeAPath(string value) + { + var trimmed = value.Trim(); + try + { + if (Path.IsPathRooted(trimmed)) return true; + } + catch (ArgumentException) { return false; } + + return Path.GetExtension(trimmed).Length > 1 + && (trimmed.Contains('/') || trimmed.Contains('\\')); + } + private static IEnumerable Parts(string value) { foreach (var raw in value.Split(CompositeSeparators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) diff --git a/tests/D365FO.Core.Tests/CatalogCrossCheckTests.cs b/tests/D365FO.Core.Tests/CatalogCrossCheckTests.cs new file mode 100644 index 0000000..211b100 --- /dev/null +++ b/tests/D365FO.Core.Tests/CatalogCrossCheckTests.cs @@ -0,0 +1,172 @@ +using D365FO.Core.Analysis; +using D365FO.Core.Index; +using D365FO.Core.FormPatterns; +using Xunit; + +namespace D365FO.Core.Tests; + +/// +/// Issue #164 / R5 — the mined-usage cross-check: what the installation uses versus what this +/// repo's catalogs claim to know. +/// +public sealed class CatalogCrossCheckTests : IDisposable +{ + private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"crosscheck-{Guid.NewGuid():N}.sqlite"); + private readonly string _packages = Path.Combine(Path.GetTempPath(), $"crosscheck-pkg-{Guid.NewGuid():N}"); + private readonly MetadataRepository _repo; + + public CatalogCrossCheckTests() + { + _repo = new MetadataRepository(_dbPath); + _repo.EnsureSchema(); + Directory.CreateDirectory(_packages); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + foreach (var ext in new[] { "", "-wal", "-shm" }) + { + var p = _dbPath + ext; + if (File.Exists(p)) { try { File.Delete(p); } catch { } } + } + try { Directory.Delete(_packages, recursive: true); } catch { } + } + + private void SeedForms(params (string Name, string? Pattern)[] forms) => + _repo.ApplyExtract(new ExtractBatch( + Model: "ConFleet", Publisher: "Contoso", Layer: "isv", IsCustom: true, + Tables: [], Classes: [], Edts: [], Enums: [], MenuItems: [], CocExtensions: [], Labels: []) + { + Forms = forms.Select(f => new ExtractedForm(f.Name, null, Array.Empty()) + { + Pattern = f.Pattern, + PatternVersion = f.Pattern is null ? null : "1.0", + }).ToArray(), + }); + + /// Create <packages>/<Package>/<Model>/<folder>. + private void SeedAotFolder(string package, string folder) => + Directory.CreateDirectory(Path.Combine(_packages, package, package, folder)); + + // ── form patterns ──────────────────────────────────────────────────────── + + [Fact] + public void A_pattern_the_registry_knows_is_not_a_gap() + { + SeedForms(("ConVehicleListPage", "SimpleList")); + + var report = CatalogCrossCheck.Run(_repo); + + Assert.True(report.Clean); + Assert.Empty(report.Gaps); + } + + [Fact] + public void A_pattern_in_use_that_the_registry_has_never_heard_of_is_a_gap() + { + SeedForms(("ConVehicleHub", "ConInventedPattern"), ("ConVehicleHub2", "ConInventedPattern")); + + var report = CatalogCrossCheck.Run(_repo); + + var gap = Assert.Single(report.Gaps); + Assert.Equal(CatalogCrossCheck.FormPatternCatalog, gap.Catalog); + Assert.Equal("ConInventedPattern", gap.Item); + Assert.Equal(2, gap.Observed); + Assert.Contains("emit-form-patterns.ps1", gap.Detail); + Assert.False(report.Clean); + } + + [Fact] + public void Custom_is_the_AOT_marker_for_no_pattern_and_is_never_a_gap() + { + // The most important false positive to suppress: Custom is the fourth most common + // value on a real installation, and every form carrying it has no + // PatternVersion. Treating it as a pattern reports the largest "gap" in the report and + // it is not a gap at all. + SeedForms(("ConVehicleCustom", "Custom"), ("ConVehicleNone", null)); + + var report = CatalogCrossCheck.Run(_repo); + + Assert.True(report.Clean); + } + + [Fact] + public void A_pattern_the_registry_knows_but_nothing_uses_is_reported_separately() + { + SeedForms(("ConVehicleListPage", "SimpleList")); + + var report = CatalogCrossCheck.Run(_repo); + + Assert.NotEmpty(report.Unused); + Assert.All(report.Unused, u => Assert.Equal(CatalogCrossCheck.FormPatternCatalog, u.Catalog)); + // Unused entries say nothing about correctness. + Assert.True(report.Clean); + } + + // ── AOT folders ────────────────────────────────────────────────────────── + + [Fact] + public void An_AOT_folder_the_registry_does_not_name_is_uncovered_not_a_gap() + { + // The tool covers what it was built to cover; a real installation has dozens of families + // it does not. That is narrowness, not wrongness, and mixing the two buries the findings + // that matter. + SeedAotFolder("ConFleet", "AxKPI"); + SeedAotFolder("ConFleet2", "AxKPI"); + + var report = CatalogCrossCheck.Run(_repo, _packages); + + var uncovered = Assert.Single(report.Uncovered); + Assert.Equal("AxKPI", uncovered.Folder); + Assert.Equal(2, uncovered.Models); + Assert.True(report.Clean); + } + + [Fact] + public void A_folder_the_registry_names_is_neither() + { + SeedAotFolder("ConFleet", "AxTable"); + + var report = CatalogCrossCheck.Run(_repo, _packages); + + Assert.Empty(report.Uncovered); + Assert.Empty(report.Gaps); + } + + [Fact] + public void Non_Ax_folders_are_ignored() + { + SeedAotFolder("ConFleet", "Descriptor"); + SeedAotFolder("ConFleet", "XppMetadata"); + + var report = CatalogCrossCheck.Run(_repo, _packages); + + Assert.Empty(report.Uncovered); + } + + [Fact] + public void A_missing_packages_path_skips_the_folder_half_rather_than_failing() + { + SeedForms(("ConVehicleListPage", "SimpleList")); + + var absent = CatalogCrossCheck.Run(_repo, Path.Combine(_packages, "does-not-exist")); + var none = CatalogCrossCheck.Run(_repo, null); + + Assert.Empty(absent.Uncovered); + Assert.Empty(none.Uncovered); + Assert.True(absent.Clean); + } + + // ── the check against the catalogs as shipped ──────────────────────────── + + [Fact] + public void Every_registry_pattern_name_resolves_in_the_registry_itself() + { + // Cheap self-consistency: the check trusts VersionsOf() to answer for any name the + // registry lists, so a name that does not resolve would make the cross-check report + // the catalog as short of itself. + foreach (var name in FormPatternRegistry.All.Where(p => p.Active).Select(p => p.Name).Distinct()) + Assert.NotEmpty(FormPatternRegistry.VersionsOf(name)); + } +} diff --git a/tests/D365FO.Core.Tests/FormClonerTests.cs b/tests/D365FO.Core.Tests/FormClonerTests.cs new file mode 100644 index 0000000..5ae6dbf --- /dev/null +++ b/tests/D365FO.Core.Tests/FormClonerTests.cs @@ -0,0 +1,176 @@ +using System.Text.RegularExpressions; +using System.Xml.Linq; +using D365FO.Core.FormPatterns; +using Xunit; + +namespace D365FO.Core.Tests; + +/// +/// Issue #164 / R5 — cloning a reference form under a new name. +/// +/// +/// The fixture mirrors the shape of a real shipped form: the root name, the X++ class +/// declaration, a datasource entry under <SourceCode> (where override methods live), +/// the design datasource, and a control pointing at it by name. Verified against +/// ApplicationSuite\Foundation\AxForm\CustGroup.xml on a live installation — a 16 KB form +/// where the clone differs from the source on exactly the intended lines and is byte-identical +/// everywhere else. +/// +public class FormClonerTests +{ + private const string Source = """ + + + CustGroup + + + + classDeclaration + [Form] public class CustGroup extends FormRun { void go() { formStr(CustGroup); } } + + + + + CustGroup + + + + + + + CustGroup + CustGroup
+
+
+ + + Grid_CustGroupId + CustGroupId + CustGroup + + +
+ """; + + private static Dictionary Rebind(string from, string to) => new() { [from] = to }; + + [Fact] + public void The_clone_takes_the_new_name_everywhere_the_form_names_itself() + { + var result = FormCloner.Clone(Source, "ConVehicleGroup"); + + Assert.Contains("ConVehicleGroup", result.Xml); + Assert.Contains("public class ConVehicleGroup extends FormRun", result.Xml); + Assert.Contains("formStr(ConVehicleGroup)", result.Xml); + Assert.DoesNotContain("public class CustGroup", result.Xml); + } + + [Fact] + public void Without_a_rebind_the_datasource_still_points_at_the_original_table() + { + // Cloning is not rebinding. A clone of a CustGroup form is still bound to CustGroup + // unless the caller says otherwise. + var result = FormCloner.Clone(Source, "ConVehicleGroup"); + + Assert.Contains("CustGroup
", result.Xml); + Assert.Empty(result.Rebound); + } + + [Fact] + public void A_rebind_moves_the_table_the_datasource_and_every_control_that_names_it() + { + var result = FormCloner.Clone(Source, "ConVehicleGroup", Rebind("CustGroup", "ConVehicleGroupTable")); + + Assert.Contains("ConVehicleGroupTable
", result.Xml); + Assert.Contains("ConVehicleGroupTable", result.Xml); + // Both datasource entries: the design one, and the SourceCode one holding overrides. + Assert.Equal(2, Regex.Matches(result.Xml, "ConVehicleGroupTable").Count); + Assert.Single(result.Rebound); + Assert.Single(result.RenamedDataSources); + } + + [Fact] + public void The_form_is_renamed_before_the_rebind_so_the_root_never_takes_the_table_name() + { + // Load-bearing ordering. The form, its datasource and its table are all called + // "CustGroup" here — which is the normal case — so a rebind that ran first would rename + // the form itself to the new table's name. + var result = FormCloner.Clone(Source, "ConVehicleGroup", Rebind("CustGroup", "ConVehicleGroupTable")); + + var root = XDocument.Parse(result.Xml).Root!; + Assert.Equal("ConVehicleGroup", root.Elements().First(e => e.Name.LocalName == "Name").Value); + } + + [Fact] + public void Everything_else_is_left_alone() + { + // The reason this is string surgery and not a round-trip: an AxForm's Design subtree is + // written in the empty namespace with i:type on every control, and loading it into an + // XDocument and writing it back rewrites namespace declarations nobody asked to change. + var result = FormCloner.Clone(Source, "ConVehicleGroup"); + + Assert.Contains("", result.Xml); + Assert.Contains("CustGroupId", result.Xml); + + // Three self-references changed and nothing else, so the length moves by exactly that. + var delta = ("ConVehicleGroup".Length - "CustGroup".Length) * 3; + Assert.Equal(Source.Length + delta, result.Xml.Length); + } + + [Fact] + public void A_control_name_that_merely_contains_the_form_name_is_not_touched() + { + // Form names are short and appear inside unrelated identifiers. A blind replace would + // rename Grid_CustGroupId and the DataField with it. + var result = FormCloner.Clone(Source, "ConVehicleGroup"); + + Assert.Contains("Grid_CustGroupId", result.Xml); + Assert.Contains("CustGroupId", result.Xml); + } + + [Fact] + public void A_rebind_of_a_table_no_datasource_uses_is_reported_rather_than_silently_ignored() + { + var result = FormCloner.Clone(Source, "ConVehicleGroup", Rebind("VendTable", "ConVendorTable")); + + Assert.Empty(result.Rebound); + Assert.Contains(result.Warnings, w => w.Contains("No datasource is bound to 'VendTable'")); + } + + [Fact] + public void A_rebind_warns_that_the_bound_fields_were_not_checked() + { + var result = FormCloner.Clone(Source, "ConVehicleGroup", Rebind("CustGroup", "ConVehicleGroupTable")); + + Assert.Contains(result.Warnings, w => w.Contains("validate references")); + } + + [Fact] + public void Every_clone_warns_about_the_references_it_cannot_reach() + { + var result = FormCloner.Clone(Source, "ConVehicleGroup"); + + Assert.Contains(result.Warnings, w => w.Contains("menu items")); + } + + [Fact] + public void Cloning_onto_the_same_name_is_refused() + => Assert.Throws(() => FormCloner.Clone(Source, "CustGroup")); + + [Fact] + public void A_document_that_is_not_a_form_is_refused() + { + Assert.Throws(() => + FormCloner.Clone("CustTable", "ConVehicle")); + Assert.Throws(() => + FormCloner.Clone("", "ConVehicle")); + } + + [Fact] + public void The_clone_is_still_well_formed_and_still_a_form() + { + var result = FormCloner.Clone(Source, "ConVehicleGroup", Rebind("CustGroup", "ConVehicleGroupTable")); + + Assert.Equal("AxForm", XDocument.Parse(result.Xml).Root!.Name.LocalName); + } +} diff --git a/tests/D365FO.Core.Tests/PropertyHonestyTests.cs b/tests/D365FO.Core.Tests/PropertyHonestyTests.cs index df86d20..c1ce5f0 100644 --- a/tests/D365FO.Core.Tests/PropertyHonestyTests.cs +++ b/tests/D365FO.Core.Tests/PropertyHonestyTests.cs @@ -69,6 +69,27 @@ public void Values_that_select_a_shape_rather_than_supply_one_are_not_traced(str Assert.Empty(PropertyHonesty.Reconcile([("--flagish", value)], Table)); } + [Theory] + [InlineData(@"K:\AosService\PackagesLocalDirectory\ApplicationSuite\Foundation\AxForm\CustGroup.xml")] + [InlineData("/var/models/ConFleet/AxForm/ConVehicle.xml")] + [InlineData(@"out\companions\ConVehicleContract.xml")] + public void A_value_that_names_a_file_is_not_a_property_of_the_object(string path) + { + // --from, --add-to, --into-role and the --out-* family name documents, not properties. + // Reconciling them reports one gap per path segment and buries everything that matters. + Assert.Empty(PropertyHonesty.Reconcile([("--from", path)], Table)); + } + + [Fact] + public void A_nested_spec_that_merely_uses_a_slash_is_still_reconciled() + { + // --constrained Header/Line nests a policy's constrained-table tree; every segment has + // to reach the document, so this must not be mistaken for a path. + var gaps = PropertyHonesty.Reconcile([("--constrained", "ConHeader/ConLine")], Table); + + Assert.Equal(["ConHeader", "ConLine"], gaps.Select(g => g.Missing).Order().ToArray()); + } + [Fact] public void An_empty_request_says_nothing() {