@phuc1403/musketeer 0.10.0 → 0.11.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.
- package/INSTALLATION.md +1 -0
- package/README.md +1 -1
- package/manifest.json +19 -2
- package/package.json +1 -1
- package/template/.claude/hooks/validate-cml-hook.js +6 -5
- package/template/.claude/skills/logical-components/.gitattributes +2 -0
- package/template/.claude/skills/logical-components/SKILL.md +102 -0
- package/template/.claude/skills/logical-components/references/component-classifier-prompt.md +53 -0
- package/template/.claude/skills/logical-components/references/responsibility-agent-prompt.md +67 -0
- package/template/.claude/skills/logical-components/references/responsibility-verifier-prompt.md +39 -0
- package/template/.claude/skills/logical-components/scripts/Directory.Build.props +7 -0
- package/template/.claude/skills/logical-components/scripts/Directory.Packages.props +8 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/BatchPlanning.cs +69 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/BatchesCommand.cs +39 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/CandidatesCommand.cs +70 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/CitableSource.cs +39 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/Citation.cs +37 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ClassifyBatchesCommand.cs +65 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ClassifyMergeCommand.cs +187 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentDiscovery.cs +166 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentRule.cs +87 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentsFile.cs +83 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/CouplingResolver.cs +196 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/DisplayName.cs +14 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/EdgeExtraction.cs +111 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ExtractCommand.cs +72 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ExtractConfig.cs +79 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/LogicalComponents.csproj +15 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/MarkdownRenderer.cs +73 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/MemberUseResolution.cs +203 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/MemberUses.cs +155 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/PortResolution.cs +75 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/Program.cs +27 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ProjectFilters.cs +76 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/PruneCommand.cs +132 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/RenderCommand.cs +173 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ResponsibilityBatches.cs +84 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ResponsibilityValidation.cs +178 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/SeamDispatch.cs +59 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/SymbolNames.cs +53 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/TypeMap.cs +75 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/WalkRoots.cs +63 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/WorkspaceLoader.cs +227 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/AssignIdsTests.cs +41 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/BatchPlanningTests.cs +82 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CandidatesCommandTests.cs +34 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CitationTests.cs +42 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ClassifyCommandsTests.cs +176 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ComponentDiscoveryTests.cs +190 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CouplingExtractionTests.cs +174 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/DisplayNameTests.cs +17 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ExtractConfigTests.cs +72 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/FixturePaths.cs +32 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/LogicalComponents.Tests.csproj +23 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/MarkdownRendererTests.cs +50 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ProjectFiltersTests.cs +65 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/PruneCommandTests.cs +119 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/RenderCommandTests.cs +162 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/RenderGoldenTests.cs +34 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/RenderTestData.cs +49 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ResponsibilityValidationTests.cs +227 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/SampleAppWorkspace.cs +27 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/WorkspaceLoaderTests.cs +87 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/BrokenApp/src/Broken.Application/Broken.Application.csproj +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/BrokenApp/src/Broken.Application/Broken.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/MultiTargetApp.slnx +6 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Legacy.FSharp/Legacy.FSharp.fsproj +8 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Legacy.FSharp/Library.fs +5 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Multi.Application/Multi.Application.csproj +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Multi.Application/Scheduling.cs +14 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/NoSolutionApp/src/Solo.Application/Solo.Application.csproj +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/NoSolutionApp/src/Solo.Application/SoloGreeting.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/NoSolutionApp/src/Solo.Application.Tests/FakeGreeting.cs +6 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/NoSolutionApp/src/Solo.Application.Tests/Solo.Application.Tests.csproj +11 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/components.json +63 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/expected.md +28 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/repo/src/App/OrderAcceptance.cs +16 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/repo/src/Infra/OrderQueue.cs +10 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/resp/batch-01.json +18 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/SampleApp.slnx +10 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/logical-components.json +78 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/CandidateCases.cs +37 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/CouplingCases.cs +33 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/DependencyInjection.cs +18 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/EdgeCases.cs +63 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/LoudNotifying.Base.cs +5 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/LoudNotifying.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderAcceptance.cs +17 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderOptions.cs +9 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderRejectedException.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderRequest.cs +4 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderRunning.cs +18 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderValidation.cs +6 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/Ports.cs +19 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/PriceRules.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/ReceiptPrinting.Totals.cs +6 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/ReceiptPrinting.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/ReviewCases.cs +107 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/ReviewFixCases.cs +143 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/RunClock.cs +12 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/Sample.Application.csproj +10 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/SeeThroughCases.cs +89 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Billing.Application/OrderRunning.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Billing.Application/Sample.Billing.Application.csproj +11 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/ForwardedNotifier.cs +8 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/InfrastructureDependencyInjection.cs +16 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/Mailer.cs +6 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/OrderQueue.cs +11 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/QueueEntry.cs +4 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/QueueStore.cs +8 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/ReviewCases.cs +35 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/ReviewFixCases.cs +13 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/Sample.Infrastructure.csproj +13 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/SeeThroughCases.cs +18 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/UnreachedPlumbing.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/tests/Sample.Tests/FakeOrderQueue.cs +11 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/tests/Sample.Tests/Sample.Tests.csproj +11 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SharedOutsideRepo/SharedClock.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/global.json +5 -0
- package/template/.claude/skills/logical-components/scripts/smoke-sample.sh +26 -0
package/template/.claude/skills/logical-components/scripts/LogicalComponents/CouplingResolver.cs
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
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
|
+
|
|
41
|
+
// Up the base chain: the first component ancestor is an 'inherits' edge; the code of non-component source
|
|
42
|
+
// bases in between (typically abstract) is this component's own behaviour, so it is walked as such.
|
|
43
|
+
for (var baseType = symbol.BaseType?.OriginalDefinition;
|
|
44
|
+
baseType is not null && CitableSource.IsRepoSource(baseType, loaded);
|
|
45
|
+
baseType = baseType.BaseType?.OriginalDefinition)
|
|
46
|
+
{
|
|
47
|
+
var baseName = SymbolNames.FullName(baseType);
|
|
48
|
+
if (components.Contains(baseName))
|
|
49
|
+
{
|
|
50
|
+
var (file, line) = BaseListLocation(declarations, loaded) ?? (component.Spans[0].File, component.Spans[0].Start);
|
|
51
|
+
raw.Add(new(component.Type, baseName, EdgeExtraction.Inherits, [], file, line));
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
self.Add(baseName);
|
|
56
|
+
roots.AddRange(baseType.DeclaringSyntaxReferences.Select(r => r.GetSyntax()));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
foreach (var reference in EdgeExtraction.DiReferences(symbol, loaded))
|
|
60
|
+
{
|
|
61
|
+
raw.AddRange(await DiEdgesAsync(component.Type, reference, components, ports, portResolution, loaded, cancellationToken));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// In (file, position) order, so the first path the walk records is the same on every checkout.
|
|
65
|
+
var ordered = roots.OrderBy(r => r.SyntaxTree.FilePath, StringComparer.Ordinal).ThenBy(r => r.SpanStart);
|
|
66
|
+
var uses = await MemberUses.ReadAsync(ordered, loaded, cancellationToken);
|
|
67
|
+
var resolved = await resolution.ResolveAsync(component.Type, self, uses, cancellationToken);
|
|
68
|
+
raw.AddRange(resolved.Edges);
|
|
69
|
+
reaches[component.Type] = resolved.Reaches;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
var ids = discovery.Components.ToDictionary(c => c.Type, c => c.Id, StringComparer.Ordinal);
|
|
73
|
+
var externalNodes = PortNodes(ports, ids);
|
|
74
|
+
foreach (var node in externalNodes)
|
|
75
|
+
{
|
|
76
|
+
ids[node.Type] = node.Id;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
warnings.AddRange(ports.Keys.Select(p => $"port {p} has no implementation"));
|
|
80
|
+
return new ExtractResult(
|
|
81
|
+
discovery.Components.Select(c => c with { Reaches = reaches[c.Type] }).ToList(),
|
|
82
|
+
externalNodes,
|
|
83
|
+
MergeEdges(raw, ids),
|
|
84
|
+
[.. discovery.Warnings, .. warnings.Where(w => !discovery.Warnings.Contains(w)).Distinct().Order(StringComparer.Ordinal)]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// <summary>
|
|
88
|
+
/// A constructor parameter as edges: to the component it is, to every component implementing the seam it is, or to
|
|
89
|
+
/// a port node when nothing in the repo implements that seam. A parameter of a helper type is no edge; what the
|
|
90
|
+
/// component calls on it is found by <see cref="MemberUseResolution"/>.
|
|
91
|
+
/// </summary>
|
|
92
|
+
private static async Task<List<MemberUseResolution.RawEdge>> DiEdgesAsync(
|
|
93
|
+
string from,
|
|
94
|
+
EdgeExtraction.Reference reference,
|
|
95
|
+
IReadOnlySet<string> components,
|
|
96
|
+
SortedDictionary<string, INamedTypeSymbol> ports,
|
|
97
|
+
PortResolution portResolution,
|
|
98
|
+
LoadedWorkspace loaded,
|
|
99
|
+
CancellationToken cancellationToken)
|
|
100
|
+
{
|
|
101
|
+
var target = reference.Target;
|
|
102
|
+
if (!EdgeExtraction.IsSeam(target))
|
|
103
|
+
{
|
|
104
|
+
var name = SymbolNames.FullName(target);
|
|
105
|
+
return components.Contains(name) ? [new(from, name, EdgeExtraction.Di, [], reference.File, reference.Line)] : [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Seams from the framework (ILogger<T>, IReadOnlyList<T>...) are plumbing, not design.
|
|
109
|
+
if (!CitableSource.IsRepoSource(target.OriginalDefinition, loaded))
|
|
110
|
+
{
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
var implementations = await portResolution.ImplementationsAsync(target, cancellationToken);
|
|
115
|
+
if (implementations.Count == 0)
|
|
116
|
+
{
|
|
117
|
+
var portName = SymbolNames.FullName(target.OriginalDefinition);
|
|
118
|
+
ports[portName] = target.OriginalDefinition;
|
|
119
|
+
return [new(from, portName, EdgeExtraction.Di, [], reference.File, reference.Line)];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return implementations
|
|
123
|
+
.Select(SymbolNames.FullName)
|
|
124
|
+
.Where(components.Contains)
|
|
125
|
+
.Select(to => new MemberUseResolution.RawEdge(from, to, EdgeExtraction.Di, [target.Name], reference.File, reference.Line))
|
|
126
|
+
.ToList();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// <summary>Where a partial class names its base class: the base list of the declaration that has one.</summary>
|
|
130
|
+
private static (string File, int Line)? BaseListLocation(IEnumerable<SyntaxNode> declarations, LoadedWorkspace loaded)
|
|
131
|
+
{
|
|
132
|
+
foreach (var baseList in declarations.OfType<Microsoft.CodeAnalysis.CSharp.Syntax.TypeDeclarationSyntax>()
|
|
133
|
+
.Select(d => d.BaseList).OfType<Microsoft.CodeAnalysis.CSharp.Syntax.BaseListSyntax>())
|
|
134
|
+
{
|
|
135
|
+
var (kind, file) = CitableSource.Classify(loaded, baseList.SyntaxTree);
|
|
136
|
+
if (kind == CitableSource.Kind.InRepo)
|
|
137
|
+
{
|
|
138
|
+
return (file, baseList.GetLocation().GetLineSpan().StartLinePosition.Line + 1);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/// <summary>Code that does not compile binds partially, so edges can be missing: say so instead of staying silent.</summary>
|
|
146
|
+
private static async Task<List<string>> CompileErrorWarningsAsync(Solution solution, CancellationToken cancellationToken)
|
|
147
|
+
{
|
|
148
|
+
var warnings = new List<string>();
|
|
149
|
+
foreach (var project in solution.Projects.OrderBy(p => p.Name, StringComparer.Ordinal))
|
|
150
|
+
{
|
|
151
|
+
var compilation = await project.GetCompilationAsync(cancellationToken);
|
|
152
|
+
var errors = compilation?.GetDiagnostics(cancellationToken).Count(d => d.Severity == DiagnosticSeverity.Error) ?? 0;
|
|
153
|
+
if (errors > 0)
|
|
154
|
+
{
|
|
155
|
+
warnings.Add($"project {project.Name} has {errors} compile error(s); coupling may be incomplete");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return warnings;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// <summary>Port nodes use the seam's simple name as id, unless that clashes with a component or another port.</summary>
|
|
163
|
+
private static List<ExternalNode> PortNodes(
|
|
164
|
+
SortedDictionary<string, INamedTypeSymbol> ports, IReadOnlyDictionary<string, string> componentIds)
|
|
165
|
+
{
|
|
166
|
+
var taken = componentIds.Values.ToHashSet(StringComparer.Ordinal);
|
|
167
|
+
var bySimpleName = ports.Values.GroupBy(p => p.Name).ToDictionary(g => g.Key, g => g.Count());
|
|
168
|
+
return ports
|
|
169
|
+
.Select(p =>
|
|
170
|
+
{
|
|
171
|
+
var simple = p.Value.Name;
|
|
172
|
+
var id = bySimpleName[simple] == 1 && !taken.Contains(simple) ? simple : SymbolNames.ToIdentifier(p.Key);
|
|
173
|
+
return new ExternalNode(id, p.Key, DisplayName.FromTypeName(simple), "port");
|
|
174
|
+
})
|
|
175
|
+
.OrderBy(n => n.Id, StringComparer.Ordinal)
|
|
176
|
+
.ToList();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/// <summary>One edge per (from, to): kinds and via as sorted sets, cited at the earliest (file, line) recorded use.</summary>
|
|
180
|
+
private static List<Edge> MergeEdges(List<MemberUseResolution.RawEdge> raw, IReadOnlyDictionary<string, string> ids) =>
|
|
181
|
+
raw.Where(e => e.From != e.To)
|
|
182
|
+
.GroupBy(e => (From: ids[e.From], To: ids[e.To]))
|
|
183
|
+
.Select(g =>
|
|
184
|
+
{
|
|
185
|
+
var first = g.OrderBy(e => e.File, StringComparer.Ordinal).ThenBy(e => e.Line).First();
|
|
186
|
+
return new Edge(
|
|
187
|
+
g.Key.From,
|
|
188
|
+
g.Key.To,
|
|
189
|
+
g.Select(e => e.Kind).Distinct().Order(StringComparer.Ordinal).ToList(),
|
|
190
|
+
g.SelectMany(e => e.Via).Distinct().Order(StringComparer.Ordinal).ToList(),
|
|
191
|
+
$"{first.File}:{first.Line}");
|
|
192
|
+
})
|
|
193
|
+
.OrderBy(e => e.From, StringComparer.Ordinal)
|
|
194
|
+
.ThenBy(e => e.To, StringComparer.Ordinal)
|
|
195
|
+
.ToList();
|
|
196
|
+
}
|
|
@@ -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
|
+
}
|
package/template/.claude/skills/logical-components/scripts/LogicalComponents/EdgeExtraction.cs
ADDED
|
@@ -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<IX></c>,
|
|
55
|
+
/// <c>IReadOnlyList<IX></c>, <c>Lazy<X></c>, <c>Func<X></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<Order></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
|
+
}
|
package/template/.claude/skills/logical-components/scripts/LogicalComponents/ExtractCommand.cs
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
namespace LogicalComponents;
|
|
2
|
+
|
|
3
|
+
/// <summary>
|
|
4
|
+
/// <c>extract <repoPath> [--out <components.json>]</c>: loads the repo and writes the deterministic extraction.
|
|
5
|
+
/// Progress and warnings go to <c>errors</c> (stderr); the artifact goes to the --out file.
|
|
6
|
+
/// Exit codes: 0 written, 1 usage, 2 the target repo or its config could not be loaded, 4 candidates not classified
|
|
7
|
+
/// in the repo config yet (one <c>unclassified <type></c> line each; nothing is written).
|
|
8
|
+
/// </summary>
|
|
9
|
+
public static class ExtractCommand
|
|
10
|
+
{
|
|
11
|
+
public const string Usage = "usage: LogicalComponents extract <repoPath> [--out <components.json>]";
|
|
12
|
+
|
|
13
|
+
public static async Task<int> RunAsync(string[] args, TextWriter errors)
|
|
14
|
+
{
|
|
15
|
+
if (args is not [var repoPath, ..] || !TryParseOut(args[1..], out var outPath))
|
|
16
|
+
{
|
|
17
|
+
await errors.WriteLineAsync(Usage);
|
|
18
|
+
return 1;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try
|
|
22
|
+
{
|
|
23
|
+
using var loaded = await WorkspaceLoader.LoadAsync(repoPath, log: errors);
|
|
24
|
+
await errors.WriteLineAsync($"Loaded {loaded.ProjectNames.Count} projects from {loaded.Source}.");
|
|
25
|
+
|
|
26
|
+
var result = await CouplingResolver.ExtractAsync(loaded, ExtractConfig.Load(loaded.RepoRoot));
|
|
27
|
+
foreach (var warning in result.Warnings)
|
|
28
|
+
{
|
|
29
|
+
await errors.WriteLineAsync($"warning: {warning}");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outPath))!);
|
|
33
|
+
await File.WriteAllBytesAsync(outPath, ComponentsFile.Serialize(result));
|
|
34
|
+
await errors.WriteLineAsync(
|
|
35
|
+
$"{result.Components.Count} components, {result.ExternalNodes.Count} ports, {result.Edges.Count} edges, "
|
|
36
|
+
+ $"{result.Warnings.Count} warnings -> {Path.GetFullPath(outPath)}");
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
catch (UnclassifiedCandidatesException e)
|
|
40
|
+
{
|
|
41
|
+
foreach (var type in e.Types)
|
|
42
|
+
{
|
|
43
|
+
await errors.WriteLineAsync($"unclassified {type}");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await errors.WriteLineAsync($"{e.Message}: run the classify step, review it, then extract again.");
|
|
47
|
+
return 4;
|
|
48
|
+
}
|
|
49
|
+
catch (Exception e) when (e is DirectoryNotFoundException or WorkspaceLoadException or ExtractConfigException)
|
|
50
|
+
{
|
|
51
|
+
await errors.WriteLineAsync(e.Message);
|
|
52
|
+
return 2;
|
|
53
|
+
}
|
|
54
|
+
catch (Exception e)
|
|
55
|
+
{
|
|
56
|
+
// Anything else (I/O, access, build host) is still a load failure: report it, don't crash with a stack trace.
|
|
57
|
+
await errors.WriteLineAsync($"Unexpected error while extracting {repoPath}: {e.Message}");
|
|
58
|
+
return 2;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private static bool TryParseOut(string[] options, out string outPath)
|
|
63
|
+
{
|
|
64
|
+
outPath = "components.json";
|
|
65
|
+
return options switch
|
|
66
|
+
{
|
|
67
|
+
[] => true,
|
|
68
|
+
["--out", var path] => (outPath = path) is not null,
|
|
69
|
+
_ => false,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -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>
|
package/template/.claude/skills/logical-components/scripts/LogicalComponents/MarkdownRenderer.cs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
using System.Text;
|
|
2
|
+
|
|
3
|
+
namespace LogicalComponents;
|
|
4
|
+
|
|
5
|
+
/// <summary>
|
|
6
|
+
/// Writes the final document: <c># Logical Components</c> (one section per component, sorted by display name, each
|
|
7
|
+
/// responsibility with its citation) and <c># Coupling</c> (a legend, then a Mermaid flowchart of the extracted edges).
|
|
8
|
+
/// Everything but the responsibility text comes from the deterministic extraction; the output has LF line endings and no
|
|
9
|
+
/// timestamp.
|
|
10
|
+
/// Call only with responsibilities that passed <see cref="ResponsibilityValidation"/>.
|
|
11
|
+
/// </summary>
|
|
12
|
+
public static class MarkdownRenderer
|
|
13
|
+
{
|
|
14
|
+
/// <summary>
|
|
15
|
+
/// How to read the diagram, so incoming and outgoing arrows are not read backwards, and what it leaves out, so an
|
|
16
|
+
/// entry point with no incoming arrow is not mistaken for dead code.
|
|
17
|
+
/// </summary>
|
|
18
|
+
private const string Legend =
|
|
19
|
+
"`A --> B` means A depends on B: it calls, creates, holds or inherits from B. A component's incoming arrows are its "
|
|
20
|
+
+ "afferent coupling, its outgoing arrows its efferent coupling. Only components in `*.Application` and "
|
|
21
|
+
+ "`*.Infrastructure` projects are drawn; callers in other projects, such as the host, are not shown.";
|
|
22
|
+
|
|
23
|
+
public static string Render(
|
|
24
|
+
ExtractResult extraction, IReadOnlyDictionary<string, IReadOnlyList<Responsibility>> responsibilities)
|
|
25
|
+
{
|
|
26
|
+
var md = new StringBuilder();
|
|
27
|
+
md.Append("# Logical Components\n\n");
|
|
28
|
+
foreach (var component in extraction.Components
|
|
29
|
+
.OrderBy(c => c.Name, StringComparer.Ordinal)
|
|
30
|
+
.ThenBy(c => c.Id, StringComparer.Ordinal))
|
|
31
|
+
{
|
|
32
|
+
md.Append($"## {component.Name}\n\n");
|
|
33
|
+
md.Append($"`{SymbolNames.ReadableFullName(component.Type)}`\n\n");
|
|
34
|
+
foreach (var responsibility in responsibilities[component.Id])
|
|
35
|
+
{
|
|
36
|
+
md.Append($"- {responsibility.Text.Trim()} (`{Citation.Parse(responsibility.Cite)}`)\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
md.Append('\n');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ELK lays out a large flowchart far more readably than Mermaid's default dagre.
|
|
43
|
+
md.Append("# Coupling\n\n").Append(Legend).Append("\n\n```mermaid\n%%{init: {\"layout\": \"elk\"}}%%\nflowchart LR\n");
|
|
44
|
+
var nodes = extraction.Components
|
|
45
|
+
.Select(c => (c.Id, c.Name))
|
|
46
|
+
.Concat(extraction.ExternalNodes.Select(n => (n.Id, n.Name)))
|
|
47
|
+
.OrderBy(n => n.Id, StringComparer.Ordinal);
|
|
48
|
+
foreach (var (id, name) in nodes)
|
|
49
|
+
{
|
|
50
|
+
md.Append($" {NodeId(id)}[\"{Escape(name)}\"]\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A --> B: A depends on B (efferent for A, afferent for B). How A reaches B (di, new, static, inherits) is not
|
|
54
|
+
// drawn; it stays in components.json.
|
|
55
|
+
foreach (var edge in extraction.Edges)
|
|
56
|
+
{
|
|
57
|
+
md.Append($" {NodeId(edge.From)} --> {NodeId(edge.To)}\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
md.Append("```\n");
|
|
61
|
+
return md.ToString();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Mermaid node labels are double-quoted; a literal quote is written as an entity.
|
|
65
|
+
private static string Escape(string label) => label.Replace("\"", "#quot;");
|
|
66
|
+
|
|
67
|
+
/// <summary>
|
|
68
|
+
/// A Mermaid-safe node id: prefixed so a component called <c>end</c>, <c>graph</c> or <c>class</c> is never read as a
|
|
69
|
+
/// keyword, and reduced to letters, digits and underscores so a module name with spaces cannot break a line.
|
|
70
|
+
/// </summary>
|
|
71
|
+
private static string NodeId(string id) =>
|
|
72
|
+
"n_" + new string(id.Select(c => char.IsAsciiLetterOrDigit(c) ? c : '_').ToArray());
|
|
73
|
+
}
|