@phuc1403/musketeer 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTALLATION.md +1 -0
- package/README.md +2 -2
- package/manifest.json +29 -3
- package/package.json +1 -1
- package/src/provisioner/detect.js +17 -2
- package/src/resolver.js +8 -3
- package/src/schema.js +1 -0
- package/src/settings-merger.js +0 -0
- package/template/.claude/hooks/inject-naming-rule-into-subagents.cjs +44 -0
- package/template/.claude/hooks/validate-cml-hook.js +6 -5
- package/template/.claude/rules/csharp-identifier-naming.md +77 -0
- package/template/.claude/skills/code-review/SKILL.md +4 -1
- package/template/.claude/skills/code-review/references/naming-rule-review.md +41 -0
- package/template/.claude/skills/logical-components/.gitattributes +2 -0
- package/template/.claude/skills/logical-components/SKILL.md +107 -0
- package/template/.claude/skills/logical-components/references/component-classifier-prompt.md +53 -0
- package/template/.claude/skills/logical-components/references/responsibility-agent-prompt.md +67 -0
- package/template/.claude/skills/logical-components/references/responsibility-verifier-prompt.md +39 -0
- package/template/.claude/skills/logical-components/scripts/Directory.Build.props +7 -0
- package/template/.claude/skills/logical-components/scripts/Directory.Build.rsp +1 -0
- package/template/.claude/skills/logical-components/scripts/Directory.Build.targets +6 -0
- package/template/.claude/skills/logical-components/scripts/Directory.Packages.props +8 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/BatchPlanning.cs +70 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/BatchesCommand.cs +47 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/CandidatesCommand.cs +98 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/CitableSource.cs +54 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/Citation.cs +37 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ClassifyBatchesCommand.cs +86 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ClassifyMergeCommand.cs +219 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentDiscovery.cs +180 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentRule.cs +130 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentsFile.cs +101 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/CouplingResolver.cs +251 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/DisplayName.cs +14 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/EdgeExtraction.cs +111 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ExtractCommand.cs +87 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ExtractConfig.cs +79 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/LogicalComponents.csproj +15 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/MarkdownCodeSpans.cs +72 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/MarkdownRenderer.cs +78 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/MemberUseResolution.cs +272 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/MemberUses.cs +231 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/PlainText.cs +55 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/PortResolution.cs +75 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/Program.cs +27 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ProjectFilters.cs +77 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/PruneCommand.cs +164 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/RenderCommand.cs +234 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ResponsibilityBatches.cs +84 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ResponsibilityValidation.cs +197 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/SeamDispatch.cs +115 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/SourceCodeLines.cs +79 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/SymbolNames.cs +53 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/TypeMap.cs +106 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/WalkRoots.cs +63 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/WorkspaceLoader.cs +297 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/AssignIdsTests.cs +41 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/BatchPlanningTests.cs +90 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/BatchesCommandTests.cs +37 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CandidatesCommandTests.cs +59 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CitationTests.cs +42 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ClassifyCommandsTests.cs +249 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ComponentDiscoveryTests.cs +253 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CouplingAppExtractionTests.cs +188 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CouplingAppWorkspace.cs +30 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CouplingExtractionTests.cs +174 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/DisplayNameTests.cs +17 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ExtractConfigTests.cs +72 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/FixturePaths.cs +36 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/LogicalComponents.Tests.csproj +23 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/MarkdownRendererTests.cs +93 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ProjectFiltersTests.cs +69 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/PruneCommandTests.cs +149 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/RenderCommandTests.cs +225 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/RenderGoldenTests.cs +34 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/RenderTestData.cs +55 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ResponsibilityValidationTests.cs +315 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/SampleAppWorkspace.cs +27 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/WorkspaceLoaderTests.cs +266 -0
- package/template/.claude/skills/logical-components/scripts/NuGet.config +17 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/BrokenApp/src/Broken.Application/Broken.Application.csproj +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/BrokenApp/src/Broken.Application/Broken.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/CompositionRootApp.slnx +5 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/HostBuilderExtensions.cs +15 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/HostBuilderShims.cs +25 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/NativeInterop.cs +17 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/Root.Application.csproj +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/RootAutofacModule.cs +11 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/RootGreeter.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/logical-components.json +48 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/AbstractMemberCases.cs +27 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/AttributeCases.cs +23 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/CouplingApp.Application.csproj +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/GenericBaseCases.cs +14 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/GenericSeamBindingCases.cs +59 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/ImplicitCallCases.cs +230 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/PartialInheritCases.First.cs +11 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/PartialInheritCases.Second.cs +14 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/StackOverflowCases.cs +14 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/ViaDisambiguationCases.cs +36 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/MultiTargetApp.slnx +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Legacy.FSharp/Legacy.FSharp.fsproj +8 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Legacy.FSharp/Library.fs +5 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Multi.Application/Multi.Application.csproj +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Multi.Application/Scheduling.cs +14 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Multi.Infrastructure/Multi.Infrastructure.csproj +12 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Multi.Infrastructure/SchedulingClient.cs +12 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/NoSolutionApp/src/Solo.Application/Solo.Application.csproj +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/NoSolutionApp/src/Solo.Application/SoloGreeting.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/NoSolutionApp/src/Solo.Application.Tests/FakeGreeting.cs +6 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/NoSolutionApp/src/Solo.Application.Tests/Solo.Application.Tests.csproj +11 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/ObjOutputApp/ObjOutputApp.slnx +5 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/ObjOutputApp/src/Obj.Application/Greeting.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/ObjOutputApp/src/Obj.Application/Obj.Application.csproj +14 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/components.json +67 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/expected.md +28 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/repo/src/App/OrderAcceptance.cs +16 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/repo/src/Infra/OrderQueue.cs +10 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/resp/batch-01.json +18 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/SampleApp.slnx +10 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/logical-components.json +78 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/CandidateCases.cs +37 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/CouplingCases.cs +33 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/DependencyInjection.cs +18 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/EdgeCases.cs +63 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/LoudNotifying.Base.cs +5 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/LoudNotifying.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderAcceptance.cs +17 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderOptions.cs +9 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderRejectedException.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderRequest.cs +4 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderRunning.cs +18 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/OrderValidation.cs +6 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/Ports.cs +19 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/PriceRules.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/ReceiptPrinting.Totals.cs +6 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/ReceiptPrinting.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/ReviewCases.cs +107 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/ReviewFixCases.cs +143 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/RunClock.cs +12 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/Sample.Application.csproj +10 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Application/SeeThroughCases.cs +89 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Billing.Application/OrderRunning.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Billing.Application/Sample.Billing.Application.csproj +11 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/ForwardedNotifier.cs +8 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/InfrastructureDependencyInjection.cs +16 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/Mailer.cs +6 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/OrderQueue.cs +11 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/QueueEntry.cs +4 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/QueueStore.cs +8 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/ReviewCases.cs +35 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/ReviewFixCases.cs +13 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/Sample.Infrastructure.csproj +13 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/SeeThroughCases.cs +18 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/src/Sample.Infrastructure/UnreachedPlumbing.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/tests/Sample.Tests/FakeOrderQueue.cs +11 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SampleApp/tests/Sample.Tests/Sample.Tests.csproj +11 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/SharedOutsideRepo/SharedClock.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/global.json +5 -0
- package/template/.claude/skills/logical-components/scripts/smoke-sample.sh +26 -0
package/template/.claude/skills/logical-components/scripts/LogicalComponents/ClassifyMergeCommand.cs
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
using System.Text.Json;
|
|
2
|
+
|
|
3
|
+
namespace LogicalComponents;
|
|
4
|
+
|
|
5
|
+
/// <summary>
|
|
6
|
+
/// <c>classify-merge --batches <dir> --verdicts <dir> --repo <repoPath></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, 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).
|
|
12
|
+
/// The config must be unchanged since classify-batches, so nothing can slip an entry past the review.
|
|
13
|
+
/// Prints each added entry (helpers first, for review) and a summary on <c>output</c>.
|
|
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).
|
|
16
|
+
/// </summary>
|
|
17
|
+
public static class ClassifyMergeCommand
|
|
18
|
+
{
|
|
19
|
+
public const string Usage = "usage: LogicalComponents classify-merge --batches <dir> --verdicts <dir> --repo <repoPath>";
|
|
20
|
+
|
|
21
|
+
private const int MaxReasonLength = 200;
|
|
22
|
+
private const int MaxCitedLines = 30;
|
|
23
|
+
|
|
24
|
+
private sealed record VerdictJson(string? Type, string? Verdict, string? Reason, string? Cite);
|
|
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
|
+
|
|
29
|
+
public static async Task<int> RunAsync(string[] args, TextWriter output)
|
|
30
|
+
{
|
|
31
|
+
if (args is not ["--batches", var batchDir, "--verdicts", var verdictDir, "--repo", var repo]
|
|
32
|
+
|| !Directory.Exists(batchDir) || !Directory.Exists(verdictDir) || !Directory.Exists(repo))
|
|
33
|
+
{
|
|
34
|
+
await output.WriteLineAsync(Usage);
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
ExtractConfig existing;
|
|
39
|
+
try
|
|
40
|
+
{
|
|
41
|
+
existing = ExtractConfig.Load(repo);
|
|
42
|
+
}
|
|
43
|
+
catch (ExtractConfigException e)
|
|
44
|
+
{
|
|
45
|
+
await output.WriteLineAsync(e.Message);
|
|
46
|
+
return 2;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
var problems = new List<string>();
|
|
50
|
+
var hashFile = Path.Combine(batchDir, ClassifyBatchesCommand.ConfigHashFile);
|
|
51
|
+
if (!File.Exists(hashFile) || File.ReadAllText(hashFile) != ClassifyBatchesCommand.ConfigHash(repo))
|
|
52
|
+
{
|
|
53
|
+
problems.Add($"{ExtractConfig.FileName} changed since classify-batches; review that change, then batch again");
|
|
54
|
+
}
|
|
55
|
+
|
|
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
|
+
|
|
67
|
+
if (problems.Count > 0)
|
|
68
|
+
{
|
|
69
|
+
foreach (var problem in problems)
|
|
70
|
+
{
|
|
71
|
+
await output.WriteLineAsync(problem);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return 3;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
var components = existing.Components.ToHashSet(StringComparer.Ordinal);
|
|
78
|
+
var helpers = new SortedDictionary<string, string>(existing.Helpers.ToDictionary(), StringComparer.Ordinal);
|
|
79
|
+
var added = verdicts.OrderBy(v => v.Type, StringComparer.Ordinal).ToList();
|
|
80
|
+
foreach (var verdict in added)
|
|
81
|
+
{
|
|
82
|
+
if (verdict.Verdict == CandidatesCommand.Helper)
|
|
83
|
+
{
|
|
84
|
+
helpers[verdict.Type!] = verdict.Reason!;
|
|
85
|
+
}
|
|
86
|
+
else
|
|
87
|
+
{
|
|
88
|
+
components.Add(verdict.Type!);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (added.Count > 0)
|
|
93
|
+
{
|
|
94
|
+
await File.WriteAllBytesAsync(
|
|
95
|
+
Path.Combine(repo, ExtractConfig.FileName),
|
|
96
|
+
ComponentsFile.ToJson(new { components = components.Order(StringComparer.Ordinal), helpers }));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
foreach (var verdict in added.OrderBy(v => v.Verdict == CandidatesCommand.Helper ? 0 : 1))
|
|
100
|
+
{
|
|
101
|
+
await output.WriteLineAsync($"{verdict.Verdict} {verdict.Type}: {verdict.Reason}");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await output.WriteLineAsync(
|
|
105
|
+
$"{added.Count(v => v.Verdict == CandidatesCommand.Component)} components, "
|
|
106
|
+
+ $"{added.Count(v => v.Verdict == CandidatesCommand.Helper)} helpers added");
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// <summary>Every verdict of every batch, or problems (in batch file order) when the agents' output breaks the contract.</summary>
|
|
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)
|
|
113
|
+
{
|
|
114
|
+
var batchFiles = Directory.EnumerateFiles(batchDir, "batch-*.json").Select(Path.GetFileName).OfType<string>()
|
|
115
|
+
.Order(StringComparer.Ordinal).ToList();
|
|
116
|
+
problems.AddRange(Directory.EnumerateFiles(verdictDir, "batch-*.json").Select(Path.GetFileName).OfType<string>()
|
|
117
|
+
.Where(name => !batchFiles.Contains(name))
|
|
118
|
+
.Order(StringComparer.Ordinal)
|
|
119
|
+
.Select(name => $"{name}: verdicts for a batch that does not exist (left from an earlier round?)"));
|
|
120
|
+
|
|
121
|
+
var all = new List<VerdictJson>();
|
|
122
|
+
foreach (var name in batchFiles)
|
|
123
|
+
{
|
|
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
|
+
|
|
134
|
+
var verdictPath = Path.Combine(verdictDir, name);
|
|
135
|
+
if (!File.Exists(verdictPath))
|
|
136
|
+
{
|
|
137
|
+
problems.Add($"{name}: no verdict file");
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
List<VerdictJson?> read;
|
|
142
|
+
try
|
|
143
|
+
{
|
|
144
|
+
read = ComponentsFile.FromJson<List<VerdictJson?>>(File.ReadAllBytes(verdictPath));
|
|
145
|
+
}
|
|
146
|
+
catch (Exception e) when (e is IOException or UnauthorizedAccessException or JsonException)
|
|
147
|
+
{
|
|
148
|
+
problems.Add($"{name}: not a verdict array ({e.Message})");
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
var verdicts = read.OfType<VerdictJson>().ToList();
|
|
153
|
+
var candidates = batch.Candidates.ToDictionary(c => c.Type, StringComparer.Ordinal);
|
|
154
|
+
foreach (var group in verdicts.GroupBy(v => v.Type ?? "", StringComparer.Ordinal))
|
|
155
|
+
{
|
|
156
|
+
if (!candidates.TryGetValue(group.Key, out var candidate))
|
|
157
|
+
{
|
|
158
|
+
problems.Add($"{name}: {Printable(group.Key)}: not a candidate of this batch");
|
|
159
|
+
}
|
|
160
|
+
else if (group.Count() > 1)
|
|
161
|
+
{
|
|
162
|
+
problems.Add($"{name}: {group.Key}: more than one verdict");
|
|
163
|
+
}
|
|
164
|
+
else if (Problem(group.Single(), candidate, existing) is { } problem)
|
|
165
|
+
{
|
|
166
|
+
problems.Add($"{name}: {group.Key}: {problem}");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
problems.AddRange(candidates.Keys.Where(type => verdicts.All(v => v.Type != type))
|
|
171
|
+
.Select(type => $"{name}: {type}: no verdict"));
|
|
172
|
+
all.AddRange(verdicts);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return all;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private static string? Problem(VerdictJson verdict, ClassifyBatchesCommand.ClassifyCandidate candidate, ExtractConfig existing)
|
|
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
|
+
|
|
187
|
+
if (verdict.Verdict is not (CandidatesCommand.Component or CandidatesCommand.Helper))
|
|
188
|
+
{
|
|
189
|
+
return "verdict must be component or helper";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (string.IsNullOrWhiteSpace(verdict.Reason))
|
|
193
|
+
{
|
|
194
|
+
return "reason is empty";
|
|
195
|
+
}
|
|
196
|
+
|
|
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))
|
|
200
|
+
{
|
|
201
|
+
return "reason has control characters";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (verdict.Reason.Length > MaxReasonLength)
|
|
205
|
+
{
|
|
206
|
+
return $"reason is {verdict.Reason.Length} characters; keep it under {MaxReasonLength}";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
var cite = Citation.Parse(verdict.Cite ?? "");
|
|
210
|
+
return cite is not null
|
|
211
|
+
&& cite.LineCount <= MaxCitedLines
|
|
212
|
+
&& candidate.Spans.Any(s => s.File == cite.File && s.Start <= cite.Start && cite.End <= s.End)
|
|
213
|
+
? null
|
|
214
|
+
: $"cite '{verdict.Cite}' is not inside its declaration";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// An unknown type is agent text: print it without anything that could start a new line at the review gate.
|
|
218
|
+
private static string Printable(string text) => new(text.Select(c => PlainText.IsDisallowed(c) ? '?' : c).ToArray());
|
|
219
|
+
}
|
package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentDiscovery.cs
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
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 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
|
|
30
|
+
.Where(t => t.Layer is (ApplicationLayer or InfrastructureLayer) && ComponentRule.IsCandidate(t.Symbol))
|
|
31
|
+
.Select(t => ToComponent(t, loaded, warnings))
|
|
32
|
+
.OfType<Component>()
|
|
33
|
+
.ToList();
|
|
34
|
+
return new CandidateList(candidates, warnings);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public static async Task<DiscoveryResult> DiscoverAsync(
|
|
38
|
+
LoadedWorkspace loaded, ExtractConfig config, CancellationToken cancellationToken = default)
|
|
39
|
+
{
|
|
40
|
+
var (candidates, candidateWarnings) = await CandidatesAsync(loaded, cancellationToken);
|
|
41
|
+
var known = candidates.Select(c => c.Type).ToHashSet(StringComparer.Ordinal);
|
|
42
|
+
var stale = config.Components.Concat(config.Helpers.Keys)
|
|
43
|
+
.Where(name => !known.Contains(name))
|
|
44
|
+
.Distinct()
|
|
45
|
+
.Order(StringComparer.Ordinal)
|
|
46
|
+
.Select(name => $"config entry '{name}' names no candidate; ignored");
|
|
47
|
+
|
|
48
|
+
return new DiscoveryResult(
|
|
49
|
+
AssignIds(candidates.Where(c => config.Components.Contains(c.Type)).ToList()),
|
|
50
|
+
candidates.Where(c => !config.IsClassified(c.Type)).Select(c => c.Type).ToList(),
|
|
51
|
+
[.. candidateWarnings, .. stale]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// <summary>A top-level type declared in a loaded project, with the layer and module its project gives it.</summary>
|
|
55
|
+
public sealed record SourceType(INamedTypeSymbol Symbol, string Layer, string Module);
|
|
56
|
+
|
|
57
|
+
public static async Task<List<SourceType>> SourceTypesAsync(Solution solution, CancellationToken cancellationToken)
|
|
58
|
+
{
|
|
59
|
+
var types = new List<SourceType>();
|
|
60
|
+
foreach (var project in solution.Projects)
|
|
61
|
+
{
|
|
62
|
+
var compilation = await project.GetCompilationAsync(cancellationToken)
|
|
63
|
+
?? throw new InvalidOperationException($"No compilation for {project.Name}");
|
|
64
|
+
var (layer, module) = LayerAndModule(project.Name);
|
|
65
|
+
types.AddRange(TopLevelTypes(compilation.Assembly.GlobalNamespace)
|
|
66
|
+
.Where(t => t.Locations.Any(l => l.IsInSource))
|
|
67
|
+
.Select(t => new SourceType(t, layer, module)));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return types.OrderBy(t => SymbolNames.FullName(t.Symbol), StringComparer.Ordinal).ToList();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// <summary>
|
|
74
|
+
/// <c>WorkerDwg.Export.Application</c> → (Application, Export); <c>Sample.Infrastructure</c> → (Infrastructure, Sample);
|
|
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).
|
|
77
|
+
/// </summary>
|
|
78
|
+
public static (string Layer, string Module) LayerAndModule(string projectName)
|
|
79
|
+
{
|
|
80
|
+
foreach (var layer in new[] { ApplicationLayer, InfrastructureLayer })
|
|
81
|
+
{
|
|
82
|
+
if (string.Equals(projectName, layer, StringComparison.OrdinalIgnoreCase))
|
|
83
|
+
{
|
|
84
|
+
return (layer, projectName);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (projectName.EndsWith("." + layer, StringComparison.OrdinalIgnoreCase))
|
|
88
|
+
{
|
|
89
|
+
var rest = projectName[..^(layer.Length + 1)];
|
|
90
|
+
return (layer, rest[(rest.LastIndexOf('.') + 1)..]);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return ("Other", projectName[(projectName.LastIndexOf('.') + 1)..]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/// <summary>
|
|
98
|
+
/// Ids and names are the plain type name unless several components share it; then every one of them is prefixed
|
|
99
|
+
/// with its module (<c>Export_JobOutcomeReporting</c>, "Job Outcome Reporting (Export)"). If that still clashes
|
|
100
|
+
/// (same module, different namespace or generic arity), the full type name disambiguates. The same full type twice
|
|
101
|
+
/// (a file linked into two projects) has no honest id and is an error.
|
|
102
|
+
/// </summary>
|
|
103
|
+
public static List<Component> AssignIds(IReadOnlyList<Component> components)
|
|
104
|
+
{
|
|
105
|
+
var duplicateTypes = components.GroupBy(c => c.Type).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
|
|
106
|
+
if (duplicateTypes.Count > 0)
|
|
107
|
+
{
|
|
108
|
+
throw new InvalidOperationException(
|
|
109
|
+
$"Declared in more than one project, so no unique component id: {string.Join(", ", duplicateTypes.Order(StringComparer.Ordinal))}");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
var bySimpleName = components.GroupBy(c => SymbolNames.SimpleName(c.Type)).ToDictionary(g => g.Key, g => g.Count());
|
|
113
|
+
var named = components.Select(c =>
|
|
114
|
+
{
|
|
115
|
+
var simple = SymbolNames.SimpleName(c.Type);
|
|
116
|
+
var words = DisplayName.FromTypeName(simple);
|
|
117
|
+
return bySimpleName[simple] == 1
|
|
118
|
+
? c with { Id = simple, Name = words }
|
|
119
|
+
: c with { Id = $"{c.Module}_{simple}", Name = $"{words} ({c.Module})" };
|
|
120
|
+
}).ToList();
|
|
121
|
+
|
|
122
|
+
var clashingIds = named.GroupBy(c => c.Id).Where(g => g.Count() > 1).Select(g => g.Key).ToHashSet();
|
|
123
|
+
return named
|
|
124
|
+
.Select(c => clashingIds.Contains(c.Id)
|
|
125
|
+
? c with
|
|
126
|
+
{
|
|
127
|
+
Id = SymbolNames.ToIdentifier(c.Type),
|
|
128
|
+
Name = $"{DisplayName.FromTypeName(SymbolNames.SimpleName(c.Type))} ({SymbolNames.ReadableFullName(c.Type)})",
|
|
129
|
+
}
|
|
130
|
+
: c)
|
|
131
|
+
.OrderBy(c => c.Type, StringComparer.Ordinal)
|
|
132
|
+
.ToList();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/// <summary>
|
|
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.
|
|
139
|
+
/// </summary>
|
|
140
|
+
public static Component? ToComponent(SourceType type, LoadedWorkspace loaded, List<string> warnings)
|
|
141
|
+
{
|
|
142
|
+
var spans = new List<SourceSpan>();
|
|
143
|
+
foreach (var reference in type.Symbol.DeclaringSyntaxReferences)
|
|
144
|
+
{
|
|
145
|
+
var (kind, relative) = CitableSource.Classify(loaded, reference.SyntaxTree);
|
|
146
|
+
if (kind == CitableSource.Kind.OutsideRepo)
|
|
147
|
+
{
|
|
148
|
+
var warning = $"declaration of {SymbolNames.FullName(type.Symbol)} outside the repo was ignored";
|
|
149
|
+
if (!warnings.Contains(warning))
|
|
150
|
+
{
|
|
151
|
+
warnings.Add(warning);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (kind != CitableSource.Kind.InRepo)
|
|
156
|
+
{
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
var lines = reference.GetSyntax().GetLocation().GetLineSpan();
|
|
161
|
+
spans.Add(new SourceSpan(relative, lines.StartLinePosition.Line + 1, lines.EndLinePosition.Line + 1));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (spans.Count == 0)
|
|
165
|
+
{
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return new Component(
|
|
170
|
+
Id: "",
|
|
171
|
+
Type: SymbolNames.FullName(type.Symbol),
|
|
172
|
+
Name: "",
|
|
173
|
+
Module: type.Module,
|
|
174
|
+
Layer: type.Layer,
|
|
175
|
+
Spans: spans.OrderBy(s => s.File, StringComparer.Ordinal).ThenBy(s => s.Start).ToList());
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private static IEnumerable<INamedTypeSymbol> TopLevelTypes(INamespaceSymbol ns) =>
|
|
179
|
+
ns.GetTypeMembers().Concat(ns.GetNamespaceMembers().SelectMany(TopLevelTypes));
|
|
180
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
// 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";
|
|
24
|
+
|
|
25
|
+
/// <summary>
|
|
26
|
+
/// A candidate is a top-level, source-declared class (static classes included) or record (abstract records and
|
|
27
|
+
/// record structs included) that is not an abstract class (a seam), not file-local, not an exception, not an
|
|
28
|
+
/// <c>*Options</c> class, not a DI composition root, and declares at least one method with a body of its own.
|
|
29
|
+
/// </summary>
|
|
30
|
+
public static bool IsCandidate(INamedTypeSymbol type) =>
|
|
31
|
+
(type.TypeKind == TypeKind.Class || (type.TypeKind == TypeKind.Struct && type.IsRecord))
|
|
32
|
+
&& !EdgeExtraction.IsSeam(type)
|
|
33
|
+
&& !type.IsImplicitlyDeclared
|
|
34
|
+
// A file-local type's metadata name embeds a hash of its absolute path: different per checkout.
|
|
35
|
+
&& !type.IsFileLocal
|
|
36
|
+
&& type.ContainingType is null
|
|
37
|
+
&& type.Locations.Any(l => l.IsInSource)
|
|
38
|
+
&& !type.Name.EndsWith("Options", StringComparison.Ordinal)
|
|
39
|
+
&& !IsException(type)
|
|
40
|
+
&& !IsCompositionRoot(type)
|
|
41
|
+
&& DeclaresBehaviour(type);
|
|
42
|
+
|
|
43
|
+
/// <summary>
|
|
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.
|
|
47
|
+
/// </summary>
|
|
48
|
+
public static bool IsCompositionRoot(INamedTypeSymbol 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);
|
|
55
|
+
|
|
56
|
+
// An exception carries a failure; even with factory methods of its own it is data, not a unit of behaviour.
|
|
57
|
+
private static bool IsException(INamedTypeSymbol type)
|
|
58
|
+
{
|
|
59
|
+
for (var current = type.BaseType; current is not null; current = current.BaseType)
|
|
60
|
+
{
|
|
61
|
+
if (SymbolNames.QualifiedName(current) == "System.Exception")
|
|
62
|
+
{
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// <summary>
|
|
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).
|
|
76
|
+
/// </summary>
|
|
77
|
+
private static bool DeclaresBehaviour(INamedTypeSymbol type) =>
|
|
78
|
+
type.GetMembers().Concat(ExtensionBlocks(type).SelectMany(block => block.GetMembers()))
|
|
79
|
+
.OfType<IMethodSymbol>()
|
|
80
|
+
.Any(m => !m.IsImplicitlyDeclared
|
|
81
|
+
&& !m.IsAbstract
|
|
82
|
+
&& !(m.IsPartialDefinition && m.PartialImplementationPart is null)
|
|
83
|
+
&& m.MethodKind is MethodKind.Ordinary or MethodKind.ExplicitInterfaceImplementation
|
|
84
|
+
&& !OverridesObjectMember(m)
|
|
85
|
+
&& !IsNativeInterop(m));
|
|
86
|
+
|
|
87
|
+
private static bool OverridesObjectMember(IMethodSymbol method)
|
|
88
|
+
{
|
|
89
|
+
var overridden = method.OverriddenMethod;
|
|
90
|
+
while (overridden?.OverriddenMethod is { } further)
|
|
91
|
+
{
|
|
92
|
+
overridden = further;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return overridden?.ContainingType.SpecialType == SpecialType.System_Object;
|
|
96
|
+
}
|
|
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
|
+
|
|
111
|
+
private static IEnumerable<INamedTypeSymbol> ExtensionBlocks(INamedTypeSymbol type) =>
|
|
112
|
+
type.GetTypeMembers().Where(t => t.IsExtension);
|
|
113
|
+
|
|
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
|
+
}
|
|
130
|
+
}
|
package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentsFile.cs
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
using System.Security.Cryptography;
|
|
2
|
+
using System.Text;
|
|
3
|
+
using System.Text.Encodings.Web;
|
|
4
|
+
using System.Text.Json;
|
|
5
|
+
|
|
6
|
+
namespace LogicalComponents;
|
|
7
|
+
|
|
8
|
+
/// <summary>Where a component's declaration lives: repo-relative path with forward slashes, 1-based inclusive lines.</summary>
|
|
9
|
+
public sealed record SourceSpan(string File, int Start, int End);
|
|
10
|
+
|
|
11
|
+
/// <summary>
|
|
12
|
+
/// One logical component: a class the repo config classifies as a unit of behaviour.
|
|
13
|
+
/// <see cref="Id"/> is what the diagram uses; <see cref="Type"/> is the full metadata name. <see cref="Reaches"/> is
|
|
14
|
+
/// the helper code (members of helpers, records and other non-components) that the component's code runs: a rule held
|
|
15
|
+
/// there is part of what the component does, so its responsibilities may cite it. Null until coupling is resolved.
|
|
16
|
+
/// </summary>
|
|
17
|
+
public sealed record Component(
|
|
18
|
+
string Id,
|
|
19
|
+
string Type,
|
|
20
|
+
string Name,
|
|
21
|
+
string Module,
|
|
22
|
+
string Layer,
|
|
23
|
+
IReadOnlyList<SourceSpan> Spans,
|
|
24
|
+
IReadOnlyList<SourceSpan>? Reaches = null);
|
|
25
|
+
|
|
26
|
+
/// <summary>
|
|
27
|
+
/// The classified components, the candidates the config does not classify yet (extract refuses to run with any), and
|
|
28
|
+
/// warnings.
|
|
29
|
+
/// </summary>
|
|
30
|
+
public sealed record DiscoveryResult(
|
|
31
|
+
IReadOnlyList<Component> Components, IReadOnlyList<string> Unclassified, IReadOnlyList<string> Warnings);
|
|
32
|
+
|
|
33
|
+
/// <summary>A diagram node that is not a component: an interface (<c>layer: "port"</c>) with no modelled implementation.</summary>
|
|
34
|
+
public sealed record ExternalNode(string Id, string Type, string Name, string Layer);
|
|
35
|
+
|
|
36
|
+
/// <summary>
|
|
37
|
+
/// One coupling edge between two node ids. <see cref="Kinds"/> is the sorted set of di/new/static, <see cref="Via"/> the
|
|
38
|
+
/// sorted interfaces it went through, <see cref="At"/> its first occurrence as <c>file:line</c>.
|
|
39
|
+
/// </summary>
|
|
40
|
+
public sealed record Edge(string From, string To, IReadOnlyList<string> Kinds, IReadOnlyList<string> Via, string At);
|
|
41
|
+
|
|
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>
|
|
48
|
+
public sealed record ExtractResult(
|
|
49
|
+
IReadOnlyList<Component> Components,
|
|
50
|
+
IReadOnlyList<ExternalNode> ExternalNodes,
|
|
51
|
+
IReadOnlyList<Edge> Edges,
|
|
52
|
+
IReadOnlyList<string> Warnings,
|
|
53
|
+
IReadOnlyDictionary<string, string>? FileHashes = null);
|
|
54
|
+
|
|
55
|
+
/// <summary>
|
|
56
|
+
/// Writes <c>components.json</c>. Byte-identical for identical input: fixed property order, LF line endings,
|
|
57
|
+
/// no BOM, no timestamps, and only repo-relative paths.
|
|
58
|
+
/// </summary>
|
|
59
|
+
public static class ComponentsFile
|
|
60
|
+
{
|
|
61
|
+
private static readonly JsonSerializerOptions Options = new()
|
|
62
|
+
{
|
|
63
|
+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
64
|
+
WriteIndented = true,
|
|
65
|
+
IndentSize = 2,
|
|
66
|
+
NewLine = "\n",
|
|
67
|
+
// Keep names and paths readable; the file is consumed by our own tools and by agents, not by browsers.
|
|
68
|
+
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
public static byte[] Serialize(ExtractResult result) =>
|
|
72
|
+
ToJson(new
|
|
73
|
+
{
|
|
74
|
+
components = result.Components,
|
|
75
|
+
externalNodes = result.ExternalNodes,
|
|
76
|
+
edges = result.Edges,
|
|
77
|
+
warnings = result.Warnings,
|
|
78
|
+
fileHashes = result.FileHashes,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/// <summary>Any file the tool writes: indented camelCase JSON, LF line endings, no BOM, trailing newline.</summary>
|
|
82
|
+
public static byte[] ToJson<T>(T value) =>
|
|
83
|
+
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(JsonSerializer.Serialize(value, Options) + "\n");
|
|
84
|
+
|
|
85
|
+
/// <summary>Reads a components.json written by <see cref="Serialize(ExtractResult)"/>.</summary>
|
|
86
|
+
public static ExtractResult Deserialize(byte[] json) => FromJson<ExtractResult>(json);
|
|
87
|
+
|
|
88
|
+
/// <summary>Reads any file written by <see cref="ToJson{T}"/>.</summary>
|
|
89
|
+
public static T FromJson<T>(byte[] json) =>
|
|
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"))));
|
|
101
|
+
}
|