@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
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
using System.Text;
|
|
2
|
+
using System.Text.Json;
|
|
3
|
+
using Microsoft.CodeAnalysis.Text;
|
|
4
|
+
|
|
5
|
+
namespace LogicalComponents;
|
|
6
|
+
|
|
7
|
+
/// <summary>
|
|
8
|
+
/// <c>render --components <components.json> --resp <dir|file>... --repo <repoPath> --out <md></c>.
|
|
9
|
+
/// Merges the agents' batch files (a directory means every <c>batch-*.json</c> in it, read in name order), validates them
|
|
10
|
+
/// against the extraction and the repo's files, and writes the markdown only when everything is valid.
|
|
11
|
+
/// Exit codes: 0 written; 1 usage; 2 inputs unusable (unreadable components.json, a missing path, a repo that does not
|
|
12
|
+
/// match components.json, an unwritable --out); 3 responsibilities rejected, every problem on stderr and no --out left.
|
|
13
|
+
/// </summary>
|
|
14
|
+
public static class RenderCommand
|
|
15
|
+
{
|
|
16
|
+
public const string Usage =
|
|
17
|
+
"usage: LogicalComponents render --components <components.json> --resp <dir|file>... --repo <repoPath> --out <md>";
|
|
18
|
+
|
|
19
|
+
private static readonly JsonSerializerOptions ReadOptions = new()
|
|
20
|
+
{
|
|
21
|
+
PropertyNameCaseInsensitive = true,
|
|
22
|
+
ReadCommentHandling = JsonCommentHandling.Skip,
|
|
23
|
+
AllowTrailingCommas = true,
|
|
24
|
+
// The same component twice in one file must not silently keep the last list.
|
|
25
|
+
AllowDuplicateProperties = false,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
private sealed record Arguments(string Components, IReadOnlyList<string> Resp, string Repo, string Out);
|
|
29
|
+
|
|
30
|
+
public static async Task<int> RunAsync(string[] args, TextWriter errors)
|
|
31
|
+
{
|
|
32
|
+
var arguments = ParseArgs(args);
|
|
33
|
+
if (arguments is null)
|
|
34
|
+
{
|
|
35
|
+
await errors.WriteLineAsync(Usage);
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
var extraction = await ReadExtractionAsync(arguments.Components, errors);
|
|
40
|
+
if (extraction is null)
|
|
41
|
+
{
|
|
42
|
+
return 2;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
var missing = arguments.Resp.Where(p => !Directory.Exists(p) && !File.Exists(p))
|
|
46
|
+
.Concat(Directory.Exists(arguments.Repo) ? [] : [arguments.Repo])
|
|
47
|
+
.ToList();
|
|
48
|
+
if (missing.Count > 0)
|
|
49
|
+
{
|
|
50
|
+
await errors.WriteLineAsync($"Not found: {string.Join(", ", missing)}");
|
|
51
|
+
return 2;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Checked before any citation: a repo that does not match components.json is not the agents' fault.
|
|
55
|
+
var sourceLines = ReadSourceLines(extraction, arguments.Repo, out var missingSources);
|
|
56
|
+
if (missingSources.Count > 0)
|
|
57
|
+
{
|
|
58
|
+
await errors.WriteLineAsync(
|
|
59
|
+
$"The repo does not match components.json (re-run extract); missing: {string.Join(", ", missingSources)}");
|
|
60
|
+
return 2;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
var batches = ResponsibilityBatches.Load(arguments.Resp, ReadOptions);
|
|
64
|
+
var problems = batches.Problems
|
|
65
|
+
.Concat(ResponsibilityValidation.Validate(extraction, batches.Responsibilities, sourceLines, batches.FileOf))
|
|
66
|
+
.ToList();
|
|
67
|
+
if (problems.Count > 0)
|
|
68
|
+
{
|
|
69
|
+
foreach (var problem in problems)
|
|
70
|
+
{
|
|
71
|
+
await errors.WriteLineAsync(problem);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Never leave a document from an earlier run looking like this run's result.
|
|
75
|
+
if (File.Exists(arguments.Out))
|
|
76
|
+
{
|
|
77
|
+
File.Delete(arguments.Out);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return 3;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try
|
|
84
|
+
{
|
|
85
|
+
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(arguments.Out))!);
|
|
86
|
+
var markdown = MarkdownRenderer.Render(extraction, batches.Responsibilities);
|
|
87
|
+
await File.WriteAllBytesAsync(arguments.Out, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(markdown));
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
|
|
91
|
+
{
|
|
92
|
+
await errors.WriteLineAsync($"Cannot write {arguments.Out}: {e.Message}");
|
|
93
|
+
return 2;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private static async Task<ExtractResult?> ReadExtractionAsync(string path, TextWriter errors)
|
|
98
|
+
{
|
|
99
|
+
try
|
|
100
|
+
{
|
|
101
|
+
var extraction = ComponentsFile.Deserialize(await File.ReadAllBytesAsync(path));
|
|
102
|
+
// Deserialization leaves absent lists null despite the non-nullable types: `{}` is not a components.json.
|
|
103
|
+
if (extraction.Components is not null && extraction.ExternalNodes is not null
|
|
104
|
+
&& extraction.Edges is not null && extraction.Warnings is not null)
|
|
105
|
+
{
|
|
106
|
+
return extraction;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
await errors.WriteLineAsync($"{path} is not a components.json written by extract");
|
|
110
|
+
}
|
|
111
|
+
catch (Exception e) when (e is IOException or UnauthorizedAccessException or JsonException)
|
|
112
|
+
{
|
|
113
|
+
await errors.WriteLineAsync($"Cannot read {path}: {e.Message}");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// <summary>
|
|
120
|
+
/// The lines of every file a component is declared in, split the way Roslyn splits them (so U+2028 and friends count
|
|
121
|
+
/// exactly as they did when extract measured the spans). Files missing from the repo are reported, not read.
|
|
122
|
+
/// </summary>
|
|
123
|
+
private static Dictionary<string, IReadOnlyList<string>> ReadSourceLines(
|
|
124
|
+
ExtractResult extraction, string repo, out List<string> missing)
|
|
125
|
+
{
|
|
126
|
+
var lines = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
|
|
127
|
+
missing = [];
|
|
128
|
+
var files = extraction.Components.SelectMany(c => c.Spans.Concat(c.Reaches ?? []).Select(s => s.File));
|
|
129
|
+
foreach (var file in files.Distinct().Order(StringComparer.Ordinal))
|
|
130
|
+
{
|
|
131
|
+
var path = Path.Combine(repo, file);
|
|
132
|
+
if (!File.Exists(path))
|
|
133
|
+
{
|
|
134
|
+
missing.Add(file);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
var text = SourceText.From(File.ReadAllText(path));
|
|
139
|
+
lines[file] = text.Lines.Select(l => text.ToString(l.Span)).ToList();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return lines;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/// <summary>Each flag exactly once; <c>--resp</c> takes every following value up to the next flag.</summary>
|
|
146
|
+
private static Arguments? ParseArgs(string[] args)
|
|
147
|
+
{
|
|
148
|
+
var values = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
|
149
|
+
for (var i = 0; i < args.Length; i++)
|
|
150
|
+
{
|
|
151
|
+
var flag = args[i];
|
|
152
|
+
if (flag is not ("--components" or "--resp" or "--repo" or "--out") || values.ContainsKey(flag))
|
|
153
|
+
{
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
var taken = new List<string>();
|
|
158
|
+
while (i + 1 < args.Length && !args[i + 1].StartsWith("--", StringComparison.Ordinal))
|
|
159
|
+
{
|
|
160
|
+
taken.Add(args[++i]);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
values[flag] = taken;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
string? Single(string flag) => values.TryGetValue(flag, out var v) && v.Count == 1 ? v[0] : null;
|
|
167
|
+
var resp = values.GetValueOrDefault("--resp") ?? [];
|
|
168
|
+
return Single("--components") is { } components && Single("--repo") is { } repo && Single("--out") is { } output
|
|
169
|
+
&& resp.Count > 0
|
|
170
|
+
? new Arguments(components, resp, repo, output)
|
|
171
|
+
: null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
using System.Text.Json;
|
|
2
|
+
|
|
3
|
+
namespace LogicalComponents;
|
|
4
|
+
|
|
5
|
+
/// <summary>
|
|
6
|
+
/// The agents' batch files merged by component id, with every problem found while reading them. A component answered in
|
|
7
|
+
/// two files keeps the first file's answer (so validation can still report everything else) and is a problem; a null
|
|
8
|
+
/// list reads as no responsibilities; a null item is a problem naming its file.
|
|
9
|
+
/// </summary>
|
|
10
|
+
public sealed record ResponsibilityBatches(
|
|
11
|
+
Dictionary<string, IReadOnlyList<Responsibility>> Responsibilities,
|
|
12
|
+
Dictionary<string, string> FileOf,
|
|
13
|
+
List<string> Problems)
|
|
14
|
+
{
|
|
15
|
+
/// <summary>A directory contributes its <c>batch-*.json</c> files in name order; any other path is read as a file.</summary>
|
|
16
|
+
public static ResponsibilityBatches Load(IEnumerable<string> paths, JsonSerializerOptions options)
|
|
17
|
+
{
|
|
18
|
+
var files = paths
|
|
19
|
+
.SelectMany(p => Directory.Exists(p)
|
|
20
|
+
? Directory.EnumerateFiles(p, "batch-*.json").Order(StringComparer.Ordinal)
|
|
21
|
+
: (IEnumerable<string>)[p])
|
|
22
|
+
.ToList();
|
|
23
|
+
var batches = new ResponsibilityBatches(new(StringComparer.Ordinal), new(StringComparer.Ordinal), []);
|
|
24
|
+
var sources = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
|
25
|
+
|
|
26
|
+
foreach (var file in files)
|
|
27
|
+
{
|
|
28
|
+
var name = Path.GetFileName(file);
|
|
29
|
+
Dictionary<string, List<Responsibility?>?>? content;
|
|
30
|
+
try
|
|
31
|
+
{
|
|
32
|
+
content = JsonSerializer.Deserialize<Dictionary<string, List<Responsibility?>?>>(File.ReadAllText(file), options);
|
|
33
|
+
}
|
|
34
|
+
catch (Exception e) when (e is IOException or UnauthorizedAccessException or JsonException)
|
|
35
|
+
{
|
|
36
|
+
batches.Problems.Add($"{name}: not a valid responsibilities file ({e.Message})");
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
foreach (var (id, items) in content ?? [])
|
|
41
|
+
{
|
|
42
|
+
if (!sources.TryGetValue(id, out var seenIn))
|
|
43
|
+
{
|
|
44
|
+
seenIn = [];
|
|
45
|
+
sources[id] = seenIn;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
seenIn.Add(name);
|
|
49
|
+
if (seenIn.Count > 1)
|
|
50
|
+
{
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
batches.FileOf[id] = name;
|
|
55
|
+
batches.Responsibilities[id] = Clean(id, name, items ?? [], batches.Problems);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
batches.Problems.AddRange(sources
|
|
60
|
+
.Where(s => s.Value.Count > 1)
|
|
61
|
+
.OrderBy(s => s.Key, StringComparer.Ordinal)
|
|
62
|
+
.Select(s => $"{s.Key}: responsibilities in more than one file ({string.Join(", ", s.Value)})"));
|
|
63
|
+
return batches;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Null items become problems; null fields become empty strings so validation reports them as empty text or bad cites.
|
|
67
|
+
private static List<Responsibility> Clean(string id, string file, List<Responsibility?> items, List<string> problems)
|
|
68
|
+
{
|
|
69
|
+
var clean = new List<Responsibility>();
|
|
70
|
+
for (var index = 0; index < items.Count; index++)
|
|
71
|
+
{
|
|
72
|
+
if (items[index] is { } item)
|
|
73
|
+
{
|
|
74
|
+
clean.Add(new Responsibility(item.Text ?? "", item.Cite ?? ""));
|
|
75
|
+
}
|
|
76
|
+
else
|
|
77
|
+
{
|
|
78
|
+
problems.Add($"{id}[{index + 1}]: item is null ({file})");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return clean;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
using System.Text.RegularExpressions;
|
|
2
|
+
|
|
3
|
+
namespace LogicalComponents;
|
|
4
|
+
|
|
5
|
+
/// <summary>
|
|
6
|
+
/// Checks the agents' responsibilities against the deterministic extraction before anything is rendered. Agents may
|
|
7
|
+
/// only describe the components extract found; every component needs at least one responsibility; each is one plain
|
|
8
|
+
/// line of text, said once; and its citation must cover real code inside that component's own declaration. All problems
|
|
9
|
+
/// are reported at once, sorted by component id then item, so a retry can fix everything in one pass.
|
|
10
|
+
/// </summary>
|
|
11
|
+
public static partial class ResponsibilityValidation
|
|
12
|
+
{
|
|
13
|
+
public const int MaxCitedLines = 60;
|
|
14
|
+
public const int MaxTextLength = 300;
|
|
15
|
+
|
|
16
|
+
/// <param name="sourceLines">The lines of every file a component is declared in, keyed by repo-relative path.</param>
|
|
17
|
+
/// <param name="fileOf">Which batch file each responsibility id came from, to route unknown-id errors.</param>
|
|
18
|
+
public static List<string> Validate(
|
|
19
|
+
ExtractResult extraction,
|
|
20
|
+
IReadOnlyDictionary<string, IReadOnlyList<Responsibility>> responsibilities,
|
|
21
|
+
IReadOnlyDictionary<string, IReadOnlyList<string>> sourceLines,
|
|
22
|
+
IReadOnlyDictionary<string, string>? fileOf = null)
|
|
23
|
+
{
|
|
24
|
+
var errors = new List<(string Id, int Item, string Message)>();
|
|
25
|
+
var components = extraction.Components.ToDictionary(c => c.Id, StringComparer.Ordinal);
|
|
26
|
+
|
|
27
|
+
foreach (var id in responsibilities.Keys.Where(id => !components.ContainsKey(id)))
|
|
28
|
+
{
|
|
29
|
+
errors.Add((id, 0, UnknownId(id, components.Keys, fileOf)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
foreach (var component in extraction.Components)
|
|
33
|
+
{
|
|
34
|
+
if (!responsibilities.TryGetValue(component.Id, out var items) || items.Count == 0)
|
|
35
|
+
{
|
|
36
|
+
errors.Add((component.Id, 0, $"{component.Id}: no responsibilities"));
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
var seen = new Dictionary<string, int>(StringComparer.Ordinal);
|
|
41
|
+
for (var index = 0; index < items.Count; index++)
|
|
42
|
+
{
|
|
43
|
+
var item = index + 1;
|
|
44
|
+
var prefix = $"{component.Id}[{item}]: ";
|
|
45
|
+
if (TextProblem(items[index].Text) is { } textProblem)
|
|
46
|
+
{
|
|
47
|
+
errors.Add((component.Id, item, prefix + textProblem));
|
|
48
|
+
}
|
|
49
|
+
else if (!seen.TryAdd(Normalize(items[index].Text), item))
|
|
50
|
+
{
|
|
51
|
+
errors.Add((component.Id, item, $"{prefix}repeats item {seen[Normalize(items[index].Text)]}"));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (CiteProblem(items[index].Cite, component, sourceLines) is { } citeProblem)
|
|
55
|
+
{
|
|
56
|
+
errors.Add((component.Id, item, prefix + citeProblem));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return errors
|
|
62
|
+
.OrderBy(e => e.Id, StringComparer.Ordinal)
|
|
63
|
+
.ThenBy(e => e.Item)
|
|
64
|
+
.Select(e => e.Message)
|
|
65
|
+
.ToList();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private static string UnknownId(string id, IEnumerable<string> componentIds, IReadOnlyDictionary<string, string>? fileOf)
|
|
69
|
+
{
|
|
70
|
+
var message = $"{id}: not a component in components.json";
|
|
71
|
+
if (fileOf?.TryGetValue(id, out var file) == true)
|
|
72
|
+
{
|
|
73
|
+
message += $" ({file})";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
var match = componentIds.FirstOrDefault(c => string.Equals(c, id, StringComparison.OrdinalIgnoreCase));
|
|
77
|
+
return match is null ? message : $"{message}; did you mean '{match}'?";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// <summary>One plain markdown line: no HTML or links (they would render as something else), balanced code spans.</summary>
|
|
81
|
+
private static string? TextProblem(string text)
|
|
82
|
+
{
|
|
83
|
+
if (string.IsNullOrWhiteSpace(text))
|
|
84
|
+
{
|
|
85
|
+
return "text is empty";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (text.Contains('\n') || text.Contains('\r'))
|
|
89
|
+
{
|
|
90
|
+
return "text must be a single line";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (text.Trim().Length > MaxTextLength)
|
|
94
|
+
{
|
|
95
|
+
return $"text is {text.Trim().Length} characters; keep it under {MaxTextLength}";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (text.Count(c => c == '`') % 2 != 0)
|
|
99
|
+
{
|
|
100
|
+
return "text has an unclosed ` code span";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
var outsideCode = CodeSpan().Replace(text, "");
|
|
104
|
+
if (HtmlTag().IsMatch(outsideCode))
|
|
105
|
+
{
|
|
106
|
+
return "text contains HTML; put code in `backticks`";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return outsideCode.Contains("](") ? "text contains a link" : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private static string? CiteProblem(
|
|
113
|
+
string cite, Component component, IReadOnlyDictionary<string, IReadOnlyList<string>> sourceLines)
|
|
114
|
+
{
|
|
115
|
+
if (Citation.Parse(cite) is not { } citation)
|
|
116
|
+
{
|
|
117
|
+
return $"cite '{cite}' is not <file>:<line> or <file>:<start>-<end>";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
var citable = component.Spans.Concat(component.Reaches ?? []).ToList();
|
|
121
|
+
var spans = citable.Where(s => s.File == citation.File).ToList();
|
|
122
|
+
if (spans.Count == 0)
|
|
123
|
+
{
|
|
124
|
+
return $"cite '{cite}' is not in the component's files: {string.Join(", ", citable.Select(s => s.File).Distinct())}";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The render command checks every span file exists before validating; a missing one here is a caller bug.
|
|
128
|
+
var lines = sourceLines[citation.File];
|
|
129
|
+
if (citation.End > lines.Count)
|
|
130
|
+
{
|
|
131
|
+
return $"cite '{cite}' is past the end of the file ({lines.Count} lines)";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Every code line must be citable, and the first and last lines must lie inside a citable span. Lines between
|
|
135
|
+
// two citable members (blank, comment, brace) are not code, so a citation may run across neighbouring helper
|
|
136
|
+
// members without claiming anything uncited, but it cannot start or end outside them.
|
|
137
|
+
bool Citable(int line) => spans.Any(s => s.Start <= line && line <= s.End);
|
|
138
|
+
if (Enumerable.Range(citation.Start, citation.LineCount).Any(n => !Citable(n) && (IsCode(lines[n - 1]) || n == citation.Start || n == citation.End)))
|
|
139
|
+
{
|
|
140
|
+
var where = component.Reaches is { Count: > 0 }
|
|
141
|
+
? "the component's declaration and the helper code it runs"
|
|
142
|
+
: "the component's declaration";
|
|
143
|
+
return $"cite '{cite}' is outside {where} ({string.Join(", ", spans.Select(s => $"{s.Start}-{s.End}"))})";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (citation.LineCount > MaxCitedLines)
|
|
147
|
+
{
|
|
148
|
+
return $"cite '{cite}' spans {citation.LineCount} lines; cite at most {MaxCitedLines}";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return Enumerable.Range(citation.Start, citation.LineCount).Any(n => IsCode(lines[n - 1]))
|
|
152
|
+
? null
|
|
153
|
+
: $"cite '{cite}' covers no code (only blank, comment or brace lines)";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/// <summary>Not blank, not only a comment, not only braces, parentheses and semicolons.</summary>
|
|
157
|
+
private static bool IsCode(string line)
|
|
158
|
+
{
|
|
159
|
+
var trimmed = line.Trim();
|
|
160
|
+
return trimmed.Length > 0
|
|
161
|
+
&& !trimmed.StartsWith("//", StringComparison.Ordinal)
|
|
162
|
+
&& !trimmed.StartsWith("/*", StringComparison.Ordinal)
|
|
163
|
+
&& !trimmed.StartsWith('*')
|
|
164
|
+
&& trimmed.Trim('{', '}', '(', ')', ';', ' ', '\t').Length > 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// " Stores EACH order. " and "stores each order." are the same responsibility.
|
|
168
|
+
private static string Normalize(string text) => Whitespace().Replace(text.Trim(), " ").ToLowerInvariant();
|
|
169
|
+
|
|
170
|
+
[GeneratedRegex("`[^`]*`")]
|
|
171
|
+
private static partial Regex CodeSpan();
|
|
172
|
+
|
|
173
|
+
[GeneratedRegex("<[A-Za-z/!]")]
|
|
174
|
+
private static partial Regex HtmlTag();
|
|
175
|
+
|
|
176
|
+
[GeneratedRegex(@"\s+")]
|
|
177
|
+
private static partial Regex Whitespace();
|
|
178
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
using Microsoft.CodeAnalysis;
|
|
2
|
+
|
|
3
|
+
namespace LogicalComponents;
|
|
4
|
+
|
|
5
|
+
/// <summary>Which member runs in an implementation when a member of a seam (interface or abstract class) is used.</summary>
|
|
6
|
+
public static class SeamDispatch
|
|
7
|
+
{
|
|
8
|
+
/// <summary>
|
|
9
|
+
/// The member that runs in <paramref name="implementation"/> when <paramref name="member"/> of
|
|
10
|
+
/// <paramref name="seam"/> (as constructed at the use) is used: the implementation of that exact construction of the
|
|
11
|
+
/// interface member (a class implementing <c>IHandler<Ping></c> and <c>IHandler<Pong></c> runs its Pong
|
|
12
|
+
/// member for a Pong use), else the one of an open construction; for an abstract class, the most derived override,
|
|
13
|
+
/// possibly the seam's own body.
|
|
14
|
+
/// </summary>
|
|
15
|
+
public static ISymbol? ImplementationOf(INamedTypeSymbol implementation, INamedTypeSymbol seam, ISymbol member)
|
|
16
|
+
{
|
|
17
|
+
var wanted = SymbolNames.MemberId(member);
|
|
18
|
+
if (seam.TypeKind == TypeKind.Interface)
|
|
19
|
+
{
|
|
20
|
+
var definition = SymbolNames.FullName(seam.OriginalDefinition);
|
|
21
|
+
var constructions = implementation.AllInterfaces
|
|
22
|
+
.Where(i => SymbolNames.FullName(i.OriginalDefinition) == definition)
|
|
23
|
+
.ToList();
|
|
24
|
+
var exact = constructions.Where(i => Display(i) == Display(seam)).ToList();
|
|
25
|
+
return (exact.Count > 0 ? exact : constructions)
|
|
26
|
+
.SelectMany(i => i.GetMembers(member.Name))
|
|
27
|
+
.Where(candidate => SymbolNames.MemberId(candidate) == wanted)
|
|
28
|
+
.Select(implementation.FindImplementationForInterfaceMember)
|
|
29
|
+
.FirstOrDefault(found => found is not null);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (var type = implementation; type is not null; type = type.BaseType)
|
|
33
|
+
{
|
|
34
|
+
foreach (var candidate in type.GetMembers(member.Name))
|
|
35
|
+
{
|
|
36
|
+
for (var overridden = candidate; overridden is not null; overridden = Overridden(overridden))
|
|
37
|
+
{
|
|
38
|
+
if (SymbolNames.MemberId(overridden) == wanted)
|
|
39
|
+
{
|
|
40
|
+
return candidate;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private static ISymbol? Overridden(ISymbol symbol) => symbol switch
|
|
50
|
+
{
|
|
51
|
+
IMethodSymbol method => method.OverriddenMethod,
|
|
52
|
+
IPropertySymbol property => property.OverriddenProperty,
|
|
53
|
+
IEventSymbol @event => @event.OverriddenEvent,
|
|
54
|
+
_ => null,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Compared as text: symbols of the same type seen from two projects' compilations are not the same instance.
|
|
58
|
+
private static string Display(ITypeSymbol type) => type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
|
|
59
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
using Microsoft.CodeAnalysis;
|
|
2
|
+
|
|
3
|
+
namespace LogicalComponents;
|
|
4
|
+
|
|
5
|
+
/// <summary>Stable textual names for symbols, independent of nullable annotations and of where the repo is checked out.</summary>
|
|
6
|
+
public static class SymbolNames
|
|
7
|
+
{
|
|
8
|
+
// No miscellaneous options: in particular no nullable '?' suffix, so IServiceCollection? == IServiceCollection.
|
|
9
|
+
private static readonly SymbolDisplayFormat Qualified = new(
|
|
10
|
+
typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces);
|
|
11
|
+
|
|
12
|
+
/// <summary><c>Microsoft.Extensions.DependencyInjection.IServiceCollection</c>, whatever its nullability.</summary>
|
|
13
|
+
public static string QualifiedName(ITypeSymbol type) => type.ToDisplayString(Qualified);
|
|
14
|
+
|
|
15
|
+
/// <summary>
|
|
16
|
+
/// Full metadata name: namespace plus metadata name (generic arity kept, e.g. <c>Ns.Cache`1</c>). A nested type
|
|
17
|
+
/// carries its containing types (<c>Ns.Receipts+Builder</c>), so it can never collide with a top-level namesake.
|
|
18
|
+
/// </summary>
|
|
19
|
+
public static string FullName(INamedTypeSymbol type) =>
|
|
20
|
+
type.ContainingType is { } outer
|
|
21
|
+
? $"{FullName(outer)}+{type.MetadataName}"
|
|
22
|
+
: type.ContainingNamespace.IsGlobalNamespace
|
|
23
|
+
? type.MetadataName
|
|
24
|
+
: $"{type.ContainingNamespace.ToDisplayString()}.{type.MetadataName}";
|
|
25
|
+
|
|
26
|
+
/// <summary>
|
|
27
|
+
/// A member's identity across compilations (symbols of one member seen from two projects are not the same instance):
|
|
28
|
+
/// the documentation-comment id of its definition, e.g. <c>M:Ns.Handler`1.Handle(`0)</c>.
|
|
29
|
+
/// </summary>
|
|
30
|
+
public static string MemberId(ISymbol symbol) =>
|
|
31
|
+
symbol.OriginalDefinition.GetDocumentationCommentId() ?? symbol.OriginalDefinition.ToDisplayString();
|
|
32
|
+
|
|
33
|
+
/// <summary>A name safe as a diagram id: <c>Ns.Receipts+Cache`1</c> → <c>Ns_Receipts_Cache_1</c>.</summary>
|
|
34
|
+
public static string ToIdentifier(string fullName) =>
|
|
35
|
+
fullName.Replace('.', '_').Replace('+', '_').Replace('`', '_');
|
|
36
|
+
|
|
37
|
+
/// <summary>The simple name without arity: <c>Ns.Cache`1</c> → <c>Cache</c>, <c>Ns.Outer+Inner</c> → <c>Inner</c>.</summary>
|
|
38
|
+
public static string SimpleName(string fullName)
|
|
39
|
+
{
|
|
40
|
+
var name = fullName[(fullName.LastIndexOfAny(['.', '+']) + 1)..];
|
|
41
|
+
var tick = name.IndexOf('`');
|
|
42
|
+
return tick < 0 ? name : name[..tick];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// <summary>The full name as a reader writes it: <c>Ns.Cache`1</c> → <c>Ns.Cache<></c>, <c>`2</c> → <c><,></c>.</summary>
|
|
46
|
+
public static string ReadableFullName(string fullName)
|
|
47
|
+
{
|
|
48
|
+
var tick = fullName.LastIndexOf('`');
|
|
49
|
+
return tick < 0 || !int.TryParse(fullName[(tick + 1)..], out var arity)
|
|
50
|
+
? fullName
|
|
51
|
+
: $"{fullName[..tick]}<{new string(',', arity - 1)}>";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
using System.Collections.Immutable;
|
|
2
|
+
using Microsoft.CodeAnalysis;
|
|
3
|
+
|
|
4
|
+
namespace LogicalComponents;
|
|
5
|
+
|
|
6
|
+
/// <summary>
|
|
7
|
+
/// What a generic helper's type parameters stand for at the use being followed. Walking <c>Dispatch.Send<T></c>
|
|
8
|
+
/// for <c>Send(new Ping())</c> maps <c>T</c> to <c>Ping</c>, so the <c>ICommandHandler<T></c> it calls is
|
|
9
|
+
/// resolved as <c>ICommandHandler<Ping></c> and reaches only the Ping handlers, not every handler.
|
|
10
|
+
/// </summary>
|
|
11
|
+
public sealed class TypeMap
|
|
12
|
+
{
|
|
13
|
+
private readonly Dictionary<ITypeParameterSymbol, ITypeSymbol> _map;
|
|
14
|
+
|
|
15
|
+
private TypeMap(Dictionary<ITypeParameterSymbol, ITypeSymbol> map)
|
|
16
|
+
{
|
|
17
|
+
_map = map;
|
|
18
|
+
Key = string.Join(";", map
|
|
19
|
+
.Select(p => $"{p.Key.ContainingSymbol.ToDisplayString()}.{p.Key.Name}={p.Value.ToDisplayString()}")
|
|
20
|
+
.Order(StringComparer.Ordinal));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public static TypeMap Empty { get; } = new(new Dictionary<ITypeParameterSymbol, ITypeSymbol>(SymbolEqualityComparer.Default));
|
|
24
|
+
|
|
25
|
+
/// <summary>Stable text of the bindings: the same member walked for two different bindings is two walks.</summary>
|
|
26
|
+
public string Key { get; }
|
|
27
|
+
|
|
28
|
+
/// <summary>The type with every mapped type parameter replaced, inside generic arguments too.</summary>
|
|
29
|
+
public ITypeSymbol Substitute(ITypeSymbol type) => type switch
|
|
30
|
+
{
|
|
31
|
+
ITypeParameterSymbol parameter when _map.TryGetValue(parameter, out var bound) => bound,
|
|
32
|
+
INamedTypeSymbol { IsGenericType: true } named when named.TypeArguments.Any(Mentions) =>
|
|
33
|
+
named.OriginalDefinition.Construct([.. named.TypeArguments.Select(Substitute)]),
|
|
34
|
+
_ => type,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/// <summary>
|
|
38
|
+
/// The map for walking <paramref name="member"/>'s own code: the type parameters of its definition and of its
|
|
39
|
+
/// containing types, bound to the arguments this use supplies (read through this map, for nested generic helpers).
|
|
40
|
+
/// </summary>
|
|
41
|
+
public TypeMap For(ISymbol member)
|
|
42
|
+
{
|
|
43
|
+
var map = new Dictionary<ITypeParameterSymbol, ITypeSymbol>(SymbolEqualityComparer.Default);
|
|
44
|
+
void Bind(ImmutableArray<ITypeParameterSymbol> parameters, ImmutableArray<ITypeSymbol> arguments)
|
|
45
|
+
{
|
|
46
|
+
for (var i = 0; i < parameters.Length && i < arguments.Length; i++)
|
|
47
|
+
{
|
|
48
|
+
var argument = Substitute(arguments[i]);
|
|
49
|
+
if (!SymbolEqualityComparer.Default.Equals(parameters[i], argument))
|
|
50
|
+
{
|
|
51
|
+
map[parameters[i]] = argument;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (member is IMethodSymbol method)
|
|
57
|
+
{
|
|
58
|
+
Bind(method.OriginalDefinition.TypeParameters, method.TypeArguments);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (var type = member.ContainingType; type is not null; type = type.ContainingType)
|
|
62
|
+
{
|
|
63
|
+
Bind(type.OriginalDefinition.TypeParameters, type.TypeArguments);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return map.Count == 0 ? Empty : new TypeMap(map);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private bool Mentions(ITypeSymbol type) => type switch
|
|
70
|
+
{
|
|
71
|
+
ITypeParameterSymbol parameter => _map.ContainsKey(parameter),
|
|
72
|
+
INamedTypeSymbol { IsGenericType: true } named => named.TypeArguments.Any(Mentions),
|
|
73
|
+
_ => false,
|
|
74
|
+
};
|
|
75
|
+
}
|