@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.
Files changed (82) hide show
  1. package/README.md +2 -2
  2. package/manifest.json +12 -3
  3. package/package.json +1 -1
  4. package/src/provisioner/detect.js +17 -2
  5. package/src/resolver.js +8 -3
  6. package/src/schema.js +1 -0
  7. package/src/settings-merger.js +0 -0
  8. package/template/.claude/hooks/inject-naming-rule-into-subagents.cjs +44 -0
  9. package/template/.claude/rules/csharp-identifier-naming.md +77 -0
  10. package/template/.claude/skills/code-review/SKILL.md +4 -1
  11. package/template/.claude/skills/code-review/references/naming-rule-review.md +41 -0
  12. package/template/.claude/skills/logical-components/SKILL.md +23 -18
  13. package/template/.claude/skills/logical-components/references/component-classifier-prompt.md +1 -1
  14. package/template/.claude/skills/logical-components/references/responsibility-agent-prompt.md +1 -1
  15. package/template/.claude/skills/logical-components/scripts/Directory.Build.rsp +1 -0
  16. package/template/.claude/skills/logical-components/scripts/Directory.Build.targets +6 -0
  17. package/template/.claude/skills/logical-components/scripts/LogicalComponents/BatchPlanning.cs +2 -1
  18. package/template/.claude/skills/logical-components/scripts/LogicalComponents/BatchesCommand.cs +9 -1
  19. package/template/.claude/skills/logical-components/scripts/LogicalComponents/CandidatesCommand.cs +39 -11
  20. package/template/.claude/skills/logical-components/scripts/LogicalComponents/CitableSource.cs +19 -4
  21. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ClassifyBatchesCommand.cs +25 -4
  22. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ClassifyMergeCommand.cs +43 -11
  23. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentDiscovery.cs +20 -6
  24. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentRule.cs +57 -14
  25. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentsFile.cs +20 -2
  26. package/template/.claude/skills/logical-components/scripts/LogicalComponents/CouplingResolver.cs +70 -15
  27. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ExtractCommand.cs +28 -13
  28. package/template/.claude/skills/logical-components/scripts/LogicalComponents/MarkdownCodeSpans.cs +72 -0
  29. package/template/.claude/skills/logical-components/scripts/LogicalComponents/MarkdownRenderer.cs +11 -6
  30. package/template/.claude/skills/logical-components/scripts/LogicalComponents/MemberUseResolution.cs +91 -22
  31. package/template/.claude/skills/logical-components/scripts/LogicalComponents/MemberUses.cs +85 -9
  32. package/template/.claude/skills/logical-components/scripts/LogicalComponents/PlainText.cs +55 -0
  33. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ProjectFilters.cs +3 -2
  34. package/template/.claude/skills/logical-components/scripts/LogicalComponents/PruneCommand.cs +36 -4
  35. package/template/.claude/skills/logical-components/scripts/LogicalComponents/RenderCommand.cs +75 -14
  36. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ResponsibilityValidation.cs +45 -26
  37. package/template/.claude/skills/logical-components/scripts/LogicalComponents/SeamDispatch.cs +56 -0
  38. package/template/.claude/skills/logical-components/scripts/LogicalComponents/SourceCodeLines.cs +79 -0
  39. package/template/.claude/skills/logical-components/scripts/LogicalComponents/TypeMap.cs +31 -0
  40. package/template/.claude/skills/logical-components/scripts/LogicalComponents/WorkspaceLoader.cs +86 -16
  41. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/BatchPlanningTests.cs +8 -0
  42. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/BatchesCommandTests.cs +37 -0
  43. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CandidatesCommandTests.cs +25 -0
  44. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ClassifyCommandsTests.cs +73 -0
  45. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ComponentDiscoveryTests.cs +63 -0
  46. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CouplingAppExtractionTests.cs +188 -0
  47. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CouplingAppWorkspace.cs +30 -0
  48. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CouplingExtractionTests.cs +1 -1
  49. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/FixturePaths.cs +4 -0
  50. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/MarkdownRendererTests.cs +45 -2
  51. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ProjectFiltersTests.cs +4 -0
  52. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/PruneCommandTests.cs +30 -0
  53. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/RenderCommandTests.cs +66 -3
  54. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/RenderTestData.cs +19 -13
  55. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ResponsibilityValidationTests.cs +89 -1
  56. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/WorkspaceLoaderTests.cs +181 -2
  57. package/template/.claude/skills/logical-components/scripts/NuGet.config +17 -0
  58. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/CompositionRootApp.slnx +5 -0
  59. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/HostBuilderExtensions.cs +15 -0
  60. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/HostBuilderShims.cs +25 -0
  61. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/NativeInterop.cs +17 -0
  62. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/Root.Application.csproj +7 -0
  63. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/RootAutofacModule.cs +11 -0
  64. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/RootGreeter.cs +7 -0
  65. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/logical-components.json +48 -0
  66. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/AbstractMemberCases.cs +27 -0
  67. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/AttributeCases.cs +23 -0
  68. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/CouplingApp.Application.csproj +7 -0
  69. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/GenericBaseCases.cs +14 -0
  70. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/GenericSeamBindingCases.cs +59 -0
  71. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/ImplicitCallCases.cs +230 -0
  72. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/PartialInheritCases.First.cs +11 -0
  73. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/PartialInheritCases.Second.cs +14 -0
  74. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/StackOverflowCases.cs +14 -0
  75. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/ViaDisambiguationCases.cs +36 -0
  76. package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/MultiTargetApp.slnx +1 -0
  77. package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Multi.Infrastructure/Multi.Infrastructure.csproj +12 -0
  78. package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Multi.Infrastructure/SchedulingClient.cs +12 -0
  79. package/template/.claude/skills/logical-components/scripts/fixtures/ObjOutputApp/ObjOutputApp.slnx +5 -0
  80. package/template/.claude/skills/logical-components/scripts/fixtures/ObjOutputApp/src/Obj.Application/Greeting.cs +7 -0
  81. package/template/.claude/skills/logical-components/scripts/fixtures/ObjOutputApp/src/Obj.Application/Obj.Application.csproj +14 -0
  82. 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 &lt;candidates.json&gt; --agents &lt;n&gt; --repo &lt;repoPath&gt; --out &lt;dir&gt;</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. It also records the
10
- /// repo config's hash, so classify-merge can refuse to merge if anything edited the config in between.
11
- /// Exit codes: 0 written, 1 usage, 2 unreadable candidates.json.
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);
@@ -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, and a citation inside the candidate's declaration.
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 contract or the config
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
- var verdicts = Check(batchDir, verdictDir, problems);
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
- private static List<VerdictJson> Check(string batchDir, string verdictDir, List<string> problems)
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
- var batch = ComponentsFile.FromJson<ClassifyBatchesCommand.ClassifyBatch>(File.ReadAllBytes(Path.Combine(batchDir, name)));
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 lines.
167
- if (verdict.Reason.Any(char.IsControl))
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 => char.IsControl(c) ? '?' : c).ToArray());
218
+ private static string Printable(string text) => new(text.Select(c => PlainText.IsDisallowed(c) ? '?' : c).ToArray());
187
219
  }
@@ -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 candidates = (await SourceTypesAsync(loaded.Solution, cancellationToken))
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
- /// any other project → (Other, its last name segment).
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 (projectName.EndsWith("." + layer, StringComparison.Ordinal))
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 (under obj/)
123
- /// is skipped silently; a declaration outside the repo cannot be cited, so it is skipped with a warning. No span
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
  {
@@ -9,7 +9,18 @@ namespace LogicalComponents;
9
9
  /// </summary>
10
10
  public static class ComponentRule
11
11
  {
12
- private const string ServiceCollection = "Microsoft.Extensions.DependencyInjection.IServiceCollection";
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 C# 14
34
- /// <c>extension(IServiceCollection)</c> block) wires the app together and <c>new</c>s nearly every class; counting it
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
- && (type.GetMembers().OfType<IMethodSymbol>().Any(m =>
40
- m.IsExtensionMethod && m.Parameters.Length > 0 && IsServiceCollection(m.Parameters[0].Type))
41
- || ExtensionBlocks(type).Any(block =>
42
- block.ExtensionParameter is { } parameter && IsServiceCollection(parameter.Type)));
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 members, and
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 IsServiceCollection(ITypeSymbol type) => SymbolNames.QualifiedName(type) == ServiceCollection;
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
  }
@@ -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>Everything <c>extract</c> writes: the components, the ports they reach, the edges, and the warnings.</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
  }
@@ -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
- for (var baseType = symbol.BaseType?.OriginalDefinition;
44
- baseType is not null && CitableSource.IsRepoSource(baseType, loaded);
45
- baseType = baseType.BaseType?.OriginalDefinition)
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) = BaseListLocation(declarations, loaded) ?? (component.Spans[0].File, component.Spans[0].Start);
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, [target.Name], reference.File, reference.Line))
133
+ .Select(to => new MemberUseResolution.RawEdge(from, to, EdgeExtraction.Di, [via], reference.File, reference.Line))
126
134
  .ToList();
127
135
  }
128
136
 
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)
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 == CitableSource.Kind.InRepo)
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
- return (file, baseList.GetLocation().GetLineSpan().StartLinePosition.Line + 1);
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).Count(d => d.Severity == DiagnosticSeverity.Error) ?? 0;
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>One edge per (from, to): kinds and via as sorted sets, cited at the earliest (file, line) recorded use.</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
- g.SelectMany(e => e.Via).Distinct().Order(StringComparer.Ordinal).ToList(),
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
  }
@@ -1,18 +1,22 @@
1
1
  namespace LogicalComponents;
2
2
 
3
3
  /// <summary>
4
- /// <c>extract &lt;repoPath&gt; [--out &lt;components.json&gt;]</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.
4
+ /// <c>extract &lt;repoPath&gt; [--out &lt;components.json&gt;] [--solution &lt;file&gt;]</c>: loads the repo and writes the
5
+ /// deterministic extraction, including a hash of every cited file's content (see <see cref="FileHashes"/>) so
6
+ /// <c>render</c> can tell whether the repo changed since. <c>--solution</c> picks the solution file as in
7
+ /// <c>candidates</c>. Progress and warnings go to <c>errors</c> (stderr); the artifact goes to the --out file.
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 &lt;type&gt;</c> line each; nothing is written).
8
10
  /// </summary>
9
11
  public static class ExtractCommand
10
12
  {
11
- public const string Usage = "usage: LogicalComponents extract <repoPath> [--out <components.json>]";
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, ..] || !TryParseOut(args[1..], out var outPath))
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 result = await CouplingResolver.ExtractAsync(loaded, ExtractConfig.Load(loaded.RepoRoot));
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
- private static bool TryParseOut(string[] options, out string outPath)
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
- outPath = "components.json";
65
- return options switch
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
- [] => true,
68
- ["--out", var path] => (outPath = path) is not null,
69
- _ => false,
70
- };
82
+ hashes[file] = ComponentsFile.HashFile(Path.Combine(repoRoot, file));
83
+ }
84
+
85
+ return hashes;
71
86
  }
72
87
  }