Skip to content

feat(parsing): lossless roundtrip via unified guide ingest/emit pipeline - #198

Merged
oldrepublicwizard merged 17 commits into
masterfrom
feat/lossless-roundtrip-universal-pipeline
Jul 30, 2026
Merged

feat(parsing): lossless roundtrip via unified guide ingest/emit pipeline#198
oldrepublicwizard merged 17 commits into
masterfrom
feat/lossless-roundtrip-universal-pipeline

Conversation

@oldrepublicwizard

Copy link
Copy Markdown
Owner

Summary

The guide markdown that ModSync ingests has drifted from what the parser assumed: the canonical corpus (KOTOR-Community-Portal/mod-builds) switched to Docusaurus-style :::note/:::warning fences in October 2025, never had a paired TOML file the way the parser's tests assumed, and its author has permanently declined to standardize free-text instruction wording. This PR closes the gaps that assumption left behind, grounded in reading the actual live corpus and the guide author's own correspondence rather than guessing.

Parser generalization:

  • Admonition-fence parsing (:::note/:::warning/:::tip), previously hardcoded to 2 of 9 multiline fields, now applies to all of them
  • Directions prose that reads as actionable but matches no known pattern now surfaces as an explicit, reviewable gap instead of silently vanishing
  • The K2CP+HD-Visas nested conditional ("delete X; if also using Y, additionally delete Z") now decomposes into distinct instructions instead of merging or dropping the conditional half
  • Redrob's cleanlist-driven conditional deletion is now detected and drafted as a CleanList instruction referencing the external file, instead of being silently resolved to a wrong fixed file list
  • A real bug: 16 regex patterns used a bare . as a clause-boundary marker, which also matched a filename's own extension separator, truncating keblastore.utm to keblastore mid-parse. Fixed by requiring the period be followed by whitespace or end-of-string.

Unified pipeline:

  • IGuideIngestService/IGuideEmitService now carry preamble/epilogue/widescreen/Aspyr/trace/warnings instead of dropping them at the port boundary (the generic deserializer discarded this by writing to a throwaway MainConfig instance)
  • CLI convert and GUI file-open/paste now route through that one shared port instead of each calling MarkdownParser/DraftInstructionService independently, so CLI/GUI drafting parity is now structural rather than something a separate integration test has to prove

Measurement:

  • Cloned the pinned corpus (KOTOR-Community-Portal/mod-builds dev @ 336b36d9) and ran the full suite against it
  • Confirmed a severe, pre-existing performance defect: MarkdownParser's per-field regex scanning scales roughly cubically with document size (verified against the pre-branch baseline, not introduced by this PR — though the fence generalization does add a modest ~1.7x constant-factor cost on fence-heavy documents). A full real guide (~150KB) takes on the order of tens of minutes. Tagged the affected test [Category("Slow")] per the project's existing convention rather than silently working around it — this needs its own dedicated fix.
  • Fixed the last corpus-test hard-fail (MarkdownTomlParityTests threw instead of skipping when ./mod-builds was absent) and corrected AGENTS.md's stale clone instructions (the org was renamed/forked; canonical is KOTOR-Community-Portal, not th3w1zard1)

Code Review

A Tier 2 review ran (9 personas, given the diff's size) and found 3 real bugs, confirmed by cross-reviewer agreement — all fixed and covered by a new regression test:

  1. Zero-draft components incorrectly got the review flag. GenerateDraftInstructions changed to return one result per component with Directions prose (not just drafted ones), but both CLI and GUI still applied the "DRAFT INSTRUCTIONS" flag unconditionally — now gated on DraftInstructionCount > 0.
  2. CLI's "Drafted instructions for N component(s)" count was inflated by the same issue.
  3. GuideIngestService double-parsed markdown on the no-format-hint auto-detect path (DetectFormatFromContent parses to detect, then IngestMarkdown parsed again) — now tries TOML first (cheap, preserves detection order) and reuses the markdown parse instead of repeating it.

Known Residuals

Lower-priority findings tracked but not fixed in this pass (all owner: human, none blocking):

  • Conditional-clause decomposition only recognizes the one named K2CP+HD-Visas shape; chained or suffix-style conditional phrasings can go untagged or mis-attribute the condition
  • TryHandleCleanlistReference hardcodes the CleanList destination to Override
  • NaturalLanguageInstructionParser.cs has crossed the 1k-line threshold without decomposition
  • GUI FileLoadingService.cs's non-editor branch does a manual field-by-field GuideIngestResultMarkdownParserResult mapping alongside editor-mode's separate dialog-based construction
  • The GUI's generic file-open path (CLI-preload, modsync:// links) still bypasses the new port for .md content — only the "Load File" button and paste flows get full preamble/epilogue/widescreen/Aspyr/trace parity

Test Plan

  • Full solution build clean
  • Full test suite green except one pre-existing unrelated bug (RawRegexPattern_ExtractsCategoryTier) and two Avalonia headless font-rendering failures (sandbox environment limitation, not code)
  • New tests: admonition-fence generalization, unparsed-gap surfacing, conditional-clause decomposition, cleanlist detection, filename-truncation regression, guide-port content-carrying, CLI/GUI review-flag regression, genuine Choose-tree round-trip
  • Corpus-pinned measurement against a live clone at the recorded commit

Post-Deploy Monitoring & Validation

Ships in the next ModSync release.

  • Watch for: guides losing preamble/epilogue/widescreen/Aspyr content after CLI convert or GUI file-open/paste (search Logger output for "Stored 0 characters in preamble" where content was expected); an uptick in "DRAFT INSTRUCTIONS" flags on components that shouldn't have any drafted instructions (would indicate the zero-draft-flag fix regressed); CLI convert taking much longer than before on stdin/no-format-hint input (would indicate the double-parse fix regressed).
  • Healthy signal: guides round-trip through CLI/GUI with all content sections intact; review flags appear only on components with actual drafted instructions.
  • Rollback: revert the commit range on this branch — isolated parsing/port changes, no schema or data migration involved.

Compound Engineering
Claude Code

Copilot added 16 commits July 30, 2026 04:50
Embedded <!--<<ModSync>> metadata blocks never carry a Name field (it
lives in the markdown header), but DeserializeComponent required Name
unconditionally, so parsing that block always threw and silently fell
back to a lossy legacy parser that assigned a fresh random GUID. Thread
an optional requireName parameter through DeserializeComponent,
DeserializeYamlComponent, DeserializeModComponentFromTomlString, and
ModComponent.DeserializeTomlComponent, defaulting to true everywhere
except the two embedded-metadata-block parse paths in MarkdownParser.

Also fix instruction/option grouping in ProcessInstructionsAndOptions
and GroupKeyValuePairsIntoInstructions/GroupKeyValuePairsIntoOptions:
boundary detection keyed on a hardcoded field name (Action/Name) broke
when Guid legitimately preceded it in field order. Use duplicate-key
detection instead, which is order-independent.

Corrects MarkdownFileTests' own fixture, which was itself invalid YAML
(brace-wrapped GUIDs parse as YAML flow-mappings; unindented fields
de-indent out of their list item), and adds C2RoundtripInvariantTests
as a self-contained regression guard for the invariant.
…params

ProcessInstructionsAndOptions's inline Instructions/Options loops handled
a KeyValuePair<string, object> case that can never execute there: that
branch only runs when hasKeyValuePairs is false, and any KeyValuePair
item's runtime type name always starts with "KeyValuePair", which would
have made hasKeyValuePairs true. Removed the dead branch and the now-
unused accumulator dictionaries.

Also added XML doc comments for the requireName parameter on
DeserializeYamlComponent, DeserializeModComponentFromTomlString, and
ModComponent.DeserializeTomlComponent, which had gained it without
documentation (DeserializeComponent already had one).
…API fixups)

U1: remove the <Compile Remove> exclusions for DocumentationRoundTripTests.cs,
MarkdownFileTests.cs, and MarkdownImportTests.cs in ModSync.Tests.csproj so
they compile and run again.

U2: fix the compile/runtime issues re-enabling them surfaced:
- DocumentationRoundTripTests.cs: skip via Assert.Inconclusive when the
  test_modbuild_k1.md fixture is absent, instead of a hard Assert.Fail;
  fix a stale ModLinkFilenames reference to the current Dependencies property.
- MarkdownImportTests.cs: skip via Assert.Inconclusive when the mod-builds
  corpus is absent; update to current ModComponent/Category APIs (no more
  CSharpKOTOR.ModComponent, Category is now a list); remove assertions on
  Instruction.Guid, which no longer exists on that type; drop assertions
  tied to corpus content that no longer matches the current fixture.
…s (U1)

Docusaurus-style :::note/:::warning/:::tip fences were previously
hardcoded to only Installation Instructions and Known Bugs, and only
paired with one specific admonition type each. The live mod-builds guide
format (used since Oct 2025) wraps any field in any of the five
admonition types, so generalize BuildFencePattern across all 9 multiline
fields and strip the shared `:   ` definition-list prefix at the single
ExtractValue chokepoint.

Also fixes a real bug the new mixed-style test surfaced: fieldBoundary
(the shared lookahead terminating a bold-inline field capture) didn't
recognize a following ::: fence as a boundary, so a bold-inline field
immediately followed by a fenced field swallowed the fence text into its
own capture.
Directions prose that reads as actionable (contains a verb) but matches
no known instruction pattern was previously logged at verbose level and
silently dropped. NaturalLanguageInstructionParser.ParseInstructions now
has an overload reporting these as unparsedGaps, distinct from prose
recognized as pure commentary (which is correctly skipped, not a gap).

DraftInstructionResult carries UnparsedGaps and GenerateDraftInstructions
now emits one result per component with Directions prose - including
components where nothing drafted - so CLI/GUI callers can render
"N of M directions produced no draft" instead of the gap disappearing.
AE8: guide prose of the shape "delete X; if also using HD Visas,
additionally delete Y" previously either merged Y into the same
unconditional Delete instruction as X, or - when phrased as a single
comma-joined sentence with no semicolon/period separator - silently
dropped Y's clause entirely (confirmed via a minimal repro: only 1 of 2
described deletions drafted).

SplitIntoProcessingUnits now recognizes "if (also) using X, additionally/
also Y" as an explicit clause boundary (both the inline form and the
"bare" form already split off by an earlier semicolon/then), decomposing
it into its own tagged processing unit. ParseInstructions threads that
tag through as a new conditionalDrafts output; DraftInstructionService
surfaces it via DraftInstructionResult.ConditionalDrafts and an
InstallationWarning note, so the conditional deletion is captured as its
own instruction (never merged or dropped) and flagged so a reviewer
knows not to apply it unconditionally.
…ssing (U4)

AE7: a Directions entry describing per-mod deletion driven by an
externally-maintained cleanlist file (redrob's cleanlist_k1.txt
convention) previously fell through to the generic Delete patterns,
which mismatched fragments of the prose into a nonsensical fixed file
path (e.g. "<<modDirectory>>\files listed in cleanlist_k1") - silently
wrong, with zero gap signal.

TryHandleCleanlistReference now runs before the generic pattern list:
when the unit names a specific cleanlist file, it drafts a CleanList
instruction referencing that file (resolution of which files it deletes
stays deferred to install time, exactly like Instruction.
ExecuteCleanListAsync already does - this never fabricates a fixed
list). When only a bare "cleanlist" mention appears with no nameable
file, it surfaces as an unparsed gap instead of guessing. Either way the
unit no longer reaches the generic Delete patterns.
AE6 verification (HQ Blasters sequence: delete keblastore.utm to force
an intentional single TSLPatcher error, run the patcher, rename/delete
several more files afterward) surfaced a real, confirmed bug: 16
Delete/Move/Patcher/exception patterns share a boundary idiom that uses
a bare '.' as one alternative meaning "end of clause". Since a filename's
own extension separator is also a literal '.', this bare alternative
matched THERE instead, truncating "keblastore.utm" to "keblastore" (a
silently wrong deletion target) and, in multi-file lists, cutting the
capture short mid-list.

Changed the boundary's '.' alternative to '.(?=\s|$)' (period followed by
whitespace or end-of-string) across all 16 occurrences - a genuine
clause-ending period is always followed by whitespace/EOL by the time
SplitIntoProcessingUnits hands a unit to these patterns (trailing
periods are already stripped upstream), so this only prevents the
false-positive match on an extension dot; no legitimate clause-ending
match is affected.

Also verified the wildcard-prefix rename step in the same sequence
("rename w_ionrfl_04.* files to w_ionrfl_004.*") has no matching pattern
and correctly surfaces as an unparsed gap (U2) rather than drafting a
wrong rename or disappearing silently - satisfying AE6's "drafts
correctly or produces a clean, reviewable gap" bar without needing a new
pattern for that step.
… port (U6)

GuideIngestService previously routed everything (including markdown)
through ModComponentSerializationService.DeserializeModComponentFromString,
which internally calls MarkdownParser but discards the resulting
MarkdownParserResult's PreambleContent/EpilogueContent/WidescreenWarning
Content/AspyrExclusiveWarningContent/Trace by writing them to a
throwaway local MainConfig instance. GuideIngestResult only ever carried
Components/DraftResults/DetectedFormat, so callers going through the
port (rather than calling MarkdownParser directly) had no way to see
those sections or the parse trace.

GuideIngestService.IngestFromText now detects markdown format (via the
same format-alias list DeserializeModComponentFromString uses, or
DetectFormatFromContent when no format hint is given) and calls
MarkdownParser directly for that case, populating the new
GuideIngestResult fields. Non-markdown formats (TOML/YAML/JSON/XML)
continue through the unchanged generic deserializer path - they have no
equivalent content sections to carry.

IGuideEmitService.EmitMarkdown/EmitMarkdownAsync also gained optional
widescreenWarningContent/aspyrExclusiveWarningContent parameters,
forwarding to GenerateModDocumentation's existing support for them
(previously only reachable by bypassing the port).
…uide port (U7)

CLI convert previously loaded components via the generic deserializer
and, separately, called DraftInstructionService.GenerateDraftInstructions
directly when --parse-directions was set. Both calls are now one call
through GuideIngestService.Instance.IngestFromText, carrying the same
--parse-directions value as before (off by default). Extracted
FileLoadingService.ReadFileContentAndFormatHintAsync (Core) so CLI can
read file content with the same UTF-8-fallback/case-insensitive-path
handling LoadFromFileAsync already had, without a second deserialize
pass.

GUI FileLoadingService (paste and non-editor-mode file-open) previously
constructed a MarkdownParser and separately called
GenerateDraftInstructionsFromProseAsync (-> DraftInstructionService)
directly. Both now route through the same port call, with each caller's
existing draft-flag default preserved unchanged: file-open stays off,
paste stays on. The interactive editor-mode path (RegexImportDialog, a
live regex-configuration UI, not a plain parse-and-get-result flow) is
intentionally left calling MarkdownParser directly - rerouting it
through the generic port would lose its in-dialog preview/configuration
capability for no benefit.

GUI's "Generate Documentation" button now calls GuideEmitService.
Instance.EmitMarkdown instead of ModComponentSerializationService.
GenerateModDocumentation directly, with the same widescreen/Aspyr
parameters.

CLI/GUI drafting now share the exact same GuideIngestService.
IngestFromText call, making parity a structural guarantee rather than
something requiring a separate integration test - extended the existing
GuiSmokeHeadlessTests paste-import test to assert the draft/review-flag
behavior still holds through the new path.
C2RoundtripInvariantTests.cs already existed (cherry-picked from prior
work) but its one "Choose"-named test only documented a known
limitation (the natural-language #### Instructions/##### Option markdown
format is not parsed) - it never actually built a Choose instruction,
emitted it, and confirmed the tree survives re-ingestion.

Adds two self-contained (no ./mod-builds dependency) tests:
- C2_ChooseComponent_EmitAndReingest_PreservesChooseTreeAndBranchInstructions:
  builds a component with a Choose instruction referencing two Options,
  each with its own branch instruction, emits via the real
  GenerateModDocumentation path, and confirms the instruction, both
  option branches, and each branch's own instructions survive intact.
- C2_MixedChooseAndPlainComponents_PreservesCountAndOrder: a plain
  component and a Choose component together preserve count and document
  order through the same round-trip.
C2_ChooseComponent_EmitAndReingest_PreservesChooseTreeAndBranchInstructions
authored its expected Source paths with a literal backslash separator,
but the real emit->re-parse round trip normalizes to forward slash.
Match the fixture's expectation to what the round trip actually produces
(caught by running the corpus-pinned NUnit suite for U9).
Corrected AGENTS.md's stale clone instruction (th3w1zard1/mod-builds ->
KOTOR-Community-Portal/mod-builds, dev branch) - the canonical repo, not
the frozen non-canonical TOML-only fork.

MarkdownTomlParityTests.cs hard-failed via Assert.That(File.Exists(...),
Is.True) when ./mod-builds was absent, instead of skipping cleanly like
the other three corpus-dependent test files already did. Now uses
Assert.Ignore, with a comment explaining the canonical repo has never had
a TOMLs/ directory - it only ever existed in the separate, frozen (no
commits since 2025-10-31) oldrepublicwizard/mod-builds snapshot, so this
C3 cross-check is historical/best-effort, not a release gate.

Measured against a live pinned read of KOTOR-Community-Portal/mod-builds
dev @ 336b36d911b879b0254da5d08a6cce72c28607e5: confirmed no TOMLs/
directory exists (both parity tests skip cleanly), and surfaced a
pre-existing, severe performance defect in MarkdownParser's per-field
regex scanning - time scales roughly cubically with document size
(confirmed on the pre-branch baseline too, not solely introduced by this
plan's changes, though the admonition-fence generalization measurably
worsens the constant factor for fence-heavy documents). A full real guide
(~150KB) takes on the order of tens of minutes to parse, impractical for
default CI/local runs. Tagged RealGuide_ModBuildsMarkdown_
DraftedInstructionsAreAllSandboxed [Category("Slow")] (excluded by the
existing default TestCategory!=Slow runsettings filter, matching this
project's established convention) until the underlying scan-scoping
performance issue gets its own dedicated fix - flagging this as a
release-blocking follow-up, not silently working around it.
Deduplicated three constants/lists that had drifted into copies across
this branch's new code: the <<modDirectory>> placeholder (now shared
from DraftInstructionService), the markdown format-alias list (now
exposed once from ModComponentSerializationService instead of
duplicated in GuideServices), and the newline-split array used to strip
definition-list prefixes (now shared from MarkdownUtilities). Also
unified ApplyReviewFlag/ApplyConditionalDraftNote's identical
append-unless-present logic into one AppendWarningIfMissing helper.

Added cheap substring pre-filters (checking for "using"/"cleanlist")
before the new conditional-clause and redrob-cleanlist regexes in
NaturalLanguageInstructionParser, since both require that literal text
and were otherwise running unconditionally on every processing unit.

GUI FileLoadingService.LoadMarkdownContentAsync: narrowed
configuredProfile's scope to the editor-mode branch that actually uses
it, and made explicit (via a log warning) that a custom
MarkdownImportProfile has no effect through the non-editor-mode guide
ingest port - previously silently dropped with no signal.

No behavior change; verified via GuideIngestionTests, C2RoundtripInvariantTests,
MarkdownAdmonitionFenceTests, and MarkdownFileTests (all green).
…arse

Tier 2 code review (9 reviewers) confirmed three real bugs, one with
cross-reviewer agreement across correctness/testing/api-contract:

1. Since U2 changed GenerateDraftInstructions to return one result per
   component with Directions prose (not just drafted ones), both the CLI
   (ModBuildConverter.cs's validation-issue loop) and the GUI
   (FileLoadingService.cs's GenerateDraftInstructionsFromProseAsync) were
   unconditionally applying the "DRAFT INSTRUCTIONS" review flag to every
   component in DraftResults, including ones that drafted nothing. Both
   now gate on DraftInstructionCount > 0, matching DraftInstructionService.
   ApplyReviewFlag's own internal gate.

2. The CLI's "Drafted instructions for N component(s)" log message counted
   the same inflated DraftResults.Count instead of the actually-drafted
   subset.

3. GuideIngestService.IngestFromText's auto-detect path (no format hint)
   called DetectFormatFromContent, which parses content as markdown just
   to detect the format, then parsed the same content a second time in
   IngestMarkdown - a genuine double full-parse for the common no-hint
   markdown case (e.g. CLI convert --stdin), compounding on top of
   MarkdownParser's already-documented cubic-scaling cost. Now tries TOML
   first (cheap, fails fast, preserves the original detection precedence)
   then attempts markdown directly and keeps that result instead of
   re-parsing.

Added a regression test (CliConvert_StdinWithParseDirections_
DoesNotReviewFlagZeroDraftComponent) covering a two-component guide where
one component drafts and one doesn't, asserting only the drafted one
carries the review flag through the TOML round-trip.

Simplify pass (dedup, pre-filters, GUI FileLoadingService scope narrowing)
and remaining residual findings from review (chained-conditional-clause
edge cases, hardcoded CleanList destination, file-size threshold, GUI
generic file-open bypassing the port for non-.md-extension-checked
callers) are tracked as known residuals, not fixed in this pass - see PR
description.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

The .NET Format, Format and Fix, and lint_csharp CI checks failed on
whitespace/indentation and file-encoding issues in test files touched
this branch (C2RoundtripInvariantTests.cs, DocumentationRoundTripTests.cs,
MarkdownAdmonitionFenceTests.cs, MarkdownFileTests.cs,
MarkdownImportTests.cs). Ran `dotnet format whitespace` locally and
committed the result - whitespace-only diff, no behavioral change.
Verified via the affected test suites (all green except the one
pre-existing unrelated RawRegexPattern_ExtractsCategoryTier failure).
@oldrepublicwizard
oldrepublicwizard merged commit 166ac18 into master Jul 30, 2026
11 checks passed
@oldrepublicwizard
oldrepublicwizard deleted the feat/lossless-roundtrip-universal-pipeline branch July 30, 2026 14:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant