@phuc1403/musketeer 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +2 -2
  2. package/manifest.json +12 -3
  3. package/package.json +1 -1
  4. package/src/provisioner/detect.js +17 -2
  5. package/src/resolver.js +8 -3
  6. package/src/schema.js +1 -0
  7. package/src/settings-merger.js +0 -0
  8. package/template/.claude/hooks/inject-naming-rule-into-subagents.cjs +44 -0
  9. package/template/.claude/rules/csharp-identifier-naming.md +77 -0
  10. package/template/.claude/skills/code-review/SKILL.md +4 -1
  11. package/template/.claude/skills/code-review/references/naming-rule-review.md +41 -0
  12. package/template/.claude/skills/logical-components/SKILL.md +23 -18
  13. package/template/.claude/skills/logical-components/references/component-classifier-prompt.md +1 -1
  14. package/template/.claude/skills/logical-components/references/responsibility-agent-prompt.md +1 -1
  15. package/template/.claude/skills/logical-components/scripts/Directory.Build.rsp +1 -0
  16. package/template/.claude/skills/logical-components/scripts/Directory.Build.targets +6 -0
  17. package/template/.claude/skills/logical-components/scripts/LogicalComponents/BatchPlanning.cs +2 -1
  18. package/template/.claude/skills/logical-components/scripts/LogicalComponents/BatchesCommand.cs +9 -1
  19. package/template/.claude/skills/logical-components/scripts/LogicalComponents/CandidatesCommand.cs +39 -11
  20. package/template/.claude/skills/logical-components/scripts/LogicalComponents/CitableSource.cs +19 -4
  21. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ClassifyBatchesCommand.cs +25 -4
  22. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ClassifyMergeCommand.cs +43 -11
  23. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentDiscovery.cs +20 -6
  24. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentRule.cs +57 -14
  25. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentsFile.cs +20 -2
  26. package/template/.claude/skills/logical-components/scripts/LogicalComponents/CouplingResolver.cs +70 -15
  27. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ExtractCommand.cs +28 -13
  28. package/template/.claude/skills/logical-components/scripts/LogicalComponents/MarkdownCodeSpans.cs +72 -0
  29. package/template/.claude/skills/logical-components/scripts/LogicalComponents/MarkdownRenderer.cs +11 -6
  30. package/template/.claude/skills/logical-components/scripts/LogicalComponents/MemberUseResolution.cs +91 -22
  31. package/template/.claude/skills/logical-components/scripts/LogicalComponents/MemberUses.cs +85 -9
  32. package/template/.claude/skills/logical-components/scripts/LogicalComponents/PlainText.cs +55 -0
  33. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ProjectFilters.cs +3 -2
  34. package/template/.claude/skills/logical-components/scripts/LogicalComponents/PruneCommand.cs +36 -4
  35. package/template/.claude/skills/logical-components/scripts/LogicalComponents/RenderCommand.cs +75 -14
  36. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ResponsibilityValidation.cs +45 -26
  37. package/template/.claude/skills/logical-components/scripts/LogicalComponents/SeamDispatch.cs +56 -0
  38. package/template/.claude/skills/logical-components/scripts/LogicalComponents/SourceCodeLines.cs +79 -0
  39. package/template/.claude/skills/logical-components/scripts/LogicalComponents/TypeMap.cs +31 -0
  40. package/template/.claude/skills/logical-components/scripts/LogicalComponents/WorkspaceLoader.cs +86 -16
  41. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/BatchPlanningTests.cs +8 -0
  42. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/BatchesCommandTests.cs +37 -0
  43. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CandidatesCommandTests.cs +25 -0
  44. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ClassifyCommandsTests.cs +73 -0
  45. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ComponentDiscoveryTests.cs +63 -0
  46. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CouplingAppExtractionTests.cs +188 -0
  47. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CouplingAppWorkspace.cs +30 -0
  48. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CouplingExtractionTests.cs +1 -1
  49. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/FixturePaths.cs +4 -0
  50. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/MarkdownRendererTests.cs +45 -2
  51. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ProjectFiltersTests.cs +4 -0
  52. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/PruneCommandTests.cs +30 -0
  53. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/RenderCommandTests.cs +66 -3
  54. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/RenderTestData.cs +19 -13
  55. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ResponsibilityValidationTests.cs +89 -1
  56. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/WorkspaceLoaderTests.cs +181 -2
  57. package/template/.claude/skills/logical-components/scripts/NuGet.config +17 -0
  58. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/CompositionRootApp.slnx +5 -0
  59. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/HostBuilderExtensions.cs +15 -0
  60. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/HostBuilderShims.cs +25 -0
  61. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/NativeInterop.cs +17 -0
  62. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/Root.Application.csproj +7 -0
  63. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/RootAutofacModule.cs +11 -0
  64. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/RootGreeter.cs +7 -0
  65. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/logical-components.json +48 -0
  66. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/AbstractMemberCases.cs +27 -0
  67. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/AttributeCases.cs +23 -0
  68. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/CouplingApp.Application.csproj +7 -0
  69. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/GenericBaseCases.cs +14 -0
  70. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/GenericSeamBindingCases.cs +59 -0
  71. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/ImplicitCallCases.cs +230 -0
  72. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/PartialInheritCases.First.cs +11 -0
  73. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/PartialInheritCases.Second.cs +14 -0
  74. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/StackOverflowCases.cs +14 -0
  75. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/ViaDisambiguationCases.cs +36 -0
  76. package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/MultiTargetApp.slnx +1 -0
  77. package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Multi.Infrastructure/Multi.Infrastructure.csproj +12 -0
  78. package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Multi.Infrastructure/SchedulingClient.cs +12 -0
  79. package/template/.claude/skills/logical-components/scripts/fixtures/ObjOutputApp/ObjOutputApp.slnx +5 -0
  80. package/template/.claude/skills/logical-components/scripts/fixtures/ObjOutputApp/src/Obj.Application/Greeting.cs +7 -0
  81. package/template/.claude/skills/logical-components/scripts/fixtures/ObjOutputApp/src/Obj.Application/Obj.Application.csproj +14 -0
  82. package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/components.json +5 -1
@@ -7,9 +7,12 @@ namespace LogicalComponents;
7
7
  /// <summary>
8
8
  /// <c>render --components &lt;components.json&gt; --resp &lt;dir|file&gt;... --repo &lt;repoPath&gt; --out &lt;md&gt;</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
- /// Exit codes: 0 written; 1 usage; 2 inputs unusable (unreadable components.json, a missing path, a repo that does not
12
- /// match components.json, an unwritable --out); 3 responsibilities rejected, every problem on stderr and no --out left.
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 does not match components.json is not the agents' fault.
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
- // Never leave a document from an earlier run looking like this run's result.
75
- if (File.Exists(arguments.Out))
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(Path.GetDirectoryName(Path.GetFullPath(arguments.Out))!);
86
- var markdown = MarkdownRenderer.Render(extraction, batches.Responsibilities);
87
- await File.WriteAllBytesAsync(arguments.Out, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(markdown));
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 {arguments.Out}: {e.Message}");
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>One plain markdown line: no HTML or links (they would render as something else), balanced code spans.</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
- if (text.Count(c => c == '`') % 2 != 0)
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
- var outsideCode = CodeSpan().Replace(text, "");
104
- if (HtmlTag().IsMatch(outsideCode))
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
- return outsideCode.Contains("](") ? "text contains a link" : null;
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, Component component, IReadOnlyDictionary<string, IReadOnlyList<string>> sourceLines)
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) && (IsCode(lines[n - 1]) || n == citation.Start || n == citation.End)))
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 => IsCode(lines[n - 1]))
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
  }
@@ -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&lt;T&gt; : IHandler&lt;T&gt;</c>
62
+ /// against <c>IHandler&lt;Ping&gt;</c>), plus a generic method declared on the seam (e.g. <c>Send&lt;T&gt;</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
  }
@@ -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
  {
@@ -37,7 +37,12 @@ public static class WorkspaceLoader
37
37
  {
38
38
  private static readonly TimeSpan RestoreTimeout = TimeSpan.FromMinutes(10);
39
39
 
40
- public static async Task<LoadedWorkspace> LoadAsync(string repoPath, bool restore = true, TextWriter? log = null)
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 isSource = byFile.Key != ""
143
+ var isTest = byFile.Key != ""
137
144
  && projects[0].Language == LanguageNames.CSharp
138
- && !ProjectFilters.IsTestProject(Path.GetRelativePath(repoRoot, byFile.Key), File.ReadAllText(byFile.Key));
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
- foreach (var project in projects.Where(p => p != kept))
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(project.Id);
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
- .Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
183
- .Any(segment => segment is "bin" or "obj" || segment.StartsWith('.')))
184
- .Where(file => !ProjectFilters.IsTestProject(Path.GetRelativePath(repoRoot, file), File.ReadAllText(file)))
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
  {