Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1cd9ce8
docs(plan): add lossless roundtrip universal pipeline brainstorm and …
Jul 30, 2026
f3755ad
fix(core): preserve GUID/instructions/options across markdown round-trip
Jul 30, 2026
25c61e1
fix(review): remove unreachable KVP-handling branch, doc requireName …
Jul 30, 2026
51ca06f
fix(tests): complete U1/U2 roundtrip test re-enablement (skip logic, …
Jul 30, 2026
abeaeaf
feat(parsing): generalize admonition-fence support to all guide field…
Jul 30, 2026
f85d9a5
feat(parsing): surface unparsed directions as reviewable gaps (U2)
Jul 30, 2026
031d79d
feat(parsing): decompose K2CP+HD-Visas nested conditional clauses (U3)
Jul 30, 2026
2cc6cb1
feat(parsing): detect redrob cleanlist-driven deletion instead of gue…
Jul 30, 2026
2fa54e0
fix(parsing): stop truncating filenames at their own extension dot (U5)
Jul 30, 2026
a2ea7a4
feat(ports): carry markdown content sections through the guide ingest…
Jul 30, 2026
6c25ebb
feat(ingest): route CLI convert and GUI file-open/paste through the g…
Jul 30, 2026
9ec613a
test(roundtrip): add self-contained Choose-tree round-trip coverage (U8)
Jul 30, 2026
1587998
fix(test): normalize path separator in Choose-tree round-trip test (U8)
Jul 30, 2026
6512abd
fix(tests): clean-skip on missing corpus, tag slow real-guide test (U9)
Jul 30, 2026
e77ac6f
refactor: dedupe constants and add regex pre-filters (simplify pass)
Jul 30, 2026
a87ef1a
fix(review): stop over-flagging zero-draft components, avoid double-p…
Jul 30, 2026
484fb60
fix(ci): apply dotnet format whitespace to test files
Jul 30, 2026
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
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ scripts/
agents/ # Helper scripts for agent workflows
docs/ # Runbooks and documentation
vendor/ # Third-party binaries
mod-builds/ # Clone here: github.com/th3w1zard1/mod-builds
mod-builds/ # Clone here: github.com/KOTOR-Community-Portal/mod-builds (dev branch)
```

## Build
Expand Down Expand Up @@ -176,9 +176,9 @@ Example launch (after `mod-builds` exists at repo root and template dirs are cre

`./scripts/agents/launch_gui_desktop.sh --instruction-file ./mod-builds/TOMLs/KOTOR1_Full.toml --kotor-dir ./tmp/kotor_template --mod-dir ./tmp/mod_downloads`

Clone `mod-builds` at the repo root if missing:
Clone `mod-builds` at the repo root if missing (the canonical guide content lives on the `dev` branch; there is no `TOMLs/` directory in this repo - vendor a TOML instruction file separately, e.g. from the frozen `oldrepublicwizard/mod-builds` snapshot, if a launch command needs one):

`git clone https://github.com/th3w1zard1/mod-builds ./mod-builds`
`git clone -b dev https://github.com/KOTOR-Community-Portal/mod-builds ./mod-builds`

Typical local desktop flow:

Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

80 changes: 46 additions & 34 deletions src/ModSync.Core/CLI/ModBuildConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1912,20 +1912,38 @@ private static async Task<int> RunConvertAsync(ConvertOptions opts)
}

List<ModComponent> components;
Ports.Guides.GuideIngestResult ingestResult;
try
{
string content;
string formatHint;
if (opts.UseStdin)
{
string stdinContent = await Console.In.ReadToEndAsync().ConfigureAwait(false);
components = (await ModComponentSerializationService
.DeserializeModComponentFromStringAsync(stdinContent)
.ConfigureAwait(false)).ToList();
content = await Console.In.ReadToEndAsync().ConfigureAwait(false);
formatHint = null;
}
else
{
components = await FileLoadingService.LoadFromFileAsync(opts.InputPath).ConfigureAwait(false);
(content, formatHint) = await FileLoadingService.ReadFileContentAndFormatHintAsync(opts.InputPath).ConfigureAwait(false);
}

if (opts.ParseDirections)
{
msg = "Drafting instructions from natural-language Directions prose...";
if (s_progressDisplay != null)
{
s_progressDisplay.WriteScrollingLog(msg);
}
else
{
await Logger.LogVerboseAsync(msg).ConfigureAwait(false);
}
}

ingestResult = await Task.Run(() => Ports.Guides.GuideIngestService.Instance.IngestFromText(
content, formatHint, opts.ParseDirections)).ConfigureAwait(false);
components = ingestResult.Components.ToList();

// Handle dependency resolution
components = (List<ModComponent>)HandleDependencyResolutionErrors(components, opts.IgnoreErrors, "Convert");

Expand All @@ -1938,6 +1956,20 @@ private static async Task<int> RunConvertAsync(ConvertOptions opts)
{
await Logger.LogVerboseAsync(msg).ConfigureAwait(false);
}

if (opts.ParseDirections)
{
int draftedCount = ingestResult.DraftResults.Count(r => r.DraftInstructionCount > 0);
msg = $"Drafted instructions for {draftedCount} component(s) - all drafts are flagged for review";
if (s_progressDisplay != null)
{
s_progressDisplay.WriteScrollingLog(msg);
}
else
{
await Logger.LogAsync(msg).ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
Expand All @@ -1950,34 +1982,7 @@ private static async Task<int> RunConvertAsync(ConvertOptions opts)
throw;
}

IReadOnlyList<Parsing.DraftInstructionResult> draftResults = null;
if (opts.ParseDirections)
{
msg = "Drafting instructions from natural-language Directions prose...";
if (s_progressDisplay != null)
{
s_progressDisplay.WriteScrollingLog(msg);
}
else
{
await Logger.LogVerboseAsync(msg).ConfigureAwait(false);
}

draftResults = Parsing.DraftInstructionService.GenerateDraftInstructions(
components,
logInfo: message => Logger.Log(message),
logVerbose: message => Logger.LogVerbose(message));

msg = $"Drafted instructions for {draftResults.Count} component(s) - all drafts are flagged for review";
if (s_progressDisplay != null)
{
s_progressDisplay.WriteScrollingLog(msg);
}
else
{
await Logger.LogAsync(msg).ConfigureAwait(false);
}
}
IReadOnlyList<Parsing.DraftInstructionResult> draftResults = opts.ParseDirections ? ingestResult.DraftResults : null;

if (opts.Download)
{
Expand Down Expand Up @@ -2145,11 +2150,18 @@ private static async Task<int> RunConvertAsync(ConvertOptions opts)
// Create validation context to track issues for serialization
var validationContext = new ComponentValidationContext();

// Flag prose-drafted instructions for review in the serialized output (never auto-trusted)
// Flag prose-drafted instructions for review in the serialized output (never auto-trusted).
// DraftResults now includes components whose Directions produced zero drafts (U2 gap
// reporting) - only flag components that actually drafted instructions.
if (draftResults != null)
{
foreach (Parsing.DraftInstructionResult draftResult in draftResults)
{
if (draftResult.DraftInstructionCount == 0)
{
continue;
}

validationContext.AddModComponentIssue(
draftResult.Component.Guid,
Parsing.DraftInstructionService.ReviewFlagMessage);
Expand Down
6 changes: 4 additions & 2 deletions src/ModSync.Core/ModComponent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -807,16 +807,18 @@ public string SerializeComponent()
return Services.ModComponentSerializationService.SerializeSingleComponentAsTomlString(this);
}

/// <param name="tomlString">The raw TOML content to deserialize.</param>
/// <param name="requireName">See <see cref="Services.ModComponentSerializationService.DeserializeComponent"/>.</param>
[CanBeNull]
public static ModComponent DeserializeTomlComponent([NotNull] string tomlString)
public static ModComponent DeserializeTomlComponent([NotNull] string tomlString, bool requireName = true)
{
if (tomlString is null)
{
throw new ArgumentNullException(nameof(tomlString));
}

// Use the unified deserialization service
IReadOnlyList<ModComponent> components = Services.ModComponentSerializationService.DeserializeModComponentFromTomlString(tomlString);
IReadOnlyList<ModComponent> components = Services.ModComponentSerializationService.DeserializeModComponentFromTomlString(tomlString, requireName);
return components?.FirstOrDefault();
}
public async Task<InstallExitCode> InstallAsync(
Expand Down
92 changes: 84 additions & 8 deletions src/ModSync.Core/Parsing/DraftInstructionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,39 @@ public sealed class DraftInstructionResult

public int DraftInstructionCount { get; }

public DraftInstructionResult([NotNull] ModComponent component, int draftInstructionCount)
/// <summary>
/// Directions prose that contained an action verb but matched no known instruction pattern.
/// Never populated with commentary/informational prose - only genuine unparsed gaps. Callers
/// should render these (e.g. "N of M directions produced no draft") rather than drop them silently.
/// </summary>
[NotNull]
[ItemNotNull]
public IReadOnlyList<string> UnparsedGaps { get; }

public bool HasUnparsedGaps => UnparsedGaps.Count > 0;

/// <summary>
/// Human-readable notes for instructions drafted from a nested conditional clause (the
/// K2CP+HD-Visas pattern: "delete these files; if also using X, additionally delete these").
/// Each note names the mod the draft is conditional on - the drafted instruction is never
/// auto-applied unconditionally, but this distinguishes it from an ordinary unconditional draft.
/// </summary>
[NotNull]
[ItemNotNull]
public IReadOnlyList<string> ConditionalDrafts { get; }

public bool HasConditionalDrafts => ConditionalDrafts.Count > 0;

public DraftInstructionResult(
[NotNull] ModComponent component,
int draftInstructionCount,
[CanBeNull][ItemNotNull] IReadOnlyList<string> unparsedGaps = null,
[CanBeNull][ItemNotNull] IReadOnlyList<string> conditionalDrafts = null)
{
Component = component ?? throw new ArgumentNullException(nameof(component));
DraftInstructionCount = draftInstructionCount;
UnparsedGaps = unparsedGaps ?? Array.Empty<string>();
ConditionalDrafts = conditionalDrafts ?? Array.Empty<string>();
}
}

Expand All @@ -45,15 +74,18 @@ public static class DraftInstructionService
public const string ReviewFlagMessage =
"DRAFT INSTRUCTIONS: parsed from guide prose by the natural-language importer. Review before installing - never auto-trusted.";

[NotNull] private const string ModDirectoryPlaceholder = "<<modDirectory>>";
[NotNull] internal const string ModDirectoryPlaceholder = "<<modDirectory>>";
[NotNull] private const string KotorDirectoryPlaceholder = "<<kotorDirectory>>";
[NotNull] private const string LegacyGameDirectoryPlaceholder = "<<gameDirectory>>";

/// <summary>
/// Generates draft instructions for every component that has natural-language Directions prose
/// but no authored instructions. Components that already have instructions are never touched.
/// </summary>
/// <returns>One result per component that received at least one draft instruction.</returns>
/// <returns>
/// One result per component that has Directions prose to draft from - including components where
/// zero instructions were successfully drafted, so callers can render unparsed gaps for review.
/// </returns>
[NotNull]
[ItemNotNull]
public static IReadOnlyList<DraftInstructionResult> GenerateDraftInstructions(
Expand All @@ -79,12 +111,16 @@ public static IReadOnlyList<DraftInstructionResult> GenerateDraftInstructions(
}

ObservableCollection<Instruction> parsed;
IReadOnlyList<string> unparsedGaps;
IReadOnlyList<string> conditionalDrafts;
try
{
parsed = parser.ParseInstructions(
component.Directions,
string.IsNullOrWhiteSpace(component.DownloadInstructions) ? null : component.DownloadInstructions,
component);
component,
out unparsedGaps,
out conditionalDrafts);
}
catch (Exception ex)
{
Expand All @@ -111,8 +147,20 @@ public static IReadOnlyList<DraftInstructionResult> GenerateDraftInstructions(
{
ApplyReviewFlag(component);
info($"[DraftInstructions] Drafted {added} instruction(s) from prose for '{component.Name}' - flagged for review.");
results.Add(new DraftInstructionResult(component, added));
}

if (unparsedGaps.Count > 0)
{
info($"[DraftInstructions] {unparsedGaps.Count} direction(s) for '{component.Name}' produced no draft - review needed.");
}

if (conditionalDrafts.Count > 0)
{
ApplyConditionalDraftNote(component, conditionalDrafts);
info($"[DraftInstructions] {conditionalDrafts.Count} draft(s) for '{component.Name}' are conditional on another mod - review needed.");
}

results.Add(new DraftInstructionResult(component, added, unparsedGaps, conditionalDrafts));
}

return results;
Expand Down Expand Up @@ -181,15 +229,43 @@ public static void ApplyReviewFlag([NotNull] ModComponent component)
throw new ArgumentNullException(nameof(component));
}

AppendWarningIfMissing(component, ReviewFlagMessage, prepend: true);
}

/// <summary>
/// Appends conditional-draft notes (see <see cref="DraftInstructionResult.ConditionalDrafts"/>) to a
/// component's <see cref="ModComponent.InstallationWarning"/> so a reviewer sees, alongside the
/// general draft review flag, exactly which drafted instructions are conditional on another mod.
/// Does not duplicate notes already present.
/// </summary>
private static void ApplyConditionalDraftNote(
[NotNull] ModComponent component,
[NotNull][ItemNotNull] IReadOnlyList<string> conditionalDrafts)
{
foreach (string note in conditionalDrafts)
{
AppendWarningIfMissing(component, note, prepend: false);
}
}

/// <summary>
/// Adds <paramref name="text"/> to a component's <see cref="ModComponent.InstallationWarning"/>
/// unless it's already present. <paramref name="prepend"/> controls whether new text goes before
/// or after any existing warning content.
/// </summary>
private static void AppendWarningIfMissing([NotNull] ModComponent component, [NotNull] string text, bool prepend)
{
if (string.IsNullOrWhiteSpace(component.InstallationWarning))
{
component.InstallationWarning = ReviewFlagMessage;
component.InstallationWarning = text;
return;
}

if (component.InstallationWarning.IndexOf(ReviewFlagMessage, StringComparison.Ordinal) < 0)
if (component.InstallationWarning.IndexOf(text, StringComparison.Ordinal) < 0)
{
component.InstallationWarning = ReviewFlagMessage + Environment.NewLine + component.InstallationWarning;
component.InstallationWarning = prepend
? text + Environment.NewLine + component.InstallationWarning
: component.InstallationWarning + Environment.NewLine + text;
}
}

Expand Down
Loading
Loading