@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
@@ -0,0 +1,37 @@
1
+ namespace LogicalComponents.Tests;
2
+
3
+ /// <summary>The batches command end to end: malformed or extreme input fails cleanly instead of crashing.</summary>
4
+ public class BatchesCommandTests : IDisposable
5
+ {
6
+ private readonly DirectoryInfo _work = Directory.CreateTempSubdirectory("lc-batches-cmd-");
7
+
8
+ public void Dispose() => _work.Delete(recursive: true);
9
+
10
+ private string PathOf(string relative) => Path.Combine(_work.FullName, relative);
11
+
12
+ [Fact]
13
+ public async Task An_empty_components_json_is_an_error_not_a_crash()
14
+ {
15
+ File.WriteAllText(PathOf("components.json"), "{}");
16
+ var output = new StringWriter();
17
+
18
+ var exit = await BatchesCommand.RunAsync(
19
+ ["--components", PathOf("components.json"), "--agents", "9", "--out", PathOf("batches")], output);
20
+
21
+ Assert.Equal(2, exit);
22
+ Assert.Contains(PathOf("components.json"), output.ToString());
23
+ }
24
+
25
+ [Fact]
26
+ public async Task An_enormous_agent_count_does_not_crash()
27
+ {
28
+ File.WriteAllBytes(PathOf("components.json"), ComponentsFile.Serialize(RenderTestData.Extraction));
29
+ var output = new StringWriter();
30
+
31
+ var exit = await BatchesCommand.RunAsync(
32
+ ["--components", PathOf("components.json"), "--agents", int.MaxValue.ToString(), "--out", PathOf("batches")], output);
33
+
34
+ Assert.Equal(0, exit);
35
+ Assert.True(File.Exists(Path.Combine(PathOf("batches"), "batch-01.json")));
36
+ }
37
+ }
@@ -31,4 +31,29 @@ public class CandidatesCommandTests(SampleAppWorkspace sample)
31
31
 
32
32
  Assert.Equal(["Sample.Infrastructure.Mailer"], error.Types);
33
33
  }
34
+
35
+ [Fact]
36
+ public async Task Unknown_option_prints_usage_and_exits_1()
37
+ {
38
+ var errors = new StringWriter();
39
+
40
+ var exitCode = await CandidatesCommand.RunAsync(["repo", "--bogus"], errors);
41
+
42
+ Assert.Equal(1, exitCode);
43
+ Assert.Contains(CandidatesCommand.Usage, errors.ToString());
44
+ }
45
+
46
+ [Fact]
47
+ public async Task Solution_option_is_parsed_and_passed_through_to_the_loader()
48
+ {
49
+ var errors = new StringWriter();
50
+ var missing = Path.Combine(Path.GetTempPath(), "lc-does-not-exist-" + Guid.NewGuid());
51
+
52
+ var exitCode = await CandidatesCommand.RunAsync([missing, "--solution", "custom.slnx", "--out", "out.json"], errors);
53
+
54
+ // --solution and --out both parsed fine (no usage error); it fails later because repoPath itself is missing,
55
+ // proving the flag reached WorkspaceLoader.LoadAsync rather than being rejected as an unknown option.
56
+ Assert.Equal(2, exitCode);
57
+ Assert.Contains(missing, errors.ToString());
58
+ }
34
59
  }
@@ -110,6 +110,7 @@ public class ClassifyCommandsTests : IDisposable
110
110
  { Verdict("A.One", "maybe"), "batch-01.json: A.One: verdict must be component or helper" },
111
111
  { Verdict("A.One", "helper", ""), "batch-01.json: A.One: reason is empty" },
112
112
  { Verdict("A.One", "helper", "x\\nkept Evil: already classified"), "batch-01.json: A.One: reason has control characters" },
113
+ { Verdict("A.One", "helper", "Renamed ‮exe.txt to txt.exe"), "batch-01.json: A.One: reason has control characters" },
113
114
  { Verdict("A.One", "helper", new string('x', 201)), "batch-01.json: A.One: reason is 201 characters; keep it under 200" },
114
115
  { Verdict("A.One", "helper", cite: "src/A.One.cs:12"), "batch-01.json: A.One: cite 'src/A.One.cs:12' is not inside its declaration" },
115
116
  { Verdict("A.One", "helper", cite: "src/Other.cs:2"), "batch-01.json: A.One: cite 'src/Other.cs:2' is not inside its declaration" },
@@ -134,6 +135,78 @@ public class ClassifyCommandsTests : IDisposable
134
135
  Assert.False(File.Exists(PathOf("repo/logical-components.json")));
135
136
  }
136
137
 
138
+ [Fact]
139
+ public async Task A_verdict_for_a_type_the_config_already_classifies_fails_the_merge_without_touching_the_config()
140
+ {
141
+ // Simulates a stale candidates.json: classify-batches sent A.One as unclassified (the config had no answer
142
+ // for it yet), but by the time this round is merged the config already classifies it (an earlier round
143
+ // classified it in the meantime, so this verdict must not overwrite the reviewed entry).
144
+ await BatchAsync(1, Candidate("A.One", "unclassified"));
145
+ var updated = """{ "components": ["A.One"], "helpers": {} }""";
146
+ File.WriteAllText(PathOf("repo/logical-components.json"), updated);
147
+ WriteVerdicts("batch-01.json", Verdict("A.One", "helper", "Value object."));
148
+ var output = new StringWriter();
149
+
150
+ var exit = await MergeAsync(output);
151
+
152
+ Assert.Equal(3, exit);
153
+ Assert.Contains("batch-01.json: A.One: already classified in logical-components.json; re-run candidates", output.ToString());
154
+ Assert.Equal(updated, File.ReadAllText(PathOf("repo/logical-components.json")));
155
+ }
156
+
157
+ [Fact]
158
+ public async Task A_malformed_batch_file_fails_the_merge_cleanly_instead_of_crashing()
159
+ {
160
+ await BatchAsync(1, Candidate("A.One", "unclassified"));
161
+ File.WriteAllText(PathOf("batches/batch-01.json"), "{ not json");
162
+ WriteVerdicts("batch-01.json", Verdict("A.One", "helper"));
163
+ var output = new StringWriter();
164
+
165
+ var exit = await MergeAsync(output);
166
+
167
+ Assert.Equal(2, exit);
168
+ Assert.Contains("batch-01.json", output.ToString());
169
+ Assert.False(File.Exists(PathOf("repo/logical-components.json")));
170
+ }
171
+
172
+ [Fact]
173
+ public async Task Stale_candidates_already_classified_in_the_config_are_skipped_when_batching()
174
+ {
175
+ File.WriteAllText(PathOf("repo/logical-components.json"), """{ "components": ["A.One"], "helpers": {} }""");
176
+ File.WriteAllBytes(PathOf("candidates.json"), ComponentsFile.ToJson(new CandidatesCommand.CandidatesFile(
177
+ [Candidate("A.One", "unclassified"), Candidate("A.Two", "unclassified")], [])));
178
+ var output = new StringWriter();
179
+
180
+ var exit = await ClassifyBatchesCommand.RunAsync(
181
+ ["--candidates", PathOf("candidates.json"), "--agents", "9", "--repo", PathOf("repo"), "--out", PathOf("batches")], output);
182
+
183
+ Assert.Equal(0, exit);
184
+ var text = File.ReadAllText(PathOf("batches/batch-01.json"));
185
+ Assert.DoesNotContain("A.One", text);
186
+ Assert.Contains("A.Two", text);
187
+ Assert.StartsWith("1 unclassified candidates in 1 batches", output.ToString());
188
+ }
189
+
190
+ [Fact]
191
+ public async Task An_empty_candidates_json_fails_batching_instead_of_crashing()
192
+ {
193
+ File.WriteAllText(PathOf("candidates.json"), "{}");
194
+ var output = new StringWriter();
195
+
196
+ var exit = await ClassifyBatchesCommand.RunAsync(
197
+ ["--candidates", PathOf("candidates.json"), "--agents", "9", "--repo", PathOf("repo"), "--out", PathOf("batches")], output);
198
+
199
+ Assert.Equal(2, exit);
200
+ }
201
+
202
+ [Fact]
203
+ public async Task A_malformed_repo_config_fails_batching_cleanly()
204
+ {
205
+ File.WriteAllText(PathOf("repo/logical-components.json"), "{ not json");
206
+
207
+ Assert.Equal(2, await BatchAsync(9, Candidate("A.One", "unclassified")));
208
+ }
209
+
137
210
  [Fact]
138
211
  public async Task A_missing_verdict_file_and_a_stale_one_from_an_earlier_round_both_fail()
139
212
  {
@@ -61,6 +61,69 @@ public class ComponentDiscoveryTests(SampleAppWorkspace sample)
61
61
  Assert.DoesNotContain(result.Components, c => c.Type == "Sample.Infrastructure.Mailer");
62
62
  }
63
63
 
64
+ [Theory]
65
+ [InlineData("Foo.Application", "Application", "Foo")]
66
+ [InlineData("Foo.application", "Application", "Foo")]
67
+ [InlineData("Application", "Application", "Application")]
68
+ [InlineData("Infrastructure", "Infrastructure", "Infrastructure")]
69
+ [InlineData("infrastructure", "Infrastructure", "infrastructure")]
70
+ [InlineData("WorkerDwg.Export.Application", "Application", "Export")]
71
+ [InlineData("Sample.Infrastructure", "Infrastructure", "Sample")]
72
+ [InlineData("Foo.Domain", "Other", "Domain")]
73
+ public void LayerAndModule_accepts_bare_and_case_insensitive_layer_names(string projectName, string layer, string module)
74
+ {
75
+ Assert.Equal((layer, module), ComponentDiscovery.LayerAndModule(projectName));
76
+ }
77
+
78
+ [Fact]
79
+ public async Task Warns_naming_the_loaded_projects_when_none_matches_either_layer()
80
+ {
81
+ var repo = Directory.CreateTempSubdirectory("lc-no-layer-");
82
+ try
83
+ {
84
+ var project = Directory.CreateDirectory(Path.Combine(repo.FullName, "src", "Foo.Domain"));
85
+ File.WriteAllText(
86
+ Path.Combine(project.FullName, "Foo.Domain.csproj"),
87
+ """<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup></Project>""");
88
+ File.WriteAllText(
89
+ Path.Combine(project.FullName, "Widget.cs"),
90
+ "namespace Foo.Domain; public sealed class Widget { public string Name() => \"widget\"; }");
91
+
92
+ using var loaded = await WorkspaceLoader.LoadAsync(repo.FullName);
93
+ var result = await ComponentDiscovery.CandidatesAsync(loaded, TestContext.Current.CancellationToken);
94
+
95
+ Assert.Contains(result.Warnings, w => w.Contains("Foo.Domain") && w.Contains("Application") && w.Contains("Infrastructure"));
96
+ Assert.Empty(result.Candidates);
97
+ }
98
+ finally
99
+ {
100
+ repo.Delete(recursive: true);
101
+ }
102
+ }
103
+
104
+ [Fact]
105
+ public async Task Host_builder_setup_and_autofac_modules_are_composition_roots_not_candidates()
106
+ {
107
+ using var loaded = await WorkspaceLoader.LoadAsync(FixturePaths.CompositionRootApp);
108
+ var result = await ComponentDiscovery.CandidatesAsync(loaded, TestContext.Current.CancellationToken);
109
+ var types = result.Candidates.Select(c => c.Type).ToList();
110
+
111
+ Assert.Contains("Root.Application.RootGreeter", types);
112
+ Assert.DoesNotContain("Root.Application.HostBuilderExtensions", types);
113
+ Assert.DoesNotContain("Root.Application.RootAutofacModule", types);
114
+ }
115
+
116
+ [Fact]
117
+ public async Task Native_interop_methods_do_not_count_as_a_declared_body()
118
+ {
119
+ using var loaded = await WorkspaceLoader.LoadAsync(FixturePaths.CompositionRootApp);
120
+ var result = await ComponentDiscovery.CandidatesAsync(loaded, TestContext.Current.CancellationToken);
121
+ var types = result.Candidates.Select(c => c.Type).ToList();
122
+
123
+ Assert.DoesNotContain("Root.Application.ClassicPInvoke", types);
124
+ Assert.DoesNotContain("Root.Application.ModernPInvoke", types);
125
+ }
126
+
64
127
  [Fact]
65
128
  public async Task Config_entries_naming_no_candidate_are_warnings_not_failures()
66
129
  {
@@ -0,0 +1,188 @@
1
+ namespace LogicalComponents.Tests;
2
+
3
+ /// <summary>
4
+ /// Coupling rules checked against the CouplingApp fixture (see its <c>logical-components.json</c> and
5
+ /// <c>src/CouplingApp.Application/*.cs</c> for the classified types and the code each test exercises). SampleApp's own coupling
6
+ /// assertions live in <see cref="CouplingExtractionTests"/>; this fixture exists so those existing candidate/edge
7
+ /// counts never shift.
8
+ /// </summary>
9
+ [Collection(CouplingAppFixtureSolutions.Name)]
10
+ public class CouplingAppExtractionTests(CouplingAppWorkspace coupling)
11
+ {
12
+ private static readonly ExtractConfig FixtureConfig = ExtractConfig.Load(CouplingAppWorkspace.RepoRoot);
13
+
14
+ private Task<ExtractResult> ExtractAsync() =>
15
+ CouplingResolver.ExtractAsync(coupling.Loaded, FixtureConfig, TestContext.Current.CancellationToken);
16
+
17
+ [Fact]
18
+ public async Task A_generic_helper_that_instantiates_itself_deeper_at_every_call_does_not_overflow_the_stack()
19
+ {
20
+ // Nest<T>.Go(n) calls Nest<List<T>>.Go(n - 1): every recursive call binds T one level deeper, so the walk
21
+ // never revisits the same (member, bindings) pair and the cycle guard alone cannot stop it.
22
+ var result = await ExtractAsync();
23
+
24
+ Assert.Contains(
25
+ "CouplingApp.Application.NestRunning: coupling walk stopped at depth 200 following Go, "
26
+ + "possible unbounded generic recursion",
27
+ result.Warnings);
28
+ }
29
+
30
+ [Fact]
31
+ public async Task A_generic_method_declared_on_a_seam_binds_its_own_type_argument_behind_the_implementation()
32
+ {
33
+ // Dispatch.Send<T> (found through IDispatch) calls IHandler<T>.Handle: walking Send with T unbound would make
34
+ // IHandler<T> look like it matches every construction, reaching both Ping and Pong handlers.
35
+ var result = await ExtractAsync();
36
+
37
+ Assert.Contains(result.Edges, e => e.From == "GenericMethodDispatching" && e.To == "PingHandling");
38
+ Assert.DoesNotContain(result.Edges, e => e.From == "GenericMethodDispatching" && e.To == "PongHandling");
39
+ }
40
+
41
+ [Fact]
42
+ public async Task An_open_generic_decorator_wrapping_the_same_seam_keeps_its_type_argument_bound()
43
+ {
44
+ // Decorator<T> : IHandler<T> forwards to another IHandler<T>: walking it with T unbound would make the
45
+ // forwarded call look like it matches every construction too.
46
+ var result = await ExtractAsync();
47
+
48
+ Assert.Contains(result.Edges, e => e.From == "DecoratedDispatching" && e.To == "PingHandling");
49
+ Assert.DoesNotContain(result.Edges, e => e.From == "DecoratedDispatching" && e.To == "PongHandling");
50
+ }
51
+
52
+ [Fact]
53
+ public async Task A_generic_abstract_base_binds_its_type_parameter_to_what_the_subclass_supplies()
54
+ {
55
+ // HandlerBase<T>(IHandler<T> inner) is walked as PingRunning's own code (a non-component base); T must bind
56
+ // to Ping, the construction PingRunning : HandlerBase<Ping> actually supplies.
57
+ var result = await ExtractAsync();
58
+
59
+ Assert.Contains(result.Edges, e => e.From == "PingRunning" && e.To == "PingHandling");
60
+ Assert.DoesNotContain(result.Edges, e => e.From == "PingRunning" && e.To == "PongHandling");
61
+ }
62
+
63
+ [Fact]
64
+ public async Task An_attribute_on_a_component_is_not_coupling()
65
+ {
66
+ // AuditedAttribute's constructor calls AttributeTarget.Touch(); applying [Audited] must not create an edge or
67
+ // a reach into it.
68
+ var result = await ExtractAsync();
69
+ var reaches = result.Components.ToDictionary(c => c.Id, c => c.Reaches ?? []);
70
+
71
+ Assert.DoesNotContain(result.Edges, e => e.From == "AttributedRunning" && e.To == "AttributeTarget");
72
+ Assert.Empty(reaches["AttributedRunning"]);
73
+ }
74
+
75
+ [Fact]
76
+ public async Task A_collection_initializer_element_runs_the_targets_add_method()
77
+ {
78
+ var result = await ExtractAsync();
79
+
80
+ Assert.Contains(result.Edges, e => e.From == "CollectionInitializerRunning" && e.To == "AddTarget");
81
+ }
82
+
83
+ [Fact]
84
+ public async Task A_deconstruction_assignment_runs_deconstruct()
85
+ {
86
+ var result = await ExtractAsync();
87
+
88
+ Assert.Contains(result.Edges, e => e.From == "DeconstructAssigning" && e.To == "DeconstructTarget");
89
+ }
90
+
91
+ [Fact]
92
+ public async Task A_foreach_deconstruction_runs_deconstruct()
93
+ {
94
+ var result = await ExtractAsync();
95
+
96
+ Assert.Contains(result.Edges, e => e.From == "ForeachDeconstructing" && e.To == "DeconstructTarget");
97
+ }
98
+
99
+ [Fact]
100
+ public async Task A_static_constructor_runs_on_a_static_member_read()
101
+ {
102
+ // StaticSeed.Value has no initializer of its own; only the explicit static constructor runs StaticCtorTarget.
103
+ var result = await ExtractAsync();
104
+
105
+ Assert.Contains(result.Edges, e => e.From == "StaticSeedReading" && e.To == "StaticCtorTarget");
106
+ }
107
+
108
+ [Fact]
109
+ public async Task Await_runs_GetAwaiter_IsCompleted_and_GetResult_on_a_custom_awaitable()
110
+ {
111
+ var result = await ExtractAsync();
112
+
113
+ Assert.Contains(result.Edges, e => e.From == "Awaiting" && e.To == "GetAwaiterTarget");
114
+ Assert.Contains(result.Edges, e => e.From == "Awaiting" && e.To == "IsCompletedTarget");
115
+ Assert.Contains(result.Edges, e => e.From == "Awaiting" && e.To == "GetResultTarget");
116
+ }
117
+
118
+ [Fact]
119
+ public async Task A_foreach_over_a_hand_written_enumerator_runs_MoveNext_and_Current()
120
+ {
121
+ var result = await ExtractAsync();
122
+
123
+ Assert.Contains(result.Edges, e => e.From == "CustomForEaching" && e.To == "MoveNextTarget");
124
+ Assert.Contains(result.Edges, e => e.From == "CustomForEaching" && e.To == "CurrentTarget");
125
+ }
126
+
127
+ [Fact]
128
+ public async Task A_conditional_access_indexer_runs_the_indexers_getter()
129
+ {
130
+ var result = await ExtractAsync();
131
+
132
+ Assert.Contains(result.Edges, e => e.From == "ConditionalIndexing" && e.To == "IndexerTarget");
133
+ }
134
+
135
+ [Fact]
136
+ public async Task Via_lists_the_first_path_found_not_every_raw_edges_via_merged_and_alphabetised()
137
+ {
138
+ // GenericMethodDispatching -> PingHandling is reachable only through Dispatch.Send<T>: its via must be that
139
+ // one discovered path, in the order the walk actually followed it.
140
+ var result = await ExtractAsync();
141
+
142
+ var edge = Assert.Single(result.Edges, e => e.From == "GenericMethodDispatching" && e.To == "PingHandling");
143
+ Assert.Equal(["IDispatch", "Dispatch", "IHandler", "Decorator", "IHandler"], edge.Via);
144
+ }
145
+
146
+ [Fact]
147
+ public async Task Via_disambiguates_two_different_types_that_share_a_simple_name()
148
+ {
149
+ // CouplingApp.Application.Left.Builder and CouplingApp.Application.Right.Builder are unrelated types that
150
+ // both happen to be named Builder: via must not show two bare, indistinguishable "Builder" entries.
151
+ var result = await ExtractAsync();
152
+
153
+ var edge = Assert.Single(result.Edges, e => e.From == "ViaDisambiguating" && e.To == "ViaTarget");
154
+ Assert.Equal(
155
+ ["CouplingApp.Application.Left.Builder", "CouplingApp.Application.Right.Builder"],
156
+ edge.Via);
157
+ }
158
+
159
+ [Fact]
160
+ public async Task An_inherits_edge_for_a_partial_class_cites_the_declaration_that_actually_names_the_base()
161
+ {
162
+ // PartialInheriting's first-declared partial fragment lists only IMarker; the second names PartialBase. The
163
+ // citation must land on the second, not merely the first fragment that has any base list at all.
164
+ var result = await ExtractAsync();
165
+
166
+ var edge = Assert.Single(result.Edges, e => e.From == "PartialInheriting" && e.To == "PartialBase");
167
+ Assert.Contains("inherits", edge.Kinds);
168
+ Assert.Equal("src/CouplingApp.Application/PartialInheritCases.Second.cs:11", edge.At);
169
+ }
170
+
171
+ [Fact]
172
+ public async Task A_non_virtual_member_of_an_abstract_class_is_walked_even_with_no_concrete_subclass()
173
+ {
174
+ // PartlyImplemented.Ready has a body and cannot be overridden, so it runs for any (or no) subclass.
175
+ var result = await ExtractAsync();
176
+
177
+ Assert.Contains(result.Edges, e => e.From == "PartlyImplementedUsing" && e.To == "ReadyTarget");
178
+ }
179
+
180
+ [Fact]
181
+ public async Task An_abstract_member_of_an_abstract_class_with_no_implementation_warns_instead_of_staying_silent()
182
+ {
183
+ // PartlyImplemented.Missing is abstract and nothing in the repo derives from PartlyImplemented.
184
+ var result = await ExtractAsync();
185
+
186
+ Assert.Contains("CouplingApp.Application.PartlyImplemented.Missing has no concrete subclass in the repo", result.Warnings);
187
+ }
188
+ }
@@ -0,0 +1,30 @@
1
+ namespace LogicalComponents.Tests;
2
+
3
+ /// <summary>
4
+ /// Every test class that builds the CouplingApp fixture solution. Kept in its own collection (see
5
+ /// <see cref="FixtureSolutions"/>) so its restore/design-time-build never races the SampleApp fixture's.
6
+ /// </summary>
7
+ [CollectionDefinition(Name)]
8
+ public sealed class CouplingAppFixtureSolutions : ICollectionFixture<CouplingAppWorkspace>
9
+ {
10
+ public const string Name = "coupling app fixture solutions";
11
+ }
12
+
13
+ /// <summary>Loads the CouplingApp fixture once and shares it across the classes in its collection.</summary>
14
+ public sealed class CouplingAppWorkspace : IAsyncLifetime
15
+ {
16
+ // FixturePaths only exposes the fixtures it already knew about; CouplingApp is a sibling directory under the same
17
+ // fixtures root, owned by this bug-fix batch's own tests.
18
+ public static string RepoRoot { get; } = Path.Combine(FixturePaths.Root, "CouplingApp");
19
+
20
+ public LoadedWorkspace Loaded { get; private set; } = null!;
21
+
22
+ public async ValueTask InitializeAsync() => Loaded = await WorkspaceLoader.LoadAsync(RepoRoot);
23
+
24
+ public ValueTask DisposeAsync()
25
+ {
26
+ // Null when loading failed: disposing must not hide that failure behind a NullReferenceException.
27
+ Loaded?.Dispose();
28
+ return ValueTask.CompletedTask;
29
+ }
30
+ }
@@ -38,7 +38,7 @@ public class CouplingExtractionTests(SampleAppWorkspace sample)
38
38
  "Echoing -> PriceFormatting [static] via PingText",
39
39
  "ForwardedNotifier -> Mailer [call,di]",
40
40
  "JobReporting -> CallbackNormalizing [static] via Job",
41
- "LabelMaking -> RunClock [call,new] via LabelBase,ShortLabel",
41
+ "LabelMaking -> RunClock [call,new] via ShortLabel,LabelBase",
42
42
  "Labelling -> RunClock [call] via LabelFormat",
43
43
  "LazyWork -> ForwardedNotifier [di] via INotifier",
44
44
  "LazyWork -> OrderQueue [di] via IOrderQueue",
@@ -16,6 +16,10 @@ internal static class FixturePaths
16
16
 
17
17
  public static string BrokenApp => Path.Combine(Root, "BrokenApp");
18
18
 
19
+ public static string ObjOutputApp => Path.Combine(Root, "ObjOutputApp");
20
+
21
+ public static string CompositionRootApp => Path.Combine(Root, "CompositionRootApp");
22
+
19
23
  private static string FindFixturesRoot()
20
24
  {
21
25
  for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
@@ -33,8 +33,51 @@ public class MarkdownRendererTests
33
33
  var markdown = MarkdownRenderer.Render(extraction, responsibilities);
34
34
 
35
35
  Assert.Contains(" n_end[\"Say #quot;Hi#quot;\"]\n", markdown);
36
- Assert.Contains(" n_My_Module_Queue[\"Order Queue\"]\n", markdown);
37
- Assert.Contains(" n_end --> n_My_Module_Queue\n", markdown);
36
+ Assert.Contains(" n_My_x20_Module_x5f_Queue[\"Order Queue\"]\n", markdown);
37
+ Assert.Contains(" n_end --> n_My_x20_Module_x5f_Queue\n", markdown);
38
+ }
39
+
40
+ [Fact]
41
+ public void Ids_that_would_collapse_to_the_same_node_under_naive_sanitizing_get_distinct_node_ids()
42
+ {
43
+ var extraction = RenderTestData.Extraction with
44
+ {
45
+ Components =
46
+ [
47
+ RenderTestData.Extraction.Components[0] with { Id = "A B" },
48
+ RenderTestData.Extraction.Components[2] with { Id = "A-B" },
49
+ ],
50
+ ExternalNodes = [],
51
+ Edges = [new Edge("A B", "A-B", ["di"], [], "src/App/OrderAcceptance.cs:4")],
52
+ };
53
+ var responsibilities = RenderTestData.ValidResponsibilities();
54
+ responsibilities["A B"] = responsibilities["OrderAcceptance"];
55
+ responsibilities["A-B"] = responsibilities["OrderQueue"];
56
+
57
+ var markdown = MarkdownRenderer.Render(extraction, responsibilities);
58
+
59
+ Assert.Contains("n_A_x20_B[", markdown);
60
+ Assert.Contains("n_A_x2d_B[", markdown);
61
+ Assert.Contains("n_A_x20_B --> n_A_x2d_B", markdown);
62
+ }
63
+
64
+ [Fact]
65
+ public void Labels_escape_angle_brackets_as_well_as_quotes()
66
+ {
67
+ var extraction = RenderTestData.Extraction with
68
+ {
69
+ Components = [RenderTestData.Extraction.Components[0] with { Id = "X", Name = "<script>alert(1)</script>" }],
70
+ ExternalNodes = [],
71
+ Edges = [],
72
+ };
73
+ var responsibilities = RenderTestData.ValidResponsibilities();
74
+ responsibilities["X"] = responsibilities["OrderAcceptance"];
75
+
76
+ var markdown = MarkdownRenderer.Render(extraction, responsibilities);
77
+
78
+ // Only the Mermaid label needs escaping: a component's `## Name` heading, unlike a label, is never wrapped
79
+ // in a way a browser could reinterpret, and Name only ever comes from a C# identifier in practice.
80
+ Assert.Contains("n_X[\"#lt;script#gt;alert(1)#lt;/script#gt;\"]", markdown);
38
81
  }
39
82
 
40
83
  [Fact]
@@ -12,6 +12,8 @@ public class ProjectFiltersTests
12
12
  [InlineData("src/Foo.IntegrationTests/Foo.IntegrationTests.csproj")]
13
13
  [InlineData("src/Foo.Test/Foo.Test.csproj")]
14
14
  [InlineData("src/Foo.Specs/Foo.Specs.csproj")]
15
+ [InlineData("src/Foo.Spec/Foo.Spec.csproj")]
16
+ [InlineData("src/Foo.FunctionalTests/Foo.FunctionalTests.csproj")]
15
17
  public void Test_folders_and_test_project_names_are_test_projects(string relativePath)
16
18
  {
17
19
  Assert.True(ProjectFilters.IsTestProject(relativePath, PlainLibrary));
@@ -37,6 +39,8 @@ public class ProjectFiltersTests
37
39
  [InlineData("src/Foo.Contest/Foo.Contest.csproj")]
38
40
  [InlineData("src/Latest.Feeds/Latest.Feeds.csproj")]
39
41
  [InlineData("src/Foo.Testing/Foo.Testing.csproj")]
42
+ [InlineData("src/Net.SpeedTest/Net.SpeedTest.csproj")]
43
+ [InlineData("src/Experiments.ABTest/Experiments.ABTest.csproj")]
40
44
  public void Source_projects_are_not_test_projects(string relativePath)
41
45
  {
42
46
  Assert.False(ProjectFilters.IsTestProject(relativePath, PlainLibrary));
@@ -108,6 +108,36 @@ public class PruneCommandTests : IDisposable
108
108
  Assert.False(Directory.Exists(Pruned));
109
109
  }
110
110
 
111
+ [Fact]
112
+ public async Task A_null_verdict_entry_is_malformed_not_a_crash()
113
+ {
114
+ WriteVerdicts("""
115
+ { "OrderAcceptance": [ null, { "item": 2, "supported": true } ],
116
+ "OrderQueue": [ { "item": 1, "supported": true } ] }
117
+ """);
118
+
119
+ var (exit, output) = await PruneAsync();
120
+
121
+ Assert.Equal(3, exit);
122
+ Assert.Contains("OrderAcceptance: a null verdict entry (batch-01.json)", output);
123
+ Assert.False(Directory.Exists(Pruned));
124
+ }
125
+
126
+ [Fact]
127
+ public async Task A_verdict_missing_supported_is_malformed_not_silently_rejected()
128
+ {
129
+ WriteVerdicts("""
130
+ { "OrderAcceptance": [ { "item": 1 }, { "item": 2, "supported": true } ],
131
+ "OrderQueue": [ { "item": 1, "supported": true } ] }
132
+ """);
133
+
134
+ var (exit, output) = await PruneAsync();
135
+
136
+ Assert.Equal(3, exit);
137
+ Assert.Contains("OrderAcceptance[1]: verdict is missing 'supported' (batch-01.json)", output);
138
+ Assert.False(Directory.Exists(Pruned));
139
+ }
140
+
111
141
  [Fact]
112
142
  public async Task A_batch_without_a_verdict_file_fails()
113
143
  {