Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 91 additions & 1 deletion src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,15 @@ public sealed class NaturalLanguageInstructionParser
["file_types"] = new Regex(@"(?<types>(?:\.[\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<string> s_invalidSourceTokens = new HashSet<string>(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<string, string> s_destinationMappings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1082,7 +1099,7 @@ private static List<string> 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))
Expand All @@ -1109,6 +1126,79 @@ private static List<string> ExtractSources([NotNull] string sourceText, [NotNull
return sources;
}

/// <summary>
/// Rejects pronouns and other non-path tokens guides use in prose ("rename them to …").
/// </summary>
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));
}

/// <summary>
/// Validates each sandboxed source path, including the relative segment after the placeholder.
/// </summary>
private static bool IsValidSandboxedSourcePath([NotNull] string sourcePath)
{
if (string.IsNullOrWhiteSpace(sourcePath))
{
return false;
}

const string modPrefix = "<<modDirectory>>";
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;
}

/// <summary>
/// Generates a list of file sources from a range specification.
/// </summary>
Expand Down
59 changes: 59 additions & 0 deletions src/ModSync.Tests/GuideIngestionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DraftInstructionResult> 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()
{
Expand Down