From b2e76b66e6ede8c9db3c997f454cb70bb151acba Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 18 Jul 2026 13:30:58 -0500 Subject: [PATCH 1/2] fix(ingestion): nested folder Move paths for K2 prestige advisories Expand NLP folder Move drafts with nested wildcards and slug/fuzzy folder variants so Prestige Class installs resolve jedimaster_sithlord fixes after real zip extract nesting. --- docs/knowledgebase/cli-selection-semantics.md | 2 +- .../Parsing/DraftInstructionService.cs | 128 ++++++++++++++++++ .../NaturalLanguageInstructionParser.cs | 8 +- src/ModSync.Tests/GuideIngestionTests.cs | 87 ++++++++++++ src/ModSync.Tests/K2FullGuideDownloadTests.cs | 2 + .../K2FullGuideRoundTripHelper.cs | 14 +- 6 files changed, 232 insertions(+), 9 deletions(-) diff --git a/docs/knowledgebase/cli-selection-semantics.md b/docs/knowledgebase/cli-selection-semantics.md index 6d74af5a..69315d27 100644 --- a/docs/knowledgebase/cli-selection-semantics.md +++ b/docs/knowledgebase/cli-selection-semantics.md @@ -34,7 +34,7 @@ Full round-trip (golden URLs + ingested NLP → download → extract → Overrid ./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. +Wraps `K2FullGuideFixture_RoundTripSilentSion_DownloadAndInstalls_LongRunning`. NLP Move drafts for single loose files include nested `<>/*…/filename` sources so post-extract paths resolve. Folder advisories (e.g. Prestige Class “only install the Jedi Master/Sith Lord fixes”) add nested folder wildcards plus slug/fuzzy variants (`jedimaster_sithlord fixes`) via `DraftInstructionService.BuildFolderMoveSources`. ## `validate` without `--select` (default) diff --git a/src/ModSync.Core/Parsing/DraftInstructionService.cs b/src/ModSync.Core/Parsing/DraftInstructionService.cs index ea9fd0d0..5a01ae00 100644 --- a/src/ModSync.Core/Parsing/DraftInstructionService.cs +++ b/src/ModSync.Core/Parsing/DraftInstructionService.cs @@ -269,6 +269,134 @@ internal static List ExpandLooseFileMoveSources( return merged; } + /// + /// Builds sandboxed Move sources for a named folder (optionally path-like prose), including nested + /// paths after Extract and slug/fuzzy variants when archive folders differ from guide wording. + /// + [NotNull] + internal static List BuildFolderMoveSources([NotNull] string folderPhrase, int maxNestedDepth = 3) + { + if (string.IsNullOrWhiteSpace(folderPhrase)) + { + throw new ArgumentException("Folder phrase is required.", nameof(folderPhrase)); + } + + string trimmed = folderPhrase.Trim().Trim('"', '\'', '.', ' ', ',', ';'); + trimmed = Regex.Replace(trimmed, @"^\s*the\s+", string.Empty, RegexOptions.IgnoreCase); + + var folderCandidates = new HashSet(StringComparer.OrdinalIgnoreCase) + { + trimmed.Replace('/', '\\'), + }; + + foreach (string slug in GenerateFolderSlugCandidates(trimmed)) + { + folderCandidates.Add(slug); + } + + var sources = new List(); + foreach (string candidate in folderCandidates) + { + if (string.IsNullOrWhiteSpace(candidate)) + { + continue; + } + + sources.Add($"{ModDirectoryPlaceholder}\\{candidate}\\*"); + + for (int depth = 1; depth <= maxNestedDepth; depth++) + { + sources.Add($"{ModDirectoryPlaceholder}\\{string.Join("\\", Enumerable.Repeat("*", depth))}\\{candidate}\\*"); + } + + string fuzzySegment = BuildFuzzyFolderSegmentPattern(candidate); + if (!string.IsNullOrWhiteSpace(fuzzySegment)) + { + for (int depth = 1; depth <= maxNestedDepth; depth++) + { + sources.Add($"{ModDirectoryPlaceholder}\\{string.Join("\\", Enumerable.Repeat("*", depth))}\\{fuzzySegment}\\*"); + } + } + } + + return sources; + } + + /// + /// Expands existing Move sources with nested folder search paths for guide prose folder names. + /// + [NotNull] + internal static List ExpandFolderMoveSources( + [CanBeNull] IEnumerable existingSources, + [NotNull] string folderPhrase, + int maxNestedDepth = 3) + { + var merged = new List(); + if (existingSources != null) + { + merged.AddRange(existingSources.Where(source => !string.IsNullOrWhiteSpace(source))); + } + + foreach (string source in BuildFolderMoveSources(folderPhrase, maxNestedDepth)) + { + if (!merged.Any(existing => string.Equals(existing, source, StringComparison.OrdinalIgnoreCase))) + { + merged.Add(source); + } + } + + return merged; + } + + [NotNull] + private static IEnumerable GenerateFolderSlugCandidates([NotNull] string phrase) + { + string[] segments = phrase + .Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries) + .Select(segment => segment.Trim()) + .Where(segment => segment.Length > 0) + .ToArray(); + + if (segments.Length == 0) + { + yield break; + } + + yield return string.Join("\\", segments); + + if (segments.Length >= 2) + { + string head = segments[0].Replace(" ", string.Empty).ToLowerInvariant(); + string tail = string.Join(" ", segments.Skip(1)).ToLowerInvariant(); + tail = Regex.Replace(tail, @"\bsith\s+lord\b", "sithlord", RegexOptions.IgnoreCase); + yield return $"{head}_{tail}"; + } + } + + [CanBeNull] + private static string BuildFuzzyFolderSegmentPattern([NotNull] string folderCandidate) + { + string segment = folderCandidate; + int lastSeparator = Math.Max(folderCandidate.LastIndexOf('\\'), folderCandidate.LastIndexOf('/')); + if (lastSeparator >= 0 && lastSeparator < folderCandidate.Length - 1) + { + segment = folderCandidate.Substring(lastSeparator + 1); + } + + string merged = Regex.Replace(segment.ToLowerInvariant(), @"\bsith\s+lord\b", "sithlord"); + string[] tokens = merged + .Split(new[] { ' ', '_', '-', '/', '\\' }, StringSplitOptions.RemoveEmptyEntries) + .Where(token => token.Length > 3 || string.Equals(token, "fixes", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (tokens.Length == 0) + { + return null; + } + + return "*" + string.Join("*", tokens) + "*"; + } + /// /// 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 2fdbc453..cd5ae426 100644 --- a/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs +++ b/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs @@ -1048,15 +1048,13 @@ private static List ExtractSources([NotNull] string sourceText, [NotNull if (folderMatch.Success) { string folder = folderMatch.Groups["folder"].Value.Trim(); - // Normalize folder name folder = folder.Trim('"', '\'', ' '); - sources.Add($"<>\\{folder}\\*"); + sources.AddRange(DraftInstructionService.BuildFolderMoveSources(folder)); - // Check for second folder if (folderMatch.Groups["folder2"].Success) { string folder2 = folderMatch.Groups["folder2"].Value.Trim().Trim('"', '\'', ' '); - sources.Add($"<>\\{folder2}\\*"); + sources.AddRange(DraftInstructionService.BuildFolderMoveSources(folder2)); } return sources; } @@ -1114,7 +1112,7 @@ private static List ExtractSources([NotNull] string sourceText, [NotNull // Otherwise treat as folder else if (actionType == Instruction.ActionType.Move || actionType == Instruction.ActionType.Copy) { - sources.Add($"<>\\{cleaned}\\*"); + sources.AddRange(DraftInstructionService.BuildFolderMoveSources(cleaned)); } else { diff --git a/src/ModSync.Tests/GuideIngestionTests.cs b/src/ModSync.Tests/GuideIngestionTests.cs index 17730361..20ca1d7a 100644 --- a/src/ModSync.Tests/GuideIngestionTests.cs +++ b/src/ModSync.Tests/GuideIngestionTests.cs @@ -703,6 +703,93 @@ public void DraftInstructions_SilentSionMoveJust_IncludesNestedSearchPaths() }); } + [Test] + public void DraftInstructions_PrestigeClassAdvisory_IncludesNestedFolderSearch() + { + ModComponent component = new ModComponent + { + Name = "Prestige Class Saving Throw Fixes", + InstallationMethod = "Loose-File Mod", + Directions = "I advise users to only install the Jedi Master/Sith Lord fixes.", + }; + + IReadOnlyList results = DraftInstructionService.GenerateDraftInstructions(new[] { component }); + + Assert.Multiple(() => + { + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(component.Instructions[0].Action, Is.EqualTo(Instruction.ActionType.Move)); + Assert.That( + component.Instructions[0].Source.Any(source => + source.IndexOf("Jedi Master", StringComparison.OrdinalIgnoreCase) >= 0 + && source.IndexOf('*', StringComparison.Ordinal) >= 0), + Is.True, + "Folder Move should include nested search paths for Jedi Master/Sith Lord fixes"); + Assert.That( + component.Instructions[0].Source.Any(source => + source.IndexOf("sithlord", StringComparison.OrdinalIgnoreCase) >= 0 + && source.IndexOf("fixes", StringComparison.OrdinalIgnoreCase) >= 0), + Is.True, + "Folder Move should include slug/fuzzy variants for jedimaster_sithlord fixes"); + }); + } + + [Test] + public void K2FullGuideFixture_IngestedPrestige_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 nestedFolder = Path.Combine( + modDir, + "TSL_prestige_save_fixes", + "TSL_prestige_save_fixes", + "jedimaster_sithlord fixes"); + Directory.CreateDirectory(nestedFolder); + File.WriteAllText(Path.Combine(nestedFolder, "prestige_fix.2da"), "2DA V2.0\n"); + + 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, "Prestige Class Saving Throw Fixes"); + + int installExit = ModBuildConverter.Run(new[] + { + "install", + "--input", ingestedToml, + "--game-dir", kotorDir, + "--source-dir", modDir, + "--select", "mod:Prestige Class Saving Throw Fixes", + "--skip-validation", + "--best-effort", + "-y", + }); + + Assert.Multiple(() => + { + Assert.That(installExit, Is.EqualTo(0)); + Assert.That(File.Exists(Path.Combine(kotorDir, "Override", "prestige_fix.2da")), Is.True, + "Folder Move draft should find prestige_fix.2da after extract-style nesting"); + }); + } + [Test] public void K2FullGuideFixture_IngestedMultiModInstallSmoke_InstallsViaModSelect() { diff --git a/src/ModSync.Tests/K2FullGuideDownloadTests.cs b/src/ModSync.Tests/K2FullGuideDownloadTests.cs index 6d18e45b..194ccead 100644 --- a/src/ModSync.Tests/K2FullGuideDownloadTests.cs +++ b/src/ModSync.Tests/K2FullGuideDownloadTests.cs @@ -26,6 +26,8 @@ public sealed class K2FullGuideDownloadTests private static readonly string GoldenTomlRelative = Path.Combine("mod-builds", "TOMLs", "KOTOR2_Full.toml"); private const string SilentSionModName = "Silent Sion Restoration"; private const string SilentSionArchive = "Silent Sion Restoration.zip"; + private const string PrestigeModName = "Prestige Class Saving Throw Fixes"; + private const string PrestigeArchive = "TSL_prestige_save_fixes.zip"; private string _testDirectory; private string _modDirectory; diff --git a/src/ModSync.Tests/K2FullGuideRoundTripHelper.cs b/src/ModSync.Tests/K2FullGuideRoundTripHelper.cs index bd1550c2..f3fa5a98 100644 --- a/src/ModSync.Tests/K2FullGuideRoundTripHelper.cs +++ b/src/ModSync.Tests/K2FullGuideRoundTripHelper.cs @@ -229,13 +229,21 @@ private static void RefineMoveInstructionsFromDirections(ModComponent component) @"\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) + if (fileMatch.Success) { + move.Source = DraftInstructionService.ExpandLooseFileMoveSources(move.Source, fileMatch.Groups[1].Value); return; } - string fileName = fileMatch.Groups[1].Value; - move.Source = DraftInstructionService.ExpandLooseFileMoveSources(move.Source, fileName); + Match folderMatch = Regex.Match( + component.Directions, + @"(?:only\s+install|install\s+only)\s+(?:the\s+)?(?[^.;\n]+?)(?:\.|$|\s)", + RegexOptions.IgnoreCase); + + if (folderMatch.Success) + { + move.Source = DraftInstructionService.ExpandFolderMoveSources(move.Source, folderMatch.Groups["folder"].Value); + } } private static bool ShouldIncludeRegistryArchive(string modName, string archiveFileName) From 71401f67eaba65f0679fced9b066d3a956488a78 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 18 Jul 2026 13:37:34 -0500 Subject: [PATCH 2/2] test(k2): add Prestige round-trip download+install LongRunning smoke Extend the #191 round-trip harness with Prestige Class folder-Move coverage, overlay helper assertions, and agent script --mod routing for network smoke. --- docs/knowledgebase/cli-selection-semantics.md | 2 +- ...merged_roundtrip_download_install_smoke.sh | 20 ++++- src/ModSync.Tests/K2FullGuideDownloadTests.cs | 89 +++++++++++++++++++ 3 files changed, 106 insertions(+), 5 deletions(-) diff --git a/docs/knowledgebase/cli-selection-semantics.md b/docs/knowledgebase/cli-selection-semantics.md index 69315d27..5d9a3768 100644 --- a/docs/knowledgebase/cli-selection-semantics.md +++ b/docs/knowledgebase/cli-selection-semantics.md @@ -34,7 +34,7 @@ Full round-trip (golden URLs + ingested NLP → download → extract → Overrid ./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. Folder advisories (e.g. Prestige Class “only install the Jedi Master/Sith Lord fixes”) add nested folder wildcards plus slug/fuzzy variants (`jedimaster_sithlord fixes`) via `DraftInstructionService.BuildFolderMoveSources`. +Wraps `K2FullGuideFixture_RoundTripSilentSion_DownloadAndInstalls_LongRunning` by default; set `MOD="Prestige Class Saving Throw Fixes"` (or `FILTER=…RoundTripPrestige…`) for the Prestige Class folder-Move round-trip. NLP Move drafts for single loose files include nested `<>/*…/filename` sources so post-extract paths resolve. Folder advisories (e.g. Prestige Class “only install the Jedi Master/Sith Lord fixes”) add nested folder wildcards plus slug/fuzzy variants (`jedimaster_sithlord fixes`) via `DraftInstructionService.BuildFolderMoveSources`. ## `validate` without `--select` (default) diff --git a/scripts/agents/k2_merged_roundtrip_download_install_smoke.sh b/scripts/agents/k2_merged_roundtrip_download_install_smoke.sh index a7c9828a..a97cee0c 100755 --- a/scripts/agents/k2_merged_roundtrip_download_install_smoke.sh +++ b/scripts/agents/k2_merged_roundtrip_download_install_smoke.sh @@ -8,14 +8,14 @@ cd "$ROOT" GOLDEN="${GOLDEN:-$ROOT/mod-builds/TOMLs/KOTOR2_Full.toml}" FILTER="${FILTER:-FullyQualifiedName~K2FullGuideFixture_RoundTripSilentSion_DownloadAndInstalls_LongRunning}" +MOD="${MOD:-Silent Sion Restoration}" 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. +Runs a K2 Full round-trip LongRunning test: merge neocities fixture with golden TOML, +overlay ingested NLP instructions, download the archive, extract, and install to Override. Requires: - mod-builds cloned at ./mod-builds (KOTOR2_Full.toml) @@ -23,21 +23,33 @@ Requires: Options: --golden PATH Golden TOML path (default: ./mod-builds/TOMLs/KOTOR2_Full.toml) + --mod NAME Mod name for round-trip overlay (default: Silent Sion Restoration) -h, --help Show this help Environment: - GOLDEN, FILTER (dotnet test --filter override) + GOLDEN, FILTER, MOD (MOD selects default FILTER when FILTER unset) + +Examples: + ./scripts/agents/k2_merged_roundtrip_download_install_smoke.sh + MOD="Prestige Class Saving Throw Fixes" \\ + FILTER='FullyQualifiedName~K2FullGuideFixture_RoundTripPrestige_DownloadAndInstalls_LongRunning' \\ + ./scripts/agents/k2_merged_roundtrip_download_install_smoke.sh EOF } while [[ $# -gt 0 ]]; do case "$1" in --golden) GOLDEN="$2"; shift 2 ;; + --mod) MOD="$2"; shift 2 ;; -h|--help) usage; exit 0 ;; *) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;; esac done +if [[ "$MOD" == "Prestige Class Saving Throw Fixes" && "$FILTER" == "FullyQualifiedName~K2FullGuideFixture_RoundTripSilentSion_DownloadAndInstalls_LongRunning" ]]; then + FILTER="FullyQualifiedName~K2FullGuideFixture_RoundTripPrestige_DownloadAndInstalls_LongRunning" +fi + 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 diff --git a/src/ModSync.Tests/K2FullGuideDownloadTests.cs b/src/ModSync.Tests/K2FullGuideDownloadTests.cs index 194ccead..572380ca 100644 --- a/src/ModSync.Tests/K2FullGuideDownloadTests.cs +++ b/src/ModSync.Tests/K2FullGuideDownloadTests.cs @@ -167,6 +167,59 @@ public void K2FullGuideFixture_RoundTripSilentSion_DownloadAndInstalls_LongRunni }); } + [Test] + [Timeout(600_000)] + public void K2FullGuideFixture_RoundTripPrestige_DownloadAndInstalls_LongRunning() + { + (string fixturePath, string goldenTomlPath) = ResolveInputs(); + string roundTripToml = Path.Combine(_testDirectory, "k2_roundtrip_prestige.toml"); + + K2FullGuideRoundTripHelper.WriteRoundTripToml( + fixturePath, + goldenTomlPath, + roundTripToml, + PrestigeModName); + + int installExit = ModBuildConverter.Run(new[] + { + "install", + "--input", roundTripToml, + "--game-dir", _kotorDirectory, + "--source-dir", _modDirectory, + "--select", "mod:" + PrestigeModName, + "--download", + "--skip-validation", + "--best-effort", + "-y", + }); + + string overrideDir = Path.Combine(_kotorDirectory, "Override"); + string[] overrideTwoDas = Directory.Exists(overrideDir) + ? Directory.GetFiles(overrideDir, "*.2da", SearchOption.TopDirectoryOnly) + : Array.Empty(); + 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 Prestige Class (best-effort)"); + Assert.That( + File.Exists(Path.Combine(_modDirectory, PrestigeArchive)) + || modWorkspaceFiles.Any(path => path.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)), + Is.True, + "Prestige archive should be present in the mod workspace after download"); + Assert.That( + modWorkspaceFiles.Any(path => + path.IndexOf("jedimaster_sithlord", StringComparison.OrdinalIgnoreCase) >= 0), + Is.True, + "Extract should unpack jedimaster_sithlord fixes under the mod workspace"); + Assert.That(overrideTwoDas.Length, Is.GreaterThan(0), + "Jedi Master/Sith Lord folder Move should install at least one .2da to Override"); + }); + } + [Test] public void MergedSilentSion_ResourceRegistryListsArchiveFilenames() { @@ -238,6 +291,42 @@ public void RoundTripHelper_OverlaySilentSion_AddsExtractForRegistryArchive() }); } + [Test] + public void RoundTripHelper_OverlayPrestige_AddsExtractAndNestedFolderMove() + { + (string fixturePath, string goldenTomlPath) = ResolveInputs(); + string roundTripToml = Path.Combine(_testDirectory, "k2_roundtrip_prestige.toml"); + + K2FullGuideRoundTripHelper.WriteRoundTripToml( + fixturePath, + goldenTomlPath, + roundTripToml, + PrestigeModName); + + List components = FileLoadingService.LoadFromFile(roundTripToml).ToList(); + ModComponent prestige = components.First(c => + c.Name.IndexOf(PrestigeModName, StringComparison.OrdinalIgnoreCase) >= 0); + + Instruction moveInstruction = prestige.Instructions.First(i => i.Action == Instruction.ActionType.Move); + + Assert.Multiple(() => + { + Assert.That(prestige.ResourceRegistry?.Count, Is.GreaterThan(0)); + Assert.That( + prestige.Instructions.Any(i => + i.Action == Instruction.ActionType.Extract + && i.Source.Any(s => s.IndexOf(PrestigeArchive, StringComparison.OrdinalIgnoreCase) >= 0)), + Is.True, + "Round-trip overlay should prepend Extract for TSL_prestige_save_fixes.zip"); + Assert.That( + moveInstruction.Source.Any(source => + source.IndexOf("sithlord", StringComparison.OrdinalIgnoreCase) >= 0 + && source.IndexOf('*', StringComparison.Ordinal) >= 0), + Is.True, + "Round-trip Move should search nested folders for jedimaster_sithlord fixes"); + }); + } + private static (string fixturePath, string goldenTomlPath) ResolveInputs() { string repoRoot = ResolveRepoRoot();