From 1091cd9f0c4d19ddffbd6521096c54d6a22f630a Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 17 Jul 2026 05:46:24 -0500 Subject: [PATCH] test(cli): add K2 ingested validate dry-run regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject pronoun tokens in NLP source paths (e.g. "rename them to …") and add GuideIngestionTests coverage for validate --dry-run-only on the site-scraped K2 Full fixture TOML. --- .../NaturalLanguageInstructionParser.cs | 92 ++++++++++++++++++- src/ModSync.Tests/GuideIngestionTests.cs | 59 ++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) diff --git a/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs b/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs index 19587ca1..e6d6bfee 100644 --- a/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs +++ b/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs @@ -489,6 +489,15 @@ public sealed class NaturalLanguageInstructionParser ["file_types"] = new Regex(@"(?(?:\.[\w]+(?:\s+&\s+|,\s*|\s+and\s+))+\.[\w]+)", RegexOptions.IgnoreCase), }; + // Prose pronouns and generic words that must never become mod paths. + private static readonly HashSet s_invalidSourceTokens = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "them", "they", "it", "this", "that", "these", "those", + "files", "file", "contents", "content", "everything", "all", + "mod", "the", "two", "both", "each", "any", "some", "copies", "copy", + "override", "folder", "directory", "archives", "archive", "installer", + }; + // Destination normalization mappings private static readonly Dictionary s_destinationMappings = new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -983,6 +992,14 @@ private static bool ValidateInstruction([NotNull] Instruction instruction) { return false; } + + foreach (string source in instruction.Source) + { + if (!IsValidSandboxedSourcePath(source)) + { + return false; + } + } } // Destination-required actions @@ -1082,7 +1099,7 @@ private static List ExtractSources([NotNull] string sourceText, [NotNull if (!string.IsNullOrWhiteSpace(sourceText)) { string cleaned = sourceText.Trim('"', '\'', ' ', ',', ';'); - if (cleaned.Length > 0) + if (cleaned.Length > 0 && IsValidSourceToken(cleaned)) { // Check if it looks like a file (has extension) if (Path.HasExtension(cleaned)) @@ -1109,6 +1126,79 @@ private static List ExtractSources([NotNull] string sourceText, [NotNull return sources; } + /// + /// Rejects pronouns and other non-path tokens guides use in prose ("rename them to …"). + /// + private static bool IsValidSourceToken([NotNull] string token) + { + if (string.IsNullOrWhiteSpace(token)) + { + return false; + } + + string cleaned = token.Trim('"', '\'', ' ', ',', ';', '.'); + if (cleaned.Length == 0 || s_invalidSourceTokens.Contains(cleaned)) + { + return false; + } + + if (Path.HasExtension(cleaned) || cleaned.Contains("*") || cleaned.Contains("?")) + { + return true; + } + + // Allow folder-like tokens (e.g. Straight Fixes, Override) but not bare lowercase prose. + return cleaned.Any(c => c == '_' || c == '-' || char.IsUpper(c)); + } + + /// + /// Validates each sandboxed source path, including the relative segment after the placeholder. + /// + private static bool IsValidSandboxedSourcePath([NotNull] string sourcePath) + { + if (string.IsNullOrWhiteSpace(sourcePath)) + { + return false; + } + + const string modPrefix = "<>"; + if (!sourcePath.StartsWith(modPrefix, StringComparison.Ordinal)) + { + return true; + } + + string remainder = sourcePath.Length > modPrefix.Length + ? sourcePath.Substring(modPrefix.Length).TrimStart('\\', '/') + : string.Empty; + + if (string.IsNullOrEmpty(remainder) || remainder == "*") + { + return true; + } + + foreach (string segment in remainder.Split('\\', '/')) + { + if (string.IsNullOrEmpty(segment) || segment == "*") + { + continue; + } + + string baseSegment = segment; + int wildcardIndex = baseSegment.IndexOf('*'); + if (wildcardIndex >= 0) + { + baseSegment = baseSegment.Substring(0, wildcardIndex); + } + + if (!string.IsNullOrEmpty(baseSegment) && !IsValidSourceToken(baseSegment)) + { + return false; + } + } + + return true; + } + /// /// Generates a list of file sources from a range specification. /// diff --git a/src/ModSync.Tests/GuideIngestionTests.cs b/src/ModSync.Tests/GuideIngestionTests.cs index 354016b3..2644fc17 100644 --- a/src/ModSync.Tests/GuideIngestionTests.cs +++ b/src/ModSync.Tests/GuideIngestionTests.cs @@ -518,6 +518,65 @@ public void DraftInstructions_K2SiteProsePatterns_DraftSandboxedInstructions() } } + [Test] + public void DraftInstructions_RenameThemProse_DoesNotUsePronounAsSource() + { + const string prose = + "Take the two copied files and rename them to PLC_CompPnl_b, retaining their original file extensions. " + + "When the files are moved to the override, you should be moving four files: PLC_CompPnl.tga, PLC_CompPnl.txi, PLC_CompPnl_b.tga, and PLC_CompPnl_b.txi"; + + ModComponent component = CreateComponent(prose); + component.InstallationMethod = "Loose-File Mod"; + + IReadOnlyList results = DraftInstructionService.GenerateDraftInstructions(new[] { component }); + + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(component.Instructions.Any(i => + i.Source.Any(s => s.IndexOf("\\them", StringComparison.OrdinalIgnoreCase) >= 0)), Is.False, + "Pronoun 'them' must never become a mod source path"); + Assert.That(component.Instructions.Any(i => i.Action == Instruction.ActionType.Move), Is.True); + } + + [Test] + public void K2FullGuideFixture_ValidateDryRunOnly_ExitsZero() + { + 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 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), "convert --parse-directions should succeed for K2 Full fixture"); + + int validateExit = ModBuildConverter.Run(new[] + { + "validate", + "--input", ingestedToml, + "--game-dir", kotorDir, + "--source-dir", modDir, + "--dry-run-only", + "--errors-only", + }); + + Assert.That(validateExit, Is.EqualTo(0), "validate --dry-run-only should pass on template dirs for ingested K2 Full TOML"); + Assert.That(File.ReadAllText(ingestedToml), Does.Contain("thisMod")); + } + [Test] public void DraftInstructions_EmptyDirections_ProducesNoDrafts() {