@phuc1403/musketeer 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/INSTALLATION.md +1 -0
  2. package/README.md +2 -2
  3. package/manifest.json +29 -3
  4. package/package.json +1 -1
  5. package/src/provisioner/detect.js +17 -2
  6. package/src/resolver.js +8 -3
  7. package/src/schema.js +1 -0
  8. package/src/settings-merger.js +0 -0
  9. package/template/.claude/hooks/inject-naming-rule-into-subagents.cjs +44 -0
  10. package/template/.claude/hooks/validate-cml-hook.js +6 -5
  11. package/template/.claude/rules/csharp-identifier-naming.md +77 -0
  12. package/template/.claude/skills/code-review/SKILL.md +4 -1
  13. package/template/.claude/skills/code-review/references/naming-rule-review.md +41 -0
  14. package/template/.claude/skills/logical-components/.gitattributes +2 -0
  15. package/template/.claude/skills/logical-components/SKILL.md +107 -0
  16. package/template/.claude/skills/logical-components/references/component-classifier-prompt.md +53 -0
  17. package/template/.claude/skills/logical-components/references/responsibility-agent-prompt.md +67 -0
  18. package/template/.claude/skills/logical-components/references/responsibility-verifier-prompt.md +39 -0
  19. package/template/.claude/skills/logical-components/scripts/Directory.Build.props +7 -0
  20. package/template/.claude/skills/logical-components/scripts/Directory.Build.rsp +1 -0
  21. package/template/.claude/skills/logical-components/scripts/Directory.Build.targets +6 -0
  22. package/template/.claude/skills/logical-components/scripts/Directory.Packages.props +8 -0
  23. package/template/.claude/skills/logical-components/scripts/LogicalComponents/BatchPlanning.cs +70 -0
  24. package/template/.claude/skills/logical-components/scripts/LogicalComponents/BatchesCommand.cs +47 -0
  25. package/template/.claude/skills/logical-components/scripts/LogicalComponents/CandidatesCommand.cs +98 -0
  26. package/template/.claude/skills/logical-components/scripts/LogicalComponents/CitableSource.cs +54 -0
  27. package/template/.claude/skills/logical-components/scripts/LogicalComponents/Citation.cs +37 -0
  28. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ClassifyBatchesCommand.cs +86 -0
  29. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ClassifyMergeCommand.cs +219 -0
  30. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentDiscovery.cs +180 -0
  31. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentRule.cs +130 -0
  32. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentsFile.cs +101 -0
  33. package/template/.claude/skills/logical-components/scripts/LogicalComponents/CouplingResolver.cs +251 -0
  34. package/template/.claude/skills/logical-components/scripts/LogicalComponents/DisplayName.cs +14 -0
  35. package/template/.claude/skills/logical-components/scripts/LogicalComponents/EdgeExtraction.cs +111 -0
  36. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ExtractCommand.cs +87 -0
  37. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ExtractConfig.cs +79 -0
  38. package/template/.claude/skills/logical-components/scripts/LogicalComponents/LogicalComponents.csproj +15 -0
  39. package/template/.claude/skills/logical-components/scripts/LogicalComponents/MarkdownCodeSpans.cs +72 -0
  40. package/template/.claude/skills/logical-components/scripts/LogicalComponents/MarkdownRenderer.cs +78 -0
  41. package/template/.claude/skills/logical-components/scripts/LogicalComponents/MemberUseResolution.cs +272 -0
  42. package/template/.claude/skills/logical-components/scripts/LogicalComponents/MemberUses.cs +231 -0
  43. package/template/.claude/skills/logical-components/scripts/LogicalComponents/PlainText.cs +55 -0
  44. package/template/.claude/skills/logical-components/scripts/LogicalComponents/PortResolution.cs +75 -0
  45. package/template/.claude/skills/logical-components/scripts/LogicalComponents/Program.cs +27 -0
  46. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ProjectFilters.cs +77 -0
  47. package/template/.claude/skills/logical-components/scripts/LogicalComponents/PruneCommand.cs +164 -0
  48. package/template/.claude/skills/logical-components/scripts/LogicalComponents/RenderCommand.cs +234 -0
  49. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ResponsibilityBatches.cs +84 -0
  50. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ResponsibilityValidation.cs +197 -0
  51. package/template/.claude/skills/logical-components/scripts/LogicalComponents/SeamDispatch.cs +115 -0
  52. package/template/.claude/skills/logical-components/scripts/LogicalComponents/SourceCodeLines.cs +79 -0
  53. package/template/.claude/skills/logical-components/scripts/LogicalComponents/SymbolNames.cs +53 -0
  54. package/template/.claude/skills/logical-components/scripts/LogicalComponents/TypeMap.cs +106 -0
  55. package/template/.claude/skills/logical-components/scripts/LogicalComponents/WalkRoots.cs +63 -0
  56. package/template/.claude/skills/logical-components/scripts/LogicalComponents/WorkspaceLoader.cs +297 -0
  57. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/AssignIdsTests.cs +41 -0
  58. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/BatchPlanningTests.cs +90 -0
  59. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/BatchesCommandTests.cs +37 -0
  60. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CandidatesCommandTests.cs +59 -0
  61. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CitationTests.cs +42 -0
  62. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ClassifyCommandsTests.cs +249 -0
  63. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ComponentDiscoveryTests.cs +253 -0
  64. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CouplingAppExtractionTests.cs +188 -0
  65. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CouplingAppWorkspace.cs +30 -0
  66. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CouplingExtractionTests.cs +174 -0
  67. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/DisplayNameTests.cs +17 -0
  68. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ExtractConfigTests.cs +72 -0
  69. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/FixturePaths.cs +36 -0
  70. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/LogicalComponents.Tests.csproj +23 -0
  71. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/MarkdownRendererTests.cs +93 -0
  72. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ProjectFiltersTests.cs +69 -0
  73. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/PruneCommandTests.cs +149 -0
  74. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/RenderCommandTests.cs +225 -0
  75. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/RenderGoldenTests.cs +34 -0
  76. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/RenderTestData.cs +55 -0
  77. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ResponsibilityValidationTests.cs +315 -0
  78. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/SampleAppWorkspace.cs +27 -0
  79. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/WorkspaceLoaderTests.cs +266 -0
  80. package/template/.claude/skills/logical-components/scripts/NuGet.config +17 -0
  81. package/template/.claude/skills/logical-components/scripts/fixtures/BrokenApp/src/Broken.Application/Broken.Application.csproj +7 -0
  82. package/template/.claude/skills/logical-components/scripts/fixtures/BrokenApp/src/Broken.Application/Broken.cs +7 -0
  83. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/CompositionRootApp.slnx +5 -0
  84. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/HostBuilderExtensions.cs +15 -0
  85. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/HostBuilderShims.cs +25 -0
  86. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/NativeInterop.cs +17 -0
  87. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/Root.Application.csproj +7 -0
  88. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/RootAutofacModule.cs +11 -0
  89. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/RootGreeter.cs +7 -0
  90. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/logical-components.json +48 -0
  91. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/AbstractMemberCases.cs +27 -0
  92. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/AttributeCases.cs +23 -0
  93. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/CouplingApp.Application.csproj +7 -0
  94. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/GenericBaseCases.cs +14 -0
  95. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/GenericSeamBindingCases.cs +59 -0
  96. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/ImplicitCallCases.cs +230 -0
  97. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/PartialInheritCases.First.cs +11 -0
  98. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/PartialInheritCases.Second.cs +14 -0
  99. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/StackOverflowCases.cs +14 -0
  100. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/ViaDisambiguationCases.cs +36 -0
  101. package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/MultiTargetApp.slnx +7 -0
  102. package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Legacy.FSharp/Legacy.FSharp.fsproj +8 -0
  103. package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Legacy.FSharp/Library.fs +5 -0
  104. package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Multi.Application/Multi.Application.csproj +7 -0
  105. package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Multi.Application/Scheduling.cs +14 -0
  106. package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Multi.Infrastructure/Multi.Infrastructure.csproj +12 -0
  107. package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Multi.Infrastructure/SchedulingClient.cs +12 -0
  108. package/template/.claude/skills/logical-components/scripts/fixtures/NoSolutionApp/src/Solo.Application/Solo.Application.csproj +7 -0
  109. package/template/.claude/skills/logical-components/scripts/fixtures/NoSolutionApp/src/Solo.Application/SoloGreeting.cs +7 -0
  110. package/template/.claude/skills/logical-components/scripts/fixtures/NoSolutionApp/src/Solo.Application.Tests/FakeGreeting.cs +6 -0
  111. package/template/.claude/skills/logical-components/scripts/fixtures/NoSolutionApp/src/Solo.Application.Tests/Solo.Application.Tests.csproj +11 -0
  112. package/template/.claude/skills/logical-components/scripts/fixtures/ObjOutputApp/ObjOutputApp.slnx +5 -0
  113. package/template/.claude/skills/logical-components/scripts/fixtures/ObjOutputApp/src/Obj.Application/Greeting.cs +7 -0
  114. package/template/.claude/skills/logical-components/scripts/fixtures/ObjOutputApp/src/Obj.Application/Obj.Application.csproj +14 -0
  115. package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/components.json +67 -0
  116. package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/expected.md +28 -0
  117. package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/repo/src/App/OrderAcceptance.cs +16 -0
  118. package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/repo/src/Infra/OrderQueue.cs +10 -0
  119. package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/resp/batch-01.json +18 -0
  120. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/SampleApp.slnx +10 -0
  121. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/logical-components.json +78 -0
  122. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/CandidateCases.cs +37 -0
  123. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/CouplingCases.cs +33 -0
  124. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/DependencyInjection.cs +18 -0
  125. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/EdgeCases.cs +63 -0
  126. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/LoudNotifying.Base.cs +5 -0
  127. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/LoudNotifying.cs +7 -0
  128. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderAcceptance.cs +17 -0
  129. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderOptions.cs +9 -0
  130. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderRejectedException.cs +7 -0
  131. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderRequest.cs +4 -0
  132. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderRunning.cs +18 -0
  133. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderValidation.cs +6 -0
  134. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/Ports.cs +19 -0
  135. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/PriceRules.cs +7 -0
  136. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/ReceiptPrinting.Totals.cs +6 -0
  137. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/ReceiptPrinting.cs +7 -0
  138. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/ReviewCases.cs +107 -0
  139. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/ReviewFixCases.cs +143 -0
  140. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/RunClock.cs +12 -0
  141. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/Sample.Application.csproj +10 -0
  142. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/SeeThroughCases.cs +89 -0
  143. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Billing.Application/OrderRunning.cs +7 -0
  144. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Billing.Application/Sample.Billing.Application.csproj +11 -0
  145. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/ForwardedNotifier.cs +8 -0
  146. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/InfrastructureDependencyInjection.cs +16 -0
  147. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/Mailer.cs +6 -0
  148. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/OrderQueue.cs +11 -0
  149. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/QueueEntry.cs +4 -0
  150. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/QueueStore.cs +8 -0
  151. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/ReviewCases.cs +35 -0
  152. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/ReviewFixCases.cs +13 -0
  153. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/Sample.Infrastructure.csproj +13 -0
  154. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/SeeThroughCases.cs +18 -0
  155. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/UnreachedPlumbing.cs +7 -0
  156. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/tests/Sample.Tests/FakeOrderQueue.cs +11 -0
  157. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/tests/Sample.Tests/Sample.Tests.csproj +11 -0
  158. package/template/.claude/skills/logical-components/scripts/fixtures/SharedOutsideRepo/SharedClock.cs +7 -0
  159. package/template/.claude/skills/logical-components/scripts/global.json +5 -0
  160. package/template/.claude/skills/logical-components/scripts/smoke-sample.sh +26 -0
@@ -0,0 +1,251 @@
1
+ using Microsoft.CodeAnalysis;
2
+
3
+ namespace LogicalComponents;
4
+
5
+ /// <summary>
6
+ /// The whole <c>extract</c> computation: the classified components, and an edge A → B for every way A depends on B:
7
+ /// A takes B (or a seam B implements) in its constructor (<c>di</c>), derives from B (<c>inherits</c>), or its code
8
+ /// runs B's code, directly or through seams and helpers (<c>new</c>, <c>static</c>, <c>call</c>; see
9
+ /// <see cref="MemberUseResolution"/>). Output order is fixed, so the same code and config yield the same bytes.
10
+ /// </summary>
11
+ public static class CouplingResolver
12
+ {
13
+ public static async Task<ExtractResult> ExtractAsync(
14
+ LoadedWorkspace loaded, ExtractConfig config, CancellationToken cancellationToken = default)
15
+ {
16
+ var discovery = await ComponentDiscovery.DiscoverAsync(loaded, config, cancellationToken);
17
+ if (discovery.Unclassified.Count > 0)
18
+ {
19
+ throw new UnclassifiedCandidatesException(discovery.Unclassified);
20
+ }
21
+
22
+ var sourceTypes = (await ComponentDiscovery.SourceTypesAsync(loaded.Solution, cancellationToken))
23
+ .Where(t => t.Layer is ComponentDiscovery.ApplicationLayer or ComponentDiscovery.InfrastructureLayer)
24
+ .GroupBy(t => SymbolNames.FullName(t.Symbol))
25
+ .ToDictionary(g => g.Key, g => g.First().Symbol, StringComparer.Ordinal);
26
+ var components = discovery.Components.Select(c => c.Type).ToHashSet(StringComparer.Ordinal);
27
+ var warnings = await CompileErrorWarningsAsync(loaded.Solution, cancellationToken);
28
+ var ports = new SortedDictionary<string, INamedTypeSymbol>(StringComparer.Ordinal);
29
+ var raw = new List<MemberUseResolution.RawEdge>();
30
+ var portResolution = new PortResolution(loaded.Solution);
31
+ var resolution = new MemberUseResolution(loaded, components, portResolution);
32
+ var reaches = new Dictionary<string, List<SourceSpan>>(StringComparer.Ordinal);
33
+
34
+ foreach (var component in discovery.Components)
35
+ {
36
+ var symbol = sourceTypes[component.Type];
37
+ var self = new HashSet<string>(StringComparer.Ordinal) { component.Type };
38
+ var declarations = symbol.DeclaringSyntaxReferences.Select(r => r.GetSyntax()).ToList();
39
+ var roots = new List<SyntaxNode>(declarations);
40
+ var baseBindings = new List<(ITypeParameterSymbol Parameter, ITypeSymbol Argument)>();
41
+
42
+ // Up the base chain: the first component ancestor is an 'inherits' edge; the code of non-component source
43
+ // bases in between (typically abstract) is this component's own behaviour, so it is walked as such. Roslyn
44
+ // resolves BaseType through the whole construction chain, so a generic base's own type parameters bind to
45
+ // what this component (or an intermediate base) actually supplies, however many levels deep.
46
+ for (var constructedBase = symbol.BaseType;
47
+ constructedBase is not null && CitableSource.IsRepoSource(constructedBase.OriginalDefinition, loaded);
48
+ constructedBase = constructedBase.BaseType)
49
+ {
50
+ var baseType = constructedBase.OriginalDefinition;
51
+ var baseName = SymbolNames.FullName(baseType);
52
+ if (components.Contains(baseName))
53
+ {
54
+ var (file, line) = await BaseListLocationAsync(declarations, baseType, loaded, cancellationToken)
55
+ ?? (component.Spans[0].File, component.Spans[0].Start);
56
+ raw.Add(new(component.Type, baseName, EdgeExtraction.Inherits, [], file, line));
57
+ break;
58
+ }
59
+
60
+ self.Add(baseName);
61
+ baseBindings.AddRange(TypeMap.Zip(baseType.TypeParameters, constructedBase.TypeArguments));
62
+ roots.AddRange(baseType.DeclaringSyntaxReferences.Select(r => r.GetSyntax()));
63
+ }
64
+
65
+ foreach (var reference in EdgeExtraction.DiReferences(symbol, loaded))
66
+ {
67
+ raw.AddRange(await DiEdgesAsync(component.Type, reference, components, ports, portResolution, loaded, cancellationToken));
68
+ }
69
+
70
+ // In (file, position) order, so the first path the walk records is the same on every checkout.
71
+ var ordered = roots.OrderBy(r => r.SyntaxTree.FilePath, StringComparer.Ordinal).ThenBy(r => r.SpanStart);
72
+ var uses = await MemberUses.ReadAsync(ordered, loaded, cancellationToken);
73
+ var resolved = await resolution.ResolveAsync(component.Type, self, uses, TypeMap.FromBindings(baseBindings), cancellationToken);
74
+ raw.AddRange(resolved.Edges);
75
+ reaches[component.Type] = resolved.Reaches;
76
+ warnings.AddRange(resolved.Warnings);
77
+ }
78
+
79
+ var ids = discovery.Components.ToDictionary(c => c.Type, c => c.Id, StringComparer.Ordinal);
80
+ var externalNodes = PortNodes(ports, ids);
81
+ foreach (var node in externalNodes)
82
+ {
83
+ ids[node.Type] = node.Id;
84
+ }
85
+
86
+ warnings.AddRange(ports.Keys.Select(p => $"port {p} has no implementation"));
87
+ return new ExtractResult(
88
+ discovery.Components.Select(c => c with { Reaches = reaches[c.Type] }).ToList(),
89
+ externalNodes,
90
+ MergeEdges(raw, ids),
91
+ [.. discovery.Warnings, .. warnings.Where(w => !discovery.Warnings.Contains(w)).Distinct().Order(StringComparer.Ordinal)]);
92
+ }
93
+
94
+ /// <summary>
95
+ /// A constructor parameter as edges: to the component it is, to every component implementing the seam it is, or to
96
+ /// a port node when nothing in the repo implements that seam. A parameter of a helper type is no edge; what the
97
+ /// component calls on it is found by <see cref="MemberUseResolution"/>.
98
+ /// </summary>
99
+ private static async Task<List<MemberUseResolution.RawEdge>> DiEdgesAsync(
100
+ string from,
101
+ EdgeExtraction.Reference reference,
102
+ IReadOnlySet<string> components,
103
+ SortedDictionary<string, INamedTypeSymbol> ports,
104
+ PortResolution portResolution,
105
+ LoadedWorkspace loaded,
106
+ CancellationToken cancellationToken)
107
+ {
108
+ var target = reference.Target;
109
+ if (!EdgeExtraction.IsSeam(target))
110
+ {
111
+ var name = SymbolNames.FullName(target);
112
+ return components.Contains(name) ? [new(from, name, EdgeExtraction.Di, [], reference.File, reference.Line)] : [];
113
+ }
114
+
115
+ // Seams from the framework (ILogger<T>, IReadOnlyList<T>...) are plumbing, not design.
116
+ if (!CitableSource.IsRepoSource(target.OriginalDefinition, loaded))
117
+ {
118
+ return [];
119
+ }
120
+
121
+ var implementations = await portResolution.ImplementationsAsync(target, cancellationToken);
122
+ if (implementations.Count == 0)
123
+ {
124
+ var portName = SymbolNames.FullName(target.OriginalDefinition);
125
+ ports[portName] = target.OriginalDefinition;
126
+ return [new(from, portName, EdgeExtraction.Di, [], reference.File, reference.Line)];
127
+ }
128
+
129
+ var via = SymbolNames.FullName(target.OriginalDefinition);
130
+ return implementations
131
+ .Select(SymbolNames.FullName)
132
+ .Where(components.Contains)
133
+ .Select(to => new MemberUseResolution.RawEdge(from, to, EdgeExtraction.Di, [via], reference.File, reference.Line))
134
+ .ToList();
135
+ }
136
+
137
+ /// <summary>
138
+ /// Where a partial class names <paramref name="baseType"/> as its base: the base list of the declaration whose base
139
+ /// list actually contains it, not merely the first declaration that has any base list (a partial fragment can list
140
+ /// only interfaces). Ties (there should be at most one match; a duplicate base is a compile error) break by file
141
+ /// path ordinal, then position.
142
+ /// </summary>
143
+ private static async Task<(string File, int Line)?> BaseListLocationAsync(
144
+ IEnumerable<SyntaxNode> declarations, INamedTypeSymbol baseType, LoadedWorkspace loaded, CancellationToken cancellationToken)
145
+ {
146
+ var matches = new List<(string File, int Line)>();
147
+ foreach (var baseList in declarations.OfType<Microsoft.CodeAnalysis.CSharp.Syntax.TypeDeclarationSyntax>()
148
+ .Select(d => d.BaseList).OfType<Microsoft.CodeAnalysis.CSharp.Syntax.BaseListSyntax>())
149
+ {
150
+ var (kind, file) = CitableSource.Classify(loaded, baseList.SyntaxTree);
151
+ if (kind != CitableSource.Kind.InRepo || loaded.Solution.GetDocument(baseList.SyntaxTree) is not { } document)
152
+ {
153
+ continue;
154
+ }
155
+
156
+ var model = await document.GetSemanticModelAsync(cancellationToken)
157
+ ?? throw new InvalidOperationException($"No semantic model for {file}");
158
+ var namesBaseType = baseList.Types.Any(t =>
159
+ model.GetSymbolInfo(t.Type, cancellationToken).Symbol is INamedTypeSymbol candidate
160
+ && SymbolEqualityComparer.Default.Equals(candidate.OriginalDefinition, baseType));
161
+ if (namesBaseType)
162
+ {
163
+ matches.Add((file, baseList.GetLocation().GetLineSpan().StartLinePosition.Line + 1));
164
+ }
165
+ }
166
+
167
+ return matches.Count == 0 ? null : matches.OrderBy(m => m.File, StringComparer.Ordinal).ThenBy(m => m.Line).First();
168
+ }
169
+
170
+ /// <summary>
171
+ /// CS1705: a referenced assembly was built against a newer framework. The loader keeps one target framework per
172
+ /// multi-targeted project, so a project on an older framework that references it always gets this error, yet every
173
+ /// symbol still binds. It says nothing about missing edges.
174
+ /// </summary>
175
+ private const string NewerFrameworkReference = "CS1705";
176
+
177
+ /// <summary>Code that does not compile binds partially, so edges can be missing: say so instead of staying silent.</summary>
178
+ private static async Task<List<string>> CompileErrorWarningsAsync(Solution solution, CancellationToken cancellationToken)
179
+ {
180
+ var warnings = new List<string>();
181
+ foreach (var project in solution.Projects.OrderBy(p => p.Name, StringComparer.Ordinal))
182
+ {
183
+ var compilation = await project.GetCompilationAsync(cancellationToken);
184
+ var errors = compilation?.GetDiagnostics(cancellationToken)
185
+ .Count(d => d.Severity == DiagnosticSeverity.Error && d.Id != NewerFrameworkReference) ?? 0;
186
+ if (errors > 0)
187
+ {
188
+ warnings.Add($"project {project.Name} has {errors} compile error(s); coupling may be incomplete");
189
+ }
190
+ }
191
+
192
+ return warnings;
193
+ }
194
+
195
+ /// <summary>Port nodes use the seam's simple name as id, unless that clashes with a component or another port.</summary>
196
+ private static List<ExternalNode> PortNodes(
197
+ SortedDictionary<string, INamedTypeSymbol> ports, IReadOnlyDictionary<string, string> componentIds)
198
+ {
199
+ var taken = componentIds.Values.ToHashSet(StringComparer.Ordinal);
200
+ var bySimpleName = ports.Values.GroupBy(p => p.Name).ToDictionary(g => g.Key, g => g.Count());
201
+ return ports
202
+ .Select(p =>
203
+ {
204
+ var simple = p.Value.Name;
205
+ var id = bySimpleName[simple] == 1 && !taken.Contains(simple) ? simple : SymbolNames.ToIdentifier(p.Key);
206
+ return new ExternalNode(id, p.Key, DisplayName.FromTypeName(simple), "port");
207
+ })
208
+ .OrderBy(n => n.Id, StringComparer.Ordinal)
209
+ .ToList();
210
+ }
211
+
212
+ /// <summary>
213
+ /// One edge per (from, to): kinds as a sorted set, cited at the earliest (file, line) recorded use, with <c>via</c>
214
+ /// naming the seams and seen-through types on that earliest use's own path (the first path found), in the order the
215
+ /// walk actually followed it rather than merged and alphabetised across every raw edge to the same target.
216
+ /// </summary>
217
+ private static List<Edge> MergeEdges(List<MemberUseResolution.RawEdge> raw, IReadOnlyDictionary<string, string> ids) =>
218
+ raw.Where(e => e.From != e.To)
219
+ .GroupBy(e => (From: ids[e.From], To: ids[e.To]))
220
+ .Select(g =>
221
+ {
222
+ var first = g.OrderBy(e => e.File, StringComparer.Ordinal).ThenBy(e => e.Line).First();
223
+ return new Edge(
224
+ g.Key.From,
225
+ g.Key.To,
226
+ g.Select(e => e.Kind).Distinct().Order(StringComparer.Ordinal).ToList(),
227
+ DisambiguatedVia(first.Via),
228
+ $"{first.File}:{first.Line}");
229
+ })
230
+ .OrderBy(e => e.From, StringComparer.Ordinal)
231
+ .ThenBy(e => e.To, StringComparer.Ordinal)
232
+ .ToList();
233
+
234
+ /// <summary>
235
+ /// <paramref name="via"/> holds each step's full metadata name (stable identity). Displayed as its plain simple
236
+ /// name, except when a simple name on the path is shared by two genuinely different full names (two unrelated
237
+ /// <c>Builder</c>s): every step with that simple name then falls back to its readable full name instead of
238
+ /// collapsing into one indistinguishable entry. A single type revisited twice on the same path (it is not its own
239
+ /// collision) still displays by its plain simple name both times.
240
+ /// </summary>
241
+ private static List<string> DisambiguatedVia(IReadOnlyList<string> via)
242
+ {
243
+ var colliding = via.GroupBy(SymbolNames.SimpleName)
244
+ .Where(g => g.Distinct(StringComparer.Ordinal).Count() > 1)
245
+ .Select(g => g.Key)
246
+ .ToHashSet(StringComparer.Ordinal);
247
+ return via
248
+ .Select(name => colliding.Contains(SymbolNames.SimpleName(name)) ? SymbolNames.ReadableFullName(name) : SymbolNames.SimpleName(name))
249
+ .ToList();
250
+ }
251
+ }
@@ -0,0 +1,14 @@
1
+ using System.Text.RegularExpressions;
2
+
3
+ namespace LogicalComponents;
4
+
5
+ /// <summary>Turns a PascalCase type name into the words shown in the doc: ExportJobAcceptance → Export Job Acceptance.</summary>
6
+ public static partial class DisplayName
7
+ {
8
+ public static string FromTypeName(string typeName) => WordBoundary().Replace(typeName, " ");
9
+
10
+ // A boundary sits before an uppercase letter that follows a lowercase letter or digit (JobAcceptance, Utf8Decoding),
11
+ // or before the last capital of an acronym run that starts a new word (IOStream → IO Stream).
12
+ [GeneratedRegex("(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")]
13
+ private static partial Regex WordBoundary();
14
+ }
@@ -0,0 +1,111 @@
1
+ using Microsoft.CodeAnalysis;
2
+
3
+ namespace LogicalComponents;
4
+
5
+ /// <summary>
6
+ /// The edge kinds, and what a component's constructors take (<c>di</c>: each parameter's type, primary constructors
7
+ /// included, looked through wrappers). The uses inside code are read by <see cref="MemberUses"/>.
8
+ /// </summary>
9
+ public static class EdgeExtraction
10
+ {
11
+ public const string Call = "call";
12
+ public const string Di = "di";
13
+ public const string Inherits = "inherits";
14
+ public const string New = "new";
15
+ public const string Static = "static";
16
+
17
+ /// <summary>A constructor parameter's type, at a repo-relative file and 1-based line.</summary>
18
+ public sealed record Reference(INamedTypeSymbol Target, string Kind, string File, int Line);
19
+
20
+ /// <summary>What the type's constructors take: each parameter's type, looked through wrappers.</summary>
21
+ public static List<Reference> DiReferences(INamedTypeSymbol type, LoadedWorkspace loaded)
22
+ {
23
+ var references = new List<Reference>();
24
+ foreach (var constructor in type.InstanceConstructors.Where(c => !c.IsImplicitlyDeclared))
25
+ {
26
+ foreach (var parameter in constructor.Parameters)
27
+ {
28
+ var location = parameter.Locations.FirstOrDefault(l => l.IsInSource);
29
+ var (kind, file) = CitableSource.Classify(loaded, location?.SourceTree);
30
+ if (kind == CitableSource.Kind.InRepo)
31
+ {
32
+ var line = location!.GetLineSpan().StartLinePosition.Line + 1;
33
+ references.AddRange(DiTargets(parameter.Type).Select(t => new Reference(t, Di, file, line)));
34
+ }
35
+ }
36
+ }
37
+
38
+ return references;
39
+ }
40
+
41
+ // Containers that deliver a dependency without being one: the dependency is what they wrap.
42
+ private static readonly HashSet<string> Wrappers =
43
+ [
44
+ "System.Collections.Generic.IEnumerable`1",
45
+ "System.Collections.Generic.IReadOnlyCollection`1",
46
+ "System.Collections.Generic.IReadOnlyList`1",
47
+ "System.Collections.Generic.ICollection`1",
48
+ "System.Collections.Generic.IList`1",
49
+ "System.Lazy`1",
50
+ "System.Func`1",
51
+ ];
52
+
53
+ /// <summary>
54
+ /// What a constructor parameter depends on: the type itself, or what a wrapper delivers (<c>IEnumerable&lt;IX&gt;</c>,
55
+ /// <c>IReadOnlyList&lt;IX&gt;</c>, <c>Lazy&lt;X&gt;</c>, <c>Func&lt;X&gt;</c>, <c>X[]</c>), looked through recursively.
56
+ /// </summary>
57
+ private static IEnumerable<INamedTypeSymbol> DiTargets(ITypeSymbol type)
58
+ {
59
+ switch (type)
60
+ {
61
+ case IArrayTypeSymbol array:
62
+ foreach (var element in DiTargets(array.ElementType))
63
+ {
64
+ yield return element;
65
+ }
66
+
67
+ break;
68
+ case INamedTypeSymbol { IsGenericType: true } named
69
+ when Wrappers.Contains(SymbolNames.FullName(named.OriginalDefinition)):
70
+ foreach (var element in DiTargets(named.TypeArguments[0]))
71
+ {
72
+ yield return element;
73
+ }
74
+
75
+ break;
76
+ case INamedTypeSymbol named:
77
+ yield return Normalize(named);
78
+ break;
79
+ }
80
+ }
81
+
82
+ /// <summary>
83
+ /// The type a reference couples to. Interfaces and abstract classes keep their construction (<c>IHandler&lt;Order&gt;</c>)
84
+ /// so ports resolve to implementations of exactly that construction. Any other type is taken as its generic
85
+ /// definition, and a nested type as its outermost containing type: using <c>Receipts.Builder</c> couples to
86
+ /// <c>Receipts</c>, never to an unrelated top-level <c>Builder</c>.
87
+ /// </summary>
88
+ public static INamedTypeSymbol Normalize(INamedTypeSymbol type)
89
+ {
90
+ if (IsSeam(type))
91
+ {
92
+ return type;
93
+ }
94
+
95
+ var outermost = type.OriginalDefinition;
96
+ while (outermost.ContainingType is { } outer)
97
+ {
98
+ outermost = outer.OriginalDefinition;
99
+ }
100
+
101
+ return outermost;
102
+ }
103
+
104
+ /// <summary>
105
+ /// An interface or abstract class: a dependency on it resolves to the concrete classes behind it. An abstract
106
+ /// record is a data hierarchy (LabelSegment → LiteralSegment, PlaceholderSegment), not a seam.
107
+ /// </summary>
108
+ public static bool IsSeam(INamedTypeSymbol type) =>
109
+ type.TypeKind == TypeKind.Interface
110
+ || (type.TypeKind == TypeKind.Class && type.IsAbstract && !type.IsStatic && !type.IsRecord);
111
+ }
@@ -0,0 +1,87 @@
1
+ namespace LogicalComponents;
2
+
3
+ /// <summary>
4
+ /// <c>extract &lt;repoPath&gt; [--out &lt;components.json&gt;] [--solution &lt;file&gt;]</c>: loads the repo and writes the
5
+ /// deterministic extraction, including a hash of every cited file's content (see <see cref="FileHashes"/>) so
6
+ /// <c>render</c> can tell whether the repo changed since. <c>--solution</c> picks the solution file as in
7
+ /// <c>candidates</c>. Progress and warnings go to <c>errors</c> (stderr); the artifact goes to the --out file.
8
+ /// Exit codes: 0 written, 1 usage, 2 the target repo or its config could not be loaded, 4 candidates not classified
9
+ /// in the repo config yet (one <c>unclassified &lt;type&gt;</c> line each; nothing is written).
10
+ /// </summary>
11
+ public static class ExtractCommand
12
+ {
13
+ public const string Usage =
14
+ "usage: LogicalComponents extract <repoPath> [--out <components.json>] [--solution <file>]";
15
+
16
+ public static async Task<int> RunAsync(string[] args, TextWriter errors)
17
+ {
18
+ if (args is not [var repoPath, ..]
19
+ || !CandidatesCommand.TryParseOptions(args[1..], "components.json", out var outPath, out var solution))
20
+ {
21
+ await errors.WriteLineAsync(Usage);
22
+ return 1;
23
+ }
24
+
25
+ try
26
+ {
27
+ using var loaded = await WorkspaceLoader.LoadAsync(repoPath, solution: solution, log: errors);
28
+ await errors.WriteLineAsync($"Loaded {loaded.ProjectNames.Count} projects from {loaded.Source}.");
29
+
30
+ var extracted = await CouplingResolver.ExtractAsync(loaded, ExtractConfig.Load(loaded.RepoRoot));
31
+ var result = extracted with { FileHashes = FileHashes(loaded.RepoRoot, extracted) };
32
+ foreach (var warning in result.Warnings)
33
+ {
34
+ await errors.WriteLineAsync($"warning: {warning}");
35
+ }
36
+
37
+ Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outPath))!);
38
+ await File.WriteAllBytesAsync(outPath, ComponentsFile.Serialize(result));
39
+ await errors.WriteLineAsync(
40
+ $"{result.Components.Count} components, {result.ExternalNodes.Count} ports, {result.Edges.Count} edges, "
41
+ + $"{result.Warnings.Count} warnings -> {Path.GetFullPath(outPath)}");
42
+ return 0;
43
+ }
44
+ catch (UnclassifiedCandidatesException e)
45
+ {
46
+ foreach (var type in e.Types)
47
+ {
48
+ await errors.WriteLineAsync($"unclassified {type}");
49
+ }
50
+
51
+ await errors.WriteLineAsync($"{e.Message}: run the classify step, review it, then extract again.");
52
+ return 4;
53
+ }
54
+ catch (Exception e) when (e is DirectoryNotFoundException or WorkspaceLoadException or ExtractConfigException)
55
+ {
56
+ await errors.WriteLineAsync(e.Message);
57
+ return 2;
58
+ }
59
+ catch (Exception e)
60
+ {
61
+ // Anything else (I/O, access, build host) is still a load failure: report it, don't crash with a stack trace.
62
+ await errors.WriteLineAsync($"Unexpected error while extracting {repoPath}: {e.Message}");
63
+ return 2;
64
+ }
65
+ }
66
+
67
+ /// <summary>
68
+ /// A repo-relative-file → content-hash map (see <see cref="ComponentsFile.HashFile"/>) for every file any component's
69
+ /// spans, reaches or outgoing edge citations point into, so <c>render</c> can later tell whether the repo changed.
70
+ /// </summary>
71
+ private static IReadOnlyDictionary<string, string> FileHashes(string repoRoot, ExtractResult result)
72
+ {
73
+ var files = result.Components
74
+ .SelectMany(c => c.Spans.Concat(c.Reaches ?? []).Select(s => s.File))
75
+ .Concat(result.Edges.Select(e => Citation.Parse(e.At)?.File).OfType<string>())
76
+ .Distinct(StringComparer.Ordinal)
77
+ .Order(StringComparer.Ordinal);
78
+
79
+ var hashes = new SortedDictionary<string, string>(StringComparer.Ordinal);
80
+ foreach (var file in files)
81
+ {
82
+ hashes[file] = ComponentsFile.HashFile(Path.Combine(repoRoot, file));
83
+ }
84
+
85
+ return hashes;
86
+ }
87
+ }
@@ -0,0 +1,79 @@
1
+ using System.Text.Json;
2
+ using System.Text.Json.Serialization;
3
+
4
+ namespace LogicalComponents;
5
+
6
+ /// <summary>A config file in the target repo that could not be read, or contradicts itself.</summary>
7
+ public sealed class ExtractConfigException(string message) : Exception(message);
8
+
9
+ /// <summary>Candidates the repo config does not classify yet: extract refuses to guess.</summary>
10
+ public sealed class UnclassifiedCandidatesException(IReadOnlyList<string> types)
11
+ : Exception($"{types.Count} candidate(s) not classified in {ExtractConfig.FileName}")
12
+ {
13
+ public IReadOnlyList<string> Types { get; } = types;
14
+ }
15
+
16
+ /// <summary>
17
+ /// The frozen classification in <c>logical-components.json</c> at the repo root: every candidate class is listed, by
18
+ /// full metadata name, either as a component or as a helper (with the reason it is not a component). An LLM proposes
19
+ /// the entries once and a person reviews them; extract only ever reads this file, so the same code and the same file
20
+ /// always give the same components.
21
+ /// </summary>
22
+ public sealed record ExtractConfig(IReadOnlyList<string> Components, IReadOnlyDictionary<string, string> Helpers)
23
+ {
24
+ public const string FileName = "logical-components.json";
25
+
26
+ public static ExtractConfig Empty { get; } = new([], new Dictionary<string, string>());
27
+
28
+ private static readonly JsonSerializerOptions Options = new()
29
+ {
30
+ PropertyNameCaseInsensitive = true,
31
+ ReadCommentHandling = JsonCommentHandling.Skip,
32
+ AllowTrailingCommas = true,
33
+ // A misspelt key ("helper") must fail rather than silently classify nothing.
34
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
35
+ };
36
+
37
+ public bool IsClassified(string fullName) => Helpers.ContainsKey(fullName) || Components.Contains(fullName);
38
+
39
+ public static ExtractConfig Load(string repoRoot)
40
+ {
41
+ var path = Path.Combine(repoRoot, FileName);
42
+ if (!File.Exists(path))
43
+ {
44
+ return Empty;
45
+ }
46
+
47
+ ConfigFile? file;
48
+ try
49
+ {
50
+ file = JsonSerializer.Deserialize<ConfigFile>(File.ReadAllText(path), Options);
51
+ }
52
+ catch (JsonException e)
53
+ {
54
+ throw new ExtractConfigException($"Invalid {FileName}: {e.Message}");
55
+ }
56
+
57
+ if (file?.Exclude is not null || file?.Include is not null)
58
+ {
59
+ throw new ExtractConfigException(
60
+ $"Invalid {FileName}: 'exclude'/'include' were replaced by 'components' and 'helpers' "
61
+ + "(full type names); re-run the classify step to regenerate it.");
62
+ }
63
+
64
+ var components = file?.Components ?? [];
65
+ var helpers = file?.Helpers ?? [];
66
+ var both = components.Where(helpers.ContainsKey).Distinct().Order(StringComparer.Ordinal).ToList();
67
+ if (both.Count > 0)
68
+ {
69
+ throw new ExtractConfigException(
70
+ $"Invalid {FileName}: {string.Join(", ", both.Select(n => $"'{n}'"))} is in both components and helpers.");
71
+ }
72
+
73
+ return new ExtractConfig(components, helpers);
74
+ }
75
+
76
+ // Exclude and Include are read only to reject a file written for the old format with a useful message.
77
+ private sealed record ConfigFile(
78
+ string[]? Components, Dictionary<string, string>? Helpers, string[]? Exclude, string[]? Include);
79
+ }
@@ -0,0 +1,15 @@
1
+ <Project Sdk="Microsoft.NET.Sdk">
2
+
3
+ <PropertyGroup>
4
+ <TargetFramework>net10.0</TargetFramework>
5
+ <OutputType>Exe</OutputType>
6
+ <Nullable>enable</Nullable>
7
+ <ImplicitUsings>enable</ImplicitUsings>
8
+ </PropertyGroup>
9
+
10
+ <ItemGroup>
11
+ <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.9.0" />
12
+ <PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="5.9.0" />
13
+ </ItemGroup>
14
+
15
+ </Project>
@@ -0,0 +1,72 @@
1
+ using System.Text;
2
+
3
+ namespace LogicalComponents;
4
+
5
+ /// <summary>
6
+ /// CommonMark code-span parsing for <see cref="ResponsibilityValidation"/>'s plain-text check: a backtick run of
7
+ /// length n opens a code span, which closes at the next run of exactly length n (a run of a different length inside is
8
+ /// literal content; a run with no matching close is an unclosed delimiter, not a span). A naive `` `[^`]*` `` match
9
+ /// gets this wrong for an escaped or mismatched backtick, which could let HTML or a link hide inside what looks like,
10
+ /// but is not, a real code span.
11
+ /// </summary>
12
+ public static class MarkdownCodeSpans
13
+ {
14
+ /// <summary>The text with every real code span removed, and whether any backtick run was left unmatched.</summary>
15
+ public static (string Outside, bool Unclosed) Strip(string text)
16
+ {
17
+ var outside = new StringBuilder();
18
+ var unclosed = false;
19
+ var i = 0;
20
+ while (i < text.Length)
21
+ {
22
+ if (text[i] != '`')
23
+ {
24
+ outside.Append(text[i]);
25
+ i++;
26
+ continue;
27
+ }
28
+
29
+ var openStart = i;
30
+ while (i < text.Length && text[i] == '`')
31
+ {
32
+ i++;
33
+ }
34
+
35
+ var openLength = i - openStart;
36
+ var closeStart = -1;
37
+ var j = i;
38
+ while (j < text.Length)
39
+ {
40
+ if (text[j] != '`')
41
+ {
42
+ j++;
43
+ continue;
44
+ }
45
+
46
+ var runStart = j;
47
+ while (j < text.Length && text[j] == '`')
48
+ {
49
+ j++;
50
+ }
51
+
52
+ if (j - runStart == openLength)
53
+ {
54
+ closeStart = runStart;
55
+ break;
56
+ }
57
+ }
58
+
59
+ if (closeStart >= 0)
60
+ {
61
+ i = closeStart + openLength;
62
+ }
63
+ else
64
+ {
65
+ unclosed = true;
66
+ outside.Append('`', openLength);
67
+ }
68
+ }
69
+
70
+ return (outside.ToString(), unclosed);
71
+ }
72
+ }