Skip to content

Commit 3496dca

Browse files
committed
Add --changed-only to developer-cli lint command (opt-in) and rename detect-changes to detect-scope
1 parent d95c1b0 commit 3496dca

5 files changed

Lines changed: 89 additions & 44 deletions

File tree

.claude/skills/lint/SKILL.md

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ description: Lint code via the developer CLI - backend (.NET via JetBrains inspe
66
# Lint
77

88
```bash
9-
dotnet run --project developer-cli -- lint [--backend] [--frontend] [--cli] [--self-contained-system <name>] [--no-build] --quiet
9+
dotnet run --project developer-cli -- lint [--backend] [--frontend] [--cli] [--self-contained-system <name>] [--no-build] [--changed-only] --quiet
1010
```
1111

1212
Use `developer-cli` exactly as written - do not expand to an absolute worktree path.
@@ -16,18 +16,28 @@ Use `developer-cli` exactly as written - do not expand to an absolute worktree p
1616
- `--cli` - the developer CLI itself
1717
- `--self-contained-system <name>` - narrows backend linting to one SCS (e.g. `account`, `main`)
1818
- `--no-build` - skip the rebuild step (faster after a recent build)
19+
- `--changed-only` - lint only `.cs` files changed against `origin/main` (much faster; see guidance below)
1920

20-
No arguments lints everything. Every finding fails CI regardless of severity - fix all of them.
21+
No arguments lints the whole solution. Every finding fails CI regardless of severity - fix all of them.
2122

2223
After `build` succeeds, run `format`, `lint`, `test` in parallel with `--no-build`. Backend lint is slow - run last. Frontend lint often needs code rewrites - run after each bigger change.
2324

25+
## When to use `--changed-only`
26+
27+
Inspectcode has cross-file rules ("unused public method", "member can be private", flow analysis across method calls). `--changed-only` only inspects the listed files - it doesn't catch issues in untouched files that became invalid because of edits elsewhere.
28+
29+
- **Routine work:** use `--changed-only`. Most lint findings are local (style, naming, hints) and the saving is large (~4m → ~30s).
30+
- **Larger changes that affect other files** (refactoring a public API, deleting a method's only caller, changing a widely-used type): omit `--changed-only` and lint the full solution.
31+
32+
CI always lints the full solution, so anything missed by a local `--changed-only` run gets caught before merge.
33+
2434
## Examples
2535

2636
```bash
27-
dotnet run --project developer-cli -- lint --quiet # everything
28-
dotnet run --project developer-cli -- lint --backend --quiet # all backend
29-
dotnet run --project developer-cli -- lint --frontend --quiet # frontend
30-
dotnet run --project developer-cli -- lint --backend --self-contained-system main --quiet # one SCS
37+
dotnet run --project developer-cli -- lint --quiet # everything (full solution)
38+
dotnet run --project developer-cli -- lint --backend --changed-only --quiet # backend, changed files only (recommended for routine work)
39+
dotnet run --project developer-cli -- lint --frontend --quiet # frontend
40+
dotnet run --project developer-cli -- lint --backend --self-contained-system main --quiet # one SCS, full
3141
```
3242

3343
## Always pass --quiet

.github/workflows/code-style.yml

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,14 @@ permissions:
2323
pull-requests: write
2424

2525
jobs:
26-
detect-changes:
27-
name: Detect Changes
26+
detect-scope:
27+
name: Detect Scope
2828
runs-on: ubuntu-24.04
2929
outputs:
3030
backend_scope: ${{ steps.scope.outputs.backend_scope }}
3131
backend_label: ${{ steps.scope.outputs.backend_label }}
3232
format_all_files_flag: ${{ steps.scope.outputs.format_all_files_flag }}
33+
format_label: ${{ steps.scope.outputs.format_label }}
3334
steps:
3435
- name: Checkout Code
3536
uses: actions/checkout@v6
@@ -82,8 +83,9 @@ jobs:
8283
fi
8384
8485
# Force --all-files when the JetBrains tool manifest moves. Tool upgrades change the
85-
# cleanupcode rule set, so latent formatting drift in untouched files needs reformatting
86-
# in the same PR. Otherwise the next PR that happens to touch one of those files fails CI.
86+
# inspectcode + cleanupcode rule sets, so latent issues in untouched files need to be
87+
# caught in the same PR. Otherwise the next PR that happens to touch one of those files
88+
# fails CI on issues it didn't introduce.
8789
if echo "$changed" | grep -q '^application/dotnet-tools.json$'; then
8890
echo "format_all_files_flag=--all-files" >> "$GITHUB_OUTPUT"
8991
echo "format_label=all files (JetBrains tools upgraded)" >> "$GITHUB_OUTPUT"
@@ -95,11 +97,11 @@ jobs:
9597
- name: Print Scope
9698
run: |
9799
echo "Backend scope - ${{ steps.scope.outputs.backend_label }}"
98-
echo "Format scope - ${{ steps.scope.outputs.format_label }}"
100+
echo "Format mode - ${{ steps.scope.outputs.format_label }}"
99101
100102
code-linting:
101103
name: Code Linting
102-
needs: detect-changes
104+
needs: detect-scope
103105
runs-on: ubuntu-24.04
104106

105107
steps:
@@ -130,12 +132,12 @@ jobs:
130132

131133
- name: Build Backend Solution
132134
working-directory: developer-cli
133-
run: dotnet run -- build --backend ${{ needs.detect-changes.outputs.backend_scope }} --quiet
135+
run: dotnet run -- build --backend ${{ needs.detect-scope.outputs.backend_scope }} --quiet
134136

135137
- name: Run Backend Linting
136138
working-directory: developer-cli
137139
run: |
138-
dotnet run lint --backend ${{ needs.detect-changes.outputs.backend_scope }} --no-build | tee lint-output.log
140+
dotnet run lint --backend ${{ needs.detect-scope.outputs.backend_scope }} --no-build | tee lint-output.log
139141
140142
if ! grep -q "No backend issues found!" lint-output.log; then
141143
echo "Code linting issues found."
@@ -162,7 +164,7 @@ jobs:
162164
163165
code-formatting:
164166
name: Code Formatting
165-
needs: detect-changes
167+
needs: detect-scope
166168
runs-on: ubuntu-24.04
167169

168170
steps:
@@ -193,21 +195,21 @@ jobs:
193195

194196
- name: Build Backend Solution
195197
working-directory: developer-cli
196-
run: dotnet run -- build --backend ${{ needs.detect-changes.outputs.backend_scope }} --quiet
198+
run: dotnet run -- build --backend ${{ needs.detect-scope.outputs.backend_scope }} --quiet
197199

198200
- name: Check for Code Formatting Issues
199201
working-directory: developer-cli
200202
run: |
201-
dotnet run format --backend ${{ needs.detect-changes.outputs.backend_scope }} ${{ needs.detect-changes.outputs.format_all_files_flag }} --no-build
203+
dotnet run format --backend ${{ needs.detect-scope.outputs.backend_scope }} ${{ needs.detect-scope.outputs.format_all_files_flag }} --no-build
202204
203205
git diff --exit-code || {
204-
echo "Formatting issues detected. Please run 'dotnet run --project developer-cli -- format --backend ${{ needs.detect-changes.outputs.backend_scope }} ${{ needs.detect-changes.outputs.format_all_files_flag }}' locally and commit the formatted code."
206+
echo "Formatting issues detected. Please run 'dotnet run --project developer-cli -- format --backend ${{ needs.detect-scope.outputs.backend_scope }} ${{ needs.detect-scope.outputs.format_all_files_flag }}' locally and commit the formatted code."
205207
exit 1
206208
}
207209
208210
sonarcloud:
209211
name: SonarCloud Analysis
210-
needs: detect-changes
212+
needs: detect-scope
211213
runs-on: ubuntu-24.04
212214

213215
steps:

developer-cli/Commands/FormatCommand.cs

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ private static void RunBackendFormat(string? selfContainedSystem, bool noBuild,
134134
var includeArgument = string.Empty;
135135
if (!allFiles)
136136
{
137-
var changedCsFiles = GetChangedCsFiles(solutionFile.Directory!.FullName);
137+
var changedCsFiles = GitHelper.GetChangedCsFilesInDirectory(solutionFile.Directory!.FullName);
138138
if (changedCsFiles.Length == 0)
139139
{
140140
if (!quiet) AnsiConsole.MarkupLine("[green]No changed C# files found, skipping backend format.[/]");
@@ -173,7 +173,7 @@ private static void RunDeveloperCliFormat(bool noBuild, bool allFiles, bool quie
173173
var includeArgument = string.Empty;
174174
if (!allFiles)
175175
{
176-
var changedCsFiles = GetChangedCsFiles(solutionFile.Directory!.FullName);
176+
var changedCsFiles = GitHelper.GetChangedCsFilesInDirectory(solutionFile.Directory!.FullName);
177177
if (changedCsFiles.Length == 0)
178178
{
179179
if (!quiet) AnsiConsole.MarkupLine("[green]No changed C# files found, skipping developer-cli format.[/]");
@@ -197,20 +197,4 @@ private static void RunDeveloperCliFormat(bool noBuild, bool allFiles, bool quie
197197
);
198198
}
199199

200-
// Returns relative paths (relative to solutionDirectory) of .cs files changed compared to origin/main.
201-
// Fetches origin/main first so this works on shallow CI clones; the fetch is best-effort and silent on failure.
202-
private static string[] GetChangedCsFiles(string solutionDirectory)
203-
{
204-
ProcessHelper.StartProcess("git fetch origin main --depth=1", Configuration.SourceCodeFolder, true, exitOnError: false);
205-
var output = ProcessHelper.StartProcess("git diff --name-only origin/main -- \"*.cs\"", Configuration.SourceCodeFolder, true, exitOnError: false);
206-
if (string.IsNullOrWhiteSpace(output)) return [];
207-
208-
var repoRoot = Configuration.SourceCodeFolder;
209-
return output
210-
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
211-
.Select(relativePath => Path.GetFullPath(Path.Combine(repoRoot, relativePath.Trim())))
212-
.Where(fullPath => fullPath.StartsWith(solutionDirectory, StringComparison.OrdinalIgnoreCase) && File.Exists(fullPath))
213-
.Select(fullPath => Path.GetRelativePath(solutionDirectory, fullPath))
214-
.ToArray();
215-
}
216200
}

developer-cli/Commands/LintCommand.cs

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ public class LintCommand : Command
1616
var cliOption = new Option<bool>("--cli", "-c") { Description = "Run developer-cli linting" };
1717
var selfContainedSystemOption = new Option<string?>("<self-contained-system>", "--self-contained-system", "-s") { Description = "The name of the self-contained system to lint (e.g., main, account, back-office)" };
1818
var noBuildOption = new Option<bool>("--no-build") { Description = "Skip building and restoring the solution before running linting" };
19+
var changedOnlyOption = new Option<bool>("--changed-only") { Description = "Lint only .cs files changed against origin/main. Default is to lint the full solution. Recommended for routine local runs; CI always lints the full solution." };
1920
var quietOption = new Option<bool>("--quiet", "-q") { Description = "Minimal output mode" };
2021

2122
Options.Add(backendOption);
2223
Options.Add(frontendOption);
2324
Options.Add(cliOption);
2425
Options.Add(selfContainedSystemOption);
2526
Options.Add(noBuildOption);
27+
Options.Add(changedOnlyOption);
2628
Options.Add(quietOption);
2729

2830
SetAction(parseResult => Execute(
@@ -31,12 +33,13 @@ public class LintCommand : Command
3133
parseResult.GetValue(cliOption),
3234
parseResult.GetValue(selfContainedSystemOption),
3335
parseResult.GetValue(noBuildOption),
36+
parseResult.GetValue(changedOnlyOption),
3437
parseResult.GetValue(quietOption)
3538
)
3639
);
3740
}
3841

39-
private static void Execute(bool backend, bool frontend, bool developerCli, string? selfContainedSystem, bool noBuild, bool quiet)
42+
private static void Execute(bool backend, bool frontend, bool developerCli, string? selfContainedSystem, bool noBuild, bool changedOnly, bool quiet)
4043
{
4144
var noFlags = !backend && !frontend && !developerCli;
4245
var lintBackend = backend || noFlags;
@@ -54,7 +57,7 @@ private static void Execute(bool backend, bool frontend, bool developerCli, stri
5457
if (lintBackend)
5558
{
5659
Prerequisite.Ensure(Prerequisite.Dotnet);
57-
hasIssues = RunBackendLinting(selfContainedSystem, noBuild, quiet);
60+
hasIssues = RunBackendLinting(selfContainedSystem, noBuild, changedOnly, quiet);
5861
backendTime = Stopwatch.GetElapsedTime(startTime);
5962
}
6063

@@ -69,7 +72,7 @@ private static void Execute(bool backend, bool frontend, bool developerCli, stri
6972
if (lintDeveloperCli)
7073
{
7174
Prerequisite.Ensure(Prerequisite.Dotnet);
72-
var developerCliHasIssues = RunDeveloperCliLinting(noBuild, quiet);
75+
var developerCliHasIssues = RunDeveloperCliLinting(noBuild, changedOnly, quiet);
7376
hasIssues = hasIssues || developerCliHasIssues;
7477
developerCliTime = Stopwatch.GetElapsedTime(startTime) - backendTime - frontendTime;
7578
}
@@ -119,10 +122,24 @@ private static void Execute(bool backend, bool frontend, bool developerCli, stri
119122
}
120123
}
121124

122-
private static bool RunBackendLinting(string? selfContainedSystem, bool noBuild, bool quiet)
125+
private static bool RunBackendLinting(string? selfContainedSystem, bool noBuild, bool changedOnly, bool quiet)
123126
{
124127
var solutionFile = SelfContainedSystemHelper.GetSolutionFile(selfContainedSystem);
125128

129+
var includeArgument = string.Empty;
130+
if (changedOnly)
131+
{
132+
var changedCsFiles = GitHelper.GetChangedCsFilesInDirectory(solutionFile.Directory!.FullName);
133+
if (changedCsFiles.Length == 0)
134+
{
135+
if (!quiet) AnsiConsole.MarkupLine("[green]No changed C# files found, skipping backend linting.[/]");
136+
return false;
137+
}
138+
139+
includeArgument = $""" --include="{string.Join(";", changedCsFiles)}" """.TrimEnd();
140+
if (!quiet) AnsiConsole.MarkupLine($"[blue]Linting {changedCsFiles.Length} changed file(s)...[/]");
141+
}
142+
126143
if (!noBuild)
127144
{
128145
if (!quiet) AnsiConsole.MarkupLine("[blue]Running backend code linting...[/]");
@@ -142,7 +159,7 @@ private static bool RunBackendLinting(string? selfContainedSystem, bool noBuild,
142159
// but those attributes are mandatory for cross-client email rendering and cannot be removed.
143160
// The dist folder is gitignored build output, not source.
144161
ProcessHelper.Run(
145-
$"dotnet jb inspectcode {solutionFile.Name} --no-build --no-restore --output=result.json --severity=SUGGESTION --exclude=**/emails/dist/**",
162+
$"dotnet jb inspectcode {solutionFile.Name} --no-build --no-restore --output=result.json --severity=SUGGESTION --exclude=**/emails/dist/**{includeArgument}",
146163
solutionFile.Directory!.FullName,
147164
"Linting",
148165
quiet
@@ -229,10 +246,24 @@ private static bool CheckMissingTranslations(bool quiet)
229246
return true;
230247
}
231248

232-
private static bool RunDeveloperCliLinting(bool noBuild, bool quiet)
249+
private static bool RunDeveloperCliLinting(bool noBuild, bool changedOnly, bool quiet)
233250
{
234251
var solutionFile = new FileInfo(Path.Combine(Configuration.CliFolder, "DeveloperCli.slnx"));
235252

253+
var includeArgument = string.Empty;
254+
if (changedOnly)
255+
{
256+
var changedCsFiles = GitHelper.GetChangedCsFilesInDirectory(solutionFile.Directory!.FullName);
257+
if (changedCsFiles.Length == 0)
258+
{
259+
if (!quiet) AnsiConsole.MarkupLine("[green]No changed C# files found, skipping developer-cli linting.[/]");
260+
return false;
261+
}
262+
263+
includeArgument = $""" --include="{string.Join(";", changedCsFiles)}" """.TrimEnd();
264+
if (!quiet) AnsiConsole.MarkupLine($"[blue]Linting {changedCsFiles.Length} changed file(s)...[/]");
265+
}
266+
236267
if (!noBuild)
237268
{
238269
if (!quiet) AnsiConsole.MarkupLine("[blue]Running developer-cli code linting...[/]");
@@ -248,7 +279,7 @@ private static bool RunDeveloperCliLinting(bool noBuild, bool quiet)
248279
}
249280

250281
ProcessHelper.Run(
251-
$"dotnet jb inspectcode {solutionFile.Name} --no-build --no-restore --output=result.json --severity=SUGGESTION",
282+
$"dotnet jb inspectcode {solutionFile.Name} --no-build --no-restore --output=result.json --severity=SUGGESTION{includeArgument}",
252283
solutionFile.Directory!.FullName,
253284
"Linting",
254285
quiet

developer-cli/Utilities/GitHelper.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,24 @@ public static void FetchFromOrigin()
141141
ProcessHelper.StartProcess("git fetch origin", Configuration.SourceCodeFolder);
142142
}
143143

144+
// Returns relative paths (relative to solutionDirectory) of .cs files changed compared to
145+
// origin/main. Fetches origin/main first so this works on shallow CI clones; the fetch is
146+
// best-effort and silent on failure.
147+
public static string[] GetChangedCsFilesInDirectory(string solutionDirectory)
148+
{
149+
ProcessHelper.StartProcess("git fetch origin main --depth=1", Configuration.SourceCodeFolder, true, exitOnError: false);
150+
var output = ProcessHelper.StartProcess("git diff --name-only origin/main -- \"*.cs\"", Configuration.SourceCodeFolder, true, exitOnError: false);
151+
if (string.IsNullOrWhiteSpace(output)) return [];
152+
153+
var repoRoot = Configuration.SourceCodeFolder;
154+
return output
155+
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
156+
.Select(relativePath => Path.GetFullPath(Path.Combine(repoRoot, relativePath.Trim())))
157+
.Where(fullPath => fullPath.StartsWith(solutionDirectory, StringComparison.OrdinalIgnoreCase) && File.Exists(fullPath))
158+
.Select(fullPath => Path.GetRelativePath(solutionDirectory, fullPath))
159+
.ToArray();
160+
}
161+
144162
public static void StashChanges(string message = "Stashed by Developer CLI")
145163
{
146164
ProcessHelper.StartProcess($"git stash push -m \"{message}\"", Configuration.SourceCodeFolder);

0 commit comments

Comments
 (0)