@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
package/README.md CHANGED
@@ -37,8 +37,8 @@ musketeers are in this project; promote = upgrade the binary._
37
37
  | **core** | always on (locked, hidden) | research, handoff, skill-creator, git (+ git-manager agent) · statusline, usage-quota, format-json hooks |
38
38
  | **architecture** | off | adr-writer, architecture-characteristic-writer, context-map · CML validation hook |
39
39
  | **hallmark** | off | hallmark, hallmark-explore, hallmark-loop · auditor/explorer agents |
40
- | **code-review** | off | code-review skill + code-reviewer agent |
41
- | **dotnet** | off | tdd, knowledge-crunching, logical-components · EF migration guard hook · always merges 4 quality-gate props into root `Directory.Build.props` (`MUSKETEER_SKIP_DOTNET_PROPS=1` to skip) · scaffolds a generic `src`/`tests` Clean Architecture skeleton when the project is blank |
40
+ | **code-review** | off | code-review skill + code-reviewer agent · a parallel naming pass on `.cs` diffs when the C# naming rule is installed |
41
+ | **dotnet** | off | tdd, knowledge-crunching, logical-components · C# identifier naming rule (`.claude/rules/`, injected into every subagent by a SubagentStart hook; deleting the file turns it off until the next muster, deselecting dotnet removes it) · EF migration guard hook · always merges 4 quality-gate props into root `Directory.Build.props` (`MUSKETEER_SKIP_DOTNET_PROPS=1` to skip) · scaffolds a generic `src`/`tests` Clean Architecture skeleton when the project is blank |
42
42
  | **design-docs** | off | inject-design-docs SessionStart hook |
43
43
 
44
44
  The muster starts every musketeer **unselected**, except ones you already installed (pre-checked from
package/manifest.json CHANGED
@@ -171,7 +171,7 @@
171
171
  },
172
172
  "dotnet": {
173
173
  "label": "dotnet",
174
- "description": ".NET extras: tdd, knowledge-crunching, logical-components + EF migration-guard & ubiquitous-language auto-load hooks. Muster also always merges 4 quality-gate MSBuild properties into root Directory.Build.props (any dotnet-selected project, set MUSKETEER_SKIP_DOTNET_PROPS=1 to skip) and scaffolds a generic src/tests Clean Architecture skeleton when the project is genuinely blank.",
174
+ "description": ".NET extras: tdd, knowledge-crunching, logical-components + EF migration-guard & ubiquitous-language auto-load hooks + the C# identifier naming rule (.claude/rules, also injected into every subagent). Muster also always merges 4 quality-gate MSBuild properties into root Directory.Build.props (any dotnet-selected project, set MUSKETEER_SKIP_DOTNET_PROPS=1 to skip) and scaffolds a generic src/tests Clean Architecture skeleton when the project is genuinely blank.",
175
175
  "locked": false,
176
176
  "deps": [],
177
177
  "files": [
@@ -179,7 +179,9 @@
179
179
  "skills/knowledge-crunching/**",
180
180
  "skills/logical-components/**",
181
181
  "hooks/block-migration-edits.cjs",
182
- "hooks/inject-ubiquitous-language.cjs"
182
+ "hooks/inject-ubiquitous-language.cjs",
183
+ "hooks/inject-naming-rule-into-subagents.cjs",
184
+ "rules/csharp-identifier-naming.md"
183
185
  ],
184
186
  "settings": [
185
187
  {
@@ -195,6 +197,12 @@
195
197
  "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/inject-ubiquitous-language.cjs\"",
196
198
  "order": 1,
197
199
  "statusMessage": "Loading ubiquitous language"
200
+ },
201
+ {
202
+ "event": "SubagentStart",
203
+ "matcher": null,
204
+ "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/inject-naming-rule-into-subagents.cjs\"",
205
+ "order": 1
198
206
  }
199
207
  ],
200
208
  "prereqs": [
@@ -293,7 +301,8 @@
293
301
  }
294
302
  },
295
303
  "dotnet": {
296
- "detect": "dotnet --version",
304
+ "detect": "dotnet --list-sdks",
305
+ "versionPick": "highest",
297
306
  "minVersion": "10",
298
307
  "kind": "package",
299
308
  "install": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phuc1403/musketeer",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Distributable custom Claude Code harness — one declarative command scaffolds a curated company of musketeers (skills/agents/hooks) into any project's .claude/.",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -29,6 +29,20 @@ function parseVersion(text) {
29
29
  return [Number(m[1] || 0), Number(m[2] || 0), Number(m[3] || 0)];
30
30
  }
31
31
 
32
+ /**
33
+ * Highest version among the lines that start with one. For list-style detect output such as `dotnet --list-sdks`
34
+ * ("8.0.100 [path]" per line), where the first match is only the oldest install.
35
+ */
36
+ function parseHighestVersion(text) {
37
+ let best = null;
38
+ for (const line of String(text).split(/\r?\n/)) {
39
+ if (!/^\s*v?\d/.test(line)) continue;
40
+ const ver = parseVersion(line);
41
+ if (ver && (!best || !versionGte(best, ver.join('.')))) best = ver;
42
+ }
43
+ return best;
44
+ }
45
+
32
46
  /** found >= min ? (min may be "18" or "3.8") */
33
47
  function versionGte(found, min) {
34
48
  if (!found) return false;
@@ -75,7 +89,8 @@ function detectPrereq(name, prereq, ctx) {
75
89
  let r = run(prereq.detect);
76
90
  if (r.code !== 0 && name === 'python') r = run('python3 --version'); // unix fallback
77
91
  const out = r.stdout + r.stderr;
78
- const ver = parseVersion(out);
92
+ // `versionPick: "highest"` reads every installed version the detect command lists and keeps the newest.
93
+ const ver = prereq.versionPick === 'highest' ? parseHighestVersion(out) : parseVersion(out);
79
94
  if (r.code !== 0) return { name, present: false, version: null, reason: 'not found' };
80
95
  if (prereq.minVersion && !versionGte(ver, prereq.minVersion)) {
81
96
  return {
@@ -90,4 +105,4 @@ function detectPrereq(name, prereq, ctx) {
90
105
  }
91
106
  }
92
107
 
93
- module.exports = { detectPrereq, parseVersion, versionGte, defaultRun, SECRET_ENV };
108
+ module.exports = { detectPrereq, parseVersion, parseHighestVersion, versionGte, defaultRun, SECRET_ENV };
package/src/resolver.js CHANGED
@@ -5,9 +5,13 @@ const path = require('path');
5
5
 
6
6
  const CORE_ID = 'core';
7
7
 
8
+ // Build output of tools shipped inside skills (a skill's .NET tool builds into bin/ and obj/). A git checkout of the
9
+ // template can hold it; it must never be copied into a project. Mirrors the `!template/**/bin|obj` package excludes.
10
+ const BUILD_OUTPUT_DIRS = new Set(['bin', 'obj']);
11
+
8
12
  /**
9
13
  * Recursively list every file under `root`, returned as POSIX-style paths
10
- * relative to `root` (forward slashes, stable sorted).
14
+ * relative to `root` (forward slashes, stable sorted), skipping build output dirs.
11
15
  * @param {string} root
12
16
  * @returns {string[]}
13
17
  */
@@ -22,8 +26,9 @@ function listFiles(root) {
22
26
  }
23
27
  for (const e of entries) {
24
28
  const abs = path.join(dir, e.name);
25
- if (e.isDirectory()) walk(abs);
26
- else if (e.isFile()) out.push(path.relative(root, abs).split(path.sep).join('/'));
29
+ if (e.isDirectory()) {
30
+ if (!BUILD_OUTPUT_DIRS.has(e.name)) walk(abs);
31
+ } else if (e.isFile()) out.push(path.relative(root, abs).split(path.sep).join('/'));
27
32
  }
28
33
  }
29
34
  walk(root);
package/src/schema.js CHANGED
@@ -39,6 +39,7 @@ const VALID_EVENTS = new Set([
39
39
  'SessionStart',
40
40
  'SessionEnd',
41
41
  'Stop',
42
+ 'SubagentStart',
42
43
  'SubagentStop',
43
44
  'Notification',
44
45
  'PreCompact',
Binary file
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ // SubagentStart hook (dotnet company): inject `.claude/rules/csharp-identifier-naming.md`
3
+ // into every subagent. The main session loads the rule itself (an unscoped rule file), but
4
+ // subagents do not: checked on Claude Code 2.1.284, an Explore subagent saw no rule without
5
+ // this hook. The failure the rule exists for came from an agent rewriting many files.
6
+ //
7
+ // A missing rule file injects nothing, silently (muster copies it back on its next run).
8
+ // Any other error fails open (emits nothing, exit 0) so it can never block a subagent.
9
+ const fs = require("fs");
10
+ const path = require("path");
11
+
12
+ const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
13
+ const REL = ".claude/rules/csharp-identifier-naming.md";
14
+ const file = path.join(root, ".claude", "rules", "csharp-identifier-naming.md");
15
+
16
+ try {
17
+ let content;
18
+ try {
19
+ content = fs.readFileSync(file, "utf-8");
20
+ } catch {
21
+ process.exit(0); // no rule file: the project opted out
22
+ }
23
+
24
+ // A rule file may carry YAML frontmatter for Claude Code's loader; the subagent needs only the body.
25
+ const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "").trim();
26
+ if (!body) process.exit(0);
27
+
28
+ const additionalContext =
29
+ `Project naming rule (${REL}) — injected into every subagent. Apply it to any C# you ` +
30
+ "write or review in this task.\n\n" +
31
+ body;
32
+
33
+ process.stdout.write(
34
+ JSON.stringify({
35
+ hookSpecificOutput: {
36
+ hookEventName: "SubagentStart",
37
+ additionalContext,
38
+ },
39
+ })
40
+ );
41
+ process.exit(0);
42
+ } catch {
43
+ process.exit(0); // fail open
44
+ }
@@ -0,0 +1,77 @@
1
+ # C# identifier naming: name it after its type
2
+
3
+ ## The rule
4
+
5
+ **An identifier is named after its type.** Parameters and locals alike: take the type name, drop a leading `I` on
6
+ an interface, drop generic arguments, camelCase the rest. It reaches result and data types too, not only the
7
+ collaborators you call.
8
+
9
+ ```csharp
10
+ // right
11
+ public sealed class CheckoutPipeline(
12
+ OrderFetcher orderFetcher,
13
+ IPaymentGateway paymentGateway,
14
+ IReceiptPublisher receiptPublisher,
15
+ TimeSpan deadline,
16
+ ILogger<CheckoutPipeline> logger)
17
+
18
+ PricedBasket pricedBasket = basketPricer.Price(basket); // BasketPricer basketPricer, Basket basket
19
+ var shipmentLabel = new ShipmentLabel(...);
20
+
21
+ // wrong: each of these names a role, not the thing
22
+ OrderFetcher fetcher, IPaymentGateway gateway, IReceiptPublisher publisher,
23
+ PricedBasket priced, var label
24
+ ```
25
+
26
+ ## Why
27
+
28
+ A component's class is named after the component **so that grepping the name finds every use of it**. A
29
+ role-named identifier defeats that inside every body that holds one: `gateway.Charge(...)` does not match a search
30
+ for `PaymentGateway`. The failure is invisible in review, because each file reads fine on its own. It only shows
31
+ when someone greps for a component, gets an incomplete answer, and believes it.
32
+
33
+ ## Where it stops: three exceptions, and only three
34
+
35
+ | Exception | Example | Why |
36
+ |---|---|---|
37
+ | Framework and primitive types keep a meaningful name | `TimeSpan deadline`, `TimeSpan budget`, `CancellationToken ct`, `Uri statusUrl`, `int`, `string`, `Stream`, collections | The rule exists to make a name findable. A framework type name carries no domain meaning to find, and `timeSpan` would give a deadline and a per-call budget the same name. |
38
+ | Several instances of one type in one scope | `Coordinate from, Coordinate to`, `Placement inner, Placement outer` | The role *is* the distinguishing information. Never `coordinate1, coordinate2`. |
39
+ | Third-party types | `BlockRecord mark` | Not ours to name; the role says more than `blockRecord`. |
40
+
41
+ **The test is arity, not taste: one instance of the type in scope → name it after the type; several → let the
42
+ roles distinguish them.** It is decided per site, not per type: a type used as `from`/`to` in one method is still
43
+ `coordinate` in a method that holds only one.
44
+
45
+ ## Boundaries that were tried and are wrong
46
+
47
+ Both read well and will be re-derived unless stated:
48
+
49
+ - **Behaviour vs data** ("rename the collaborators you call, leave data types alone"). Wrong: `PricedBasket priced`
50
+ is exactly as ungreppable as `OrderFetcher fetcher`. Pure data types take the rename too.
51
+ - **Ours vs not quite ours** (by module or ownership). Wrong: every type the repo defines is ours to find.
52
+
53
+ Arity in scope is the only axis.
54
+
55
+ ## What it costs to skip
56
+
57
+ - A port was renamed twice; the types followed, the identifiers did not. A constructor read
58
+ `IOrderRecording legacyOrderStore`, a port named after the component and a parameter named after the mechanism
59
+ it replaced. A call site still said `legacyOrderStore.Open(...)`, so grepping `OrderRecording` missed it, which
60
+ was the whole failure the renames were meant to fix.
61
+ - A feature was deleted and rebuilt by an agent from its design document, dozens of new files at once. Every
62
+ injected collaborator in the rebuilt pipeline came back role-named. The build did not object and review did not
63
+ catch it; a human noticed while reading one constructor. The convention was only a few commits old at the time.
64
+
65
+ A rule that lives only in a commit message is a rule the next rewrite will not see. That is why it is written here.
66
+
67
+ ## Renaming safely
68
+
69
+ Renaming a parameter that a pattern match then rebinds produces `if (labelSpec is { } labelSpec)`: the pattern
70
+ variable shadows the parameter and the build fails (CS0136 for a method parameter; CS9113 and CS0841 for a
71
+ primary-constructor parameter). Rename the pattern variable (or use the parameter directly) in the same edit.
72
+
73
+ ## Enforcement
74
+
75
+ Deliberately none beyond this rule and the code-review naming pass: no fitness test, no edit hook. A regex over C#
76
+ parameter lists misfires on generics, tuples, defaults and multi-line signatures, and a hook that cries wolf gets
77
+ switched off.
@@ -64,6 +64,7 @@ Verify before implementing. Ask before assuming. Evidence before claims.
64
64
  | Edge case scouting | After implementation, before review | `references/edge-case-scouting.md` |
65
65
  | **Checklist review** | Pre-landing, pre-merge, security audit | `references/checklist-workflow.md` |
66
66
  | **Task-managed reviews** | Multi-file features (3+ files), parallel reviewers, fix cycles | `references/task-management-reviews.md` |
67
+ | **Naming pass** | Diff touches `.cs` files and `.claude/rules/csharp-identifier-naming.md` exists | `references/naming-rule-review.md` |
67
68
 
68
69
  ## Quick Decision Tree
69
70
 
@@ -83,7 +84,8 @@ SITUATION?
83
84
  │ ├─ Stage 1: Spec compliance review (references/spec-compliance-review.md)
84
85
  │ │ └─ PASS? → Stage 2 │ FAIL? → Fix → Re-review Stage 1
85
86
  │ ├─ Stage 2: Code quality review (code-reviewer subagent)
86
- │ │ └─ Scout edge cases → Review standards, performance
87
+ │ │ ├─ Scout edge cases → Review standards, performance
88
+ │ │ └─ .cs in diff + naming rule present? → parallel naming pass
87
89
  │ └─ Verification gate → Run required tests/builds before claims
88
90
  ├─ Completed work (no plan) → Scout → Code quality → Verification
89
91
  ├─ Pre-landing / ship → Load checklists → Two-pass review → Verification
@@ -101,6 +103,7 @@ SITUATION?
101
103
  **Stage 2 — Code Quality** (code-reviewer subagent)
102
104
  - Only runs AFTER spec compliance passes
103
105
  - Standards, security, performance, edge cases
106
+ - **Naming pass:** when the diff touches `.cs` files and `.claude/rules/csharp-identifier-naming.md` exists, dispatch a second `code-reviewer` **in the same message** with the prompt in `references/naming-rule-review.md`. A general review samples names; this pass enumerates every declared identifier, which is what catches role-named parameters. Merge its violations into the report under **Naming**.
104
107
 
105
108
  **Final Verification**
106
109
  - Runs AFTER Stage 2 passes
@@ -0,0 +1,41 @@
1
+ # Naming pass — agent prompt
2
+
3
+ Run as a second `code-reviewer`, in parallel with the main review, when the diff touches `.cs` files and
4
+ `.claude/rules/csharp-identifier-naming.md` exists. Fill `{{...}}` and send the part between the `---` lines.
5
+
6
+ - `{{DIFF_SCOPE}}`: how to get the diff, exactly as the main review resolved it (`git diff <base>..<head> -- '*.cs'`,
7
+ `gh pr diff <n>`, `git diff HEAD -- '*.cs'`).
8
+ - `{{RULE_FILE}}`: absolute path of `.claude/rules/csharp-identifier-naming.md`.
9
+
10
+ ---
11
+
12
+ You review one thing only: whether the C# identifiers **declared in this change** follow the project's naming rule.
13
+ Ignore every other concern; the main review covers them. Never edit files.
14
+
15
+ 1. Read `{{RULE_FILE}}` in full. It defines the rule (an identifier is named after its type), its three exceptions
16
+ and the arity test. It is the authority; do not apply a stricter or looser version.
17
+ 2. Get the change: `{{DIFF_SCOPE}}`. Only identifiers **declared on added or changed lines** are in scope:
18
+ constructor parameters (including primary constructors), method and lambda parameters, locals (`var` included:
19
+ use the inferred type), pattern and `foreach` variables, `out var` declarations. Open the file around a
20
+ declaration when you need the type or the other identifiers in the same scope.
21
+ 3. For every in-scope identifier, decide:
22
+ - **ok**: named after its type (leading `I` dropped, generic arguments dropped, camelCase);
23
+ - **exempt**: say which exception applies:
24
+ - framework or primitive type (`TimeSpan deadline`, `CancellationToken ct`, collections);
25
+ - several instances of the same type in the same scope (`Coordinate from, Coordinate to`). Count per scope,
26
+ not per type: a single instance is not exempt;
27
+ - third-party type;
28
+ - **violation**: give the type-named replacement (`OrderFetcher fetcher` → `orderFetcher`).
29
+ 4. For each violation, check whether renaming would collide with another identifier in scope, or with a pattern
30
+ variable that rebinds it (`x is { } x`: CS0136, or CS9113/CS0841 for a primary-constructor parameter). If so,
31
+ say so and suggest the rename for that one too.
32
+
33
+ ## Output
34
+
35
+ Reply with violations only, in file and line order. Nothing else, no summary of the ok/exempt ones:
36
+
37
+ ```
38
+ file:line | identifier | type | suggested name | note (collision, pattern rebind, or empty)
39
+ ```
40
+
41
+ Then one line: `Naming: <n> violations in <m> declarations checked`. If there are none, reply only that line.
@@ -13,40 +13,44 @@ Produces `# Logical Components` (name, type, responsibilities with `file:lines`
13
13
  **What is deterministic and what is not.** Which classes are components comes from `logical-components.json` in the repo root, written once by classifier agents and reviewed by the user. Every edge comes from Roslyn. Same code plus same config gives the same bytes. Agents write only the responsibility sentences. Each sentence must cite code inside its own component, is checked by a separate verifier agent, and is rejected by `render` if its citation is not real code in that component. Never edit components, edges or the diagram by hand, and never render a document with a component missing.
14
14
 
15
15
  **Rules the tool applies** (details in `scripts/LogicalComponents/*.cs`):
16
- - **Candidate:** a top-level class or record (abstract records and record structs included) in an `*.Application` or `*.Infrastructure` project that declares at least one method with a body of its own. It doesn't count: constructors, accessors, operators, partial methods without an implementation, and `ToString`/`Equals`/`GetHashCode` overrides. Excluded: interfaces and abstract classes (seams), DI setup classes, `*Options`, exceptions, `file` classes, nested types.
16
+ - **Candidate:** a top-level class or record (abstract records and record structs included) in an `*.Application` or `*.Infrastructure` project (or one named exactly `Application`/`Infrastructure`; matched case-insensitively) that declares at least one method with a body of its own. It doesn't count: constructors, accessors, operators, partial methods without an implementation, `extern`/`[DllImport]`/`[LibraryImport]` methods, and `ToString`/`Equals`/`GetHashCode` overrides. Excluded: interfaces and abstract classes (seams), DI setup classes (static classes extending `IServiceCollection`, `IHostApplicationBuilder`, `WebApplicationBuilder` or `IHostBuilder`, and Autofac modules overriding `Load`), `*Options`, exceptions, `file` classes, nested types, and files under `obj/` or `bin/` (build output). When no project matches either layer, `candidates` warns.
17
17
  - **Component or helper:** judged once per candidate by classifier agents with `references/component-classifier-prompt.md`, then frozen in `logical-components.json` as `{ "components": [full type names], "helpers": { full type name: reason } }`. A component decides or enforces a rule, coordinates, does I/O, or owns state. A helper converts formats, looks things up, holds data, delegates, or is a value object (even a self-validating one). A candidate in neither list stops `extract` (exit 4).
18
18
  - **Edge A → B** (A depends on B):
19
19
  - `di`: a constructor parameter, looked through `IEnumerable`/lists/`Lazy`/`Func`/arrays;
20
- - `new` (an object creation, or a `: this(...)`/`: base(...)` constructor call), `static` (a static member, an extension, a user-defined operator or conversion; const reads excluded), `call` (an instance member used on any receiver: parameter, local, field, returned value, an indexer, the enumerator a `foreach` runs);
20
+ - `new` (an object creation, or a `: this(...)`/`: base(...)` constructor call), `static` (a static member, an extension, a user-defined operator or conversion; const reads excluded), `call` (an instance member used on any receiver: parameter, local, field, returned value, an indexer including `x?[i]`, and what the compiler calls for you: the enumerator's `GetEnumerator`/`MoveNext`/`Current` a `foreach` runs, the `Deconstruct` a deconstruction runs, the `Add` a collection initializer runs, and `GetAwaiter`/`IsCompleted`/`GetResult` for an `await`). A seen-through type's static constructor is walked with its first static use or construction, like its static field initializers;
21
21
  - `inherits`.
22
- - Interfaces and abstract classes resolve to the member that runs in each implementation of the construction used. A class implementing `IHandler<Ping>` and `IHandler<Pong>` runs its Pong member for a Pong call.
22
+ - Interfaces and abstract classes resolve to the member that runs in each implementation of the construction used. A class implementing `IHandler<Ping>` and `IHandler<Pong>` runs its Pong member for a Pong call. Type arguments carry through the seam: a generic `IDispatch.Send<T>` called with `Ping` reaches only the Ping handlers of the implementation that runs.
23
+ - A non-virtual member of an abstract class runs as written, so it is walked directly. An abstract or virtual member with no implementation in the repo gives an `extract` warning.
23
24
  - Helpers, records and other non-component types are **seen through**, following only the code that runs for the use. That means the member A calls, only the getter for a read or the setter for a write, and the constructor, initializers and base constructor for a `new`. Whatever that code reaches is A's. So:
24
25
  - reading one property of a record never couples A to what another property or a setter calls;
25
26
  - compiler-written record members (`ToString`, `Equals`) run nothing;
26
- - a generic helper is walked with its type parameters bound to A's type arguments, so a dispatcher called with `Ping` reaches only the Ping handlers.
27
- - `via` in `components.json` lists the seams and seen-through types on the first path found to each target.
27
+ - a generic helper is walked with its type parameters bound to A's type arguments, so a dispatcher called with `Ping` reaches only the Ping handlers. A component's generic base class is walked with its type parameters bound too (`PingRunner : HandlerBase<Ping>`).
28
+ - a generic helper that instantiates itself one level deeper at every call is cut off at a fixed depth, with a warning.
29
+ - `via` in `components.json` lists the seams and seen-through types on the first path found to each target, in path order, by simple name (full name when two types on the path share one).
28
30
  - **Not followed:**
29
31
  - overrides of virtual members of concrete classes;
30
32
  - static abstract interface members;
31
33
  - `Dispose` run by `using`;
32
- - implicit user-defined conversions.
34
+ - implicit user-defined conversions;
35
+ - attributes (an attribute's constructor runs only when something reads it through reflection).
33
36
  - **Reaches:** the helper members a component's code runs are recorded as its `reaches` spans. A rule held in a helper, such as `SrcPath` rejecting paths outside the imports root, is described in the responsibilities of the component that runs it, citing the helper's lines. `render` accepts citations inside a component's own `spans` or its `reaches`. A helper only the DI setup touches is reached by no component, so it appears nowhere. `render` rejects a citation over 60 lines; the prompts ask for at most 30, leaving room for a writer's off-by-a-few ranges.
34
37
 
35
38
  ## Arguments
36
39
 
37
40
  - `<repoPath>`: the repo root (holds the `.sln`/`.slnx`, or `src/`). Required.
38
41
  - `--out <md>`: default `<repoPath>/docs/logical-components.generated.md`. Never write over a hand-written `docs/logical-components.md` unless the user names it.
39
- - `--agents <n>`: agents per wave, default 9.
42
+ - `--agents <n>`: agents per wave, default 9. Pass it as `$AGENTS` wherever the steps say `--agents`.
43
+ - `--solution <file>`: the solution to load (relative to `<repoPath>`, or absolute). Required when the repo root holds more than one solution; pass it to both `candidates` and `extract`.
40
44
 
41
45
  ## Run
42
46
 
43
- Set `SKILL` to this skill's directory, `REPO` to the repo root, and `WORK` to a fresh per-run directory outside the repo (`WORK="$(mktemp -d)"`). All work files live in `WORK`. Nothing is written into the repo except `logical-components.json` (by the classify step, after the user's review) and `--out`.
47
+ Set `SKILL` to this skill's directory, `REPO` to the repo root, `OUT` to `--out` (default `$REPO/docs/logical-components.generated.md`), `AGENTS` to `--agents` (default 9), `SOL` to `--solution <file>` when given (else empty), and `WORK` to a fresh per-run directory outside the repo (`WORK="$(mktemp -d)"`). All work files live in `WORK`. Nothing is written into the repo except `logical-components.json` (by the classify step, after the user's review) and `--out`.
44
48
 
45
49
  1. **Build the tool once** (skip if the dll is newer than the sources):
46
- `(cd "$SKILL/scripts" && dotnet build LogicalComponents -c Release -nologo -v q)`. Build from inside `scripts/`: its `global.json`, `Directory.Build.props` and `Directory.Packages.props` keep the repo's own SDK pin, build settings and central package versions away from the tool. Then use `LC="dotnet $SKILL/scripts/LogicalComponents/bin/Release/net10.0/LogicalComponents.dll"`.
47
- 2. **Candidates:** `$LC candidates "$REPO" --out "$WORK/candidates.json"`. Its last line counts the components, helpers and unclassified candidates. With 0 unclassified, skip to step 4.
50
+ `(cd "$SKILL/scripts" && dotnet build LogicalComponents -c Release -nologo -v q)`. Build from inside `scripts/`: its `global.json`, `Directory.Build.props`/`.targets`/`.rsp`, `Directory.Packages.props` and `NuGet.config` keep the repo's own SDK pin, build settings, central package versions and package feeds away from the tool. Then use `LC="dotnet $SKILL/scripts/LogicalComponents/bin/Release/net10.0/LogicalComponents.dll"`.
51
+ 2. **Candidates:** `$LC candidates "$REPO" --out "$WORK/candidates.json" $SOL`. Its last line counts the components, helpers and unclassified candidates. With 0 unclassified, skip to step 4.
48
52
  3. **Classify** (only the unclassified candidates):
49
- 1. `$LC classify-batches --candidates "$WORK/candidates.json" --agents 9 --repo "$REPO" --out "$WORK/classify"`. It also records the config's hash.
53
+ 1. `$LC classify-batches --candidates "$WORK/candidates.json" --agents $AGENTS --repo "$REPO" --out "$WORK/classify"`. It also records the config's hash and skips any candidate the current config already classifies (so a stale `candidates.json` can't resend one).
50
54
  2. Empty `$WORK/verdicts` (create it if needed), then spawn one `Agent` per `classify/batch-NN.json`, **all in one message**. Prompt: `references/component-classifier-prompt.md` (the part between the `---` lines), with `{{REPO}}`, `{{BATCH_FILE}}` and `{{OUT_FILE}}` = `$WORK/verdicts/batch-NN.json`.
51
55
  3. `$LC classify-merge --batches "$WORK/classify" --verdicts "$WORK/verdicts" --repo "$REPO"`. It checks the agents' output against the batches before writing anything:
52
56
  - one verdict file per batch;
@@ -57,27 +61,28 @@ Set `SKILL` to this skill's directory, `REPO` to the repo root, and `WORK` to a
57
61
 
58
62
  It also checks that the config is unchanged since step 3.1.
59
63
  - Exit 3 lists every problem. For a batch's own problems, re-run that batch's agent once, then merge again. If the config changed, stop and tell the user: something edited it outside the review.
60
- - Exit 2: the existing config can't be read. Exit 1: usage, or a directory is missing.
61
- - It never changes an entry that is already in the file. Rewriting the file drops any `//` comments in it.
64
+ - Exit 2: the existing config or a batch file can't be read. Exit 1: usage, or a directory is missing.
65
+ - It never changes an entry that is already in the file: a verdict for a type the config already classifies is an exit-3 problem (re-run step 2). Rewriting the file drops any `//` comments in it.
62
66
  4. **Review gate:** show the user every line merge printed (helpers with reasons first, then components; the tool checked each is one plain line) and ask with `AskUserQuestion`:
63
67
  - accept;
64
68
  - "I'll edit `logical-components.json` first", then wait for them.
65
69
 
66
70
  Do not continue without an answer. This file decides what the document contains.
67
- 4. **Extract:** `$LC extract "$REPO" --out "$WORK/components.json"`.
71
+ 4. **Extract:** `$LC extract "$REPO" --out "$WORK/components.json" $SOL`. It also records a hash of every file a component cites or runs, so `render` can tell when the repo changed since.
68
72
  - Exit 4: `unclassified <type>` lines mean candidates are still missing from the config. Run step 2 again, then step 3 for those candidates only.
69
73
  - Any other non-zero exit: stop and show stderr.
70
74
  - Keep the `warning:` lines for the final report. `config entry ... names no candidate` means a class was renamed or deleted: tell the user.
71
- 5. **Batch:** `$LC batches --components "$WORK/components.json" --agents 9 --out "$WORK/batches"`.
75
+ 5. **Batch:** `$LC batches --components "$WORK/components.json" --agents $AGENTS --out "$WORK/batches"`.
72
76
  6. **Writers:** one `Agent` per `batches/batch-NN.json`, **all in one message**, with no `model` override (a cheaper writer model produced false claims). Prompt: `references/responsibility-agent-prompt.md` (the part between the `---` lines, without `## Repair`), with `{{REPO}}`, `{{BATCH_FILE}}` = `$WORK/batches/batch-NN.json`, `{{OUT_FILE}}` = `$WORK/resp/batch-NN.json`. Create `$WORK/resp` first.
73
- 7. **Verifiers:** one `Agent` per `resp/batch-NN.json`, all in one message. Prompt: `references/responsibility-verifier-prompt.md` with `{{RESP_FILE}}`, `{{OUT_FILE}}` = `$WORK/verify/batch-NN.json`.
77
+ 7. **Verifiers:** one `Agent` per `resp/batch-NN.json`, all in one message. Prompt: `references/responsibility-verifier-prompt.md` with `{{REPO}}`, `{{RESP_FILE}}` = `$WORK/resp/batch-NN.json`, `{{OUT_FILE}}` = `$WORK/verify/batch-NN.json`.
74
78
  8. **Prune:** `$LC prune --resp "$WORK/resp" --verify "$WORK/verify" --out "$WORK/pruned"`.
75
- - Exit 3 (a verdict missing or malformed): re-run the verifier of the named batches once, then prune again.
79
+ - Exit 3 (a verdict missing or malformed, including an entry without `supported`): re-run the verifier of the named batches once, then prune again.
76
80
  - Every `removed <id>[<n>]: <reason>` line goes to the repair round, including `empty <id>` lines (components that lost every claim). This covers components that lost only some of their claims.
77
81
  9. **Render:** `$LC render --components "$WORK/components.json" --resp "$WORK/pruned" --repo "$REPO" --out "$OUT"`.
78
82
  - Exit 0: done, unless step 8 produced `removed` lines. Then run the repair round before calling it done.
79
83
  - Exit 3: every line starts with a component id. Those components go to the repair round.
80
- - Exit 2: inputs do not match (the repo changed since extract, or a path is missing). Stop and report; this is not the agents' fault.
84
+ - Exit 2: inputs do not match: a cited file changed or went missing since extract (one line each), `components.json` predates the file hashes, or a path is missing. Re-run from step 4 if the repo changed on purpose; otherwise stop and report. This is not the agents' fault.
85
+ - `render` writes `--out` only on success; a failed render never touches an existing file.
81
86
 
82
87
  **Repair round (at most once per run).**
83
88
  1. Collect the problem lines per batch: the `removed`/`empty` lines from prune and the render exit-3 lines. `$WORK/batches` shows which batch each component is in.
@@ -37,7 +37,7 @@ When in doubt: if removing the class and inlining its code into its callers woul
37
37
 
38
38
  ## Reason and citation
39
39
 
40
- - `reason`: one sentence, at most 200 characters, saying what in the code decides it: "Rejects a callback URL outside the allowed hosts." or "Maps `LayerColor` values to names; no decision."
40
+ - `reason`: one plain sentence, at most 200 characters, no control or invisible characters (it is shown to the reviewer verbatim), saying what in the code decides it: "Rejects a callback URL outside the allowed hosts." or "Maps `LayerColor` values to names; no decision."
41
41
  - `cite`: `<file>:<start>-<end>` (or `<file>:<line>`), `file` copied exactly from the candidate's `spans`, at most 30 lines, showing the reason.
42
42
 
43
43
  ## Output
@@ -25,7 +25,7 @@ You write the responsibilities of a few logical components of a .NET codebase, s
25
25
  - Not a restatement of fields, properties or parameters. Not what a collaborator does: `uses` is context; you may say the component hands work to X only when the cited lines make that call.
26
26
  - A rule inside `reaches` (a check, a limit, a decision the helper makes when this component calls it) is part of what this component does. Describe it as this component's behaviour and name the helper: "Rejects a source path that leaves the imports root (through `SrcPath`)." Skip helper code that only formats or holds data.
27
27
  - Cover the component's main behaviour first. Prefer fewer precise sentences over many vague ones; do not repeat yourself.
28
- - Plain text, at most 300 characters, one line. Put code names in backticks (`LayerTitle`, `List<Feature>`). No HTML, no links, no markdown other than backticks.
28
+ - Plain text, at most 300 characters, one line, no control or invisible characters (bidi overrides, zero-width, line separators). Put code names in backticks (`LayerTitle`, `List<Feature>`). No HTML, no links, no markdown other than backticks. Outside backticks, never write a backslash, `<`, `>`, `[`, `]` or a URL (`://`, `www.`); `render` rejects them.
29
29
  - Name a technology or library only when the code is about it.
30
30
  - Never quote literals that look like credentials, keys, tokens, passwords or connection strings; describe what the code does with them instead.
31
31
 
@@ -0,0 +1 @@
1
+ # Stops MSBuild picking up a host repo's Directory.Build.rsp (extra command-line switches) for the tool.
@@ -0,0 +1,6 @@
1
+ <Project>
2
+ <!--
3
+ MSBuild searches upward for Directory.Build.targets separately from Directory.Build.props. This file stops that
4
+ search here too, so a host repo's targets (extra checks, custom errors) never reach the tool.
5
+ -->
6
+ </Project>
@@ -31,7 +31,8 @@ public static class BatchPlanning
31
31
  var names = extraction.Components.Select(c => (c.Id, c.Name))
32
32
  .Concat(extraction.ExternalNodes.Select(n => (n.Id, n.Name)))
33
33
  .ToDictionary(n => n.Id, n => n.Name, StringComparer.Ordinal);
34
- var size = (extraction.Components.Count + maxAgents - 1) / maxAgents;
34
+ // Clamp to at least 1: with an enormous --agents, Count + maxAgents - 1 can overflow int and wrap negative.
35
+ var size = Math.Max(1, (extraction.Components.Count + maxAgents - 1) / maxAgents);
35
36
 
36
37
  return extraction.Components
37
38
  .Select(c => new BatchComponent(
@@ -4,7 +4,8 @@ namespace LogicalComponents;
4
4
 
5
5
  /// <summary>
6
6
  /// <c>batches --components &lt;components.json&gt; --agents &lt;n&gt; --out &lt;dir&gt;</c>: writes one <c>batch-NN.json</c> agent
7
- /// input per batch (see <see cref="BatchPlanning"/>). Exit codes: 0 written, 1 usage, 2 unreadable components.json.
7
+ /// input per batch (see <see cref="BatchPlanning"/>). Exit codes: 0 written, 1 usage, 2 components.json is unreadable
8
+ /// or not one extract wrote.
8
9
  /// </summary>
9
10
  public static class BatchesCommand
10
11
  {
@@ -30,6 +31,13 @@ public static class BatchesCommand
30
31
  return 2;
31
32
  }
32
33
 
34
+ // Deserialization leaves absent lists null despite the non-nullable types: `{}` is not a components.json.
35
+ if (extraction.Components is null || extraction.ExternalNodes is null || extraction.Edges is null)
36
+ {
37
+ await output.WriteLineAsync($"{componentsPath} is not a components.json written by extract");
38
+ return 2;
39
+ }
40
+
33
41
  var batches = BatchPlanning.Plan(extraction, agents);
34
42
  BatchPlanning.Write(batches, outDir);
35
43
  await output.WriteLineAsync(
@@ -1,13 +1,16 @@
1
1
  namespace LogicalComponents;
2
2
 
3
3
  /// <summary>
4
- /// <c>candidates &lt;repoPath&gt; [--out &lt;candidates.json&gt;]</c>: lists every candidate with where it is declared and how
5
- /// the repo config classifies it (<c>component</c>, <c>helper</c> or <c>unclassified</c>), for the classify step.
4
+ /// <c>candidates &lt;repoPath&gt; [--out &lt;candidates.json&gt;] [--solution &lt;file&gt;]</c>: lists every candidate with where
5
+ /// it is declared and how the repo config classifies it (<c>component</c>, <c>helper</c> or <c>unclassified</c>), for
6
+ /// the classify step. <c>--solution</c> picks the solution file to load (relative to repoPath, or absolute) instead of
7
+ /// searching the repo root; it is required when the repo root has more than one top-level solution.
6
8
  /// Progress goes to <c>errors</c> (stderr). Exit codes: 0 written, 1 usage, 2 the repo or its config could not be loaded.
7
9
  /// </summary>
8
10
  public static class CandidatesCommand
9
11
  {
10
- public const string Usage = "usage: LogicalComponents candidates <repoPath> [--out <candidates.json>]";
12
+ public const string Usage =
13
+ "usage: LogicalComponents candidates <repoPath> [--out <candidates.json>] [--solution <file>]";
11
14
 
12
15
  public const string Component = "component";
13
16
  public const string Helper = "helper";
@@ -19,21 +22,15 @@ public static class CandidatesCommand
19
22
 
20
23
  public static async Task<int> RunAsync(string[] args, TextWriter errors)
21
24
  {
22
- var outPath = "candidates.json";
23
- if (args is not [var repoPath, ..] || args[1..] is not ([] or ["--out", _]))
25
+ if (args is not [var repoPath, ..] || !TryParseOptions(args[1..], "candidates.json", out var outPath, out var solution))
24
26
  {
25
27
  await errors.WriteLineAsync(Usage);
26
28
  return 1;
27
29
  }
28
30
 
29
- if (args is [_, "--out", var given])
30
- {
31
- outPath = given;
32
- }
33
-
34
31
  try
35
32
  {
36
- using var loaded = await WorkspaceLoader.LoadAsync(repoPath, log: errors);
33
+ using var loaded = await WorkspaceLoader.LoadAsync(repoPath, solution: solution, log: errors);
37
34
  var file = await ListAsync(loaded, ExtractConfig.Load(loaded.RepoRoot));
38
35
  Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outPath))!);
39
36
  await File.WriteAllBytesAsync(outPath, ComponentsFile.ToJson(file));
@@ -67,4 +64,35 @@ public static class CandidatesCommand
67
64
 
68
65
  private static string Status(ExtractConfig config, string type) =>
69
66
  config.Helpers.ContainsKey(type) ? Helper : config.Components.Contains(type) ? Component : Unclassified;
67
+
68
+ /// <summary>
69
+ /// <c>--out</c> and <c>--solution</c>, in either order; each takes exactly one value. Shared with <c>extract</c>,
70
+ /// which loads the repo the same way.
71
+ /// </summary>
72
+ internal static bool TryParseOptions(string[] options, string defaultOut, out string outPath, out string? solution)
73
+ {
74
+ outPath = defaultOut;
75
+ solution = null;
76
+ for (var i = 0; i < options.Length; i += 2)
77
+ {
78
+ if (i + 1 >= options.Length)
79
+ {
80
+ return false;
81
+ }
82
+
83
+ switch (options[i])
84
+ {
85
+ case "--out":
86
+ outPath = options[i + 1];
87
+ break;
88
+ case "--solution":
89
+ solution = options[i + 1];
90
+ break;
91
+ default:
92
+ return false;
93
+ }
94
+ }
95
+
96
+ return true;
97
+ }
70
98
  }
@@ -10,7 +10,10 @@ public static class CitableSource
10
10
  {
11
11
  public enum Kind
12
12
  {
13
- /// <summary>Not a project document, or generator output under obj/: never cited, silently skipped.</summary>
13
+ /// <summary>
14
+ /// Not a project document, a source generator's virtual output, or a real file under a bin/ or obj/ folder
15
+ /// (gRPC/NSwag output, a stray build copy): never cited, silently skipped.
16
+ /// </summary>
14
17
  NotSource,
15
18
 
16
19
  /// <summary>A real file outside the repo root (linked file, other drive): cannot be cited portably.</summary>
@@ -28,11 +31,23 @@ public static class CitableSource
28
31
  }
29
32
 
30
33
  var relative = Path.GetRelativePath(loaded.RepoRoot, tree.FilePath);
31
- return Path.IsPathRooted(relative) || relative.StartsWith("..")
32
- ? (Kind.OutsideRepo, "")
33
- : (Kind.InRepo, relative.Replace('\\', '/'));
34
+ if (Path.IsPathRooted(relative) || relative.StartsWith(".."))
35
+ {
36
+ return (Kind.OutsideRepo, "");
37
+ }
38
+
39
+ return IsBuildOutputPath(relative) ? (Kind.NotSource, "") : (Kind.InRepo, relative.Replace('\\', '/'));
34
40
  }
35
41
 
42
+ /// <summary>
43
+ /// True when any path segment is <c>bin</c> or <c>obj</c>, case-insensitively: build output is never real source,
44
+ /// whether it is design-time generator output (gRPC, NSwag) or a build step's stray copy of a project file.
45
+ /// Shared with <see cref="WorkspaceLoader"/>'s project-file discovery so the two never disagree.
46
+ /// </summary>
47
+ public static bool IsBuildOutputPath(string relativePath) =>
48
+ relativePath.Split('/', '\\').Any(segment =>
49
+ segment.Equals("bin", StringComparison.OrdinalIgnoreCase) || segment.Equals("obj", StringComparison.OrdinalIgnoreCase));
50
+
36
51
  /// <summary>Declared in a real document of the loaded solution (not metadata, not generator output).</summary>
37
52
  public static bool IsRepoSource(Microsoft.CodeAnalysis.INamedTypeSymbol type, LoadedWorkspace loaded) =>
38
53
  type.Locations.Any(l => l.IsInSource && Classify(loaded, l.SourceTree).Kind != Kind.NotSource);