@phuc1403/musketeer 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/manifest.json +12 -3
- package/package.json +1 -1
- package/src/provisioner/detect.js +17 -2
- package/src/resolver.js +8 -3
- package/src/schema.js +1 -0
- package/src/settings-merger.js +0 -0
- package/template/.claude/hooks/inject-naming-rule-into-subagents.cjs +44 -0
- package/template/.claude/rules/csharp-identifier-naming.md +77 -0
- package/template/.claude/skills/code-review/SKILL.md +4 -1
- package/template/.claude/skills/code-review/references/naming-rule-review.md +41 -0
- package/template/.claude/skills/logical-components/SKILL.md +23 -18
- package/template/.claude/skills/logical-components/references/component-classifier-prompt.md +1 -1
- package/template/.claude/skills/logical-components/references/responsibility-agent-prompt.md +1 -1
- package/template/.claude/skills/logical-components/scripts/Directory.Build.rsp +1 -0
- package/template/.claude/skills/logical-components/scripts/Directory.Build.targets +6 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/BatchPlanning.cs +2 -1
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/BatchesCommand.cs +9 -1
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/CandidatesCommand.cs +39 -11
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/CitableSource.cs +19 -4
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ClassifyBatchesCommand.cs +25 -4
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ClassifyMergeCommand.cs +43 -11
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentDiscovery.cs +20 -6
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentRule.cs +57 -14
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentsFile.cs +20 -2
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/CouplingResolver.cs +70 -15
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ExtractCommand.cs +28 -13
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/MarkdownCodeSpans.cs +72 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/MarkdownRenderer.cs +11 -6
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/MemberUseResolution.cs +91 -22
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/MemberUses.cs +85 -9
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/PlainText.cs +55 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ProjectFilters.cs +3 -2
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/PruneCommand.cs +36 -4
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/RenderCommand.cs +75 -14
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/ResponsibilityValidation.cs +45 -26
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/SeamDispatch.cs +56 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/SourceCodeLines.cs +79 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/TypeMap.cs +31 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents/WorkspaceLoader.cs +86 -16
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/BatchPlanningTests.cs +8 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/BatchesCommandTests.cs +37 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CandidatesCommandTests.cs +25 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ClassifyCommandsTests.cs +73 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ComponentDiscoveryTests.cs +63 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CouplingAppExtractionTests.cs +188 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CouplingAppWorkspace.cs +30 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CouplingExtractionTests.cs +1 -1
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/FixturePaths.cs +4 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/MarkdownRendererTests.cs +45 -2
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ProjectFiltersTests.cs +4 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/PruneCommandTests.cs +30 -0
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/RenderCommandTests.cs +66 -3
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/RenderTestData.cs +19 -13
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ResponsibilityValidationTests.cs +89 -1
- package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/WorkspaceLoaderTests.cs +181 -2
- package/template/.claude/skills/logical-components/scripts/NuGet.config +17 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/CompositionRootApp.slnx +5 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/HostBuilderExtensions.cs +15 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/HostBuilderShims.cs +25 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/NativeInterop.cs +17 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/Root.Application.csproj +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/RootAutofacModule.cs +11 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/RootGreeter.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/logical-components.json +48 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/AbstractMemberCases.cs +27 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/AttributeCases.cs +23 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/CouplingApp.Application.csproj +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/GenericBaseCases.cs +14 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/GenericSeamBindingCases.cs +59 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/ImplicitCallCases.cs +230 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/PartialInheritCases.First.cs +11 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/PartialInheritCases.Second.cs +14 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/StackOverflowCases.cs +14 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/ViaDisambiguationCases.cs +36 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/MultiTargetApp.slnx +1 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Multi.Infrastructure/Multi.Infrastructure.csproj +12 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Multi.Infrastructure/SchedulingClient.cs +12 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/ObjOutputApp/ObjOutputApp.slnx +5 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/ObjOutputApp/src/Obj.Application/Greeting.cs +7 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/ObjOutputApp/src/Obj.Application/Obj.Application.csproj +14 -0
- package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/components.json +5 -1
package/template/.claude/skills/logical-components/scripts/LogicalComponents/RenderCommand.cs
CHANGED
|
@@ -7,9 +7,12 @@ namespace LogicalComponents;
|
|
|
7
7
|
/// <summary>
|
|
8
8
|
/// <c>render --components <components.json> --resp <dir|file>... --repo <repoPath> --out <md></c>.
|
|
9
9
|
/// Merges the agents' batch files (a directory means every <c>batch-*.json</c> in it, read in name order), validates them
|
|
10
|
-
/// against the extraction and the repo's files, and writes the markdown only when everything is valid.
|
|
11
|
-
///
|
|
12
|
-
///
|
|
10
|
+
/// against the extraction and the repo's files, and writes the markdown only when everything is valid. The markdown is
|
|
11
|
+
/// written to a temp file beside --out and renamed over it, so a failed or rejected run never deletes or corrupts
|
|
12
|
+
/// whatever --out already held.
|
|
13
|
+
/// Exit codes: 0 written; 1 usage; 2 inputs unusable (unreadable components.json, a missing path, components.json has
|
|
14
|
+
/// no file-hash map from an old extract, a file the extraction cites changed or went missing since extract, an
|
|
15
|
+
/// unwritable --out); 3 responsibilities rejected, every problem on stderr, --out left exactly as it was.
|
|
13
16
|
/// </summary>
|
|
14
17
|
public static class RenderCommand
|
|
15
18
|
{
|
|
@@ -51,10 +54,28 @@ public static class RenderCommand
|
|
|
51
54
|
return 2;
|
|
52
55
|
}
|
|
53
56
|
|
|
54
|
-
// Checked before any citation: a repo that
|
|
57
|
+
// Checked before any citation: a repo that no longer matches components.json is not the agents' fault.
|
|
58
|
+
if (extraction.FileHashes is null)
|
|
59
|
+
{
|
|
60
|
+
await errors.WriteLineAsync($"{arguments.Components} has no file hashes (from an old extract); re-run extract");
|
|
61
|
+
return 2;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
var changed = ChangedFiles(extraction.FileHashes, arguments.Repo);
|
|
65
|
+
if (changed.Count > 0)
|
|
66
|
+
{
|
|
67
|
+
foreach (var file in changed)
|
|
68
|
+
{
|
|
69
|
+
await errors.WriteLineAsync($"{file}: changed since extract");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return 2;
|
|
73
|
+
}
|
|
74
|
+
|
|
55
75
|
var sourceLines = ReadSourceLines(extraction, arguments.Repo, out var missingSources);
|
|
56
76
|
if (missingSources.Count > 0)
|
|
57
77
|
{
|
|
78
|
+
// Defensive only: every span/reaches file is already covered by the file-hash check above.
|
|
58
79
|
await errors.WriteLineAsync(
|
|
59
80
|
$"The repo does not match components.json (re-run extract); missing: {string.Join(", ", missingSources)}");
|
|
60
81
|
return 2;
|
|
@@ -71,27 +92,67 @@ public static class RenderCommand
|
|
|
71
92
|
await errors.WriteLineAsync(problem);
|
|
72
93
|
}
|
|
73
94
|
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
{
|
|
77
|
-
File.Delete(arguments.Out);
|
|
78
|
-
}
|
|
79
|
-
|
|
95
|
+
// A rejected run is the agents' problem, not the user's: an --out from an earlier successful run is left
|
|
96
|
+
// exactly as it was, so a failed retry can never look like data loss.
|
|
80
97
|
return 3;
|
|
81
98
|
}
|
|
82
99
|
|
|
100
|
+
return await WriteAsync(arguments.Out, extraction, batches.Responsibilities, errors);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/// <summary>
|
|
104
|
+
/// Writes the markdown to a temp file beside <paramref name="outPath"/> and renames it over the target only once
|
|
105
|
+
/// the write succeeds, so a failure (disk full, an unwritable path) never leaves a partial or truncated --out, and
|
|
106
|
+
/// never touches whatever was already there.
|
|
107
|
+
/// </summary>
|
|
108
|
+
private static async Task<int> WriteAsync(
|
|
109
|
+
string outPath, ExtractResult extraction, IReadOnlyDictionary<string, IReadOnlyList<Responsibility>> responsibilities,
|
|
110
|
+
TextWriter errors)
|
|
111
|
+
{
|
|
112
|
+
var fullPath = Path.GetFullPath(outPath);
|
|
113
|
+
var directory = Path.GetDirectoryName(fullPath)!;
|
|
114
|
+
var temp = Path.Combine(directory, $".{Path.GetFileName(fullPath)}.{Guid.NewGuid():N}.tmp");
|
|
83
115
|
try
|
|
84
116
|
{
|
|
85
|
-
Directory.CreateDirectory(
|
|
86
|
-
var markdown = MarkdownRenderer.Render(extraction,
|
|
87
|
-
await File.WriteAllBytesAsync(
|
|
117
|
+
Directory.CreateDirectory(directory);
|
|
118
|
+
var markdown = MarkdownRenderer.Render(extraction, responsibilities);
|
|
119
|
+
await File.WriteAllBytesAsync(temp, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(markdown));
|
|
120
|
+
File.Move(temp, outPath, overwrite: true);
|
|
88
121
|
return 0;
|
|
89
122
|
}
|
|
90
123
|
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
|
|
91
124
|
{
|
|
92
|
-
await errors.WriteLineAsync($"Cannot write {
|
|
125
|
+
await errors.WriteLineAsync($"Cannot write {outPath}: {e.Message}");
|
|
93
126
|
return 2;
|
|
94
127
|
}
|
|
128
|
+
finally
|
|
129
|
+
{
|
|
130
|
+
if (File.Exists(temp))
|
|
131
|
+
{
|
|
132
|
+
File.Delete(temp);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// <summary>Every file in <paramref name="hashes"/> whose current content hash differs, in file order, or that is missing.</summary>
|
|
138
|
+
private static List<string> ChangedFiles(IReadOnlyDictionary<string, string> hashes, string repo) =>
|
|
139
|
+
hashes.OrderBy(h => h.Key, StringComparer.Ordinal)
|
|
140
|
+
.Where(h => IsChanged(Path.Combine(repo, h.Key), h.Value))
|
|
141
|
+
.Select(h => h.Key)
|
|
142
|
+
.ToList();
|
|
143
|
+
|
|
144
|
+
private static bool IsChanged(string path, string expectedHash)
|
|
145
|
+
{
|
|
146
|
+
try
|
|
147
|
+
{
|
|
148
|
+
return !File.Exists(path) || ComponentsFile.HashFile(path) != expectedHash;
|
|
149
|
+
}
|
|
150
|
+
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
|
|
151
|
+
{
|
|
152
|
+
// Became unreadable between the existence check and the hash read: treat it the same as changed, since
|
|
153
|
+
// render cannot trust an uncheckable citation either.
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
95
156
|
}
|
|
96
157
|
|
|
97
158
|
private static async Task<ExtractResult?> ReadExtractionAsync(string path, TextWriter errors)
|
|
@@ -23,6 +23,9 @@ public static partial class ResponsibilityValidation
|
|
|
23
23
|
{
|
|
24
24
|
var errors = new List<(string Id, int Item, string Message)>();
|
|
25
25
|
var components = extraction.Components.ToDictionary(c => c.Id, StringComparer.Ordinal);
|
|
26
|
+
// Computed once per file and reused across every citation into it, since CiteProblem may be called many
|
|
27
|
+
// times for the same file (several components, or several responsibilities of one component).
|
|
28
|
+
var codeLines = new Dictionary<string, IReadOnlyList<bool>>(StringComparer.Ordinal);
|
|
26
29
|
|
|
27
30
|
foreach (var id in responsibilities.Keys.Where(id => !components.ContainsKey(id)))
|
|
28
31
|
{
|
|
@@ -51,7 +54,7 @@ public static partial class ResponsibilityValidation
|
|
|
51
54
|
errors.Add((component.Id, item, $"{prefix}repeats item {seen[Normalize(items[index].Text)]}"));
|
|
52
55
|
}
|
|
53
56
|
|
|
54
|
-
if (CiteProblem(items[index].Cite, component, sourceLines) is { } citeProblem)
|
|
57
|
+
if (CiteProblem(items[index].Cite, component, sourceLines, codeLines) is { } citeProblem)
|
|
55
58
|
{
|
|
56
59
|
errors.Add((component.Id, item, prefix + citeProblem));
|
|
57
60
|
}
|
|
@@ -77,7 +80,13 @@ public static partial class ResponsibilityValidation
|
|
|
77
80
|
return match is null ? message : $"{message}; did you mean '{match}'?";
|
|
78
81
|
}
|
|
79
82
|
|
|
80
|
-
/// <summary>
|
|
83
|
+
/// <summary>
|
|
84
|
+
/// One plain markdown line: no hidden or control unicode (<see cref="PlainText"/>), balanced code spans
|
|
85
|
+
/// (<see cref="MarkdownCodeSpans"/> - CommonMark backtick-run matching, so an escaped backtick cannot smuggle real
|
|
86
|
+
/// HTML past the check by being mistaken for a delimiter), and outside those code spans no backslash, angle
|
|
87
|
+
/// bracket, square bracket or bare URL - anything that could render as something other than plain text, or point
|
|
88
|
+
/// the reader somewhere the tool never checked.
|
|
89
|
+
/// </summary>
|
|
81
90
|
private static string? TextProblem(string text)
|
|
82
91
|
{
|
|
83
92
|
if (string.IsNullOrWhiteSpace(text))
|
|
@@ -90,27 +99,48 @@ public static partial class ResponsibilityValidation
|
|
|
90
99
|
return "text must be a single line";
|
|
91
100
|
}
|
|
92
101
|
|
|
102
|
+
if (PlainText.HasDisallowedUnicode(text))
|
|
103
|
+
{
|
|
104
|
+
return "text contains a control or invisible unicode character";
|
|
105
|
+
}
|
|
106
|
+
|
|
93
107
|
if (text.Trim().Length > MaxTextLength)
|
|
94
108
|
{
|
|
95
109
|
return $"text is {text.Trim().Length} characters; keep it under {MaxTextLength}";
|
|
96
110
|
}
|
|
97
111
|
|
|
98
|
-
|
|
112
|
+
var (outsideCode, unclosed) = MarkdownCodeSpans.Strip(text);
|
|
113
|
+
if (unclosed)
|
|
99
114
|
{
|
|
100
115
|
return "text has an unclosed ` code span";
|
|
101
116
|
}
|
|
102
117
|
|
|
103
|
-
|
|
104
|
-
|
|
118
|
+
if (outsideCode.Contains('\\'))
|
|
119
|
+
{
|
|
120
|
+
return "text contains a backslash; put code in `backticks`";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (outsideCode.IndexOfAny(['<', '>']) >= 0)
|
|
105
124
|
{
|
|
106
125
|
return "text contains HTML; put code in `backticks`";
|
|
107
126
|
}
|
|
108
127
|
|
|
109
|
-
|
|
128
|
+
if (outsideCode.IndexOfAny(['[', ']']) >= 0)
|
|
129
|
+
{
|
|
130
|
+
return "text contains a link; put code in `backticks`";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return outsideCode.Contains("://", StringComparison.Ordinal)
|
|
134
|
+
|| outsideCode.Contains("www.", StringComparison.OrdinalIgnoreCase)
|
|
135
|
+
? "text contains a URL; put code in `backticks`"
|
|
136
|
+
: null;
|
|
110
137
|
}
|
|
111
138
|
|
|
112
139
|
private static string? CiteProblem(
|
|
113
|
-
string cite,
|
|
140
|
+
string cite,
|
|
141
|
+
Component component,
|
|
142
|
+
IReadOnlyDictionary<string, IReadOnlyList<string>> sourceLines,
|
|
143
|
+
Dictionary<string, IReadOnlyList<bool>> codeLines)
|
|
114
144
|
{
|
|
115
145
|
if (Citation.Parse(cite) is not { } citation)
|
|
116
146
|
{
|
|
@@ -131,11 +161,17 @@ public static partial class ResponsibilityValidation
|
|
|
131
161
|
return $"cite '{cite}' is past the end of the file ({lines.Count} lines)";
|
|
132
162
|
}
|
|
133
163
|
|
|
164
|
+
if (!codeLines.TryGetValue(citation.File, out var code))
|
|
165
|
+
{
|
|
166
|
+
code = SourceCodeLines.Compute(lines);
|
|
167
|
+
codeLines[citation.File] = code;
|
|
168
|
+
}
|
|
169
|
+
|
|
134
170
|
// Every code line must be citable, and the first and last lines must lie inside a citable span. Lines between
|
|
135
171
|
// two citable members (blank, comment, brace) are not code, so a citation may run across neighbouring helper
|
|
136
172
|
// members without claiming anything uncited, but it cannot start or end outside them.
|
|
137
173
|
bool Citable(int line) => spans.Any(s => s.Start <= line && line <= s.End);
|
|
138
|
-
if (Enumerable.Range(citation.Start, citation.LineCount).Any(n => !Citable(n) && (
|
|
174
|
+
if (Enumerable.Range(citation.Start, citation.LineCount).Any(n => !Citable(n) && (code[n - 1] || n == citation.Start || n == citation.End)))
|
|
139
175
|
{
|
|
140
176
|
var where = component.Reaches is { Count: > 0 }
|
|
141
177
|
? "the component's declaration and the helper code it runs"
|
|
@@ -148,31 +184,14 @@ public static partial class ResponsibilityValidation
|
|
|
148
184
|
return $"cite '{cite}' spans {citation.LineCount} lines; cite at most {MaxCitedLines}";
|
|
149
185
|
}
|
|
150
186
|
|
|
151
|
-
return Enumerable.Range(citation.Start, citation.LineCount).Any(n =>
|
|
187
|
+
return Enumerable.Range(citation.Start, citation.LineCount).Any(n => code[n - 1])
|
|
152
188
|
? null
|
|
153
189
|
: $"cite '{cite}' covers no code (only blank, comment or brace lines)";
|
|
154
190
|
}
|
|
155
191
|
|
|
156
|
-
/// <summary>Not blank, not only a comment, not only braces, parentheses and semicolons.</summary>
|
|
157
|
-
private static bool IsCode(string line)
|
|
158
|
-
{
|
|
159
|
-
var trimmed = line.Trim();
|
|
160
|
-
return trimmed.Length > 0
|
|
161
|
-
&& !trimmed.StartsWith("//", StringComparison.Ordinal)
|
|
162
|
-
&& !trimmed.StartsWith("/*", StringComparison.Ordinal)
|
|
163
|
-
&& !trimmed.StartsWith('*')
|
|
164
|
-
&& trimmed.Trim('{', '}', '(', ')', ';', ' ', '\t').Length > 0;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
192
|
// " Stores EACH order. " and "stores each order." are the same responsibility.
|
|
168
193
|
private static string Normalize(string text) => Whitespace().Replace(text.Trim(), " ").ToLowerInvariant();
|
|
169
194
|
|
|
170
|
-
[GeneratedRegex("`[^`]*`")]
|
|
171
|
-
private static partial Regex CodeSpan();
|
|
172
|
-
|
|
173
|
-
[GeneratedRegex("<[A-Za-z/!]")]
|
|
174
|
-
private static partial Regex HtmlTag();
|
|
175
|
-
|
|
176
195
|
[GeneratedRegex(@"\s+")]
|
|
177
196
|
private static partial Regex Whitespace();
|
|
178
197
|
}
|
package/template/.claude/skills/logical-components/scripts/LogicalComponents/SeamDispatch.cs
CHANGED
|
@@ -54,6 +54,62 @@ public static class SeamDispatch
|
|
|
54
54
|
_ => null,
|
|
55
55
|
};
|
|
56
56
|
|
|
57
|
+
/// <summary>
|
|
58
|
+
/// The bindings for walking <paramref name="runs"/> (the member <see cref="ImplementationOf"/> found) as it executes
|
|
59
|
+
/// for <paramref name="member"/> of <paramref name="seam"/> (as constructed at the use) in
|
|
60
|
+
/// <paramref name="implementation"/>: <paramref name="implementation"/>'s own type parameters, unified from its
|
|
61
|
+
/// construction of the seam (an open-generic decorator's own type argument, e.g. <c>Decorator<T> : IHandler<T></c>
|
|
62
|
+
/// against <c>IHandler<Ping></c>), plus a generic method declared on the seam (e.g. <c>Send<T></c>) bound to
|
|
63
|
+
/// the type argument the use supplied. <paramref name="outer"/> resolves a use's type argument that is itself still
|
|
64
|
+
/// an unbound type parameter of an enclosing walk. Without this, <see cref="MemberUseResolution"/> would walk
|
|
65
|
+
/// <paramref name="runs"/> with its own type parameters unbound, and a seam it calls in turn would look like it
|
|
66
|
+
/// matches every construction (<see cref="PortResolution"/>).
|
|
67
|
+
/// </summary>
|
|
68
|
+
public static TypeMap BindingFor(
|
|
69
|
+
INamedTypeSymbol implementation, INamedTypeSymbol seam, ISymbol member, ISymbol runs, TypeMap outer)
|
|
70
|
+
{
|
|
71
|
+
var bindings = new List<(ITypeParameterSymbol Parameter, ITypeSymbol Argument)>();
|
|
72
|
+
|
|
73
|
+
if (seam.IsGenericType)
|
|
74
|
+
{
|
|
75
|
+
var definition = SymbolNames.FullName(seam.OriginalDefinition);
|
|
76
|
+
var construction = Supertypes(implementation, seam.TypeKind)
|
|
77
|
+
.FirstOrDefault(s => SymbolNames.FullName(s.OriginalDefinition) == definition);
|
|
78
|
+
for (var i = 0; construction is not null && i < construction.TypeArguments.Length && i < seam.TypeArguments.Length; i++)
|
|
79
|
+
{
|
|
80
|
+
if (construction.TypeArguments[i] is ITypeParameterSymbol parameter
|
|
81
|
+
&& SymbolEqualityComparer.Default.Equals(parameter.ContainingSymbol, implementation))
|
|
82
|
+
{
|
|
83
|
+
bindings.Add((parameter, seam.TypeArguments[i]));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (member is IMethodSymbol { TypeArguments.Length: > 0 } used && runs is IMethodSymbol { TypeArguments.Length: > 0 } implemented)
|
|
89
|
+
{
|
|
90
|
+
bindings.AddRange(TypeMap.Zip(implemented.OriginalDefinition.TypeParameters, used.TypeArguments)
|
|
91
|
+
.Select(b => (b.Parameter, outer.Substitute(b.Argument))));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return TypeMap.FromBindings(bindings);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private static IEnumerable<INamedTypeSymbol> Supertypes(INamedTypeSymbol type, TypeKind seamKind)
|
|
98
|
+
{
|
|
99
|
+
if (seamKind == TypeKind.Interface)
|
|
100
|
+
{
|
|
101
|
+
return type.AllInterfaces;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
var bases = new List<INamedTypeSymbol>();
|
|
105
|
+
for (var current = type.BaseType; current is not null; current = current.BaseType)
|
|
106
|
+
{
|
|
107
|
+
bases.Add(current);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return bases;
|
|
111
|
+
}
|
|
112
|
+
|
|
57
113
|
// Compared as text: symbols of the same type seen from two projects' compilations are not the same instance.
|
|
58
114
|
private static string Display(ITypeSymbol type) => type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
|
|
59
115
|
}
|
package/template/.claude/skills/logical-components/scripts/LogicalComponents/SourceCodeLines.cs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
using System.Text;
|
|
2
|
+
|
|
3
|
+
namespace LogicalComponents;
|
|
4
|
+
|
|
5
|
+
/// <summary>
|
|
6
|
+
/// Which lines of a whole file contain real code, for <see cref="ResponsibilityValidation"/>'s citation checks. Tracks
|
|
7
|
+
/// `/* */` block-comment state across the file (so a continuation line without a leading `*` is still recognised as a
|
|
8
|
+
/// comment, and a comment that closes mid-line is not mistaken for a comment-only line), and treats punctuation-only
|
|
9
|
+
/// lines, preprocessor directives, and a lone `else`/`try`/`finally`/`do` with no condition of its own as not code.
|
|
10
|
+
/// </summary>
|
|
11
|
+
public static class SourceCodeLines
|
|
12
|
+
{
|
|
13
|
+
public static IReadOnlyList<bool> Compute(IReadOnlyList<string> lines)
|
|
14
|
+
{
|
|
15
|
+
var result = new bool[lines.Count];
|
|
16
|
+
var inBlockComment = false;
|
|
17
|
+
for (var i = 0; i < lines.Count; i++)
|
|
18
|
+
{
|
|
19
|
+
result[i] = LineHasCode(lines[i], ref inBlockComment);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// <summary>
|
|
26
|
+
/// Strips `//` line comments and `/* */` block comments from one line (updating <paramref name="inBlockComment"/>
|
|
27
|
+
/// for the next line), then true if what remains is more than punctuation <c>{}()[];,</c>, a preprocessor
|
|
28
|
+
/// directive, or a lone <c>else</c>/<c>try</c>/<c>finally</c>/<c>do</c>.
|
|
29
|
+
/// </summary>
|
|
30
|
+
private static bool LineHasCode(string line, ref bool inBlockComment)
|
|
31
|
+
{
|
|
32
|
+
var code = new StringBuilder();
|
|
33
|
+
var i = 0;
|
|
34
|
+
while (i < line.Length)
|
|
35
|
+
{
|
|
36
|
+
if (inBlockComment)
|
|
37
|
+
{
|
|
38
|
+
var close = line.IndexOf("*/", i, StringComparison.Ordinal);
|
|
39
|
+
if (close < 0)
|
|
40
|
+
{
|
|
41
|
+
break; // the rest of the line is still inside the comment
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
inBlockComment = false;
|
|
45
|
+
i = close + 2;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (line[i] == '/' && i + 1 < line.Length && line[i + 1] == '/')
|
|
50
|
+
{
|
|
51
|
+
break; // the rest of the line is a line comment
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (line[i] == '/' && i + 1 < line.Length && line[i + 1] == '*')
|
|
55
|
+
{
|
|
56
|
+
var close = line.IndexOf("*/", i + 2, StringComparison.Ordinal);
|
|
57
|
+
if (close < 0)
|
|
58
|
+
{
|
|
59
|
+
inBlockComment = true;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
i = close + 2;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
code.Append(line[i]);
|
|
68
|
+
i++;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (code.ToString().TrimStart(' ', '\t').StartsWith('#'))
|
|
72
|
+
{
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
var stripped = code.ToString().Trim('{', '}', '(', ')', '[', ']', ';', ',', ' ', '\t');
|
|
77
|
+
return stripped.Length > 0 && stripped is not ("else" or "try" or "finally" or "do");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -25,6 +25,37 @@ public sealed class TypeMap
|
|
|
25
25
|
/// <summary>Stable text of the bindings: the same member walked for two different bindings is two walks.</summary>
|
|
26
26
|
public string Key { get; }
|
|
27
27
|
|
|
28
|
+
/// <summary>
|
|
29
|
+
/// A map built from known (parameter, argument) pairs: a generic base class's own parameters bound to what the
|
|
30
|
+
/// derived class supplies (<see cref="CouplingResolver"/>), or an implementation's own parameters unified from its
|
|
31
|
+
/// construction of a seam, plus a generic seam method's parameters bound to the use's type arguments
|
|
32
|
+
/// (<see cref="SeamDispatch"/>). Pairs where the argument is the parameter itself carry no information and are
|
|
33
|
+
/// dropped, same as <see cref="For"/>.
|
|
34
|
+
/// </summary>
|
|
35
|
+
public static TypeMap FromBindings(IEnumerable<(ITypeParameterSymbol Parameter, ITypeSymbol Argument)> bindings)
|
|
36
|
+
{
|
|
37
|
+
var map = new Dictionary<ITypeParameterSymbol, ITypeSymbol>(SymbolEqualityComparer.Default);
|
|
38
|
+
foreach (var (parameter, argument) in bindings)
|
|
39
|
+
{
|
|
40
|
+
if (!SymbolEqualityComparer.Default.Equals(parameter, argument))
|
|
41
|
+
{
|
|
42
|
+
map[parameter] = argument;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return map.Count == 0 ? Empty : new TypeMap(map);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// <summary>Pairs a generic definition's own type parameters with a construction's type arguments, positionally.</summary>
|
|
50
|
+
public static IEnumerable<(ITypeParameterSymbol Parameter, ITypeSymbol Argument)> Zip(
|
|
51
|
+
ImmutableArray<ITypeParameterSymbol> parameters, ImmutableArray<ITypeSymbol> arguments)
|
|
52
|
+
{
|
|
53
|
+
for (var i = 0; i < parameters.Length && i < arguments.Length; i++)
|
|
54
|
+
{
|
|
55
|
+
yield return (parameters[i], arguments[i]);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
28
59
|
/// <summary>The type with every mapped type parameter replaced, inside generic arguments too.</summary>
|
|
29
60
|
public ITypeSymbol Substitute(ITypeSymbol type) => type switch
|
|
30
61
|
{
|
package/template/.claude/skills/logical-components/scripts/LogicalComponents/WorkspaceLoader.cs
CHANGED
|
@@ -37,7 +37,12 @@ public static class WorkspaceLoader
|
|
|
37
37
|
{
|
|
38
38
|
private static readonly TimeSpan RestoreTimeout = TimeSpan.FromMinutes(10);
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
/// <param name="solution">
|
|
41
|
+
/// An explicit solution file, relative to <paramref name="repoPath"/> or absolute, used instead of searching the
|
|
42
|
+
/// repo root. Use it to resolve the "more than one solution" error, or to pick a non-top-level solution on purpose.
|
|
43
|
+
/// </param>
|
|
44
|
+
public static async Task<LoadedWorkspace> LoadAsync(
|
|
45
|
+
string repoPath, bool restore = true, string? solution = null, TextWriter? log = null)
|
|
41
46
|
{
|
|
42
47
|
var repoRoot = Path.GetFullPath(repoPath);
|
|
43
48
|
if (!Directory.Exists(repoRoot))
|
|
@@ -47,7 +52,7 @@ public static class WorkspaceLoader
|
|
|
47
52
|
|
|
48
53
|
log ??= TextWriter.Null;
|
|
49
54
|
|
|
50
|
-
var solutionFile = FindSolutionFile(repoRoot);
|
|
55
|
+
var solutionFile = solution is null ? FindSolutionFile(repoRoot) : ResolveGivenSolution(repoRoot, solution);
|
|
51
56
|
if (solutionFile is not null)
|
|
52
57
|
{
|
|
53
58
|
var name = Path.GetFileName(solutionFile);
|
|
@@ -62,7 +67,7 @@ public static class WorkspaceLoader
|
|
|
62
67
|
log.WriteLine($"{name} contains no C# source projects; falling back to project files.");
|
|
63
68
|
}
|
|
64
69
|
|
|
65
|
-
var (projectFiles, glob) = FindProjectFiles(repoRoot);
|
|
70
|
+
var (projectFiles, glob) = FindProjectFiles(repoRoot, log);
|
|
66
71
|
if (projectFiles.Count == 0)
|
|
67
72
|
{
|
|
68
73
|
throw new WorkspaceLoadException($"No solution and no C# source project found under {repoRoot}");
|
|
@@ -115,7 +120,7 @@ public static class WorkspaceLoader
|
|
|
115
120
|
$"Loading {source} failed:{Environment.NewLine} " + string.Join(Environment.NewLine + " ", failures));
|
|
116
121
|
}
|
|
117
122
|
|
|
118
|
-
return new LoadedWorkspace(workspace, KeepSourceProjects(workspace.CurrentSolution, repoRoot), repoRoot, source);
|
|
123
|
+
return new LoadedWorkspace(workspace, KeepSourceProjects(workspace.CurrentSolution, repoRoot, log), repoRoot, source);
|
|
119
124
|
}
|
|
120
125
|
catch (Exception e)
|
|
121
126
|
{
|
|
@@ -126,23 +131,37 @@ public static class WorkspaceLoader
|
|
|
126
131
|
|
|
127
132
|
/// <summary>
|
|
128
133
|
/// Drops non-C# and test projects, and keeps one project per multi-targeted project file: the highest target
|
|
129
|
-
/// framework, renamed without its <c>(tfm)</c> suffix, so every type is seen exactly once.
|
|
134
|
+
/// framework, renamed without its <c>(tfm)</c> suffix, so every type is seen exactly once. A project referencing a
|
|
135
|
+
/// dropped copy (another TFM of the same file resolved by a referencing project that targets a lower framework)
|
|
136
|
+
/// is redirected to the kept copy first, so it never silently loses that reference.
|
|
130
137
|
/// </summary>
|
|
131
|
-
private static Solution KeepSourceProjects(Solution solution, string repoRoot)
|
|
138
|
+
private static Solution KeepSourceProjects(Solution solution, string repoRoot, TextWriter log)
|
|
132
139
|
{
|
|
133
140
|
foreach (var byFile in solution.Projects.GroupBy(p => p.FilePath ?? "", StringComparer.OrdinalIgnoreCase))
|
|
134
141
|
{
|
|
135
142
|
var projects = byFile.ToList();
|
|
136
|
-
var
|
|
143
|
+
var isTest = byFile.Key != ""
|
|
137
144
|
&& projects[0].Language == LanguageNames.CSharp
|
|
138
|
-
&&
|
|
145
|
+
&& ProjectFilters.IsTestProject(Path.GetRelativePath(repoRoot, byFile.Key), File.ReadAllText(byFile.Key));
|
|
146
|
+
if (isTest)
|
|
147
|
+
{
|
|
148
|
+
log.WriteLine($"dropped test project: {Path.GetRelativePath(repoRoot, byFile.Key)}");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
var isSource = byFile.Key != "" && projects[0].Language == LanguageNames.CSharp && !isTest;
|
|
139
152
|
var kept = isSource
|
|
140
153
|
? projects.MaxBy(p => ProjectFilters.TargetFrameworkRank(p.Name))
|
|
141
154
|
: null;
|
|
142
155
|
|
|
143
|
-
|
|
156
|
+
var dropped = projects.Where(p => p != kept).Select(p => p.Id).ToList();
|
|
157
|
+
if (dropped.Count > 0)
|
|
158
|
+
{
|
|
159
|
+
solution = RedirectProjectReferences(solution, dropped, kept?.Id);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
foreach (var id in dropped)
|
|
144
163
|
{
|
|
145
|
-
solution = solution.RemoveProject(
|
|
164
|
+
solution = solution.RemoveProject(id);
|
|
146
165
|
}
|
|
147
166
|
|
|
148
167
|
if (kept is not null)
|
|
@@ -154,6 +173,33 @@ public static class WorkspaceLoader
|
|
|
154
173
|
return solution;
|
|
155
174
|
}
|
|
156
175
|
|
|
176
|
+
/// <summary>
|
|
177
|
+
/// Points every reference to one of <paramref name="droppedIds"/> at <paramref name="keptId"/> instead (deduped
|
|
178
|
+
/// against a reference the project already has), or drops it if there is no kept copy to redirect to (the whole
|
|
179
|
+
/// group is being dropped, e.g. a test or non-C# project).
|
|
180
|
+
/// </summary>
|
|
181
|
+
private static Solution RedirectProjectReferences(
|
|
182
|
+
Solution solution, IReadOnlyCollection<ProjectId> droppedIds, ProjectId? keptId)
|
|
183
|
+
{
|
|
184
|
+
foreach (var project in solution.Projects.ToList())
|
|
185
|
+
{
|
|
186
|
+
if (droppedIds.Contains(project.Id) || project.ProjectReferences.All(r => !droppedIds.Contains(r.ProjectId)))
|
|
187
|
+
{
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
var references = project.ProjectReferences.Where(r => !droppedIds.Contains(r.ProjectId)).ToList();
|
|
192
|
+
if (keptId is not null && project.Id != keptId && references.All(r => r.ProjectId != keptId))
|
|
193
|
+
{
|
|
194
|
+
references.Add(new ProjectReference(keptId));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
solution = solution.WithProjectReferences(project.Id, references);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return solution;
|
|
201
|
+
}
|
|
202
|
+
|
|
157
203
|
private static string? FindSolutionFile(string repoRoot)
|
|
158
204
|
{
|
|
159
205
|
// Top-level only: a nested solution usually belongs to a sample or a vendored dependency.
|
|
@@ -167,21 +213,45 @@ public static class WorkspaceLoader
|
|
|
167
213
|
0 => null,
|
|
168
214
|
1 => candidates[0],
|
|
169
215
|
_ => throw new WorkspaceLoadException(
|
|
170
|
-
$"More than one solution in {repoRoot}: {string.Join(", ", candidates.Select(Path.GetFileName))}"
|
|
216
|
+
$"More than one solution in {repoRoot}: {string.Join(", ", candidates.Select(Path.GetFileName))}"
|
|
217
|
+
+ "; pass --solution <file> to pick one."),
|
|
171
218
|
};
|
|
172
219
|
}
|
|
173
220
|
|
|
221
|
+
/// <summary>Resolves an explicit <c>--solution</c> file (relative to the repo root, or absolute); errors if missing.</summary>
|
|
222
|
+
private static string ResolveGivenSolution(string repoRoot, string solution)
|
|
223
|
+
{
|
|
224
|
+
var path = Path.IsPathRooted(solution) ? solution : Path.Combine(repoRoot, solution);
|
|
225
|
+
if (!File.Exists(path))
|
|
226
|
+
{
|
|
227
|
+
throw new WorkspaceLoadException($"Solution not found: {Path.GetFullPath(path)}");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return Path.GetFullPath(path);
|
|
231
|
+
}
|
|
232
|
+
|
|
174
233
|
/// <summary>C# project files under <c>src/</c>, or under the repo root minus build output and dot folders.</summary>
|
|
175
|
-
private static (List<string> Files, string Glob) FindProjectFiles(string repoRoot)
|
|
234
|
+
private static (List<string> Files, string Glob) FindProjectFiles(string repoRoot, TextWriter log)
|
|
176
235
|
{
|
|
177
236
|
var src = Path.Combine(repoRoot, "src");
|
|
178
237
|
var (searchRoot, glob) = Directory.Exists(src) ? (src, "src/**/*.csproj") : (repoRoot, "**/*.csproj");
|
|
179
238
|
var files = Directory
|
|
180
239
|
.EnumerateFiles(searchRoot, "*.csproj", SearchOption.AllDirectories)
|
|
181
|
-
.Where(file => !Path.GetRelativePath(repoRoot, file)
|
|
182
|
-
.
|
|
183
|
-
|
|
184
|
-
|
|
240
|
+
.Where(file => !CitableSource.IsBuildOutputPath(Path.GetRelativePath(repoRoot, file))
|
|
241
|
+
&& !Path.GetRelativePath(repoRoot, file)
|
|
242
|
+
.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
|
243
|
+
.Any(segment => segment.StartsWith('.')))
|
|
244
|
+
.Where(file =>
|
|
245
|
+
{
|
|
246
|
+
var relative = Path.GetRelativePath(repoRoot, file);
|
|
247
|
+
if (!ProjectFilters.IsTestProject(relative, File.ReadAllText(file)))
|
|
248
|
+
{
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
log.WriteLine($"dropped test project: {relative}");
|
|
253
|
+
return false;
|
|
254
|
+
})
|
|
185
255
|
.Order(StringComparer.Ordinal)
|
|
186
256
|
.ToList();
|
|
187
257
|
return (files, glob);
|
|
@@ -57,6 +57,14 @@ public class BatchPlanningTests
|
|
|
57
57
|
Assert.Equal(reaches, batch.Reaches);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
[Fact]
|
|
61
|
+
public void An_enormous_agent_count_does_not_overflow_the_batch_size()
|
|
62
|
+
{
|
|
63
|
+
var batches = BatchPlanning.Plan(WithComponents(3), int.MaxValue);
|
|
64
|
+
|
|
65
|
+
Assert.Equal([1, 1, 1], batches.Select(b => b.Components.Count));
|
|
66
|
+
}
|
|
67
|
+
|
|
60
68
|
[Fact]
|
|
61
69
|
public void Batch_files_are_written_identically_every_time()
|
|
62
70
|
{
|