@phuc1403/musketeer 0.11.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.
- package/README.md +2 -2
- package/manifest.json +12 -3
- package/package.json +1 -1
- package/src/provisioner/detect.js +17 -2
- package/src/resolver.js +8 -3
- package/src/schema.js +1 -0
- package/src/settings-merger.js +0 -0
- package/template/.claude/hooks/inject-naming-rule-into-subagents.cjs +44 -0
- package/template/.claude/rules/csharp-identifier-naming.md +77 -0
- package/template/.claude/skills/code-review/SKILL.md +4 -1
- package/template/.claude/skills/code-review/references/naming-rule-review.md +41 -0
- package/template/.claude/skills/logical-components/SKILL.md +23 -18
- package/template/.claude/skills/logical-components/references/component-classifier-prompt.md +1 -1
- package/template/.claude/skills/logical-components/references/responsibility-agent-prompt.md +1 -1
- package/template/.claude/skills/logical-components/scripts/Directory.Build.rsp +1 -0
- package/template/.claude/skills/logical-components/scripts/Directory.Build.targets +6 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/BatchPlanning.cs +2 -1
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/BatchesCommand.cs +9 -1
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/CandidatesCommand.cs +39 -11
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/CitableSource.cs +19 -4
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ClassifyBatchesCommand.cs +25 -4
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ClassifyMergeCommand.cs +43 -11
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentDiscovery.cs +20 -6
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentRule.cs +57 -14
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentsFile.cs +20 -2
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/CouplingResolver.cs +70 -15
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ExtractCommand.cs +28 -13
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/MarkdownCodeSpans.cs +72 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/MarkdownRenderer.cs +11 -6
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/MemberUseResolution.cs +91 -22
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/MemberUses.cs +85 -9
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/PlainText.cs +55 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ProjectFilters.cs +3 -2
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/PruneCommand.cs +36 -4
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/RenderCommand.cs +75 -14
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ResponsibilityValidation.cs +45 -26
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/SeamDispatch.cs +56 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/SourceCodeLines.cs +79 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/TypeMap.cs +31 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/WorkspaceLoader.cs +86 -16
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/BatchPlanningTests.cs +8 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/BatchesCommandTests.cs +37 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CandidatesCommandTests.cs +25 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ClassifyCommandsTests.cs +73 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ComponentDiscoveryTests.cs +63 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CouplingAppExtractionTests.cs +188 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CouplingAppWorkspace.cs +30 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CouplingExtractionTests.cs +1 -1
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/FixturePaths.cs +4 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/MarkdownRendererTests.cs +45 -2
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ProjectFiltersTests.cs +4 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/PruneCommandTests.cs +30 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/RenderCommandTests.cs +66 -3
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/RenderTestData.cs +19 -13
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ResponsibilityValidationTests.cs +89 -1
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/WorkspaceLoaderTests.cs +181 -2
- package/template/.claude/skills/logical-components/scripts/NuGet.config +17 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/CompositionRootApp.slnx +5 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/HostBuilderExtensions.cs +15 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/HostBuilderShims.cs +25 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/NativeInterop.cs +17 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/Root.Application.csproj +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/RootAutofacModule.cs +11 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/RootGreeter.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/logical-components.json +48 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/AbstractMemberCases.cs +27 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/AttributeCases.cs +23 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/CouplingApp.Application.csproj +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/GenericBaseCases.cs +14 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/GenericSeamBindingCases.cs +59 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/ImplicitCallCases.cs +230 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/PartialInheritCases.First.cs +11 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/PartialInheritCases.Second.cs +14 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/StackOverflowCases.cs +14 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/ViaDisambiguationCases.cs +36 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/MultiTargetApp.slnx +1 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Multi.Infrastructure/Multi.Infrastructure.csproj +12 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Multi.Infrastructure/SchedulingClient.cs +12 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/ObjOutputApp/ObjOutputApp.slnx +5 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/ObjOutputApp/src/Obj.Application/Greeting.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/ObjOutputApp/src/Obj.Application/Obj.Application.csproj +14 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/components.json +5 -1
|
@@ -6,9 +6,11 @@ namespace LogicalComponents;
|
|
|
6
6
|
/// <summary>
|
|
7
7
|
/// <c>classify-batches --candidates <candidates.json> --agents <n> --repo <repoPath> --out <dir></c>: splits
|
|
8
8
|
/// the candidates the config does not classify yet into at most n equal batches, in candidates.json order, one
|
|
9
|
-
/// <c>batch-NN.json</c> per classifier agent. Already classified candidates are never sent again
|
|
10
|
-
///
|
|
11
|
-
///
|
|
9
|
+
/// <c>batch-NN.json</c> per classifier agent. Already classified candidates are never sent again: the repo config is
|
|
10
|
+
/// read fresh here (not trusted from candidates.json's own status), so a stale candidates.json - written before an
|
|
11
|
+
/// earlier classify-merge - cannot resend a type that is already reviewed. It also records the repo config's hash, so
|
|
12
|
+
/// classify-merge can refuse to merge if anything edited the config in between.
|
|
13
|
+
/// Exit codes: 0 written, 1 usage, 2 unreadable candidates.json or repo config.
|
|
12
14
|
/// </summary>
|
|
13
15
|
public static class ClassifyBatchesCommand
|
|
14
16
|
{
|
|
@@ -42,8 +44,27 @@ public static class ClassifyBatchesCommand
|
|
|
42
44
|
return 2;
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
if (file.Candidates is null)
|
|
48
|
+
{
|
|
49
|
+
await output.WriteLineAsync($"{candidatesPath} is not a candidates.json written by the candidates command");
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
ExtractConfig config;
|
|
54
|
+
try
|
|
55
|
+
{
|
|
56
|
+
config = ExtractConfig.Load(repo);
|
|
57
|
+
}
|
|
58
|
+
catch (ExtractConfigException e)
|
|
59
|
+
{
|
|
60
|
+
await output.WriteLineAsync(e.Message);
|
|
61
|
+
return 2;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// c.Status reflects the config at candidates time; re-check against the config now in case it changed since
|
|
65
|
+
// (classify-merge could have classified some of these candidates in an earlier round).
|
|
45
66
|
var unclassified = file.Candidates
|
|
46
|
-
.Where(c => c.Status == CandidatesCommand.Unclassified)
|
|
67
|
+
.Where(c => c.Status == CandidatesCommand.Unclassified && !config.IsClassified(c.Type))
|
|
47
68
|
.Select(c => new ClassifyCandidate(c.Type, c.Layer, c.Module, c.Spans))
|
|
48
69
|
.ToList();
|
|
49
70
|
var size = Math.Max(1, (unclassified.Count + agents - 1) / agents);
|
package/template/.claude/skills/logical-components/scripts/LogicalComponents/ClassifyMergeCommand.cs
CHANGED
|
@@ -7,11 +7,12 @@ namespace LogicalComponents;
|
|
|
7
7
|
/// (<c>verdicts/batch-NN.json</c>: <c>[{ type, verdict: component|helper, reason, cite }]</c>) to the repo's
|
|
8
8
|
/// <c>logical-components.json</c>. The agents' output is checked against the batches they were given before anything is
|
|
9
9
|
/// written: one verdict file per batch and none left over, exactly one verdict per candidate of the batch and no other
|
|
10
|
-
/// type, a known verdict, a one-line reason under 200 characters,
|
|
10
|
+
/// type, a known verdict, a one-line reason under 200 characters, a citation inside the candidate's declaration, and a
|
|
11
|
+
/// type the config does not already classify (a stale candidates.json could otherwise resend an already-reviewed type).
|
|
11
12
|
/// The config must be unchanged since classify-batches, so nothing can slip an entry past the review.
|
|
12
13
|
/// Prints each added entry (helpers first, for review) and a summary on <c>output</c>.
|
|
13
|
-
/// Exit codes: 0 written, 1 usage, 2 the existing config is unreadable, 3 the verdicts break the
|
|
14
|
-
/// changed (every problem listed, nothing written).
|
|
14
|
+
/// Exit codes: 0 written, 1 usage, 2 the existing config or a batch file is unreadable, 3 the verdicts break the
|
|
15
|
+
/// contract or the config changed (every problem listed, nothing written).
|
|
15
16
|
/// </summary>
|
|
16
17
|
public static class ClassifyMergeCommand
|
|
17
18
|
{
|
|
@@ -22,6 +23,9 @@ public static class ClassifyMergeCommand
|
|
|
22
23
|
|
|
23
24
|
private sealed record VerdictJson(string? Type, string? Verdict, string? Reason, string? Cite);
|
|
24
25
|
|
|
26
|
+
/// <summary>A batch file from classify-batches could not be read: an environment/tooling problem, not the agents' fault.</summary>
|
|
27
|
+
private sealed class MalformedBatchException(string message) : Exception(message);
|
|
28
|
+
|
|
25
29
|
public static async Task<int> RunAsync(string[] args, TextWriter output)
|
|
26
30
|
{
|
|
27
31
|
if (args is not ["--batches", var batchDir, "--verdicts", var verdictDir, "--repo", var repo]
|
|
@@ -49,7 +53,17 @@ public static class ClassifyMergeCommand
|
|
|
49
53
|
problems.Add($"{ExtractConfig.FileName} changed since classify-batches; review that change, then batch again");
|
|
50
54
|
}
|
|
51
55
|
|
|
52
|
-
|
|
56
|
+
List<VerdictJson> verdicts;
|
|
57
|
+
try
|
|
58
|
+
{
|
|
59
|
+
verdicts = Check(batchDir, verdictDir, existing, problems);
|
|
60
|
+
}
|
|
61
|
+
catch (MalformedBatchException e)
|
|
62
|
+
{
|
|
63
|
+
await output.WriteLineAsync(e.Message);
|
|
64
|
+
return 2;
|
|
65
|
+
}
|
|
66
|
+
|
|
53
67
|
if (problems.Count > 0)
|
|
54
68
|
{
|
|
55
69
|
foreach (var problem in problems)
|
|
@@ -94,7 +108,8 @@ public static class ClassifyMergeCommand
|
|
|
94
108
|
}
|
|
95
109
|
|
|
96
110
|
/// <summary>Every verdict of every batch, or problems (in batch file order) when the agents' output breaks the contract.</summary>
|
|
97
|
-
|
|
111
|
+
/// <exception cref="MalformedBatchException">A batch file itself (written by classify-batches, not an agent) is not valid JSON.</exception>
|
|
112
|
+
private static List<VerdictJson> Check(string batchDir, string verdictDir, ExtractConfig existing, List<string> problems)
|
|
98
113
|
{
|
|
99
114
|
var batchFiles = Directory.EnumerateFiles(batchDir, "batch-*.json").Select(Path.GetFileName).OfType<string>()
|
|
100
115
|
.Order(StringComparer.Ordinal).ToList();
|
|
@@ -106,7 +121,16 @@ public static class ClassifyMergeCommand
|
|
|
106
121
|
var all = new List<VerdictJson>();
|
|
107
122
|
foreach (var name in batchFiles)
|
|
108
123
|
{
|
|
109
|
-
|
|
124
|
+
ClassifyBatchesCommand.ClassifyBatch batch;
|
|
125
|
+
try
|
|
126
|
+
{
|
|
127
|
+
batch = ComponentsFile.FromJson<ClassifyBatchesCommand.ClassifyBatch>(File.ReadAllBytes(Path.Combine(batchDir, name)));
|
|
128
|
+
}
|
|
129
|
+
catch (Exception e) when (e is IOException or UnauthorizedAccessException or JsonException)
|
|
130
|
+
{
|
|
131
|
+
throw new MalformedBatchException($"{name}: not a valid batch file ({e.Message})");
|
|
132
|
+
}
|
|
133
|
+
|
|
110
134
|
var verdictPath = Path.Combine(verdictDir, name);
|
|
111
135
|
if (!File.Exists(verdictPath))
|
|
112
136
|
{
|
|
@@ -137,7 +161,7 @@ public static class ClassifyMergeCommand
|
|
|
137
161
|
{
|
|
138
162
|
problems.Add($"{name}: {group.Key}: more than one verdict");
|
|
139
163
|
}
|
|
140
|
-
else if (Problem(group.Single(), candidate) is { } problem)
|
|
164
|
+
else if (Problem(group.Single(), candidate, existing) is { } problem)
|
|
141
165
|
{
|
|
142
166
|
problems.Add($"{name}: {group.Key}: {problem}");
|
|
143
167
|
}
|
|
@@ -151,8 +175,15 @@ public static class ClassifyMergeCommand
|
|
|
151
175
|
return all;
|
|
152
176
|
}
|
|
153
177
|
|
|
154
|
-
private static string? Problem(VerdictJson verdict, ClassifyBatchesCommand.ClassifyCandidate candidate)
|
|
178
|
+
private static string? Problem(VerdictJson verdict, ClassifyBatchesCommand.ClassifyCandidate candidate, ExtractConfig existing)
|
|
155
179
|
{
|
|
180
|
+
// Never overwrite a reviewed entry: a stale candidates.json could otherwise resend an already-classified type
|
|
181
|
+
// and have its verdict silently replace the reviewed reason, or put the type in both lists.
|
|
182
|
+
if (existing.IsClassified(candidate.Type))
|
|
183
|
+
{
|
|
184
|
+
return $"already classified in {ExtractConfig.FileName}; re-run candidates";
|
|
185
|
+
}
|
|
186
|
+
|
|
156
187
|
if (verdict.Verdict is not (CandidatesCommand.Component or CandidatesCommand.Helper))
|
|
157
188
|
{
|
|
158
189
|
return "verdict must be component or helper";
|
|
@@ -163,8 +194,9 @@ public static class ClassifyMergeCommand
|
|
|
163
194
|
return "reason is empty";
|
|
164
195
|
}
|
|
165
196
|
|
|
166
|
-
// The reason is printed at the review gate and stored in the config: one plain line, so it cannot fake other
|
|
167
|
-
|
|
197
|
+
// The reason is printed at the review gate and stored in the config: one plain line, so it cannot fake other
|
|
198
|
+
// lines or hide a bidi/zero-width character in what looks like a normal sentence.
|
|
199
|
+
if (PlainText.HasDisallowedUnicode(verdict.Reason))
|
|
168
200
|
{
|
|
169
201
|
return "reason has control characters";
|
|
170
202
|
}
|
|
@@ -183,5 +215,5 @@ public static class ClassifyMergeCommand
|
|
|
183
215
|
}
|
|
184
216
|
|
|
185
217
|
// An unknown type is agent text: print it without anything that could start a new line at the review gate.
|
|
186
|
-
private static string Printable(string text) => new(text.Select(c =>
|
|
218
|
+
private static string Printable(string text) => new(text.Select(c => PlainText.IsDisallowed(c) ? '?' : c).ToArray());
|
|
187
219
|
}
|
package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentDiscovery.cs
CHANGED
|
@@ -18,7 +18,15 @@ public static class ComponentDiscovery
|
|
|
18
18
|
public static async Task<CandidateList> CandidatesAsync(LoadedWorkspace loaded, CancellationToken cancellationToken = default)
|
|
19
19
|
{
|
|
20
20
|
var warnings = new List<string>();
|
|
21
|
-
var
|
|
21
|
+
var types = await SourceTypesAsync(loaded.Solution, cancellationToken);
|
|
22
|
+
if (types.All(t => t.Layer is not (ApplicationLayer or InfrastructureLayer)))
|
|
23
|
+
{
|
|
24
|
+
// Nothing to classify is easy to miss: an empty document with no explanation looks like success.
|
|
25
|
+
warnings.Add(
|
|
26
|
+
$"no {ApplicationLayer} or {InfrastructureLayer} project found among: {string.Join(", ", loaded.ProjectNames)}");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
var candidates = types
|
|
22
30
|
.Where(t => t.Layer is (ApplicationLayer or InfrastructureLayer) && ComponentRule.IsCandidate(t.Symbol))
|
|
23
31
|
.Select(t => ToComponent(t, loaded, warnings))
|
|
24
32
|
.OfType<Component>()
|
|
@@ -64,13 +72,19 @@ public static class ComponentDiscovery
|
|
|
64
72
|
|
|
65
73
|
/// <summary>
|
|
66
74
|
/// <c>WorkerDwg.Export.Application</c> → (Application, Export); <c>Sample.Infrastructure</c> → (Infrastructure, Sample);
|
|
67
|
-
///
|
|
75
|
+
/// a bare <c>Application</c>/<c>Infrastructure</c> project → that layer, itself as its module; matched
|
|
76
|
+
/// case-insensitively, so <c>Foo.application</c> counts too. Any other project → (Other, its last name segment).
|
|
68
77
|
/// </summary>
|
|
69
78
|
public static (string Layer, string Module) LayerAndModule(string projectName)
|
|
70
79
|
{
|
|
71
80
|
foreach (var layer in new[] { ApplicationLayer, InfrastructureLayer })
|
|
72
81
|
{
|
|
73
|
-
if (
|
|
82
|
+
if (string.Equals(projectName, layer, StringComparison.OrdinalIgnoreCase))
|
|
83
|
+
{
|
|
84
|
+
return (layer, projectName);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (projectName.EndsWith("." + layer, StringComparison.OrdinalIgnoreCase))
|
|
74
88
|
{
|
|
75
89
|
var rest = projectName[..^(layer.Length + 1)];
|
|
76
90
|
return (layer, rest[(rest.LastIndexOf('.') + 1)..]);
|
|
@@ -119,9 +133,9 @@ public static class ComponentDiscovery
|
|
|
119
133
|
}
|
|
120
134
|
|
|
121
135
|
/// <summary>
|
|
122
|
-
/// The component for a type, with spans from real source files inside the repo only. Generator output
|
|
123
|
-
///
|
|
124
|
-
/// left means no component.
|
|
136
|
+
/// The component for a type, with spans from real source files inside the repo only. Generator output — a source
|
|
137
|
+
/// generator's virtual document, or a real file under bin/ or obj/ — is skipped silently; a declaration outside the
|
|
138
|
+
/// repo cannot be cited, so it is skipped with a warning. No span left means no component.
|
|
125
139
|
/// </summary>
|
|
126
140
|
public static Component? ToComponent(SourceType type, LoadedWorkspace loaded, List<string> warnings)
|
|
127
141
|
{
|
package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentRule.cs
CHANGED
|
@@ -9,7 +9,18 @@ namespace LogicalComponents;
|
|
|
9
9
|
/// </summary>
|
|
10
10
|
public static class ComponentRule
|
|
11
11
|
{
|
|
12
|
-
|
|
12
|
+
// Host/DI builder types a composition root wires up. Matched by qualified name, not by referencing the host
|
|
13
|
+
// packages, since a target repo may use only some of them (a library-only Infrastructure project has no
|
|
14
|
+
// WebApplicationBuilder, for instance).
|
|
15
|
+
private static readonly string[] HostBuilderTypes =
|
|
16
|
+
[
|
|
17
|
+
"Microsoft.Extensions.DependencyInjection.IServiceCollection",
|
|
18
|
+
"Microsoft.Extensions.Hosting.IHostApplicationBuilder",
|
|
19
|
+
"Microsoft.AspNetCore.Builder.WebApplicationBuilder",
|
|
20
|
+
"Microsoft.Extensions.Hosting.IHostBuilder",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
private const string AutofacModule = "Autofac.Module";
|
|
13
24
|
|
|
14
25
|
/// <summary>
|
|
15
26
|
/// A candidate is a top-level, source-declared class (static classes included) or record (abstract records and
|
|
@@ -30,16 +41,17 @@ public static class ComponentRule
|
|
|
30
41
|
&& DeclaresBehaviour(type);
|
|
31
42
|
|
|
32
43
|
/// <summary>
|
|
33
|
-
/// A static class that extends <c>IServiceCollection</c> (classic <c>this</c> parameter or a
|
|
34
|
-
/// <c>extension(
|
|
35
|
-
/// would make it look coupled to everything.
|
|
44
|
+
/// A static class that extends <c>IServiceCollection</c> or a host builder (classic <c>this</c> parameter or a
|
|
45
|
+
/// C# 14 <c>extension(...)</c> block), or an Autofac <c>Module</c> overriding <c>Load</c>, wires the app together
|
|
46
|
+
/// and <c>new</c>s or reaches nearly every class; counting it would make it look coupled to everything.
|
|
36
47
|
/// </summary>
|
|
37
48
|
public static bool IsCompositionRoot(INamedTypeSymbol type) =>
|
|
38
|
-
type.IsStatic
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
49
|
+
(type.IsStatic
|
|
50
|
+
&& (type.GetMembers().OfType<IMethodSymbol>().Any(m =>
|
|
51
|
+
m.IsExtensionMethod && m.Parameters.Length > 0 && IsHostBuilderType(m.Parameters[0].Type))
|
|
52
|
+
|| ExtensionBlocks(type).Any(block =>
|
|
53
|
+
block.ExtensionParameter is { } parameter && IsHostBuilderType(parameter.Type))))
|
|
54
|
+
|| IsAutofacModule(type);
|
|
43
55
|
|
|
44
56
|
// An exception carries a failure; even with factory methods of its own it is data, not a unit of behaviour.
|
|
45
57
|
private static bool IsException(INamedTypeSymbol type)
|
|
@@ -56,9 +68,11 @@ public static class ComponentRule
|
|
|
56
68
|
}
|
|
57
69
|
|
|
58
70
|
/// <summary>
|
|
59
|
-
/// An ordinary method, explicit interface implementation, or C# 14 extension member written in the type, with a body
|
|
60
|
-
/// Not behaviour: constructors, accessors, operators, abstract methods, compiler-synthesized record
|
|
61
|
-
/// overrides of <c>object.ToString/Equals/GetHashCode</c> (formatting or comparing what the type holds)
|
|
71
|
+
/// An ordinary method, explicit interface implementation, or C# 14 extension member written in the type, with a body
|
|
72
|
+
/// of its own. Not behaviour: constructors, accessors, operators, abstract methods, compiler-synthesized record
|
|
73
|
+
/// members, overrides of <c>object.ToString/Equals/GetHashCode</c> (formatting or comparing what the type holds),
|
|
74
|
+
/// and <c>extern</c>/<c>[DllImport]</c>/<c>[LibraryImport]</c> methods (no body of their own; the behaviour, if any,
|
|
75
|
+
/// is native code).
|
|
62
76
|
/// </summary>
|
|
63
77
|
private static bool DeclaresBehaviour(INamedTypeSymbol type) =>
|
|
64
78
|
type.GetMembers().Concat(ExtensionBlocks(type).SelectMany(block => block.GetMembers()))
|
|
@@ -67,7 +81,8 @@ public static class ComponentRule
|
|
|
67
81
|
&& !m.IsAbstract
|
|
68
82
|
&& !(m.IsPartialDefinition && m.PartialImplementationPart is null)
|
|
69
83
|
&& m.MethodKind is MethodKind.Ordinary or MethodKind.ExplicitInterfaceImplementation
|
|
70
|
-
&& !OverridesObjectMember(m)
|
|
84
|
+
&& !OverridesObjectMember(m)
|
|
85
|
+
&& !IsNativeInterop(m));
|
|
71
86
|
|
|
72
87
|
private static bool OverridesObjectMember(IMethodSymbol method)
|
|
73
88
|
{
|
|
@@ -80,8 +95,36 @@ public static class ComponentRule
|
|
|
80
95
|
return overridden?.ContainingType.SpecialType == SpecialType.System_Object;
|
|
81
96
|
}
|
|
82
97
|
|
|
98
|
+
/// <summary>
|
|
99
|
+
/// A classic <c>extern</c> P/Invoke method, or a <c>[LibraryImport]</c> partial method: the attribute lives on the
|
|
100
|
+
/// partial definition part, not the source-generator-written implementation part, so both parts are checked.
|
|
101
|
+
/// </summary>
|
|
102
|
+
private static bool IsNativeInterop(IMethodSymbol method)
|
|
103
|
+
{
|
|
104
|
+
var definition = method.PartialDefinitionPart ?? method;
|
|
105
|
+
return definition.IsExtern
|
|
106
|
+
|| definition.GetAttributes().Any(a => a.AttributeClass is { } attribute
|
|
107
|
+
&& SymbolNames.QualifiedName(attribute) is
|
|
108
|
+
"System.Runtime.InteropServices.DllImportAttribute" or "System.Runtime.InteropServices.LibraryImportAttribute");
|
|
109
|
+
}
|
|
110
|
+
|
|
83
111
|
private static IEnumerable<INamedTypeSymbol> ExtensionBlocks(INamedTypeSymbol type) =>
|
|
84
112
|
type.GetTypeMembers().Where(t => t.IsExtension);
|
|
85
113
|
|
|
86
|
-
private static bool
|
|
114
|
+
private static bool IsHostBuilderType(ITypeSymbol type) => HostBuilderTypes.Contains(SymbolNames.QualifiedName(type));
|
|
115
|
+
|
|
116
|
+
// Autofac.Module declares Load as virtual (empty by default); matching by name keeps this working without
|
|
117
|
+
// referencing Autofac.
|
|
118
|
+
private static bool IsAutofacModule(INamedTypeSymbol type)
|
|
119
|
+
{
|
|
120
|
+
for (var current = type.BaseType; current is not null; current = current.BaseType)
|
|
121
|
+
{
|
|
122
|
+
if (SymbolNames.QualifiedName(current) == AutofacModule)
|
|
123
|
+
{
|
|
124
|
+
return type.GetMembers("Load").OfType<IMethodSymbol>().Any(m => m.IsOverride);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
87
130
|
}
|
package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentsFile.cs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
using System.Security.Cryptography;
|
|
1
2
|
using System.Text;
|
|
2
3
|
using System.Text.Encodings.Web;
|
|
3
4
|
using System.Text.Json;
|
|
@@ -38,12 +39,18 @@ public sealed record ExternalNode(string Id, string Type, string Name, string La
|
|
|
38
39
|
/// </summary>
|
|
39
40
|
public sealed record Edge(string From, string To, IReadOnlyList<string> Kinds, IReadOnlyList<string> Via, string At);
|
|
40
41
|
|
|
41
|
-
/// <summary>
|
|
42
|
+
/// <summary>
|
|
43
|
+
/// Everything <c>extract</c> writes: the components, the ports they reach, the edges, and the warnings.
|
|
44
|
+
/// <see cref="FileHashes"/> is a repo-relative-file → content-hash map covering every file any component span, reach or
|
|
45
|
+
/// edge citation points into, so <c>render</c> can tell whether the repo changed since extraction. Null when read from
|
|
46
|
+
/// a components.json written before this map existed.
|
|
47
|
+
/// </summary>
|
|
42
48
|
public sealed record ExtractResult(
|
|
43
49
|
IReadOnlyList<Component> Components,
|
|
44
50
|
IReadOnlyList<ExternalNode> ExternalNodes,
|
|
45
51
|
IReadOnlyList<Edge> Edges,
|
|
46
|
-
IReadOnlyList<string> Warnings
|
|
52
|
+
IReadOnlyList<string> Warnings,
|
|
53
|
+
IReadOnlyDictionary<string, string>? FileHashes = null);
|
|
47
54
|
|
|
48
55
|
/// <summary>
|
|
49
56
|
/// Writes <c>components.json</c>. Byte-identical for identical input: fixed property order, LF line endings,
|
|
@@ -68,6 +75,7 @@ public static class ComponentsFile
|
|
|
68
75
|
externalNodes = result.ExternalNodes,
|
|
69
76
|
edges = result.Edges,
|
|
70
77
|
warnings = result.Warnings,
|
|
78
|
+
fileHashes = result.FileHashes,
|
|
71
79
|
});
|
|
72
80
|
|
|
73
81
|
/// <summary>Any file the tool writes: indented camelCase JSON, LF line endings, no BOM, trailing newline.</summary>
|
|
@@ -80,4 +88,14 @@ public static class ComponentsFile
|
|
|
80
88
|
/// <summary>Reads any file written by <see cref="ToJson{T}"/>.</summary>
|
|
81
89
|
public static T FromJson<T>(byte[] json) =>
|
|
82
90
|
JsonSerializer.Deserialize<T>(json, Options) ?? throw new JsonException("the file is empty");
|
|
91
|
+
|
|
92
|
+
/// <summary>
|
|
93
|
+
/// SHA-256 of a repo file's text, lowercase hex, with CRLF normalized to LF first so a checkout's line-ending
|
|
94
|
+
/// setting never changes the hash.
|
|
95
|
+
/// </summary>
|
|
96
|
+
public static string HashFile(string path) => HashText(File.ReadAllText(path));
|
|
97
|
+
|
|
98
|
+
/// <summary>SHA-256 of text with CRLF normalized to LF, as lowercase hex.</summary>
|
|
99
|
+
public static string HashText(string text) =>
|
|
100
|
+
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text.Replace("\r\n", "\n"))));
|
|
83
101
|
}
|
package/template/.claude/skills/logical-components/scripts/LogicalComponents/CouplingResolver.cs
CHANGED
|
@@ -37,22 +37,28 @@ public static class CouplingResolver
|
|
|
37
37
|
var self = new HashSet<string>(StringComparer.Ordinal) { component.Type };
|
|
38
38
|
var declarations = symbol.DeclaringSyntaxReferences.Select(r => r.GetSyntax()).ToList();
|
|
39
39
|
var roots = new List<SyntaxNode>(declarations);
|
|
40
|
+
var baseBindings = new List<(ITypeParameterSymbol Parameter, ITypeSymbol Argument)>();
|
|
40
41
|
|
|
41
42
|
// 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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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)
|
|
46
49
|
{
|
|
50
|
+
var baseType = constructedBase.OriginalDefinition;
|
|
47
51
|
var baseName = SymbolNames.FullName(baseType);
|
|
48
52
|
if (components.Contains(baseName))
|
|
49
53
|
{
|
|
50
|
-
var (file, line) =
|
|
54
|
+
var (file, line) = await BaseListLocationAsync(declarations, baseType, loaded, cancellationToken)
|
|
55
|
+
?? (component.Spans[0].File, component.Spans[0].Start);
|
|
51
56
|
raw.Add(new(component.Type, baseName, EdgeExtraction.Inherits, [], file, line));
|
|
52
57
|
break;
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
self.Add(baseName);
|
|
61
|
+
baseBindings.AddRange(TypeMap.Zip(baseType.TypeParameters, constructedBase.TypeArguments));
|
|
56
62
|
roots.AddRange(baseType.DeclaringSyntaxReferences.Select(r => r.GetSyntax()));
|
|
57
63
|
}
|
|
58
64
|
|
|
@@ -64,9 +70,10 @@ public static class CouplingResolver
|
|
|
64
70
|
// In (file, position) order, so the first path the walk records is the same on every checkout.
|
|
65
71
|
var ordered = roots.OrderBy(r => r.SyntaxTree.FilePath, StringComparer.Ordinal).ThenBy(r => r.SpanStart);
|
|
66
72
|
var uses = await MemberUses.ReadAsync(ordered, loaded, cancellationToken);
|
|
67
|
-
var resolved = await resolution.ResolveAsync(component.Type, self, uses, cancellationToken);
|
|
73
|
+
var resolved = await resolution.ResolveAsync(component.Type, self, uses, TypeMap.FromBindings(baseBindings), cancellationToken);
|
|
68
74
|
raw.AddRange(resolved.Edges);
|
|
69
75
|
reaches[component.Type] = resolved.Reaches;
|
|
76
|
+
warnings.AddRange(resolved.Warnings);
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
var ids = discovery.Components.ToDictionary(c => c.Type, c => c.Id, StringComparer.Ordinal);
|
|
@@ -119,29 +126,54 @@ public static class CouplingResolver
|
|
|
119
126
|
return [new(from, portName, EdgeExtraction.Di, [], reference.File, reference.Line)];
|
|
120
127
|
}
|
|
121
128
|
|
|
129
|
+
var via = SymbolNames.FullName(target.OriginalDefinition);
|
|
122
130
|
return implementations
|
|
123
131
|
.Select(SymbolNames.FullName)
|
|
124
132
|
.Where(components.Contains)
|
|
125
|
-
.Select(to => new MemberUseResolution.RawEdge(from, to, EdgeExtraction.Di, [
|
|
133
|
+
.Select(to => new MemberUseResolution.RawEdge(from, to, EdgeExtraction.Di, [via], reference.File, reference.Line))
|
|
126
134
|
.ToList();
|
|
127
135
|
}
|
|
128
136
|
|
|
129
|
-
/// <summary>
|
|
130
|
-
|
|
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)
|
|
131
145
|
{
|
|
146
|
+
var matches = new List<(string File, int Line)>();
|
|
132
147
|
foreach (var baseList in declarations.OfType<Microsoft.CodeAnalysis.CSharp.Syntax.TypeDeclarationSyntax>()
|
|
133
148
|
.Select(d => d.BaseList).OfType<Microsoft.CodeAnalysis.CSharp.Syntax.BaseListSyntax>())
|
|
134
149
|
{
|
|
135
150
|
var (kind, file) = CitableSource.Classify(loaded, baseList.SyntaxTree);
|
|
136
|
-
if (kind
|
|
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)
|
|
137
162
|
{
|
|
138
|
-
|
|
163
|
+
matches.Add((file, baseList.GetLocation().GetLineSpan().StartLinePosition.Line + 1));
|
|
139
164
|
}
|
|
140
165
|
}
|
|
141
166
|
|
|
142
|
-
return null;
|
|
167
|
+
return matches.Count == 0 ? null : matches.OrderBy(m => m.File, StringComparer.Ordinal).ThenBy(m => m.Line).First();
|
|
143
168
|
}
|
|
144
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
|
+
|
|
145
177
|
/// <summary>Code that does not compile binds partially, so edges can be missing: say so instead of staying silent.</summary>
|
|
146
178
|
private static async Task<List<string>> CompileErrorWarningsAsync(Solution solution, CancellationToken cancellationToken)
|
|
147
179
|
{
|
|
@@ -149,7 +181,8 @@ public static class CouplingResolver
|
|
|
149
181
|
foreach (var project in solution.Projects.OrderBy(p => p.Name, StringComparer.Ordinal))
|
|
150
182
|
{
|
|
151
183
|
var compilation = await project.GetCompilationAsync(cancellationToken);
|
|
152
|
-
var errors = compilation?.GetDiagnostics(cancellationToken)
|
|
184
|
+
var errors = compilation?.GetDiagnostics(cancellationToken)
|
|
185
|
+
.Count(d => d.Severity == DiagnosticSeverity.Error && d.Id != NewerFrameworkReference) ?? 0;
|
|
153
186
|
if (errors > 0)
|
|
154
187
|
{
|
|
155
188
|
warnings.Add($"project {project.Name} has {errors} compile error(s); coupling may be incomplete");
|
|
@@ -176,7 +209,11 @@ public static class CouplingResolver
|
|
|
176
209
|
.ToList();
|
|
177
210
|
}
|
|
178
211
|
|
|
179
|
-
/// <summary>
|
|
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>
|
|
180
217
|
private static List<Edge> MergeEdges(List<MemberUseResolution.RawEdge> raw, IReadOnlyDictionary<string, string> ids) =>
|
|
181
218
|
raw.Where(e => e.From != e.To)
|
|
182
219
|
.GroupBy(e => (From: ids[e.From], To: ids[e.To]))
|
|
@@ -187,10 +224,28 @@ public static class CouplingResolver
|
|
|
187
224
|
g.Key.From,
|
|
188
225
|
g.Key.To,
|
|
189
226
|
g.Select(e => e.Kind).Distinct().Order(StringComparer.Ordinal).ToList(),
|
|
190
|
-
|
|
227
|
+
DisambiguatedVia(first.Via),
|
|
191
228
|
$"{first.File}:{first.Line}");
|
|
192
229
|
})
|
|
193
230
|
.OrderBy(e => e.From, StringComparer.Ordinal)
|
|
194
231
|
.ThenBy(e => e.To, StringComparer.Ordinal)
|
|
195
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
|
+
}
|
|
196
251
|
}
|
package/template/.claude/skills/logical-components/scripts/LogicalComponents/ExtractCommand.cs
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
namespace LogicalComponents;
|
|
2
2
|
|
|
3
3
|
/// <summary>
|
|
4
|
-
/// <c>extract <repoPath> [--out <components.json>]</c>: loads the repo and writes the
|
|
5
|
-
///
|
|
4
|
+
/// <c>extract <repoPath> [--out <components.json>] [--solution <file>]</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.
|
|
6
8
|
/// Exit codes: 0 written, 1 usage, 2 the target repo or its config could not be loaded, 4 candidates not classified
|
|
7
9
|
/// in the repo config yet (one <c>unclassified <type></c> line each; nothing is written).
|
|
8
10
|
/// </summary>
|
|
9
11
|
public static class ExtractCommand
|
|
10
12
|
{
|
|
11
|
-
public const string Usage =
|
|
13
|
+
public const string Usage =
|
|
14
|
+
"usage: LogicalComponents extract <repoPath> [--out <components.json>] [--solution <file>]";
|
|
12
15
|
|
|
13
16
|
public static async Task<int> RunAsync(string[] args, TextWriter errors)
|
|
14
17
|
{
|
|
15
|
-
if (args is not [var repoPath, ..]
|
|
18
|
+
if (args is not [var repoPath, ..]
|
|
19
|
+
|| !CandidatesCommand.TryParseOptions(args[1..], "components.json", out var outPath, out var solution))
|
|
16
20
|
{
|
|
17
21
|
await errors.WriteLineAsync(Usage);
|
|
18
22
|
return 1;
|
|
@@ -20,10 +24,11 @@ public static class ExtractCommand
|
|
|
20
24
|
|
|
21
25
|
try
|
|
22
26
|
{
|
|
23
|
-
using var loaded = await WorkspaceLoader.LoadAsync(repoPath, log: errors);
|
|
27
|
+
using var loaded = await WorkspaceLoader.LoadAsync(repoPath, solution: solution, log: errors);
|
|
24
28
|
await errors.WriteLineAsync($"Loaded {loaded.ProjectNames.Count} projects from {loaded.Source}.");
|
|
25
29
|
|
|
26
|
-
var
|
|
30
|
+
var extracted = await CouplingResolver.ExtractAsync(loaded, ExtractConfig.Load(loaded.RepoRoot));
|
|
31
|
+
var result = extracted with { FileHashes = FileHashes(loaded.RepoRoot, extracted) };
|
|
27
32
|
foreach (var warning in result.Warnings)
|
|
28
33
|
{
|
|
29
34
|
await errors.WriteLineAsync($"warning: {warning}");
|
|
@@ -59,14 +64,24 @@ public static class ExtractCommand
|
|
|
59
64
|
}
|
|
60
65
|
}
|
|
61
66
|
|
|
62
|
-
|
|
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)
|
|
63
72
|
{
|
|
64
|
-
|
|
65
|
-
|
|
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)
|
|
66
81
|
{
|
|
67
|
-
[]
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
82
|
+
hashes[file] = ComponentsFile.HashFile(Path.Combine(repoRoot, file));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return hashes;
|
|
71
86
|
}
|
|
72
87
|
}
|