diff --git a/docs/superpowers/plans/2026-09-04-partial-morpheme-health-check.md b/docs/superpowers/plans/2026-09-04-partial-morpheme-health-check.md new file mode 100644 index 00000000..0bf37250 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-partial-morpheme-health-check.md @@ -0,0 +1,215 @@ +# Partial Morpheme Health Check Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an actionable `GrammarHealthChecker` warning for every distinct HermitCrab morpheme whose published `IsPartial` flag is true. + +**Architecture:** Extend the existing diagnostic-only checker with one private enumeration pass over lexical entries, ordinary morphemic rules, and template-slot rules. Deduplicate the model objects by reference, then emit one stable-coded warning whose subject is the original morpheme; do not infer partiality from POS, slots, or feature structures. + +**Tech Stack:** C#, .NET 10 test project, netstandard2.0 library, NUnit 4, CSharpier + +--- + +### Task 1: Specify partial-morpheme findings with failing tests + +**Files:** +- Modify: `tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs` + +- [ ] **Step 1: Add the morphological-rules import** + +```csharp +using SIL.Machine.Morphology.HermitCrab.MorphologicalRules; +``` + +- [ ] **Step 2: Add a failing partial lexical-entry test** + +```csharp +[Test] +public void Check_PartialLexicalEntry_ReportsActionableWarning() +{ + var table = new CharacterDefinitionTable { Name = "table1" }; + var stratum = new Stratum(table) { Name = "Surface" }; + var entry = new LexEntry { Id = "entry1", IsPartial = true }; + stratum.Entries.Add(entry); + var language = new Language(); + language.Strata.Add(stratum); + + GrammarHealthFinding finding = GrammarHealthChecker.Check(language).Single(); + + Assert.That(finding.Code, Is.EqualTo(GrammarHealthCodes.PartialMorpheme)); + Assert.That(finding.Severity, Is.EqualTo(GrammarHealthSeverity.Warning)); + Assert.That(finding.Message, Does.Contain("entry1")); + Assert.That(finding.Message, Does.Contain("partially analyzed")); + Assert.That(finding.Message, Does.Contain("final-template pruning")); + Assert.That(finding.Subjects, Is.EqualTo(new object[] { entry })); +} +``` + +- [ ] **Step 3: Add a failing ordinary-rule test** + +```csharp +[Test] +public void Check_PartialOrdinaryRule_ReportsRule() +{ + var table = new CharacterDefinitionTable { Name = "table1" }; + var stratum = new Stratum(table) { Name = "Surface" }; + var rule = new AffixProcessRule { Name = "plural", IsPartial = true }; + stratum.MorphologicalRules.Add(rule); + var language = new Language(); + language.Strata.Add(stratum); + + GrammarHealthFinding finding = GrammarHealthChecker.Check(language).Single(); + + Assert.That(finding.Code, Is.EqualTo(GrammarHealthCodes.PartialMorpheme)); + Assert.That(finding.Message, Does.Contain("plural")); + Assert.That(finding.Subjects, Is.EqualTo(new object[] { rule })); +} +``` + +- [ ] **Step 4: Add a failing template-rule deduplication test** + +```csharp +[Test] +public void Check_PartialTemplateRuleReferencedTwice_ReportsOnce() +{ + var table = new CharacterDefinitionTable { Name = "table1" }; + var stratum = new Stratum(table) { Name = "Surface" }; + var rule = new AffixProcessRule { Name = "subject", IsPartial = true }; + var template = new AffixTemplate { Name = "verb" }; + template.Slots.Add(new AffixTemplateSlot(rule)); + template.Slots.Add(new AffixTemplateSlot(rule)); + stratum.AffixTemplates.Add(template); + var language = new Language(); + language.Strata.Add(stratum); + + IList findings = GrammarHealthChecker.Check(language); + + Assert.That(findings, Has.Count.EqualTo(1)); + Assert.That(findings[0].Code, Is.EqualTo(GrammarHealthCodes.PartialMorpheme)); + Assert.That(findings[0].Subjects, Is.EqualTo(new object[] { rule })); +} +``` + +- [ ] **Step 5: Run the focused tests and verify RED** + +Run: + +```powershell +dotnet test tests\SIL.Machine.Morphology.HermitCrab.Tests\SIL.Machine.Morphology.HermitCrab.Tests.csproj --configuration Release --filter "FullyQualifiedName~GrammarHealthCheckerTests" +``` + +Expected: compilation fails because `GrammarHealthCodes.PartialMorpheme` does not exist. This is the intended red result. + +- [ ] **Step 6: Commit the failing specification** + +```powershell +git add tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs +git commit -m "test: specify partial morpheme health findings" +``` + +### Task 2: Implement the checker from the published model fact + +**Files:** +- Modify: `src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs` +- Modify: `src/SIL.Machine.Morphology.HermitCrab/GrammarHealthFinding.cs` + +- [ ] **Step 1: Add the stable finding code** + +Add to `GrammarHealthCodes`: + +```csharp +public const string PartialMorpheme = "hc-partial-morpheme"; +``` + +- [ ] **Step 2: Invoke the new diagnostic pass** + +Add after the existing checks in `GrammarHealthChecker.Check`: + +```csharp +CheckPartialMorphemes(language, findings); +``` + +- [ ] **Step 3: Enumerate every morpheme-bearing location and deduplicate references** + +Add a private method that creates: + +```csharp +var seen = new HashSet(new ReferenceEqualityComparer()); +``` + +For every stratum, visit `stratum.Entries`, `stratum.MorphologicalRules.OfType()`, and every rule in every `AffixTemplateSlot`. Pass each object to a helper that returns immediately when `!morpheme.IsPartial || !seen.Add(morpheme)`. + +- [ ] **Step 4: Emit one warning with the original object as subject** + +For a `LexEntry`, prefer its non-empty `Id`; for a `MorphemicMorphologicalRule`, prefer its non-empty `Name`; fall back to `Id`, `Gloss`, and finally `"unnamed"`. Emit: + +```csharp +new GrammarHealthFinding( + GrammarHealthSeverity.Warning, + GrammarHealthCodes.PartialMorpheme, + string.Format( + "{0} '{1}' is partially analyzed. Supply its missing category or template/slot analysis; " + + "leaving it partial can broaden analysis and disable safe final-template pruning.", + kind, + name + ), + new object[] { morpheme } +) +``` + +- [ ] **Step 5: Run the focused tests and verify GREEN** + +Run the Task 1 test command. + +Expected: all `GrammarHealthCheckerTests` pass. + +- [ ] **Step 6: Commit the implementation** + +```powershell +git add src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs src/SIL.Machine.Morphology.HermitCrab/GrammarHealthFinding.cs +git commit -m "feat: report partially analyzed morphemes" +``` + +### Task 3: Verify, document the PR, and publish the branch + +**Files:** +- Modify if formatting requires it: the two production files and one test file above +- External: GitHub pull request #475 body + +- [ ] **Step 1: Run CSharpier** + +```powershell +dotnet tool restore +dotnet csharpier format src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs src/SIL.Machine.Morphology.HermitCrab/GrammarHealthFinding.cs tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs +``` + +Expected: exit code 0. Commit any formatting-only changes. + +- [ ] **Step 2: Run the full targeted suite** + +```powershell +dotnet test tests\SIL.Machine.Morphology.HermitCrab.Tests\SIL.Machine.Morphology.HermitCrab.Tests.csproj --configuration Release +``` + +Expected: all tests pass with zero warnings. + +- [ ] **Step 3: Check the complete branch diff** + +```powershell +git diff --check origin/master...HEAD +git status --short --branch +``` + +Expected: no whitespace errors and a clean worktree. + +- [ ] **Step 4: Push the approved PR head** + +```powershell +git push origin HEAD:feature/grammar-health-checker +``` + +Expected: GitHub updates pull request #475. + +- [ ] **Step 5: Update the pull-request description** + +Add the third checker contract and its new tests to the existing PR description. Preserve the diagnostic-only and netstandard2.0 design statements. Explicitly state that the partial finding advises authors to complete missing category or slot information while #491 retains conservative final-template behavior. diff --git a/docs/superpowers/specs/2026-09-04-partial-morpheme-health-check-design.md b/docs/superpowers/specs/2026-09-04-partial-morpheme-health-check-design.md new file mode 100644 index 00000000..ab6ede90 --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-partial-morpheme-health-check-design.md @@ -0,0 +1,65 @@ +# Partial Morpheme Health Check + +## Goal + +Extend `GrammarHealthChecker` on pull request #475 to identify every partially +analyzed HermitCrab morpheme. The finding should tell grammar authors to finish +the incomplete analysis and explain that partial morphemes can broaden search +and prevent safe final-template pruning. + +This is a production-readiness diagnostic. It does not change grammar loading, +parsing, synthesis, or the conservative final-template correctness guard. + +## Diagnostic contract + +Add the stable code `hc-partial-morpheme` with warning severity. Emit one +finding for each distinct `Morpheme` whose `IsPartial` property is true. + +The check covers: + +- lexical entries in every stratum; +- ordinary morphemic morphological rules in every stratum; and +- morphemic rules referenced by affix-template slots. + +The same rule object can be referenced more than once, so enumeration must use +reference identity and report it once. Each finding's first and only subject is +the partial `Morpheme`, allowing a host to navigate to the original object. + +The message identifies whether the subject is a lexical entry or morphological +rule, names it using the best available identifier, and recommends supplying +its missing category or template/slot analysis. It also states that leaving the +morpheme partial can broaden analysis and disable safe final-template pruning. + +## Placement + +`GrammarHealthChecker.Check(Language)` will invoke a private partial-morpheme +check alongside the two existing checks. The implementation stays inside the +`netstandard2.0` HermitCrab library and remains diagnostic-only. + +No new parser option or model field is introduced. `Morpheme.IsPartial` remains +the owner of the decision; the health checker reports that published fact and +does not re-derive partiality from POS, slots, or feature structures. + +## Tests + +Tests will be written and observed failing before production code changes. They +will prove that: + +1. a partial lexical entry produces one actionable warning and exposes the + entry as its subject; +2. a partial ordinary affix rule produces one warning; +3. a partial template rule produces one warning even if referenced by multiple + slots or templates; +4. non-partial morphemes produce no partial-morpheme warning; and +5. the existing checks continue to compose with the new check. + +The targeted HermitCrab suite and formatting check must pass before the branch +is pushed. + +## Relationship to pull request #491 + +Pull request #491 keeps its safe default: final-template pruning remains +disabled wherever partial morphemes make the stronger conclusion unsafe. The +new health finding gives grammar authors an actionable route to remove that +performance blocker instead of weakening the correctness guard or silently +forcing the optimization. diff --git a/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs b/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs new file mode 100644 index 00000000..caaabbc8 --- /dev/null +++ b/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs @@ -0,0 +1,293 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using SIL.Machine.Annotations; +using SIL.Machine.FeatureModel; +using SIL.Machine.Morphology.HermitCrab.MorphologicalRules; +using SIL.ObjectModel; + +namespace SIL.Machine.Morphology.HermitCrab +{ + /// + /// Checks a loaded for problems HermitCrab does not otherwise report: + /// segments used without a declaration, declared segments with duplicate phonological feature + /// bundles, and morphemes whose analysis is marked partial. These problems can silently refuse + /// words, make morpheme identification unreliable, or broaden analysis enough to disable safe + /// final-template pruning. This checker surfaces them before the grammar ships. It is diagnostic + /// only: it never changes how a parses. + /// + public static class GrammarHealthChecker + { + /// + /// Runs every check against and returns the findings, in the + /// order the checks ran. An empty list means every registered check passed, not that nothing + /// was checked -- see for what each finding's code means. + /// + public static IList Check(Language language) + { + if (language == null) + throw new ArgumentNullException("language"); + + var findings = new List(); + CheckDuplicateFeatureBundles(language, findings); + CheckUndeclaredSegments(language, findings); + CheckPartialMorphemes(language, findings); + return findings; + } + + private static void CheckPartialMorphemes(Language language, List findings) + { + var seen = new HashSet(new ReferenceEqualityComparer()); + + foreach (Stratum stratum in language.Strata) + { + foreach (LexEntry entry in stratum.Entries) + CheckPartialMorpheme(entry, seen, findings); + + foreach (Morpheme rule in stratum.MorphologicalRules.OfType()) + CheckPartialMorpheme(rule, seen, findings); + + foreach (AffixTemplate template in stratum.AffixTemplates) + { + foreach (MorphemicMorphologicalRule rule in template.Slots.SelectMany(slot => slot.Rules)) + CheckPartialMorpheme(rule, seen, findings); + } + } + } + + private static void CheckPartialMorpheme( + Morpheme morpheme, + HashSet seen, + List findings + ) + { + if (!morpheme.IsPartial || !seen.Add(morpheme)) + return; + + string kind; + string name; + var rule = morpheme as MorphemicMorphologicalRule; + if (rule != null) + { + kind = "Morphological rule"; + name = FirstNonEmpty(rule.Name, rule.Id, rule.Gloss); + } + else + { + kind = "Lexical entry"; + name = FirstNonEmpty(morpheme.Id, morpheme.Gloss); + } + + findings.Add( + new GrammarHealthFinding( + GrammarHealthSeverity.Warning, + GrammarHealthCodes.PartialMorpheme, + string.Format( + "{0} '{1}' is partially analyzed. Supply its missing category or template/slot analysis; " + + "leaving it partial can broaden analysis and disable safe final-template pruning.", + kind, + name + ), + new object[] { morpheme } + ) + ); + } + + private static string FirstNonEmpty(params string[] values) + { + return values.FirstOrDefault(value => !string.IsNullOrEmpty(value)) ?? "unnamed"; + } + + // Every table's segments must have distinct phonological feature bundles, or a segment-changing + // rule cannot tell them apart. + private static void CheckDuplicateFeatureBundles(Language language, List findings) + { + // No feature system means every bundle is the same empty struct by construction (see + // PhonologicalBundle), not a collision. + if (language.PhonologicalFeatureSystem.Count == 0) + return; + + foreach (CharacterDefinitionTable table in language.CharacterDefinitionTables) + { + List segmentDefs = table + .Where(cd => cd.Type == HCFeatureSystem.Segment) + .OrderBy(cd => cd.Representations.First(), StringComparer.Ordinal) + .ToList(); + + // ValueEquals is the model's own deep, order-independent feature-value equality. + var groups = new List>(); + foreach (CharacterDefinition cd in segmentDefs) + { + FeatureStruct bundle = PhonologicalBundle(cd); + List group = groups.FirstOrDefault(g => + PhonologicalBundle(g[0]).ValueEquals(bundle) + ); + if (group == null) + { + group = new List(); + groups.Add(group); + } + group.Add(cd); + } + + foreach (List group in groups) + { + if (group.Count < 2) + continue; + + string names = string.Join(", ", group.Select(cd => cd.Representations.First())); + var subjects = new List { table }; + subjects.AddRange(group); + findings.Add( + new GrammarHealthFinding( + GrammarHealthSeverity.Warning, + GrammarHealthCodes.DuplicateFeatureBundle, + string.Format( + "Character definition table '{0}' has {1} segments with an identical " + + "phonological feature bundle, so a segment-changing rule cannot reliably " + + "tell them apart: {2}.", + table.Name, + group.Count, + names + ), + subjects + ) + ); + } + } + } + + // Strips Type (constant per segment) and any synthesized StrRep, neither of which the grammar author chose. + private static FeatureStruct PhonologicalBundle(CharacterDefinition cd) + { + FeatureStruct bundle = cd.FeatureStruct.Clone(); + bundle.RemoveValue(HCFeatureSystem.Type); + bundle.RemoveValue(HCFeatureSystem.StrRep); + return bundle; + } + + // Every segment the grammar actually uses must be declared in the table it is used against. + private static void CheckUndeclaredSegments(Language language, List findings) + { + var declaredTables = new HashSet(language.CharacterDefinitionTables); + + foreach (Stratum stratum in language.Strata) + { + foreach (LexEntry entry in stratum.Entries) + { + foreach (RootAllomorph allomorph in entry.Allomorphs) + { + CheckSegmentsDeclared( + allomorph.Segments, + string.Format( + "Lexical entry '{0}' allomorph '{1}'", + entry.Id, + allomorph.Segments.Representation + ), + findings + ); + } + } + + foreach (IMorphologicalRule rule in stratum.MorphologicalRules) + { + var affixRule = rule as AffixProcessRule; + if (affixRule != null) + { + foreach (AffixProcessAllomorph allomorph in affixRule.Allomorphs) + { + foreach (InsertSegments insert in allomorph.Rhs.OfType()) + { + CheckSegmentsDeclared( + insert.Segments, + string.Format( + "Morphological rule '{0}' inserted segments '{1}'", + affixRule.Name, + insert.Segments.Representation + ), + findings + ); + } + } + } + + var compoundingRule = rule as CompoundingRule; + if (compoundingRule != null) + { + foreach (CompoundingSubrule subrule in compoundingRule.Subrules) + { + foreach (InsertSegments insert in subrule.Rhs.OfType()) + { + CheckSegmentsDeclared( + insert.Segments, + string.Format( + "Compounding rule '{0}' inserted segments '{1}'", + compoundingRule.Name, + insert.Segments.Representation + ), + findings + ); + } + } + } + } + } + + foreach (NaturalClass naturalClass in language.NaturalClasses) + { + var segmentClass = naturalClass as SegmentNaturalClass; + if (segmentClass == null) + continue; + + foreach (CharacterDefinition cd in segmentClass.Segments) + { + if (cd.CharacterDefinitionTable != null && declaredTables.Contains(cd.CharacterDefinitionTable)) + continue; + + findings.Add( + new GrammarHealthFinding( + GrammarHealthSeverity.Error, + GrammarHealthCodes.UndeclaredSegment, + string.Format( + "Natural class '{0}' references a segment ('{1}') that does not belong to any " + + "character definition table in this language.", + naturalClass.Name, + cd.Representations.Count > 0 ? cd.Representations.First() : cd.FeatureStruct.ToString() + ), + new object[] { naturalClass, cd } + ) + ); + } + } + } + + // Same GetMatchingStrReps lookup used to render a shape back to text; boundary/anchor nodes are + // structural, not graphemes. + private static void CheckSegmentsDeclared(Segments segments, string where, List findings) + { + CharacterDefinitionTable table = segments.CharacterDefinitionTable; + foreach (ShapeNode node in segments.Shape) + { + if (node.Annotation.Type() != HCFeatureSystem.Segment) + continue; + if (table.GetMatchingStrReps(node).Any()) + continue; + + findings.Add( + new GrammarHealthFinding( + GrammarHealthSeverity.Error, + GrammarHealthCodes.UndeclaredSegment, + string.Format( + "{0} contains a segment with feature bundle {1} that character definition table " + + "'{2}' does not declare.", + where, + node.Annotation.FeatureStruct, + table.Name + ), + new object[] { table, segments, node } + ) + ); + } + } + } +} diff --git a/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthFinding.cs b/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthFinding.cs new file mode 100644 index 00000000..fcf44d9c --- /dev/null +++ b/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthFinding.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace SIL.Machine.Morphology.HermitCrab +{ + /// + /// How serious a is. Error means the engine will + /// behave incorrectly (or refuse the word outright) whenever the offending construct is + /// exercised, with no further information needed to know that. Warning means the + /// construct is a genuine risk to the grammar's reliability, but whether it actually causes a + /// problem for a given word depends on how the grammar's rules use it. + /// + public enum GrammarHealthSeverity + { + Warning, + Error, + } + + /// + /// The stable finding codes reports. Treat these strings, + /// not , as the identifier a host uses to filter, + /// suppress, or test for a particular kind of finding -- the message text is free to change. + /// + public static class GrammarHealthCodes + { + public const string DuplicateFeatureBundle = "hc-duplicate-feature-bundle"; + public const string PartialMorpheme = "hc-partial-morpheme"; + public const string UndeclaredSegment = "hc-undeclared-segment"; + } + + /// + /// One problem or production-readiness risk found in a by + /// . This is diagnostic only: producing a finding never + /// changes how the grammar parses. + /// + public class GrammarHealthFinding + { + private readonly ReadOnlyCollection _subjects; + + public GrammarHealthFinding( + GrammarHealthSeverity severity, + string code, + string message, + IEnumerable subjects + ) + { + if (code == null) + throw new ArgumentNullException("code"); + if (message == null) + throw new ArgumentNullException("message"); + if (subjects == null) + throw new ArgumentNullException("subjects"); + + Severity = severity; + Code = code; + Message = message; + _subjects = new ReadOnlyCollection(subjects.ToList()); + } + + public GrammarHealthSeverity Severity { get; private set; } + + /// + /// A stable identifier for the kind of problem found. See . + /// + public string Code { get; private set; } + + /// + /// A human-readable description naming the offending declaration(s). + /// + public string Message { get; private set; } + + /// + /// The model objects the finding is about (e.g. a , + /// the s that collide, a , or a + /// ), in the order most useful for a host to navigate to them. This + /// is the object model itself, not a copy or a serialized form, so a host that already + /// holds the same can use reference equality to find its own + /// project-specific wrapper around each subject. + /// + public ReadOnlyCollection Subjects + { + get { return _subjects; } + } + + public override string ToString() + { + return string.Format("[{0}] {1}: {2}", Severity, Code, Message); + } + } +} diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs new file mode 100644 index 00000000..28480f5d --- /dev/null +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs @@ -0,0 +1,206 @@ +using NUnit.Framework; +using SIL.Machine.Annotations; +using SIL.Machine.FeatureModel; +using SIL.Machine.Morphology.HermitCrab.MorphologicalRules; + +namespace SIL.Machine.Morphology.HermitCrab; + +[TestFixture] +public class GrammarHealthCheckerTests +{ + private static FeatureSystem VocFeatureSystem() + { + var featSys = new FeatureSystem + { + new SymbolicFeature("voc", new FeatureSymbol("voc+", "+"), new FeatureSymbol("voc-", "-")), + }; + featSys.Freeze(); + return featSys; + } + + [Test] + public void Check_TwoSegmentsShareFeatureBundle_ReportsBothByName() + { + FeatureSystem featSys = VocFeatureSystem(); + var table = new CharacterDefinitionTable { Name = "table1" }; + table.AddSegment("a", FeatureStruct.NewMutable(featSys).Symbol("voc+").Value); + table.AddSegment("b", FeatureStruct.NewMutable(featSys).Symbol("voc+").Value); + + var language = new Language { PhonologicalFeatureSystem = featSys }; + language.CharacterDefinitionTables.Add(table); + + IList findings = GrammarHealthChecker.Check(language); + + Assert.That(findings, Has.Count.EqualTo(1)); + GrammarHealthFinding finding = findings[0]; + Assert.That(finding.Code, Is.EqualTo(GrammarHealthCodes.DuplicateFeatureBundle)); + Assert.That(finding.Message, Does.Contain("a")); + Assert.That(finding.Message, Does.Contain("b")); + Assert.That(finding.Subjects, Contains.Item(table)); + } + + [Test] + public void Check_EverySegmentHasDistinctFeatureBundle_NoFindings() + { + FeatureSystem featSys = VocFeatureSystem(); + var table = new CharacterDefinitionTable { Name = "table1" }; + table.AddSegment("a", FeatureStruct.NewMutable(featSys).Symbol("voc+").Value); + table.AddSegment("b", FeatureStruct.NewMutable(featSys).Symbol("voc-").Value); + + var language = new Language { PhonologicalFeatureSystem = featSys }; + language.CharacterDefinitionTables.Add(table); + + Assert.That(GrammarHealthChecker.Check(language), Is.Empty); + } + + [Test] + public void Check_NoPhonologicalFeatureSystem_DoesNotFlagTriviallyIdenticalBundles() + { + // No PhonologicalFeatureSystem at all (the strrep-identity shape): every segment's bundle is + // the same empty struct by construction, so this must not be reported as a duplicate. + var table = new CharacterDefinitionTable { Name = "table1" }; + table.AddSegment("a"); + table.AddSegment("b"); + table.AddSegment("c"); + + var language = new Language(); + language.CharacterDefinitionTables.Add(table); + + Assert.That(GrammarHealthChecker.Check(language), Is.Empty); + } + + [Test] + public void Check_LexicalEntryUsesSegmentNoTableDeclares_ReportsFinding() + { + FeatureSystem featSys = VocFeatureSystem(); + var table = new CharacterDefinitionTable { Name = "table1" }; + table.AddSegment("a", FeatureStruct.NewMutable(featSys).Symbol("voc+").Value); + + var stratum = new Stratum(table) { Name = "Surface" }; + + // A Segments object built by hand rather than through CharacterDefinitionTable.Segment, which + // is the only place that validates a representation's characters against the table -- a host + // building the object model directly (not via XmlLanguageLoader) is not required to go through it. + FeatureStruct undeclaredFs = FeatureStruct.NewMutable(featSys).Symbol("voc-").Value; + undeclaredFs.AddValue(HCFeatureSystem.Type, HCFeatureSystem.Segment); + undeclaredFs.Freeze(); + var shape = new Shape(begin => new ShapeNode( + begin ? HCFeatureSystem.LeftSideAnchor : HCFeatureSystem.RightSideAnchor + )); + shape.Add(undeclaredFs); + var segments = new Segments(table, "z", shape); + + var entry = new LexEntry { Id = "e1" }; + entry.Allomorphs.Add(new RootAllomorph(segments)); + stratum.Entries.Add(entry); + + var language = new Language { PhonologicalFeatureSystem = featSys }; + language.CharacterDefinitionTables.Add(table); + language.Strata.Add(stratum); + + IList findings = GrammarHealthChecker.Check(language); + + Assert.That(findings, Has.Count.EqualTo(1)); + Assert.That(findings[0].Code, Is.EqualTo(GrammarHealthCodes.UndeclaredSegment)); + Assert.That(findings[0].Severity, Is.EqualTo(GrammarHealthSeverity.Error)); + Assert.That(findings[0].Message, Does.Contain("e1")); + } + + [Test] + public void Check_CleanGrammar_NoFindingsAtAll() + { + FeatureSystem featSys = VocFeatureSystem(); + var table = new CharacterDefinitionTable { Name = "table1" }; + table.AddSegment("a", FeatureStruct.NewMutable(featSys).Symbol("voc+").Value); + table.AddSegment("b", FeatureStruct.NewMutable(featSys).Symbol("voc-").Value); + + var stratum = new Stratum(table) { Name = "Surface" }; + var entry = new LexEntry { Id = "e1" }; + entry.Allomorphs.Add(new RootAllomorph(new Segments(table, "ab"))); + stratum.Entries.Add(entry); + + var language = new Language { PhonologicalFeatureSystem = featSys }; + language.CharacterDefinitionTables.Add(table); + language.Strata.Add(stratum); + + Assert.That(GrammarHealthChecker.Check(language), Is.Empty); + } + + [Test] + public void Check_PartialLexicalEntry_ReportsActionableWarning() + { + var table = new CharacterDefinitionTable { Name = "table1" }; + var stratum = new Stratum(table) { Name = "Surface" }; + var entry = new LexEntry { Id = "entry1", IsPartial = true }; + stratum.Entries.Add(entry); + var language = new Language(); + language.Strata.Add(stratum); + + GrammarHealthFinding finding = GrammarHealthChecker.Check(language).Single(); + + Assert.That(finding.Code, Is.EqualTo(GrammarHealthCodes.PartialMorpheme)); + Assert.That(finding.Severity, Is.EqualTo(GrammarHealthSeverity.Warning)); + Assert.That(finding.Message, Does.Contain("entry1")); + Assert.That(finding.Message, Does.Contain("partially analyzed")); + Assert.That(finding.Message, Does.Contain("final-template pruning")); + Assert.That(finding.Subjects, Is.EqualTo(new object[] { entry })); + } + + [Test] + public void Check_PartialOrdinaryRule_ReportsRule() + { + var table = new CharacterDefinitionTable { Name = "table1" }; + var stratum = new Stratum(table) { Name = "Surface" }; + var rule = new AffixProcessRule { Name = "plural", IsPartial = true }; + stratum.MorphologicalRules.Add(rule); + var language = new Language(); + language.Strata.Add(stratum); + + GrammarHealthFinding finding = GrammarHealthChecker.Check(language).Single(); + + Assert.That(finding.Code, Is.EqualTo(GrammarHealthCodes.PartialMorpheme)); + Assert.That(finding.Message, Does.Contain("plural")); + Assert.That(finding.Subjects, Is.EqualTo(new object[] { rule })); + } + + [Test] + public void Check_PartialTemplateRuleReferencedTwice_ReportsOnce() + { + var table = new CharacterDefinitionTable { Name = "table1" }; + var stratum = new Stratum(table) { Name = "Surface" }; + var rule = new AffixProcessRule { Name = "subject", IsPartial = true }; + var template = new AffixTemplate { Name = "verb" }; + template.Slots.Add(new AffixTemplateSlot(rule)); + template.Slots.Add(new AffixTemplateSlot(rule)); + stratum.AffixTemplates.Add(template); + var language = new Language(); + language.Strata.Add(stratum); + + IList findings = GrammarHealthChecker.Check(language); + + Assert.That(findings, Has.Count.EqualTo(1)); + Assert.That(findings[0].Code, Is.EqualTo(GrammarHealthCodes.PartialMorpheme)); + Assert.That(findings[0].Subjects, Is.EqualTo(new object[] { rule })); + } + + [Test] + public void Check_PartialMorphemeAndExistingProblem_ReportsBoth() + { + FeatureSystem featSys = VocFeatureSystem(); + var table = new CharacterDefinitionTable { Name = "table1" }; + table.AddSegment("a", FeatureStruct.NewMutable(featSys).Symbol("voc+").Value); + table.AddSegment("b", FeatureStruct.NewMutable(featSys).Symbol("voc+").Value); + var stratum = new Stratum(table) { Name = "Surface" }; + stratum.Entries.Add(new LexEntry { Id = "entry1", IsPartial = true }); + var language = new Language { PhonologicalFeatureSystem = featSys }; + language.CharacterDefinitionTables.Add(table); + language.Strata.Add(stratum); + + IList findings = GrammarHealthChecker.Check(language); + + Assert.That( + findings.Select(finding => finding.Code), + Is.EquivalentTo(new[] { GrammarHealthCodes.DuplicateFeatureBundle, GrammarHealthCodes.PartialMorpheme }) + ); + } +}