From 8506c5f7d729df022e23e492ae14e6748ed06fa5 Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 17 Jul 2026 10:16:34 -0500 Subject: [PATCH 1/3] feat(cli): K2 round-trip download+extract smoke for neocities guide Merge golden URLs with ingested NLP instructions via K2FullGuideRoundTripHelper, fix ResourceRegistry filename hydration from ModLinkFilenames, and add LongRunning network test proving Silent Sion downloads and extracts after round-trip overlay. --- .../ModComponentSerializationService.cs | 12 +- src/ModSync.Tests/K2FullGuideDownloadTests.cs | 114 +++++++ .../K2FullGuideRoundTripHelper.cs | 278 ++++++++++++++++++ 3 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 src/ModSync.Tests/K2FullGuideRoundTripHelper.cs diff --git a/src/ModSync.Core/Services/ModComponentSerializationService.cs b/src/ModSync.Core/Services/ModComponentSerializationService.cs index 1c805572..5be2d6f4 100644 --- a/src/ModSync.Core/Services/ModComponentSerializationService.cs +++ b/src/ModSync.Core/Services/ModComponentSerializationService.cs @@ -1747,10 +1747,18 @@ public static ModComponent DeserializeComponent([NotNull] IDictionary> kvp in deserializedFilenames) { string url = kvp.Key; - Dictionary filenames = kvp.Value; + Dictionary filenames = kvp.Value ?? new Dictionary(StringComparer.OrdinalIgnoreCase); // Check if ResourceRegistry already has an entry for this URL (keyed by URL directly) - bool urlExistsInRegistry = registryDict.ContainsKey(url); + bool urlExistsInRegistry = registryDict.TryGetValue(url, out ResourceMetadata existingMeta); + + if (urlExistsInRegistry && filenames.Count > 0 && + (existingMeta.Files == null || existingMeta.Files.Count == 0)) + { + existingMeta.Files = new Dictionary(filenames, StringComparer.OrdinalIgnoreCase); + Logger.LogVerbose($"Merged ModLinkFilenames into existing ResourceRegistry entry for URL: {url}"); + continue; + } // Empty ModLinkFilenames tables ({ }) still denote a download source; populate registry so // download/cache and FileValidation see the URL. diff --git a/src/ModSync.Tests/K2FullGuideDownloadTests.cs b/src/ModSync.Tests/K2FullGuideDownloadTests.cs index 7d58d148..2dc728a1 100644 --- a/src/ModSync.Tests/K2FullGuideDownloadTests.cs +++ b/src/ModSync.Tests/K2FullGuideDownloadTests.cs @@ -71,6 +71,7 @@ public void TearDown() } [Test] + [Timeout(600_000)] public void K2FullGuideFixture_MergedSilentSion_DownloadsViaInstallSelect_LongRunning() { (string fixturePath, string goldenTomlPath) = ResolveInputs(); @@ -119,6 +120,119 @@ public void K2FullGuideFixture_MergedSilentSion_DownloadsViaInstallSelect_LongRu }); } + [Test] + [Timeout(600_000)] + public void K2FullGuideFixture_RoundTripSilentSion_DownloadAndInstalls_LongRunning() + { + (string fixturePath, string goldenTomlPath) = ResolveInputs(); + string roundTripToml = Path.Combine(_testDirectory, "k2_roundtrip.toml"); + + K2FullGuideRoundTripHelper.WriteRoundTripToml( + fixturePath, + goldenTomlPath, + roundTripToml, + SilentSionModName); + + int installExit = ModBuildConverter.Run(new[] + { + "install", + "--input", roundTripToml, + "--game-dir", _kotorDirectory, + "--source-dir", _modDirectory, + "--select", "mod:" + SilentSionModName, + "--download", + "--skip-validation", + "--best-effort", + "-y", + }); + + string installedDlg = Path.Combine(_kotorDirectory, "Override", "153sion.dlg"); + string[] modWorkspaceFiles = Directory.Exists(_modDirectory) + ? Directory.GetFiles(_modDirectory, "*", SearchOption.AllDirectories) + : Array.Empty(); + + Assert.Multiple(() => + { + Assert.That(installExit, Is.EqualTo(0), + "round-trip install --download should complete for Silent Sion (best-effort)"); + Assert.That( + File.Exists(Path.Combine(_modDirectory, SilentSionArchive)) + || modWorkspaceFiles.Any(path => path.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)), + Is.True, + "Silent Sion archive should be present in the mod workspace after download"); + Assert.That( + modWorkspaceFiles.Any(path => + path.EndsWith("153sion.dlg", StringComparison.OrdinalIgnoreCase)) + || File.Exists(installedDlg) + || Directory.GetFiles(Path.Combine(_kotorDirectory, "Override"), "*.dlg", SearchOption.AllDirectories).Length > 0, + Is.True, + "Expected 153sion.dlg in the mod workspace or Override after download + extract"); + }); + } + + [Test] + public void MergedSilentSion_ResourceRegistryListsArchiveFilenames() + { + (string fixturePath, string goldenTomlPath) = ResolveInputs(); + string mergedToml = Path.Combine(_testDirectory, "k2_merged.toml"); + + int mergeExit = ModBuildConverter.Run(new[] + { + "merge", + "--existing", goldenTomlPath, + "--incoming", fixturePath, + "--use-existing-order", + "--prefer-existing-instructions", + "--prefer-existing-options", + "--prefer-existing-modlinks", + "-f", "toml", + "-o", mergedToml, + "--plaintext", + }); + + Assert.That(mergeExit, Is.EqualTo(0)); + + ModComponent silentSion = FileLoadingService.LoadFromFile(mergedToml) + .First(c => c.Name.IndexOf(SilentSionModName, StringComparison.OrdinalIgnoreCase) >= 0); + + int registryFileCount = silentSion.ResourceRegistry?.Sum(entry => entry.Value.Files?.Count ?? 0) ?? 0; + + Assert.That(registryFileCount, Is.GreaterThan(0), + "Merged Silent Sion ResourceRegistry entries should list archive filenames for round-trip Extract injection"); + } + + [Test] + public void RoundTripHelper_OverlaySilentSion_AddsExtractForRegistryArchive() + { + (string fixturePath, string goldenTomlPath) = ResolveInputs(); + string roundTripToml = Path.Combine(_testDirectory, "k2_roundtrip.toml"); + + K2FullGuideRoundTripHelper.WriteRoundTripToml( + fixturePath, + goldenTomlPath, + roundTripToml, + SilentSionModName); + + List components = FileLoadingService.LoadFromFile(roundTripToml).ToList(); + ModComponent silentSion = components.First(c => + c.Name.IndexOf(SilentSionModName, StringComparison.OrdinalIgnoreCase) >= 0); + + Assert.Multiple(() => + { + Assert.That(silentSion.ResourceRegistry?.Count, Is.GreaterThan(0)); + Assert.That( + silentSion.Instructions.Any(i => + i.Action == Instruction.ActionType.Extract + && i.Source.Any(s => s.IndexOf("Silent Sion Restoration.zip", StringComparison.OrdinalIgnoreCase) >= 0)), + Is.True, + "Round-trip overlay should prepend Extract for the Silent Sion archive"); + Assert.That( + silentSion.Instructions.Any(i => i.Action == Instruction.ActionType.Move), + Is.True, + "Round-trip overlay should keep ingested Move draft"); + }); + } + private static (string fixturePath, string goldenTomlPath) ResolveInputs() { string repoRoot = ResolveRepoRoot(); diff --git a/src/ModSync.Tests/K2FullGuideRoundTripHelper.cs b/src/ModSync.Tests/K2FullGuideRoundTripHelper.cs new file mode 100644 index 00000000..5972406d --- /dev/null +++ b/src/ModSync.Tests/K2FullGuideRoundTripHelper.cs @@ -0,0 +1,278 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; + +using ModSync.Core; +using ModSync.Core.CLI; +using ModSync.Core.Services; + +namespace ModSync.Tests +{ + /// + /// Builds K2 neocities round-trip TOMLs: golden download URLs + ingested NLP install instructions. + /// + internal static class K2FullGuideRoundTripHelper + { + internal static void WriteRoundTripToml( + string fixturePath, + string goldenTomlPath, + string outputTomlPath, + params string[] overlayInstructionModNames) + { + if (overlayInstructionModNames is null || overlayInstructionModNames.Length == 0) + { + throw new ArgumentException("At least one mod name is required for instruction overlay.", nameof(overlayInstructionModNames)); + } + + string tempDir = Path.Combine(Path.GetTempPath(), "ModSync_K2RoundTrip_" + Guid.NewGuid()); + Directory.CreateDirectory(tempDir); + + try + { + string mergedToml = Path.Combine(tempDir, "merged.toml"); + string ingestedToml = Path.Combine(tempDir, "ingested.toml"); + + int mergeExit = ModBuildConverter.Run(new[] + { + "merge", + "--existing", goldenTomlPath, + "--incoming", fixturePath, + "--use-existing-order", + "--prefer-existing-instructions", + "--prefer-existing-options", + "--prefer-existing-modlinks", + "-f", "toml", + "-o", mergedToml, + "--plaintext", + }); + + if (mergeExit != 0) + { + throw new InvalidOperationException($"merge failed with exit code {mergeExit}"); + } + + int convertExit = ModBuildConverter.Run(new[] + { + "convert", + "--input", fixturePath, + "-f", "toml", + "--parse-directions", + "-o", ingestedToml, + "--plaintext", + }); + + if (convertExit != 0) + { + throw new InvalidOperationException($"convert --parse-directions failed with exit code {convertExit}"); + } + + List merged = FileLoadingService.LoadFromFile(mergedToml).ToList(); + List ingested = FileLoadingService.LoadFromFile(ingestedToml).ToList(); + + OverlayIngestedInstructions(merged, ingested, overlayInstructionModNames); + FileLoadingService.SaveToFile(merged, outputTomlPath); + } + finally + { + try + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + catch + { + // Ignore cleanup errors + } + } + } + + internal static void OverlayIngestedInstructions( + List merged, + IReadOnlyList ingested, + params string[] modNameFragments) + { + var ingestedByName = ingested + .GroupBy(c => c.Name?.Trim() ?? string.Empty, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + foreach (ModComponent component in merged) + { + if (!modNameFragments.Any(fragment => + component.Name.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0)) + { + continue; + } + + if (!ingestedByName.TryGetValue(component.Name.Trim(), out ModComponent ingestedComponent)) + { + ingestedComponent = ingested.FirstOrDefault(candidate => + string.Equals(candidate.Name, component.Name, StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrEmpty(candidate.Name) + && candidate.Name.IndexOf(component.Name, StringComparison.OrdinalIgnoreCase) >= 0) + || (!string.IsNullOrEmpty(component.Name) + && component.Name.IndexOf(candidate.Name, StringComparison.OrdinalIgnoreCase) >= 0)); + } + + if (ingestedComponent is null) + { + continue; + } + + component.Instructions.Clear(); + foreach (Instruction instruction in ingestedComponent.Instructions) + { + component.Instructions.Add(instruction); + } + + component.Options.Clear(); + foreach (Option option in ingestedComponent.Options) + { + component.Options.Add(option); + } + + component.InstallationWarning = ingestedComponent.InstallationWarning; + component.Dependencies.Clear(); + component.Restrictions.Clear(); + + EnsureExtractInstructionsFromRegistry(component); + RefineMoveInstructionsFromDirections(component); + } + } + + /// + /// NLP Move-only drafts do not satisfy download auto-discover (which keys off Extract patterns). + /// Prepend Extract steps for registry archives that match this component's name. + /// + private static void EnsureExtractInstructionsFromRegistry(ModComponent component) + { + if (component.ResourceRegistry is null || component.ResourceRegistry.Count == 0) + { + return; + } + + if (component.Instructions.Any(i => i.Action == Instruction.ActionType.Extract)) + { + return; + } + + var existingExtractSources = new HashSet( + component.Instructions + .Where(i => i.Action == Instruction.ActionType.Extract) + .SelectMany(i => i.Source ?? Enumerable.Empty()), + StringComparer.OrdinalIgnoreCase); + + var archiveNames = new SortedSet(StringComparer.OrdinalIgnoreCase); + foreach (KeyValuePair resource in component.ResourceRegistry) + { + foreach (string filename in resource.Value.Files.Keys) + { + if (!string.IsNullOrWhiteSpace(filename) && ShouldIncludeRegistryArchive(component.Name, filename)) + { + archiveNames.Add(filename); + } + } + } + + int insertAt = 0; + foreach (string archiveName in archiveNames) + { + string source = $"<>/{archiveName}"; + if (existingExtractSources.Contains(source)) + { + continue; + } + + component.Instructions.Insert(insertAt++, new Instruction + { + Action = Instruction.ActionType.Extract, + Source = new List { source }, + }); + } + + if (insertAt == 0 + && component.ResourceRegistry.Count > 0 + && string.Equals(component.InstallationMethod, "Loose-File", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(component.Name)) + { + string fallbackArchive = component.Name.Trim() + ".zip"; + string source = $"<>/{fallbackArchive}"; + if (!existingExtractSources.Contains(source)) + { + component.Instructions.Insert(0, new Instruction + { + Action = Instruction.ActionType.Extract, + Source = new List { source }, + }); + } + } + } + + private static void RefineMoveInstructionsFromDirections(ModComponent component) + { + Instruction move = component.Instructions.FirstOrDefault(i => i.Action == Instruction.ActionType.Move); + if (move is null || string.IsNullOrWhiteSpace(component.Directions)) + { + return; + } + + bool usesWildcard = move.Source?.Any(source => source.IndexOf('*', StringComparison.Ordinal) >= 0) == true; + if (!usesWildcard) + { + return; + } + + Match fileMatch = Regex.Match( + component.Directions, + @"\b([\w\.\-]+\.(?:dlg|2da|tga|tpc|utc|uti|utm|utd|ute|uts|utw|ssf|bwm|mdl|mdx|txi|lip|lyt|vis|pth|ncs|gui))\b", + RegexOptions.IgnoreCase); + + if (!fileMatch.Success) + { + return; + } + + string fileName = fileMatch.Groups[1].Value; + move.Source = new List + { + $"<>\\{fileName}", + $"<>\\*\\{fileName}", + $"<>\\*{fileName}", + }; + } + + private static bool ShouldIncludeRegistryArchive(string modName, string archiveFileName) + { + if (string.IsNullOrWhiteSpace(modName) || string.IsNullOrWhiteSpace(archiveFileName)) + { + return false; + } + + string stem = Path.GetFileNameWithoutExtension(archiveFileName); + if (stem.IndexOf(modName, StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + + string[] tokens = modName + .Split(new[] { ' ', '-', '_', '(', ')' }, StringSplitOptions.RemoveEmptyEntries) + .Where(token => token.Length > 3) + .ToArray(); + + if (tokens.Length == 0) + { + return false; + } + + int hits = tokens.Count(token => stem.IndexOf(token, StringComparison.OrdinalIgnoreCase) >= 0); + return hits >= Math.Min(2, tokens.Length); + } + } +} From 096908066d29a0fe3ab6501a9c53f072754408aa Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 17 Jul 2026 14:15:23 -0500 Subject: [PATCH 2/3] fix(ingestion): nested loose-file Move paths after zip extract Silent Sion and similar mods extract archives into nested folders, but NLP Move drafts only targeted the mod workspace root. Expand single-file Move sources with depth-aware wildcards, tighten round-trip overlay refinement, and assert Override install in the LongRunning round-trip smoke. --- ...merged_roundtrip_download_install_smoke.sh | 54 +++++++++++++ src/ModSync.Core/ModComponent.cs | 3 +- .../Parsing/DraftInstructionService.cs | 64 +++++++++++++++- .../NaturalLanguageInstructionParser.cs | 8 +- src/ModSync.Tests/GuideIngestionTests.cs | 75 +++++++++++++++++++ src/ModSync.Tests/K2FullGuideDownloadTests.cs | 17 +++-- .../K2FullGuideRoundTripHelper.cs | 14 +--- 7 files changed, 210 insertions(+), 25 deletions(-) create mode 100755 scripts/agents/k2_merged_roundtrip_download_install_smoke.sh diff --git a/scripts/agents/k2_merged_roundtrip_download_install_smoke.sh b/scripts/agents/k2_merged_roundtrip_download_install_smoke.sh new file mode 100755 index 00000000..a7c9828a --- /dev/null +++ b/scripts/agents/k2_merged_roundtrip_download_install_smoke.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Full K2 neocities round-trip smoke: golden URLs + ingested NLP → download → extract → Override install. +# Wraps the LongRunning integration test (network required; mod-builds at repo root). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +GOLDEN="${GOLDEN:-$ROOT/mod-builds/TOMLs/KOTOR2_Full.toml}" +FILTER="${FILTER:-FullyQualifiedName~K2FullGuideFixture_RoundTripSilentSion_DownloadAndInstalls_LongRunning}" + +usage() { + cat <<'EOF' +Usage: k2_merged_roundtrip_download_install_smoke.sh [options] + +Runs the K2 Full round-trip LongRunning test: merge neocities fixture with golden TOML, +overlay ingested NLP instructions for Silent Sion, download the archive, extract, and +install 153sion.dlg to Override. + +Requires: + - mod-builds cloned at ./mod-builds (KOTOR2_Full.toml) + - network access to Deadly Stream download URLs + +Options: + --golden PATH Golden TOML path (default: ./mod-builds/TOMLs/KOTOR2_Full.toml) + -h, --help Show this help + +Environment: + GOLDEN, FILTER (dotnet test --filter override) +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --golden) GOLDEN="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;; + esac +done + +if [[ ! -f "$GOLDEN" ]]; then + echo "Golden TOML not found: $GOLDEN" >&2 + echo "Clone mod-builds: git clone https://github.com/th3w1zard1/mod-builds ./mod-builds" >&2 + exit 1 +fi + +export DOTNET_ROOT="${DOTNET_ROOT:-$HOME/.dotnet}" +export PATH="$DOTNET_ROOT:$PATH" + +echo "==> dotnet test (round-trip download + install)" +dotnet test "$ROOT/src/ModSync.Tests/ModSync.Tests.csproj" -c Debug -f net9.0 \ + --filter "$FILTER" + +echo "Round-trip download+install smoke passed." diff --git a/src/ModSync.Core/ModComponent.cs b/src/ModSync.Core/ModComponent.cs index f1502272..23ff0379 100644 --- a/src/ModSync.Core/ModComponent.cs +++ b/src/ModSync.Core/ModComponent.cs @@ -1393,7 +1393,8 @@ [NotNull] Services.FileSystem.IFileSystemProvider fileSystemProvider // Check if this resource contains the missing file foreach (var file in resource.Value.Files) { - if (file.Value == true && ResourceRegistryEntryMatchesMissingSource(file.Key, missingFile)) + if (ResourceRegistryEntryMatchesMissingSource(file.Key, missingFile) + && (file.Value == true || file.Value == null)) { if (!archiveMatches.ContainsKey(missingFile)) { diff --git a/src/ModSync.Core/Parsing/DraftInstructionService.cs b/src/ModSync.Core/Parsing/DraftInstructionService.cs index d0805c63..ea9fd0d0 100644 --- a/src/ModSync.Core/Parsing/DraftInstructionService.cs +++ b/src/ModSync.Core/Parsing/DraftInstructionService.cs @@ -7,6 +7,7 @@ using System.Collections.ObjectModel; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using JetBrains.Annotations; @@ -49,6 +50,14 @@ public static class DraftInstructionService [NotNull] private const string KotorDirectoryPlaceholder = "<>"; [NotNull] private const string LegacyGameDirectoryPlaceholder = "<>"; + /// + /// Matches KOTOR loose-file names mentioned in guide prose (e.g. 153sion.dlg). + /// + [NotNull] + private static readonly Regex s_looseFileNamePattern = new Regex( + @"\b([\w\.\-]+\.(?:dlg|2da|tga|tpc|utc|uti|utm|utd|ute|uts|utw|ssf|bwm|mdl|mdx|txi|lip|lyt|vis|pth|ncs|gui))\b", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + /// /// Generates draft instructions for every component that has natural-language Directions prose /// but no authored instructions. Components that already have instructions are never touched. @@ -167,10 +176,15 @@ private static Instruction TryCreateMethodFallbackInstruction([NotNull] ModCompo ? KotorDirectoryPlaceholder + @"\Movies" : KotorDirectoryPlaceholder + @"\Override"; + Match fileMatch = s_looseFileNamePattern.Match(directions); + List sources = fileMatch.Success + ? BuildLooseFileMoveSources(fileMatch.Groups[1].Value) + : new List { ModDirectoryPlaceholder + @"\*" }; + return new Instruction { Action = Instruction.ActionType.Move, - Source = new List { ModDirectoryPlaceholder + @"\*" }, + Source = sources, Destination = destination, Overwrite = directions.IndexOf("do not overwrite", StringComparison.OrdinalIgnoreCase) < 0 && directions.IndexOf("don't overwrite", StringComparison.OrdinalIgnoreCase) < 0, @@ -207,6 +221,54 @@ private static bool LooksLikeLooseFileInstall([NotNull] string text) || lower.IndexOf("loose file", StringComparison.Ordinal) >= 0; } + /// + /// Builds sandboxed Move sources for a named loose file, including nested paths after Extract. + /// + [NotNull] + internal static List BuildLooseFileMoveSources([NotNull] string fileName, int maxNestedDepth = 3) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + throw new ArgumentException("File name is required.", nameof(fileName)); + } + + string trimmed = fileName.Trim().Trim('"', '\''); + var sources = new List { $"{ModDirectoryPlaceholder}\\{trimmed}" }; + + for (int depth = 1; depth <= maxNestedDepth; depth++) + { + sources.Add($"{ModDirectoryPlaceholder}\\{string.Join("\\", Enumerable.Repeat("*", depth))}\\{trimmed}"); + } + + return sources; + } + + /// + /// Expands existing Move sources with nested mod-directory search paths for a named file. + /// + [NotNull] + internal static List ExpandLooseFileMoveSources( + [CanBeNull] IEnumerable existingSources, + [NotNull] string fileName, + int maxNestedDepth = 3) + { + var merged = new List(); + if (existingSources != null) + { + merged.AddRange(existingSources.Where(source => !string.IsNullOrWhiteSpace(source))); + } + + foreach (string source in BuildLooseFileMoveSources(fileName, maxNestedDepth)) + { + if (!merged.Any(existing => string.Equals(existing, source, StringComparison.OrdinalIgnoreCase))) + { + merged.Add(source); + } + } + + return merged; + } + /// /// Normalizes placeholders and enforces the path-sandboxing rules on a draft instruction: /// every Source entry and any non-empty Destination must start with diff --git a/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs b/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs index e6d6bfee..2fdbc453 100644 --- a/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs +++ b/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs @@ -1069,8 +1069,9 @@ private static List ExtractSources([NotNull] string sourceText, [NotNull List files = ParseFileList(filesText); foreach (string file in files) { - sources.Add($"<>\\{file}"); + sources.AddRange(DraftInstructionService.BuildLooseFileMoveSources(file)); } + if (sources.Count > 0) { return sources; @@ -1091,8 +1092,7 @@ private static List ExtractSources([NotNull] string sourceText, [NotNull if (singleFileMatch.Success) { string file = singleFileMatch.Groups["file"].Value.Trim('"', '\''); - sources.Add($"<>\\{file}"); - return sources; + return DraftInstructionService.BuildLooseFileMoveSources(file); } // === Fallback: treat as folder or pattern === @@ -1104,7 +1104,7 @@ private static List ExtractSources([NotNull] string sourceText, [NotNull // Check if it looks like a file (has extension) if (Path.HasExtension(cleaned)) { - sources.Add($"<>\\{cleaned}"); + return DraftInstructionService.BuildLooseFileMoveSources(cleaned); } // Check if it has wildcards else if (cleaned.Contains("*") || cleaned.Contains("?")) diff --git a/src/ModSync.Tests/GuideIngestionTests.cs b/src/ModSync.Tests/GuideIngestionTests.cs index d0405acd..17730361 100644 --- a/src/ModSync.Tests/GuideIngestionTests.cs +++ b/src/ModSync.Tests/GuideIngestionTests.cs @@ -628,6 +628,81 @@ public void K2FullGuideFixture_IngestedSilentSion_InstallsViaCli() }); } + [Test] + public void K2FullGuideFixture_IngestedSilentSion_InstallsFromNestedExtractFolder() + { + string fixturePath = Path.Combine(ResolveRepoRoot(), "src", "ModSync.Tests", "Fixtures", "k2_full_guide.md"); + Assert.That(File.Exists(fixturePath), Is.True); + + string kotorDir = Path.Combine(_testDirectory, "KOTOR"); + string modDir = Path.Combine(_testDirectory, "Mods"); + Directory.CreateDirectory(Path.Combine(kotorDir, "Override")); + File.WriteAllText(Path.Combine(kotorDir, "swkotor.exe"), "fake exe"); + File.WriteAllText(Path.Combine(kotorDir, "dialog.tlk"), "fake dialog"); + Directory.CreateDirectory(modDir); + + string nestedDlg = Path.Combine(modDir, "Silent Sion Restoration", "153sion.dlg"); + Directory.CreateDirectory(Path.GetDirectoryName(nestedDlg)!); + File.WriteAllText(nestedDlg, "silent sion dlg"); + + string ingestedToml = Path.Combine(_testDirectory, "k2_full_ingested.toml"); + int convertExit = ModBuildConverter.Run(new[] + { + "convert", + "--input", fixturePath, + "-f", "toml", + "--parse-directions", + "-o", ingestedToml, + "--plaintext", + }); + + Assert.That(convertExit, Is.EqualTo(0)); + + StripDependenciesForInstallSmoke(ingestedToml, "Silent Sion Restoration"); + + int installExit = ModBuildConverter.Run(new[] + { + "install", + "--input", ingestedToml, + "--game-dir", kotorDir, + "--source-dir", modDir, + "--select", "mod:Silent Sion Restoration", + "--skip-validation", + "--best-effort", + "-y", + }); + + Assert.Multiple(() => + { + Assert.That(installExit, Is.EqualTo(0)); + Assert.That(File.Exists(Path.Combine(kotorDir, "Override", "153sion.dlg")), Is.True, + "Move draft should find 153sion.dlg after extract-style nesting"); + }); + } + + [Test] + public void DraftInstructions_SilentSionMoveJust_IncludesNestedSearchPaths() + { + ModComponent component = new ModComponent + { + Name = "Silent Sion Restoration", + InstallationMethod = "Loose-File", + Directions = "Move just 153sion.dlg to the override.", + }; + + IReadOnlyList results = DraftInstructionService.GenerateDraftInstructions(new[] { component }); + + Assert.Multiple(() => + { + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(component.Instructions, Has.Count.EqualTo(1)); + Assert.That(component.Instructions[0].Action, Is.EqualTo(Instruction.ActionType.Move)); + Assert.That(component.Instructions[0].Source, Does.Contain(@"<>\153sion.dlg")); + Assert.That(component.Instructions[0].Source, Does.Contain(@"<>\*\153sion.dlg")); + Assert.That(component.Instructions[0].Destination, Is.EqualTo(@"<>\Override")); + }); + } + [Test] public void K2FullGuideFixture_IngestedMultiModInstallSmoke_InstallsViaModSelect() { diff --git a/src/ModSync.Tests/K2FullGuideDownloadTests.cs b/src/ModSync.Tests/K2FullGuideDownloadTests.cs index 2dc728a1..6d18e45b 100644 --- a/src/ModSync.Tests/K2FullGuideDownloadTests.cs +++ b/src/ModSync.Tests/K2FullGuideDownloadTests.cs @@ -160,13 +160,8 @@ public void K2FullGuideFixture_RoundTripSilentSion_DownloadAndInstalls_LongRunni || modWorkspaceFiles.Any(path => path.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)), Is.True, "Silent Sion archive should be present in the mod workspace after download"); - Assert.That( - modWorkspaceFiles.Any(path => - path.EndsWith("153sion.dlg", StringComparison.OrdinalIgnoreCase)) - || File.Exists(installedDlg) - || Directory.GetFiles(Path.Combine(_kotorDirectory, "Override"), "*.dlg", SearchOption.AllDirectories).Length > 0, - Is.True, - "Expected 153sion.dlg in the mod workspace or Override after download + extract"); + Assert.That(File.Exists(installedDlg), Is.True, + "153sion.dlg should be installed to Override after download, extract, and move"); }); } @@ -202,6 +197,7 @@ public void MergedSilentSion_ResourceRegistryListsArchiveFilenames() } [Test] + [Timeout(600_000)] public void RoundTripHelper_OverlaySilentSion_AddsExtractForRegistryArchive() { (string fixturePath, string goldenTomlPath) = ResolveInputs(); @@ -230,6 +226,13 @@ public void RoundTripHelper_OverlaySilentSion_AddsExtractForRegistryArchive() silentSion.Instructions.Any(i => i.Action == Instruction.ActionType.Move), Is.True, "Round-trip overlay should keep ingested Move draft"); + Instruction moveInstruction = silentSion.Instructions.First(i => i.Action == Instruction.ActionType.Move); + Assert.That( + moveInstruction.Source.Any(source => + source.IndexOf("153sion.dlg", StringComparison.OrdinalIgnoreCase) >= 0 + && source.IndexOf('*', StringComparison.Ordinal) >= 0), + Is.True, + "Round-trip Move should search nested extract folders for 153sion.dlg"); }); } diff --git a/src/ModSync.Tests/K2FullGuideRoundTripHelper.cs b/src/ModSync.Tests/K2FullGuideRoundTripHelper.cs index 5972406d..bd1550c2 100644 --- a/src/ModSync.Tests/K2FullGuideRoundTripHelper.cs +++ b/src/ModSync.Tests/K2FullGuideRoundTripHelper.cs @@ -10,6 +10,7 @@ using ModSync.Core; using ModSync.Core.CLI; +using ModSync.Core.Parsing; using ModSync.Core.Services; namespace ModSync.Tests @@ -223,12 +224,6 @@ private static void RefineMoveInstructionsFromDirections(ModComponent component) return; } - bool usesWildcard = move.Source?.Any(source => source.IndexOf('*', StringComparison.Ordinal) >= 0) == true; - if (!usesWildcard) - { - return; - } - Match fileMatch = Regex.Match( component.Directions, @"\b([\w\.\-]+\.(?:dlg|2da|tga|tpc|utc|uti|utm|utd|ute|uts|utw|ssf|bwm|mdl|mdx|txi|lip|lyt|vis|pth|ncs|gui))\b", @@ -240,12 +235,7 @@ private static void RefineMoveInstructionsFromDirections(ModComponent component) } string fileName = fileMatch.Groups[1].Value; - move.Source = new List - { - $"<>\\{fileName}", - $"<>\\*\\{fileName}", - $"<>\\*{fileName}", - }; + move.Source = DraftInstructionService.ExpandLooseFileMoveSources(move.Source, fileName); } private static bool ShouldIncludeRegistryArchive(string modName, string archiveFileName) From 51948422d7ce16503c4120e4a57f771fe85b0c03 Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 17 Jul 2026 14:29:14 -0500 Subject: [PATCH 3/3] docs(kb): document K2 round-trip download+install agent script Note nested loose-file Move source expansion used by the LongRunning Silent Sion smoke. --- docs/knowledgebase/cli-selection-semantics.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/knowledgebase/cli-selection-semantics.md b/docs/knowledgebase/cli-selection-semantics.md index c8a845e8..6d74af5a 100644 --- a/docs/knowledgebase/cli-selection-semantics.md +++ b/docs/knowledgebase/cli-selection-semantics.md @@ -28,6 +28,14 @@ Example (merged neocities K2 + golden TOML): ./scripts/agents/k2_ingested_merge_download_smoke.sh --download-mod "Silent Sion Restoration" ``` +Full round-trip (golden URLs + ingested NLP → download → extract → Override install; network + `mod-builds`): + +```bash +./scripts/agents/k2_merged_roundtrip_download_install_smoke.sh +``` + +Wraps `K2FullGuideFixture_RoundTripSilentSion_DownloadAndInstalls_LongRunning`. NLP Move drafts for single loose files include nested `<>/*…/filename` sources so post-extract paths resolve. + ## `validate` without `--select` (default) Validates **all loaded components**. TOML `IsSelected` is **not** used unless `--use-file-selection` or `--select` is provided.