@phuc1403/musketeer 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/INSTALLATION.md +1 -0
  2. package/README.md +1 -1
  3. package/manifest.json +19 -2
  4. package/package.json +1 -1
  5. package/template/.claude/hooks/validate-cml-hook.js +6 -5
  6. package/template/.claude/skills/logical-components/.gitattributes +2 -0
  7. package/template/.claude/skills/logical-components/SKILL.md +102 -0
  8. package/template/.claude/skills/logical-components/references/component-classifier-prompt.md +53 -0
  9. package/template/.claude/skills/logical-components/references/responsibility-agent-prompt.md +67 -0
  10. package/template/.claude/skills/logical-components/references/responsibility-verifier-prompt.md +39 -0
  11. package/template/.claude/skills/logical-components/scripts/Directory.Build.props +7 -0
  12. package/template/.claude/skills/logical-components/scripts/Directory.Packages.props +8 -0
  13. package/template/.claude/skills/logical-components/scripts/LogicalComponents/BatchPlanning.cs +69 -0
  14. package/template/.claude/skills/logical-components/scripts/LogicalComponents/BatchesCommand.cs +39 -0
  15. package/template/.claude/skills/logical-components/scripts/LogicalComponents/CandidatesCommand.cs +70 -0
  16. package/template/.claude/skills/logical-components/scripts/LogicalComponents/CitableSource.cs +39 -0
  17. package/template/.claude/skills/logical-components/scripts/LogicalComponents/Citation.cs +37 -0
  18. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ClassifyBatchesCommand.cs +65 -0
  19. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ClassifyMergeCommand.cs +187 -0
  20. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentDiscovery.cs +166 -0
  21. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentRule.cs +87 -0
  22. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentsFile.cs +83 -0
  23. package/template/.claude/skills/logical-components/scripts/LogicalComponents/CouplingResolver.cs +196 -0
  24. package/template/.claude/skills/logical-components/scripts/LogicalComponents/DisplayName.cs +14 -0
  25. package/template/.claude/skills/logical-components/scripts/LogicalComponents/EdgeExtraction.cs +111 -0
  26. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ExtractCommand.cs +72 -0
  27. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ExtractConfig.cs +79 -0
  28. package/template/.claude/skills/logical-components/scripts/LogicalComponents/LogicalComponents.csproj +15 -0
  29. package/template/.claude/skills/logical-components/scripts/LogicalComponents/MarkdownRenderer.cs +73 -0
  30. package/template/.claude/skills/logical-components/scripts/LogicalComponents/MemberUseResolution.cs +203 -0
  31. package/template/.claude/skills/logical-components/scripts/LogicalComponents/MemberUses.cs +155 -0
  32. package/template/.claude/skills/logical-components/scripts/LogicalComponents/PortResolution.cs +75 -0
  33. package/template/.claude/skills/logical-components/scripts/LogicalComponents/Program.cs +27 -0
  34. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ProjectFilters.cs +76 -0
  35. package/template/.claude/skills/logical-components/scripts/LogicalComponents/PruneCommand.cs +132 -0
  36. package/template/.claude/skills/logical-components/scripts/LogicalComponents/RenderCommand.cs +173 -0
  37. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ResponsibilityBatches.cs +84 -0
  38. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ResponsibilityValidation.cs +178 -0
  39. package/template/.claude/skills/logical-components/scripts/LogicalComponents/SeamDispatch.cs +59 -0
  40. package/template/.claude/skills/logical-components/scripts/LogicalComponents/SymbolNames.cs +53 -0
  41. package/template/.claude/skills/logical-components/scripts/LogicalComponents/TypeMap.cs +75 -0
  42. package/template/.claude/skills/logical-components/scripts/LogicalComponents/WalkRoots.cs +63 -0
  43. package/template/.claude/skills/logical-components/scripts/LogicalComponents/WorkspaceLoader.cs +227 -0
  44. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/AssignIdsTests.cs +41 -0
  45. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/BatchPlanningTests.cs +82 -0
  46. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CandidatesCommandTests.cs +34 -0
  47. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CitationTests.cs +42 -0
  48. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ClassifyCommandsTests.cs +176 -0
  49. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ComponentDiscoveryTests.cs +190 -0
  50. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CouplingExtractionTests.cs +174 -0
  51. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/DisplayNameTests.cs +17 -0
  52. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ExtractConfigTests.cs +72 -0
  53. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/FixturePaths.cs +32 -0
  54. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/LogicalComponents.Tests.csproj +23 -0
  55. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/MarkdownRendererTests.cs +50 -0
  56. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ProjectFiltersTests.cs +65 -0
  57. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/PruneCommandTests.cs +119 -0
  58. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/RenderCommandTests.cs +162 -0
  59. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/RenderGoldenTests.cs +34 -0
  60. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/RenderTestData.cs +49 -0
  61. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ResponsibilityValidationTests.cs +227 -0
  62. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/SampleAppWorkspace.cs +27 -0
  63. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/WorkspaceLoaderTests.cs +87 -0
  64. package/template/.claude/skills/logical-components/scripts/fixtures/BrokenApp/src/Broken.Application/Broken.Application.csproj +7 -0
  65. package/template/.claude/skills/logical-components/scripts/fixtures/BrokenApp/src/Broken.Application/Broken.cs +7 -0
  66. package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/MultiTargetApp.slnx +6 -0
  67. package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Legacy.FSharp/Legacy.FSharp.fsproj +8 -0
  68. package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Legacy.FSharp/Library.fs +5 -0
  69. package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Multi.Application/Multi.Application.csproj +7 -0
  70. package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Multi.Application/Scheduling.cs +14 -0
  71. package/template/.claude/skills/logical-components/scripts/fixtures/NoSolutionApp/src/Solo.Application/Solo.Application.csproj +7 -0
  72. package/template/.claude/skills/logical-components/scripts/fixtures/NoSolutionApp/src/Solo.Application/SoloGreeting.cs +7 -0
  73. package/template/.claude/skills/logical-components/scripts/fixtures/NoSolutionApp/src/Solo.Application.Tests/FakeGreeting.cs +6 -0
  74. package/template/.claude/skills/logical-components/scripts/fixtures/NoSolutionApp/src/Solo.Application.Tests/Solo.Application.Tests.csproj +11 -0
  75. package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/components.json +63 -0
  76. package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/expected.md +28 -0
  77. package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/repo/src/App/OrderAcceptance.cs +16 -0
  78. package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/repo/src/Infra/OrderQueue.cs +10 -0
  79. package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/resp/batch-01.json +18 -0
  80. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/SampleApp.slnx +10 -0
  81. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/logical-components.json +78 -0
  82. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/CandidateCases.cs +37 -0
  83. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/CouplingCases.cs +33 -0
  84. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/DependencyInjection.cs +18 -0
  85. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/EdgeCases.cs +63 -0
  86. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/LoudNotifying.Base.cs +5 -0
  87. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/LoudNotifying.cs +7 -0
  88. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderAcceptance.cs +17 -0
  89. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderOptions.cs +9 -0
  90. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderRejectedException.cs +7 -0
  91. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderRequest.cs +4 -0
  92. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderRunning.cs +18 -0
  93. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderValidation.cs +6 -0
  94. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/Ports.cs +19 -0
  95. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/PriceRules.cs +7 -0
  96. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/ReceiptPrinting.Totals.cs +6 -0
  97. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/ReceiptPrinting.cs +7 -0
  98. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/ReviewCases.cs +107 -0
  99. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/ReviewFixCases.cs +143 -0
  100. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/RunClock.cs +12 -0
  101. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/Sample.Application.csproj +10 -0
  102. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/SeeThroughCases.cs +89 -0
  103. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Billing.Application/OrderRunning.cs +7 -0
  104. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Billing.Application/Sample.Billing.Application.csproj +11 -0
  105. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/ForwardedNotifier.cs +8 -0
  106. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/InfrastructureDependencyInjection.cs +16 -0
  107. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/Mailer.cs +6 -0
  108. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/OrderQueue.cs +11 -0
  109. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/QueueEntry.cs +4 -0
  110. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/QueueStore.cs +8 -0
  111. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/ReviewCases.cs +35 -0
  112. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/ReviewFixCases.cs +13 -0
  113. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/Sample.Infrastructure.csproj +13 -0
  114. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/SeeThroughCases.cs +18 -0
  115. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/UnreachedPlumbing.cs +7 -0
  116. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/tests/Sample.Tests/FakeOrderQueue.cs +11 -0
  117. package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/tests/Sample.Tests/Sample.Tests.csproj +11 -0
  118. package/template/.claude/skills/logical-components/scripts/fixtures/SharedOutsideRepo/SharedClock.cs +7 -0
  119. package/template/.claude/skills/logical-components/scripts/global.json +5 -0
  120. package/template/.claude/skills/logical-components/scripts/smoke-sample.sh +26 -0
@@ -0,0 +1,37 @@
1
+ using System.Text.RegularExpressions;
2
+
3
+ namespace LogicalComponents;
4
+
5
+ /// <summary>One responsibility an agent wrote for a component, and the source lines it claims show it.</summary>
6
+ public sealed record Responsibility(string Text, string Cite);
7
+
8
+ /// <summary>
9
+ /// A parsed <c>file:line</c> or <c>file:start-end</c> citation: a repo-relative path with forward slashes and a 1-based,
10
+ /// inclusive line range. Anything looser (surrounding whitespace, control characters, backslashes, non-ASCII digits,
11
+ /// absurd line numbers) does not parse, so it can never reach the rendered document.
12
+ /// </summary>
13
+ public sealed partial record Citation(string File, int Start, int End)
14
+ {
15
+ public int LineCount => End - Start + 1;
16
+
17
+ public static Citation? Parse(string cite)
18
+ {
19
+ var match = Pattern().Match(cite);
20
+ if (!match.Success)
21
+ {
22
+ return null;
23
+ }
24
+
25
+ var start = int.Parse(match.Groups["start"].Value);
26
+ var end = match.Groups["end"].Success ? int.Parse(match.Groups["end"].Value) : start;
27
+ return start >= 1 && end >= start ? new Citation(match.Groups["file"].Value, start, end) : null;
28
+ }
29
+
30
+ /// <summary>The canonical form rendered in the document: <c>file:7</c> or <c>file:7-12</c>, never <c>file:0007-007</c>.</summary>
31
+ public override string ToString() => Start == End ? $"{File}:{Start}" : $"{File}:{Start}-{End}";
32
+
33
+ // At most 7 ASCII digits per number, so int.Parse cannot overflow; \z (not $) so a trailing newline cannot slip
34
+ // through; the path has no colon, backslash or control character and neither starts nor ends with whitespace.
35
+ [GeneratedRegex(@"\A(?<file>[^:\\\s\p{C}](?:[^:\\\p{C}]*[^:\\\s\p{C}])?):(?<start>[0-9]{1,7})(?:-(?<end>[0-9]{1,7}))?\z")]
36
+ private static partial Regex Pattern();
37
+ }
@@ -0,0 +1,65 @@
1
+ using System.Security.Cryptography;
2
+ using System.Text.Json;
3
+
4
+ namespace LogicalComponents;
5
+
6
+ /// <summary>
7
+ /// <c>classify-batches --candidates &lt;candidates.json&gt; --agents &lt;n&gt; --repo &lt;repoPath&gt; --out &lt;dir&gt;</c>: splits
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.
12
+ /// </summary>
13
+ public static class ClassifyBatchesCommand
14
+ {
15
+ public const string Usage =
16
+ "usage: LogicalComponents classify-batches --candidates <candidates.json> --agents <n> --repo <repoPath> --out <dir>";
17
+
18
+ /// <summary>The file in the batches directory holding the config hash at batching time.</summary>
19
+ public const string ConfigHashFile = "config.sha256";
20
+
21
+ public sealed record ClassifyCandidate(string Type, string Layer, string Module, IReadOnlyList<SourceSpan> Spans);
22
+
23
+ public sealed record ClassifyBatch(int Batch, IReadOnlyList<ClassifyCandidate> Candidates);
24
+
25
+ public static async Task<int> RunAsync(string[] args, TextWriter output)
26
+ {
27
+ if (args is not ["--candidates", var candidatesPath, "--agents", var agentsText, "--repo", var repo, "--out", var outDir]
28
+ || !int.TryParse(agentsText, out var agents) || agents < 1 || !Directory.Exists(repo))
29
+ {
30
+ await output.WriteLineAsync(Usage);
31
+ return 1;
32
+ }
33
+
34
+ CandidatesCommand.CandidatesFile file;
35
+ try
36
+ {
37
+ file = ComponentsFile.FromJson<CandidatesCommand.CandidatesFile>(await File.ReadAllBytesAsync(candidatesPath));
38
+ }
39
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException or JsonException)
40
+ {
41
+ await output.WriteLineAsync($"Cannot read {candidatesPath}: {e.Message}");
42
+ return 2;
43
+ }
44
+
45
+ var unclassified = file.Candidates
46
+ .Where(c => c.Status == CandidatesCommand.Unclassified)
47
+ .Select(c => new ClassifyCandidate(c.Type, c.Layer, c.Module, c.Spans))
48
+ .ToList();
49
+ var size = Math.Max(1, (unclassified.Count + agents - 1) / agents);
50
+ var batches = unclassified.Chunk(size).Select((chunk, index) => new ClassifyBatch(index + 1, chunk)).ToList();
51
+
52
+ BatchPlanning.Write(batches, b => b.Batch, outDir);
53
+ await File.WriteAllTextAsync(Path.Combine(outDir, ConfigHashFile), ConfigHash(repo));
54
+ await output.WriteLineAsync(
55
+ $"{unclassified.Count} unclassified candidates in {batches.Count} batches -> {Path.GetFullPath(outDir)}");
56
+ return 0;
57
+ }
58
+
59
+ /// <summary>SHA-256 of the repo's config file as it is now, or <c>absent</c>.</summary>
60
+ public static string ConfigHash(string repo)
61
+ {
62
+ var path = Path.Combine(repo, ExtractConfig.FileName);
63
+ return File.Exists(path) ? Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))) : "absent";
64
+ }
65
+ }
@@ -0,0 +1,187 @@
1
+ using System.Text.Json;
2
+
3
+ namespace LogicalComponents;
4
+
5
+ /// <summary>
6
+ /// <c>classify-merge --batches &lt;dir&gt; --verdicts &lt;dir&gt; --repo &lt;repoPath&gt;</c>: adds the classifier agents' verdicts
7
+ /// (<c>verdicts/batch-NN.json</c>: <c>[{ type, verdict: component|helper, reason, cite }]</c>) to the repo's
8
+ /// <c>logical-components.json</c>. The agents' output is checked against the batches they were given before anything is
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.
11
+ /// The config must be unchanged since classify-batches, so nothing can slip an entry past the review.
12
+ /// 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).
15
+ /// </summary>
16
+ public static class ClassifyMergeCommand
17
+ {
18
+ public const string Usage = "usage: LogicalComponents classify-merge --batches <dir> --verdicts <dir> --repo <repoPath>";
19
+
20
+ private const int MaxReasonLength = 200;
21
+ private const int MaxCitedLines = 30;
22
+
23
+ private sealed record VerdictJson(string? Type, string? Verdict, string? Reason, string? Cite);
24
+
25
+ public static async Task<int> RunAsync(string[] args, TextWriter output)
26
+ {
27
+ if (args is not ["--batches", var batchDir, "--verdicts", var verdictDir, "--repo", var repo]
28
+ || !Directory.Exists(batchDir) || !Directory.Exists(verdictDir) || !Directory.Exists(repo))
29
+ {
30
+ await output.WriteLineAsync(Usage);
31
+ return 1;
32
+ }
33
+
34
+ ExtractConfig existing;
35
+ try
36
+ {
37
+ existing = ExtractConfig.Load(repo);
38
+ }
39
+ catch (ExtractConfigException e)
40
+ {
41
+ await output.WriteLineAsync(e.Message);
42
+ return 2;
43
+ }
44
+
45
+ var problems = new List<string>();
46
+ var hashFile = Path.Combine(batchDir, ClassifyBatchesCommand.ConfigHashFile);
47
+ if (!File.Exists(hashFile) || File.ReadAllText(hashFile) != ClassifyBatchesCommand.ConfigHash(repo))
48
+ {
49
+ problems.Add($"{ExtractConfig.FileName} changed since classify-batches; review that change, then batch again");
50
+ }
51
+
52
+ var verdicts = Check(batchDir, verdictDir, problems);
53
+ if (problems.Count > 0)
54
+ {
55
+ foreach (var problem in problems)
56
+ {
57
+ await output.WriteLineAsync(problem);
58
+ }
59
+
60
+ return 3;
61
+ }
62
+
63
+ var components = existing.Components.ToHashSet(StringComparer.Ordinal);
64
+ var helpers = new SortedDictionary<string, string>(existing.Helpers.ToDictionary(), StringComparer.Ordinal);
65
+ var added = verdicts.OrderBy(v => v.Type, StringComparer.Ordinal).ToList();
66
+ foreach (var verdict in added)
67
+ {
68
+ if (verdict.Verdict == CandidatesCommand.Helper)
69
+ {
70
+ helpers[verdict.Type!] = verdict.Reason!;
71
+ }
72
+ else
73
+ {
74
+ components.Add(verdict.Type!);
75
+ }
76
+ }
77
+
78
+ if (added.Count > 0)
79
+ {
80
+ await File.WriteAllBytesAsync(
81
+ Path.Combine(repo, ExtractConfig.FileName),
82
+ ComponentsFile.ToJson(new { components = components.Order(StringComparer.Ordinal), helpers }));
83
+ }
84
+
85
+ foreach (var verdict in added.OrderBy(v => v.Verdict == CandidatesCommand.Helper ? 0 : 1))
86
+ {
87
+ await output.WriteLineAsync($"{verdict.Verdict} {verdict.Type}: {verdict.Reason}");
88
+ }
89
+
90
+ await output.WriteLineAsync(
91
+ $"{added.Count(v => v.Verdict == CandidatesCommand.Component)} components, "
92
+ + $"{added.Count(v => v.Verdict == CandidatesCommand.Helper)} helpers added");
93
+ return 0;
94
+ }
95
+
96
+ /// <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)
98
+ {
99
+ var batchFiles = Directory.EnumerateFiles(batchDir, "batch-*.json").Select(Path.GetFileName).OfType<string>()
100
+ .Order(StringComparer.Ordinal).ToList();
101
+ problems.AddRange(Directory.EnumerateFiles(verdictDir, "batch-*.json").Select(Path.GetFileName).OfType<string>()
102
+ .Where(name => !batchFiles.Contains(name))
103
+ .Order(StringComparer.Ordinal)
104
+ .Select(name => $"{name}: verdicts for a batch that does not exist (left from an earlier round?)"));
105
+
106
+ var all = new List<VerdictJson>();
107
+ foreach (var name in batchFiles)
108
+ {
109
+ var batch = ComponentsFile.FromJson<ClassifyBatchesCommand.ClassifyBatch>(File.ReadAllBytes(Path.Combine(batchDir, name)));
110
+ var verdictPath = Path.Combine(verdictDir, name);
111
+ if (!File.Exists(verdictPath))
112
+ {
113
+ problems.Add($"{name}: no verdict file");
114
+ continue;
115
+ }
116
+
117
+ List<VerdictJson?> read;
118
+ try
119
+ {
120
+ read = ComponentsFile.FromJson<List<VerdictJson?>>(File.ReadAllBytes(verdictPath));
121
+ }
122
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException or JsonException)
123
+ {
124
+ problems.Add($"{name}: not a verdict array ({e.Message})");
125
+ continue;
126
+ }
127
+
128
+ var verdicts = read.OfType<VerdictJson>().ToList();
129
+ var candidates = batch.Candidates.ToDictionary(c => c.Type, StringComparer.Ordinal);
130
+ foreach (var group in verdicts.GroupBy(v => v.Type ?? "", StringComparer.Ordinal))
131
+ {
132
+ if (!candidates.TryGetValue(group.Key, out var candidate))
133
+ {
134
+ problems.Add($"{name}: {Printable(group.Key)}: not a candidate of this batch");
135
+ }
136
+ else if (group.Count() > 1)
137
+ {
138
+ problems.Add($"{name}: {group.Key}: more than one verdict");
139
+ }
140
+ else if (Problem(group.Single(), candidate) is { } problem)
141
+ {
142
+ problems.Add($"{name}: {group.Key}: {problem}");
143
+ }
144
+ }
145
+
146
+ problems.AddRange(candidates.Keys.Where(type => verdicts.All(v => v.Type != type))
147
+ .Select(type => $"{name}: {type}: no verdict"));
148
+ all.AddRange(verdicts);
149
+ }
150
+
151
+ return all;
152
+ }
153
+
154
+ private static string? Problem(VerdictJson verdict, ClassifyBatchesCommand.ClassifyCandidate candidate)
155
+ {
156
+ if (verdict.Verdict is not (CandidatesCommand.Component or CandidatesCommand.Helper))
157
+ {
158
+ return "verdict must be component or helper";
159
+ }
160
+
161
+ if (string.IsNullOrWhiteSpace(verdict.Reason))
162
+ {
163
+ return "reason is empty";
164
+ }
165
+
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))
168
+ {
169
+ return "reason has control characters";
170
+ }
171
+
172
+ if (verdict.Reason.Length > MaxReasonLength)
173
+ {
174
+ return $"reason is {verdict.Reason.Length} characters; keep it under {MaxReasonLength}";
175
+ }
176
+
177
+ var cite = Citation.Parse(verdict.Cite ?? "");
178
+ return cite is not null
179
+ && cite.LineCount <= MaxCitedLines
180
+ && candidate.Spans.Any(s => s.File == cite.File && s.Start <= cite.Start && cite.End <= s.End)
181
+ ? null
182
+ : $"cite '{verdict.Cite}' is not inside its declaration";
183
+ }
184
+
185
+ // 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());
187
+ }
@@ -0,0 +1,166 @@
1
+ using Microsoft.CodeAnalysis;
2
+
3
+ namespace LogicalComponents;
4
+
5
+ /// <summary>
6
+ /// Lists the candidates (every type in an Application or Infrastructure project that passes <see cref="ComponentRule"/>)
7
+ /// and keeps as components the ones the repo's frozen classification calls components. Output order is by full type
8
+ /// name, so the same code and config always yield the same list.
9
+ /// </summary>
10
+ public static class ComponentDiscovery
11
+ {
12
+ public const string ApplicationLayer = "Application";
13
+ public const string InfrastructureLayer = "Infrastructure";
14
+
15
+ /// <summary>The candidates, as components not yet named (empty id and name), plus out-of-repo warnings.</summary>
16
+ public sealed record CandidateList(IReadOnlyList<Component> Candidates, IReadOnlyList<string> Warnings);
17
+
18
+ public static async Task<CandidateList> CandidatesAsync(LoadedWorkspace loaded, CancellationToken cancellationToken = default)
19
+ {
20
+ var warnings = new List<string>();
21
+ var candidates = (await SourceTypesAsync(loaded.Solution, cancellationToken))
22
+ .Where(t => t.Layer is (ApplicationLayer or InfrastructureLayer) && ComponentRule.IsCandidate(t.Symbol))
23
+ .Select(t => ToComponent(t, loaded, warnings))
24
+ .OfType<Component>()
25
+ .ToList();
26
+ return new CandidateList(candidates, warnings);
27
+ }
28
+
29
+ public static async Task<DiscoveryResult> DiscoverAsync(
30
+ LoadedWorkspace loaded, ExtractConfig config, CancellationToken cancellationToken = default)
31
+ {
32
+ var (candidates, candidateWarnings) = await CandidatesAsync(loaded, cancellationToken);
33
+ var known = candidates.Select(c => c.Type).ToHashSet(StringComparer.Ordinal);
34
+ var stale = config.Components.Concat(config.Helpers.Keys)
35
+ .Where(name => !known.Contains(name))
36
+ .Distinct()
37
+ .Order(StringComparer.Ordinal)
38
+ .Select(name => $"config entry '{name}' names no candidate; ignored");
39
+
40
+ return new DiscoveryResult(
41
+ AssignIds(candidates.Where(c => config.Components.Contains(c.Type)).ToList()),
42
+ candidates.Where(c => !config.IsClassified(c.Type)).Select(c => c.Type).ToList(),
43
+ [.. candidateWarnings, .. stale]);
44
+ }
45
+
46
+ /// <summary>A top-level type declared in a loaded project, with the layer and module its project gives it.</summary>
47
+ public sealed record SourceType(INamedTypeSymbol Symbol, string Layer, string Module);
48
+
49
+ public static async Task<List<SourceType>> SourceTypesAsync(Solution solution, CancellationToken cancellationToken)
50
+ {
51
+ var types = new List<SourceType>();
52
+ foreach (var project in solution.Projects)
53
+ {
54
+ var compilation = await project.GetCompilationAsync(cancellationToken)
55
+ ?? throw new InvalidOperationException($"No compilation for {project.Name}");
56
+ var (layer, module) = LayerAndModule(project.Name);
57
+ types.AddRange(TopLevelTypes(compilation.Assembly.GlobalNamespace)
58
+ .Where(t => t.Locations.Any(l => l.IsInSource))
59
+ .Select(t => new SourceType(t, layer, module)));
60
+ }
61
+
62
+ return types.OrderBy(t => SymbolNames.FullName(t.Symbol), StringComparer.Ordinal).ToList();
63
+ }
64
+
65
+ /// <summary>
66
+ /// <c>WorkerDwg.Export.Application</c> → (Application, Export); <c>Sample.Infrastructure</c> → (Infrastructure, Sample);
67
+ /// any other project → (Other, its last name segment).
68
+ /// </summary>
69
+ public static (string Layer, string Module) LayerAndModule(string projectName)
70
+ {
71
+ foreach (var layer in new[] { ApplicationLayer, InfrastructureLayer })
72
+ {
73
+ if (projectName.EndsWith("." + layer, StringComparison.Ordinal))
74
+ {
75
+ var rest = projectName[..^(layer.Length + 1)];
76
+ return (layer, rest[(rest.LastIndexOf('.') + 1)..]);
77
+ }
78
+ }
79
+
80
+ return ("Other", projectName[(projectName.LastIndexOf('.') + 1)..]);
81
+ }
82
+
83
+ /// <summary>
84
+ /// Ids and names are the plain type name unless several components share it; then every one of them is prefixed
85
+ /// with its module (<c>Export_JobOutcomeReporting</c>, "Job Outcome Reporting (Export)"). If that still clashes
86
+ /// (same module, different namespace or generic arity), the full type name disambiguates. The same full type twice
87
+ /// (a file linked into two projects) has no honest id and is an error.
88
+ /// </summary>
89
+ public static List<Component> AssignIds(IReadOnlyList<Component> components)
90
+ {
91
+ var duplicateTypes = components.GroupBy(c => c.Type).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
92
+ if (duplicateTypes.Count > 0)
93
+ {
94
+ throw new InvalidOperationException(
95
+ $"Declared in more than one project, so no unique component id: {string.Join(", ", duplicateTypes.Order(StringComparer.Ordinal))}");
96
+ }
97
+
98
+ var bySimpleName = components.GroupBy(c => SymbolNames.SimpleName(c.Type)).ToDictionary(g => g.Key, g => g.Count());
99
+ var named = components.Select(c =>
100
+ {
101
+ var simple = SymbolNames.SimpleName(c.Type);
102
+ var words = DisplayName.FromTypeName(simple);
103
+ return bySimpleName[simple] == 1
104
+ ? c with { Id = simple, Name = words }
105
+ : c with { Id = $"{c.Module}_{simple}", Name = $"{words} ({c.Module})" };
106
+ }).ToList();
107
+
108
+ var clashingIds = named.GroupBy(c => c.Id).Where(g => g.Count() > 1).Select(g => g.Key).ToHashSet();
109
+ return named
110
+ .Select(c => clashingIds.Contains(c.Id)
111
+ ? c with
112
+ {
113
+ Id = SymbolNames.ToIdentifier(c.Type),
114
+ Name = $"{DisplayName.FromTypeName(SymbolNames.SimpleName(c.Type))} ({SymbolNames.ReadableFullName(c.Type)})",
115
+ }
116
+ : c)
117
+ .OrderBy(c => c.Type, StringComparer.Ordinal)
118
+ .ToList();
119
+ }
120
+
121
+ /// <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.
125
+ /// </summary>
126
+ public static Component? ToComponent(SourceType type, LoadedWorkspace loaded, List<string> warnings)
127
+ {
128
+ var spans = new List<SourceSpan>();
129
+ foreach (var reference in type.Symbol.DeclaringSyntaxReferences)
130
+ {
131
+ var (kind, relative) = CitableSource.Classify(loaded, reference.SyntaxTree);
132
+ if (kind == CitableSource.Kind.OutsideRepo)
133
+ {
134
+ var warning = $"declaration of {SymbolNames.FullName(type.Symbol)} outside the repo was ignored";
135
+ if (!warnings.Contains(warning))
136
+ {
137
+ warnings.Add(warning);
138
+ }
139
+ }
140
+
141
+ if (kind != CitableSource.Kind.InRepo)
142
+ {
143
+ continue;
144
+ }
145
+
146
+ var lines = reference.GetSyntax().GetLocation().GetLineSpan();
147
+ spans.Add(new SourceSpan(relative, lines.StartLinePosition.Line + 1, lines.EndLinePosition.Line + 1));
148
+ }
149
+
150
+ if (spans.Count == 0)
151
+ {
152
+ return null;
153
+ }
154
+
155
+ return new Component(
156
+ Id: "",
157
+ Type: SymbolNames.FullName(type.Symbol),
158
+ Name: "",
159
+ Module: type.Module,
160
+ Layer: type.Layer,
161
+ Spans: spans.OrderBy(s => s.File, StringComparer.Ordinal).ThenBy(s => s.Start).ToList());
162
+ }
163
+
164
+ private static IEnumerable<INamedTypeSymbol> TopLevelTypes(INamespaceSymbol ns) =>
165
+ ns.GetTypeMembers().Concat(ns.GetNamespaceMembers().SelectMany(TopLevelTypes));
166
+ }
@@ -0,0 +1,87 @@
1
+ using Microsoft.CodeAnalysis;
2
+
3
+ namespace LogicalComponents;
4
+
5
+ /// <summary>
6
+ /// The deterministic test for "could this type be a logical component". Whether a candidate IS one is not decided here:
7
+ /// the repo's frozen classification (<see cref="ExtractConfig"/>) says component or helper. Whether a declaration comes
8
+ /// from a real file inside the repo is checked by discovery, which has the solution to hand.
9
+ /// </summary>
10
+ public static class ComponentRule
11
+ {
12
+ private const string ServiceCollection = "Microsoft.Extensions.DependencyInjection.IServiceCollection";
13
+
14
+ /// <summary>
15
+ /// A candidate is a top-level, source-declared class (static classes included) or record (abstract records and
16
+ /// record structs included) that is not an abstract class (a seam), not file-local, not an exception, not an
17
+ /// <c>*Options</c> class, not a DI composition root, and declares at least one method with a body of its own.
18
+ /// </summary>
19
+ public static bool IsCandidate(INamedTypeSymbol type) =>
20
+ (type.TypeKind == TypeKind.Class || (type.TypeKind == TypeKind.Struct && type.IsRecord))
21
+ && !EdgeExtraction.IsSeam(type)
22
+ && !type.IsImplicitlyDeclared
23
+ // A file-local type's metadata name embeds a hash of its absolute path: different per checkout.
24
+ && !type.IsFileLocal
25
+ && type.ContainingType is null
26
+ && type.Locations.Any(l => l.IsInSource)
27
+ && !type.Name.EndsWith("Options", StringComparison.Ordinal)
28
+ && !IsException(type)
29
+ && !IsCompositionRoot(type)
30
+ && DeclaresBehaviour(type);
31
+
32
+ /// <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.
36
+ /// </summary>
37
+ 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)));
43
+
44
+ // An exception carries a failure; even with factory methods of its own it is data, not a unit of behaviour.
45
+ private static bool IsException(INamedTypeSymbol type)
46
+ {
47
+ for (var current = type.BaseType; current is not null; current = current.BaseType)
48
+ {
49
+ if (SymbolNames.QualifiedName(current) == "System.Exception")
50
+ {
51
+ return true;
52
+ }
53
+ }
54
+
55
+ return false;
56
+ }
57
+
58
+ /// <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).
62
+ /// </summary>
63
+ private static bool DeclaresBehaviour(INamedTypeSymbol type) =>
64
+ type.GetMembers().Concat(ExtensionBlocks(type).SelectMany(block => block.GetMembers()))
65
+ .OfType<IMethodSymbol>()
66
+ .Any(m => !m.IsImplicitlyDeclared
67
+ && !m.IsAbstract
68
+ && !(m.IsPartialDefinition && m.PartialImplementationPart is null)
69
+ && m.MethodKind is MethodKind.Ordinary or MethodKind.ExplicitInterfaceImplementation
70
+ && !OverridesObjectMember(m));
71
+
72
+ private static bool OverridesObjectMember(IMethodSymbol method)
73
+ {
74
+ var overridden = method.OverriddenMethod;
75
+ while (overridden?.OverriddenMethod is { } further)
76
+ {
77
+ overridden = further;
78
+ }
79
+
80
+ return overridden?.ContainingType.SpecialType == SpecialType.System_Object;
81
+ }
82
+
83
+ private static IEnumerable<INamedTypeSymbol> ExtensionBlocks(INamedTypeSymbol type) =>
84
+ type.GetTypeMembers().Where(t => t.IsExtension);
85
+
86
+ private static bool IsServiceCollection(ITypeSymbol type) => SymbolNames.QualifiedName(type) == ServiceCollection;
87
+ }
@@ -0,0 +1,83 @@
1
+ using System.Text;
2
+ using System.Text.Encodings.Web;
3
+ using System.Text.Json;
4
+
5
+ namespace LogicalComponents;
6
+
7
+ /// <summary>Where a component's declaration lives: repo-relative path with forward slashes, 1-based inclusive lines.</summary>
8
+ public sealed record SourceSpan(string File, int Start, int End);
9
+
10
+ /// <summary>
11
+ /// One logical component: a class the repo config classifies as a unit of behaviour.
12
+ /// <see cref="Id"/> is what the diagram uses; <see cref="Type"/> is the full metadata name. <see cref="Reaches"/> is
13
+ /// the helper code (members of helpers, records and other non-components) that the component's code runs: a rule held
14
+ /// there is part of what the component does, so its responsibilities may cite it. Null until coupling is resolved.
15
+ /// </summary>
16
+ public sealed record Component(
17
+ string Id,
18
+ string Type,
19
+ string Name,
20
+ string Module,
21
+ string Layer,
22
+ IReadOnlyList<SourceSpan> Spans,
23
+ IReadOnlyList<SourceSpan>? Reaches = null);
24
+
25
+ /// <summary>
26
+ /// The classified components, the candidates the config does not classify yet (extract refuses to run with any), and
27
+ /// warnings.
28
+ /// </summary>
29
+ public sealed record DiscoveryResult(
30
+ IReadOnlyList<Component> Components, IReadOnlyList<string> Unclassified, IReadOnlyList<string> Warnings);
31
+
32
+ /// <summary>A diagram node that is not a component: an interface (<c>layer: "port"</c>) with no modelled implementation.</summary>
33
+ public sealed record ExternalNode(string Id, string Type, string Name, string Layer);
34
+
35
+ /// <summary>
36
+ /// One coupling edge between two node ids. <see cref="Kinds"/> is the sorted set of di/new/static, <see cref="Via"/> the
37
+ /// sorted interfaces it went through, <see cref="At"/> its first occurrence as <c>file:line</c>.
38
+ /// </summary>
39
+ public sealed record Edge(string From, string To, IReadOnlyList<string> Kinds, IReadOnlyList<string> Via, string At);
40
+
41
+ /// <summary>Everything <c>extract</c> writes: the components, the ports they reach, the edges, and the warnings.</summary>
42
+ public sealed record ExtractResult(
43
+ IReadOnlyList<Component> Components,
44
+ IReadOnlyList<ExternalNode> ExternalNodes,
45
+ IReadOnlyList<Edge> Edges,
46
+ IReadOnlyList<string> Warnings);
47
+
48
+ /// <summary>
49
+ /// Writes <c>components.json</c>. Byte-identical for identical input: fixed property order, LF line endings,
50
+ /// no BOM, no timestamps, and only repo-relative paths.
51
+ /// </summary>
52
+ public static class ComponentsFile
53
+ {
54
+ private static readonly JsonSerializerOptions Options = new()
55
+ {
56
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
57
+ WriteIndented = true,
58
+ IndentSize = 2,
59
+ NewLine = "\n",
60
+ // Keep names and paths readable; the file is consumed by our own tools and by agents, not by browsers.
61
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
62
+ };
63
+
64
+ public static byte[] Serialize(ExtractResult result) =>
65
+ ToJson(new
66
+ {
67
+ components = result.Components,
68
+ externalNodes = result.ExternalNodes,
69
+ edges = result.Edges,
70
+ warnings = result.Warnings,
71
+ });
72
+
73
+ /// <summary>Any file the tool writes: indented camelCase JSON, LF line endings, no BOM, trailing newline.</summary>
74
+ public static byte[] ToJson<T>(T value) =>
75
+ new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(JsonSerializer.Serialize(value, Options) + "\n");
76
+
77
+ /// <summary>Reads a components.json written by <see cref="Serialize(ExtractResult)"/>.</summary>
78
+ public static ExtractResult Deserialize(byte[] json) => FromJson<ExtractResult>(json);
79
+
80
+ /// <summary>Reads any file written by <see cref="ToJson{T}"/>.</summary>
81
+ public static T FromJson<T>(byte[] json) =>
82
+ JsonSerializer.Deserialize<T>(json, Options) ?? throw new JsonException("the file is empty");
83
+ }