@groma/scanner-csharp 0.1.4 → 0.2.1

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 (3) hide show
  1. package/README.md +86 -41
  2. package/dist/index.js +101 -222
  3. package/package.json +29 -9
package/README.md CHANGED
@@ -31,44 +31,84 @@ only: scanner evidence, OKF records and C4 boundaries are unchanged.
31
31
 
32
32
  ## Source inputs
33
33
 
34
- The scanner discovers tracked and unignored SDK-style `.csproj`, `.sln` and
35
- `.slnx` files. Solutions are selected before their projects; local project
36
- references are loaded once. Set `settings.input` on the existing C# scanner
37
- entry to select one project or solution relative to the repository root.
38
-
39
- An in-memory Roslyn workspace reads project XML and authored C# files.
40
- Unconditional language options, compile includes/removes, implicit usings and
41
- local project references supply context. The scanner's runtime supplies base
42
- .NET declarations. It does not run MSBuild, evaluate imported or conditional
43
- build settings, restore packages, or execute source generators. Standard .NET,
44
- Web and Worker SDK headers are accepted; this is not general framework analysis.
45
-
46
- Generated `bin` and `obj` files are excluded. A physical source file cannot
47
- belong to several selected project contexts. Project references must stay
48
- inside the repository. Mixed-language graphs and legacy projects are unsupported.
49
- Invalid syntax fails a scan. Missing external types produce diagnostics while
34
+ The scanner reads the `.csproj`, `.sln` and `.slnx` files among its files, and
35
+ every project among them that they reference, and loads each project once,
36
+ however many solutions list it. Set `settings.input` on the existing C# scanner
37
+ entry to select one of those projects or solutions, with the projects it
38
+ references, relative to the repository root. To explain a file without an owner,
39
+ the scanner lists every C# source its include list names, before exclusions: a
40
+ project may compile any repository file, so this never leaves out a file a scan
41
+ reads.
42
+
43
+ An in-memory Roslyn workspace reads project XML and authored C# files. The
44
+ nearest `Directory.Build.props`, the project file, and the files either imports
45
+ by a path the scanner can expand supply their unconditional properties and items
46
+ in document order: the language version, `DefineConstants`, `ImplicitUsings`
47
+ and `Using` items, `AllowUnsafeBlocks`, `OutputType`, and `Compile` includes and
48
+ removes. A value may use `$(Name)` for a property read before it. In a property
49
+ value an unset property is empty, as in MSBuild, while an item or import path
50
+ that names one stays unknown. Conditions, targets, property functions and SDK
51
+ files are not evaluated. The scanner's runtime supplies base .NET declarations.
52
+ It does not run MSBuild, restore packages, or execute source generators.
53
+
54
+ Every SDK-style project is read, whatever its SDK: .NET, Web, Worker, Razor,
55
+ Blazor WebAssembly, Aspire AppHost or an explicit `Sdk` import. Projects in
56
+ other languages are skipped. A C# project that is not SDK-style is skipped with
57
+ a `CSHARP_UNSUPPORTED_PROJECT` warning. Project references follow the SDK: they
58
+ are transitive.
59
+
60
+ The package declares default [include and exclude lists](../index.md#selecting-source-files).
61
+ The include list names C# sources (`**/*.cs`), projects (`**/*.csproj`),
62
+ solutions (`**/*.sln` and `**/*.slnx`) and MSBuild files (`**/*.props` and
63
+ `**/*.targets`), each extension in any letter case. MSBuild files are only a
64
+ project's build context; the list names them so that a change to one triggers a
65
+ scan. The exclude list names build output (`bin` and `obj`, in any letter case),
66
+ test code (folders named `test` or `tests` in either initial case, folders whose
67
+ names end in `Tests`, and `*.Test` folders), and generated files
68
+ (`*.Designer.cs`, `*.g.cs`, `*.g.i.cs` and `*.generated.cs`). A source, project
69
+ or solution outside the scanner's files is never read, whether a solution, a
70
+ reference or `settings.input` names it, so a declaration that only such a file
71
+ provides is reported as missing where code uses it. An included project's
72
+ nearest `Directory.Build.props` and the files it imports are read as MSBuild
73
+ reads them, even when excluded. Which files a project compiles stays built in.
74
+
75
+ For executable projects, Roslyn's resolved entry point supplies an execution fact
76
+ with its source file, project declaration, assembly name and the project's own
77
+ compiled source files. Referenced projects remain separate source units. An
78
+ explicit `OutputType`, in any case, takes precedence; the Web and Worker SDKs
79
+ default to `Exe`; otherwise top-level statements, which C# accepts only in an
80
+ executable, make the project one. Core applies the
81
+ [shared container-placement rule](../evidence.md#execution-entries-and-container-placement).
82
+ Solutions and library projects alone do not imply containers.
83
+
84
+ A source file that several projects compile belongs to each of them. It is
85
+ analyzed once, in the context of the first project by path. Invalid syntax fails
86
+ a scan. Missing external types produce warnings located by file and line, while
50
87
  local declarations and supported operations remain available.
51
88
 
52
89
  ## Evidence
53
90
 
54
91
  Partial class declarations in separate authored files are proposed as one source
55
92
  unit when Roslyn identifies the same class in the same project. Each file must
56
- contain only that type, and every declaration must qualify. Same-name classes
57
- in other namespaces or projects are independent. Files containing additional
58
- types remain separate source evidence. This does not associate Razor, XAML, or
59
- generated source.
93
+ declare only that type, apart from the types nested in it, and every declaration
94
+ must qualify. The primary is the file named after the class, else the file with
95
+ the shortest name. Same-name classes in other namespaces or projects are
96
+ independent. Files containing additional types remain separate source evidence.
97
+ This does not associate Razor, XAML, or generated source.
60
98
 
61
99
  Core creates one component with all member Code references, attaches newly
62
100
  discovered unowned partial files, and retains curated ownership on repeat scans.
63
- Conflicting owners produce review diagnostics.
64
- The existing C# source watcher includes new partial files. No project restore,
65
- build, or source-generator execution is needed.
101
+ Conflicting owners produce review diagnostics. No project restore, build, or
102
+ source-generator execution is needed.
66
103
 
67
104
  Roslyn resolves local overloads, generic methods, extensions, partial
68
- implementations and direct calls. Calls with type errors, virtual dispatch,
69
- interfaces and delegates retain uncertainty. Implicit calls, initializers,
70
- generated operations, receiver/delegate value flow, dependency injection and
71
- network protocols are not resolved.
105
+ implementations and direct calls. Virtual dispatch and interface calls retain
106
+ uncertainty. Calls with type errors, and calls that reach no repository
107
+ operation, such as calls into packages, the framework or delegates, are not
108
+ reported. A lambda is an operation unless a call that binds converts it to an
109
+ expression tree. Implicit calls, initializers, generated operations,
110
+ receiver/delegate value flow, dependency injection and network protocols are
111
+ not resolved.
72
112
 
73
113
  Solutions and projects supply source roots and file membership. They are not
74
114
  C4 components or proof of business collaborations. Core owns curated membership
@@ -76,10 +116,10 @@ and relationship selection; ordinary Markdown readers see the existing OKF
76
116
  records and Code links. The scanner adds no map level or architecture metadata.
77
117
 
78
118
  Settings also accept `configuration` (default `Debug`), `maxProjects` (128),
79
- `maxFiles` (20000) and `timeoutSeconds` (120). Configuration selects the source
80
- DEBUG/TRACE symbols; it does not execute a build configuration. Limits fail
81
- rather than publish truncated evidence. Failed scanners retain their saved
82
- evidence while other scanners can update the map.
119
+ `maxFiles` (20000) and `timeoutSeconds` (120), which apply to the whole scan.
120
+ Configuration selects the source DEBUG/TRACE symbols; it does not execute a
121
+ build configuration. Limits fail rather than publish truncated evidence. Failed
122
+ scanners retain their saved evidence while other scanners can update the map.
83
123
 
84
124
  Run `dotnet test plugins/scanners/csharp/dotnet/test/Groma.CSharpScanner.Tests.csproj`
85
125
  for Roslyn tests. Fixtures are scanned without restore. The packaged test
@@ -97,7 +137,8 @@ project, restoring packages or building.
97
137
  in the file or inside its namespaces are types. Enums and delegates have no
98
138
  members.
99
139
  - A type's members are the methods, interface method signatures, constructors,
100
- finalizers and operators declared in its body, each overload separately.
140
+ finalizers and operators declared in its body, including the methods of its
141
+ C# 14 extension blocks, each overload separately.
101
142
  Constructors are named after the type and finalizers `~Type`; operators are
102
143
  named like `operator +` or `implicit operator int`.
103
144
 
@@ -174,11 +215,13 @@ Endpoints:
174
215
  - a controller is a public, top-level, non-generic, non-abstract class declared
175
216
  in one place, named `*Controller`, marked `[Controller]` or deriving from
176
217
  `ControllerBase` or `Controller`, and not `[NonController]`, counting the
177
- attributes of its bases. A controller also serves the public methods and
178
- `[Route]` prefixes of its bases, so one with a base between it and
179
- `ControllerBase` that declares a public method or a `[Route]`, or that the
180
- source does not declare, reports nothing. An action is a public, non-static,
181
- non-generic method with a body and without `[NonAction]`.
218
+ attributes of its bases. Its class prefixes are the `[Route]` attributes of
219
+ the most derived class that declares any, as ASP.NET Core reads them, even
220
+ when that base is declared in a referenced project. A controller also serves
221
+ the public methods of its bases, so one with a base between it and
222
+ `ControllerBase` that declares a public method, or that the source does not
223
+ declare, reports nothing. An action is a public, non-static, non-generic
224
+ method with a body and without `[NonAction]`.
182
225
  - minimal APIs: `MapGet`, `MapPost`, `MapPut`, `MapDelete`, `MapPatch`, and
183
226
  `MapMethods` with literal methods, on a `MapGroup` chain or on the
184
227
  application: the result of `Build()` or `WebApplication.Create()`, or any
@@ -187,10 +230,12 @@ Endpoints:
187
230
 
188
231
  Requests:
189
232
 
190
- - `HttpClient` calls, recognized by the receiver's own type: the `GetAsync`,
191
- `GetStringAsync`, `GetFromJsonAsync`, `PostAsync`, `PostAsJsonAsync`,
192
- `PutAsync`, `PatchAsync` and `DeleteAsync` families, and `SendAsync` with a
193
- `new HttpRequestMessage(HttpMethod.Get, url)`.
233
+ - `HttpClient` calls, recognized by the receiver's own type or as the client
234
+ `IHttpClientFactory.CreateClient` returns, directly or held by a local: the
235
+ `GetAsync`, `GetStringAsync`, `GetFromJsonAsync`, `PostAsync`,
236
+ `PostAsJsonAsync`, `PutAsync`, `PatchAsync` and `DeleteAsync` families, and
237
+ `SendAsync` with a `new HttpRequestMessage(HttpMethod.Get, url)` written in
238
+ the call or held by a local the file never assigns again.
194
239
  - declarative client interfaces such as Refit: `[Get]`, `[Post]`, `[Put]`,
195
240
  `[Delete]`, `[Patch]`, `[Head]` and `[Options]` on an interface method. Such
196
241
  a method has no body, so its request declares the operation itself.
package/dist/index.js CHANGED
@@ -1,90 +1,8 @@
1
1
  // @bun
2
- // plugins/scanners/csharp/src/index.ts
3
- import { readFile } from "fs/promises";
4
- import path4 from "path";
5
-
6
- // plugins/scanners/csharp/src/config.ts
7
- import { stat } from "fs/promises";
8
- import path2 from "path";
9
-
10
- // plugins/scanners/projects.ts
11
- import { execFile } from "child_process";
12
- import { existsSync } from "fs";
2
+ // plugins/scanners/csharp/src/adapter.ts
3
+ import { access } from "fs/promises";
13
4
  import path from "path";
14
- import { promisify } from "util";
15
- var execute = promisify(execFile);
16
- var excluded = new Set([
17
- ".git",
18
- "node_modules",
19
- "vendor",
20
- "target",
21
- "dist",
22
- "build",
23
- "bin",
24
- "obj",
25
- ".gradle",
26
- ".angular",
27
- "coverage",
28
- "generated",
29
- "groma",
30
- ".groma"
31
- ]);
32
- async function repositoryFiles(root, matches) {
33
- const { stdout } = await execute("git", ["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { maxBuffer: 64 * 1024 * 1024 });
34
- return [...new Set(stdout.split("\x00").filter((file) => file && matches(file) && existsSync(path.join(root, file))))].sort();
35
- }
36
- function projectFiles(root, matches) {
37
- return repositoryFiles(root, (file) => matches(file) && !file.split("/").some((part) => excluded.has(part)));
38
- }
39
-
40
- // plugins/scanners/csharp/src/config.ts
41
- var defaults = { configuration: "Debug", maxProjects: 128, maxFiles: 20000, timeoutSeconds: 120 };
42
- var configName = "csharp scanner settings";
43
- function positiveInteger(value, key, fallback) {
44
- if (value === undefined)
45
- return fallback;
46
- if (!Number.isSafeInteger(value) || Number(value) < 1)
47
- throw new Error(`${configName}: ${key} must be a positive integer`);
48
- return Number(value);
49
- }
50
- function optionalText(value, key) {
51
- if (value === undefined)
52
- return;
53
- if (typeof value !== "string" || !value.trim())
54
- throw new Error(`${configName}: ${key} must be nonempty text`);
55
- return value;
56
- }
57
- function parseCSharpSettings(config = {}) {
58
- for (const key of Object.keys(config)) {
59
- if (!["input", ...Object.keys(defaults)].includes(key))
60
- throw new Error(`${configName}: unknown setting ${key}`);
61
- }
62
- const input = optionalText(config.input, "input");
63
- const configuration = optionalText(config.configuration, "configuration");
64
- return {
65
- ...input === undefined ? {} : { input },
66
- configuration: configuration ?? defaults.configuration,
67
- maxProjects: positiveInteger(config.maxProjects, "maxProjects", defaults.maxProjects),
68
- maxFiles: positiveInteger(config.maxFiles, "maxFiles", defaults.maxFiles),
69
- timeoutSeconds: positiveInteger(config.timeoutSeconds, "timeoutSeconds", defaults.timeoutSeconds)
70
- };
71
- }
72
- async function validateInput(root, selected) {
73
- const full = path2.resolve(root, selected);
74
- const relative = path2.relative(root, full);
75
- if (relative === ".." || relative.startsWith(`..${path2.sep}`) || path2.isAbsolute(relative))
76
- throw new Error("C# scan input must stay inside the repository");
77
- if (!/\.(?:csproj|slnx?)$/i.test(full) || !(await stat(full)).isFile())
78
- throw new Error("C# input must be an existing .csproj, .sln, or .slnx file");
79
- return full;
80
- }
81
- async function csharpInputs(root, settings) {
82
- const config = parseCSharpSettings(settings);
83
- if (config.input)
84
- return [await validateInput(root, config.input)];
85
- const files = await projectFiles(root, (file) => /\.(?:csproj|slnx?)$/i.test(file));
86
- return [...files.filter((file) => /\.slnx?$/i.test(file)), ...files.filter((file) => /\.csproj$/i.test(file))].map((file) => path2.resolve(root, file));
87
- }
5
+ import { createRequire } from "module";
88
6
 
89
7
  // packages/scanner/src/values.ts
90
8
  function object(value, label) {
@@ -202,13 +120,13 @@ function compare(...values) {
202
120
  }
203
121
  function uniqueBy(values, key, label) {
204
122
  const seen = new Set;
205
- for (const value of values) {
206
- const id = key(value);
123
+ const keyed = values.map((value) => ({ id: key(value), value }));
124
+ for (const { id } of keyed) {
207
125
  if (seen.has(id))
208
126
  throw new Error(`duplicate ${label}: ${id}`);
209
127
  seen.add(id);
210
128
  }
211
- return [...values].sort((left, right) => key(left).localeCompare(key(right)));
129
+ return keyed.sort((left, right) => left.id.localeCompare(right.id)).map((entry) => entry.value);
212
130
  }
213
131
  function createScanObservation(input) {
214
132
  const roots = validateRoots(input.roots);
@@ -219,9 +137,7 @@ function createScanObservation(input) {
219
137
  symbols: [...new Map(file.symbols.map((symbol) => [
220
138
  compare(symbol.id, symbol.kind),
221
139
  symbol
222
- ])).values()].sort((left, right) => {
223
- return compare(left.id, left.kind).localeCompare(compare(right.id, right.kind));
224
- })
140
+ ]))].sort(([left], [right]) => left.localeCompare(right)).map(([, symbol]) => symbol)
225
141
  })), (file) => file.file, "file path");
226
142
  const filePaths = new Set(files.map((file) => file.file));
227
143
  for (const file of files) {
@@ -232,24 +148,46 @@ function createScanObservation(input) {
232
148
  throw new Error(`file references unknown root: ${root}`);
233
149
  }
234
150
  }
235
- for (const diagnostic of input.diagnostics)
236
- diagnosticLocation(diagnostic);
237
151
  return {
238
152
  schemaVersion: 1,
239
153
  scanner: input.scanner,
240
154
  roots,
241
155
  files,
242
156
  ...sourceUnits(input.sourceUnits, filePaths),
157
+ ...entryPoints(input.entryPoints, filePaths),
243
158
  ...operationEvidence(input, filePaths),
244
159
  ...httpEvidence(input.httpEndpoints, input.httpRequests, input.operations && new Set(input.operations.map((operation) => operation.id))),
245
- diagnostics: [...new Map(input.diagnostics.map((diagnostic) => [
246
- diagnosticKey(diagnostic),
247
- diagnostic
248
- ])).values()].sort((left, right) => {
249
- return diagnosticKey(left).localeCompare(diagnosticKey(right));
250
- })
160
+ diagnostics: normalizeScanDiagnostics(input.diagnostics)
251
161
  };
252
162
  }
163
+ function normalizeScanDiagnostics(diagnostics) {
164
+ for (const diagnostic of diagnostics)
165
+ diagnosticLocation(diagnostic);
166
+ return [...new Map(diagnostics.map((diagnostic) => [diagnosticKey(diagnostic), diagnostic]))].sort(([left], [right]) => left.localeCompare(right)).map(([, diagnostic]) => diagnostic);
167
+ }
168
+ function entryPoints(input, paths) {
169
+ if (input === undefined)
170
+ return {};
171
+ const entries = array(input, "entryPoints").map((value) => {
172
+ const entry = object(value, "entry point");
173
+ const files = [...new Set(array(entry.files, "entry point.files").map((file) => string(file, "entry point.file")))].sort();
174
+ for (const file of files) {
175
+ if (!paths.has(file))
176
+ throw new Error(`entry point references unknown file: ${file}`);
177
+ }
178
+ const file = string(entry.file, "entry point.file");
179
+ if (!files.includes(file))
180
+ throw new Error(`entry point must include its own source file: ${file}`);
181
+ return {
182
+ file,
183
+ declaration: string(entry.declaration, "entry point.declaration"),
184
+ name: string(entry.name, "entry point.name"),
185
+ files
186
+ };
187
+ });
188
+ const key = (entry) => compare(entry.file, entry.declaration, entry.name, ...entry.files);
189
+ return { entryPoints: [...new Map(entries.map((entry) => [key(entry), entry])).values()].sort((a, b) => key(a).localeCompare(key(b))) };
190
+ }
253
191
  function sourceUnits(input, paths) {
254
192
  if (input === undefined)
255
193
  return {};
@@ -312,6 +250,7 @@ function parseScanObservation(source) {
312
250
  ...parseOperations(value),
313
251
  httpEndpoints: value.httpEndpoints,
314
252
  httpRequests: value.httpRequests,
253
+ entryPoints: value.entryPoints,
315
254
  ...sourceUnits(value.sourceUnits, new Set(array(value.files, "files").map((entry) => string(object(entry, "file").file, "file.file")))),
316
255
  roots: array(value.roots, "roots").map((entry) => {
317
256
  const root = object(entry, "root");
@@ -424,10 +363,14 @@ function operationTokens(operation) {
424
363
  const endLine = Number(operation.endLine);
425
364
  if (endLine < startLine)
426
365
  throw new Error("operation.endLine must be at or after startLine");
366
+ const tokens = array(operation.tokens, "operation.tokens");
367
+ tokens.forEach((token) => {
368
+ string(token, "operation.token");
369
+ });
427
370
  return {
428
371
  startLine,
429
372
  endLine,
430
- tokens: array(operation.tokens, "operation.tokens").map((token) => string(token, "operation.token"))
373
+ tokens
431
374
  };
432
375
  }
433
376
  function validateOperationTokens(operation) {
@@ -456,52 +399,39 @@ function sourcePosition(position) {
456
399
  return { position: Number(position) };
457
400
  }
458
401
 
459
- // plugins/scanners/observations.ts
460
- function combineObservations(parts) {
461
- if (!parts.length)
402
+ // plugins/scanners/csharp/src/config.ts
403
+ var defaults = { configuration: "Debug", maxProjects: 128, maxFiles: 20000, timeoutSeconds: 120 };
404
+ var configName = "csharp scanner settings";
405
+ function positiveInteger(value, key, fallback) {
406
+ if (value === undefined)
407
+ return fallback;
408
+ if (!Number.isSafeInteger(value) || Number(value) < 1)
409
+ throw new Error(`${configName}: ${key} must be a positive integer`);
410
+ return Number(value);
411
+ }
412
+ function optionalText(value, key) {
413
+ if (value === undefined)
462
414
  return;
463
- const roots = [];
464
- const files = new Map;
465
- const operations = [];
466
- const invocations = [];
467
- const diagnostics = [];
468
- const sourceUnits = [];
469
- const httpEndpoints = [];
470
- const httpRequests = [];
471
- for (const { key, observation } of parts) {
472
- const id = (value) => JSON.stringify([key, value]);
473
- roots.push(...observation.roots.map((root) => ({ ...root, id: id(root.id), ...root.parent ? { parent: id(root.parent) } : {} })));
474
- for (const source of observation.files) {
475
- const combined = files.get(source.file) ?? { file: source.file, roots: [], symbols: [] };
476
- combined.roots.push(...source.roots.map(id));
477
- combined.symbols.push(...source.symbols.map((symbol) => ({ ...symbol, id: id(symbol.id) })));
478
- files.set(source.file, combined);
479
- }
480
- operations.push(...(observation.operations ?? []).map((operation) => ({ ...operation, id: id(operation.id) })));
481
- invocations.push(...(observation.invocations ?? []).map((call) => ({ ...call, source: id(call.source), targets: call.targets.map(id) })));
482
- httpEndpoints.push(...(observation.httpEndpoints ?? []).map((endpoint) => ({ ...endpoint, operation: id(endpoint.operation) })));
483
- httpRequests.push(...(observation.httpRequests ?? []).map((request) => ({ ...request, operation: id(request.operation) })));
484
- diagnostics.push(...observation.diagnostics);
485
- sourceUnits.push(...observation.sourceUnits ?? []);
415
+ if (typeof value !== "string" || !value.trim())
416
+ throw new Error(`${configName}: ${key} must be nonempty text`);
417
+ return value;
418
+ }
419
+ function parseCSharpSettings(config = {}) {
420
+ for (const key of Object.keys(config)) {
421
+ if (!["input", ...Object.keys(defaults)].includes(key))
422
+ throw new Error(`${configName}: unknown setting ${key}`);
486
423
  }
487
- return createScanObservation({
488
- scanner: parts[0].observation.scanner,
489
- roots,
490
- files: [...files.values()],
491
- operations,
492
- invocations,
493
- diagnostics,
494
- ...parts.some((part) => part.observation.sourceUnits !== undefined) ? { sourceUnits } : {},
495
- ...parts.some((part) => part.observation.httpEndpoints !== undefined) ? { httpEndpoints } : {},
496
- ...parts.some((part) => part.observation.httpRequests !== undefined) ? { httpRequests } : {}
497
- });
424
+ const input = optionalText(config.input, "input");
425
+ const configuration = optionalText(config.configuration, "configuration");
426
+ return {
427
+ ...input === undefined ? {} : { input },
428
+ configuration: configuration ?? defaults.configuration,
429
+ maxProjects: positiveInteger(config.maxProjects, "maxProjects", defaults.maxProjects),
430
+ maxFiles: positiveInteger(config.maxFiles, "maxFiles", defaults.maxFiles),
431
+ timeoutSeconds: positiveInteger(config.timeoutSeconds, "timeoutSeconds", defaults.timeoutSeconds)
432
+ };
498
433
  }
499
434
 
500
- // plugins/scanners/csharp/src/adapter.ts
501
- import { access } from "fs/promises";
502
- import path3 from "path";
503
- import { createRequire } from "module";
504
-
505
435
  // plugins/scanners/csharp/src/process.ts
506
436
  import { spawn } from "child_process";
507
437
  function stopTree(child) {
@@ -573,47 +503,44 @@ function run(command, args, options) {
573
503
  }
574
504
 
575
505
  // plugins/scanners/csharp/src/adapter.ts
576
- async function requireWorker(file) {
506
+ async function requireWorker() {
577
507
  try {
578
- const manifest = file ? undefined : createRequire(import.meta.url).resolve(`@groma/scanner-csharp-${process.platform}-${process.arch}/package.json`);
579
- const worker = file ?? path3.join(path3.dirname(manifest), "worker", `Groma.CSharpScanner${process.platform === "win32" ? ".exe" : ""}`);
508
+ const manifest = createRequire(import.meta.url).resolve(`@groma/scanner-csharp-${process.platform}-${process.arch}/package.json`);
509
+ const worker = path.join(path.dirname(manifest), "worker", `Groma.CSharpScanner${process.platform === "win32" ? ".exe" : ""}`);
580
510
  await access(worker);
581
511
  return worker;
582
512
  } catch {
583
513
  throw new Error("C# runtime package is missing. Reinstall the scanner with optional dependencies enabled; contributors run bun scripts/package-csharp-scanner.ts.");
584
514
  }
585
515
  }
586
- async function prepareCSharpScan(repositoryRoot, settings, worker) {
587
- const root = path3.resolve(repositoryRoot);
516
+ function csharpInventory(root, config, files) {
517
+ const projects = files.filter((file) => /\.(?:csproj|slnx?)$/i.test(file));
518
+ const input = config.input && path.relative(root, path.resolve(root, config.input)).split(path.sep).join("/");
519
+ const inputs = input ? projects.filter((file) => file === input) : projects;
520
+ return inputs.length ? { files, inputs } : undefined;
521
+ }
522
+ async function checkCSharpReadiness(repositoryRoot, settings, files) {
523
+ if (csharpInventory(path.resolve(repositoryRoot), parseCSharpSettings(settings), files) === undefined)
524
+ throw new Error("No C# project or solution was found. The C# scanner reads the SDK-style .csproj projects among its files and the .sln or .slnx files that list them, or only the one settings.input names.");
525
+ await requireWorker();
526
+ }
527
+ async function scanCSharpSource(repositoryRoot, settings, files) {
528
+ const root = path.resolve(repositoryRoot);
588
529
  const config = parseCSharpSettings(settings);
589
- if (!config.input)
590
- throw new Error("C# analysis requires a selected project or solution input.");
591
- const input = await validateInput(root, config.input);
592
- const executable = await requireWorker(worker);
593
- return { root, config, input, executable };
594
- }
595
- async function checkCSharpReadiness(repositoryRoot, settings = {}, worker) {
596
- const { input } = await prepareCSharpScan(repositoryRoot, settings, worker);
597
- return { input };
598
- }
599
- async function scanCSharpSource(repositoryRoot, settings = {}, worker) {
600
- const { root, config, input, executable } = await prepareCSharpScan(repositoryRoot, settings, worker);
601
- const { stdout } = await run(executable, [
602
- input,
603
- "--root",
604
- root,
605
- "--configuration",
606
- config.configuration,
607
- "--max-projects",
608
- String(config.maxProjects),
609
- "--max-files",
610
- String(config.maxFiles)
611
- ], { cwd: path3.dirname(input), timeoutSeconds: config.timeoutSeconds });
530
+ const inventory = csharpInventory(root, config, files);
531
+ if (inventory === undefined)
532
+ return;
533
+ const request = JSON.stringify({ root, ...inventory, configuration: config.configuration, maxProjects: config.maxProjects, maxFiles: config.maxFiles });
534
+ const { stdout } = await run(await requireWorker(), ["--scan"], { cwd: root, timeoutSeconds: config.timeoutSeconds, input: request });
612
535
  return parseScanObservation(stdout);
613
536
  }
537
+ async function listCSharpSources(repositoryRoot, settings, candidates) {
538
+ const inventory = csharpInventory(path.resolve(repositoryRoot), parseCSharpSettings(settings), candidates);
539
+ return inventory?.files.filter((file) => /\.cs$/i.test(file)) ?? [];
540
+ }
614
541
  async function readCSharpOutline(repositoryRoot, references, settings = {}) {
615
542
  const worker = await requireWorker();
616
- const root = path3.resolve(repositoryRoot);
543
+ const root = path.resolve(repositoryRoot);
617
544
  const { stdout } = await run(worker, ["--outline"], {
618
545
  cwd: root,
619
546
  timeoutSeconds: parseCSharpSettings(settings).timeoutSeconds,
@@ -623,62 +550,14 @@ async function readCSharpOutline(repositoryRoot, references, settings = {}) {
623
550
  }
624
551
 
625
552
  // plugins/scanners/csharp/src/index.ts
626
- async function solutionProjects(input) {
627
- if (/\.csproj$/i.test(input))
628
- return [input];
629
- const source = await readFile(input, "utf8");
630
- return [...source.matchAll(/"([^"]+\.csproj)"|Path="([^"]+\.csproj)"/gi)].map((match) => path4.resolve(path4.dirname(input), (match[1] ?? match[2]).split("\\").join("/")));
631
- }
632
553
  var scanner = {
633
554
  id: "csharp",
634
- watch: {
635
- include: [
636
- "**/*.cs",
637
- "**/*.csproj",
638
- "**/*.sln",
639
- "**/*.slnx",
640
- "**/*.props",
641
- "**/*.targets",
642
- "**/global.json",
643
- "**/nuget.config",
644
- "**/packages.lock.json"
645
- ].map((pattern) => pattern.replace(/[a-z]/g, (letter) => `[${letter}${letter.toUpperCase()}]`)),
646
- exclude: ["**/[bB][iI][nN]/**", "**/[oO][bB][jJ]/**"]
647
- },
648
- checkReadiness: async (root, settings = {}) => {
649
- const inputs = await csharpInputs(root, settings);
650
- if (!inputs.length)
651
- throw new Error("No C# project or solution was found.");
652
- for (const input of inputs)
653
- await checkCSharpReadiness(root, { ...settings, input });
654
- },
555
+ checkReadiness: checkCSharpReadiness,
655
556
  readCodeStructure: readCSharpOutline,
656
- listSourceFiles: async (root, settings = {}) => {
657
- const directories = [];
658
- for (const input of await csharpInputs(root, settings)) {
659
- for (const project of await solutionProjects(input)) {
660
- directories.push(path4.relative(root, path4.dirname(project)).split(path4.sep).join("/"));
661
- }
662
- }
663
- return projectFiles(root, (file) => /\.cs$/i.test(file) && !file.split("/").some((part) => /^(?:bin|obj)$/i.test(part)) && directories.some((directory) => directory === "" || file.startsWith(`${directory}/`)));
664
- },
665
- scan: async (root, settings = {}) => {
666
- const parts = [];
667
- const covered = new Set;
668
- for (const input of await csharpInputs(root, settings)) {
669
- if (covered.has(input))
670
- continue;
671
- const observation = await scanCSharpSource(root, { ...settings, input });
672
- for (const project of observation.roots)
673
- if (project.file)
674
- covered.add(path4.resolve(root, project.file));
675
- parts.push({ key: path4.relative(root, input).split(path4.sep).join("/"), observation });
676
- }
677
- return combineObservations(parts);
678
- }
557
+ listSourceFiles: listCSharpSources,
558
+ scan: scanCSharpSource
679
559
  };
680
560
  var src_default = scanner;
681
561
  export {
682
- checkCSharpReadiness,
683
562
  src_default as default
684
563
  };
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@groma/scanner-csharp",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/MrLesk/Groma.md.git"
9
+ "url": "https://github.com/MrLesk/groma.md.git"
10
10
  },
11
- "description": "C# project evidence through Roslyn and MSBuild.",
11
+ "description": "C# project evidence through Roslyn.",
12
12
  "publishConfig": {
13
13
  "access": "public"
14
14
  },
@@ -28,16 +28,36 @@
28
28
  "x64"
29
29
  ],
30
30
  "optionalDependencies": {
31
- "@groma/scanner-csharp-darwin-arm64": "0.1.4",
32
- "@groma/scanner-csharp-linux-arm64": "0.1.4",
33
- "@groma/scanner-csharp-linux-x64": "0.1.4",
34
- "@groma/scanner-csharp-win32-arm64": "0.1.4",
35
- "@groma/scanner-csharp-win32-x64": "0.1.4"
31
+ "@groma/scanner-csharp-darwin-arm64": "0.2.1",
32
+ "@groma/scanner-csharp-linux-arm64": "0.2.1",
33
+ "@groma/scanner-csharp-linux-x64": "0.2.1",
34
+ "@groma/scanner-csharp-win32-arm64": "0.2.1",
35
+ "@groma/scanner-csharp-win32-x64": "0.2.1"
36
36
  },
37
37
  "groma": {
38
38
  "scanner": {
39
39
  "id": "csharp",
40
40
  "entry": "./dist/index.js",
41
+ "include": [
42
+ "**/*.[cC][sS]",
43
+ "**/*.[cC][sS][pP][rR][oO][jJ]",
44
+ "**/*.[sS][lL][nN]",
45
+ "**/*.[sS][lL][nN][xX]",
46
+ "**/*.[pP][rR][oO][pP][sS]",
47
+ "**/*.[tT][aA][rR][gG][eE][tT][sS]"
48
+ ],
49
+ "exclude": [
50
+ "[bB][iI][nN]/",
51
+ "[oO][bB][jJ]/",
52
+ "[tT]est/",
53
+ "[tT]ests/",
54
+ "*Tests/",
55
+ "*.Test/",
56
+ "*.[dD]esigner.cs",
57
+ "*.g.cs",
58
+ "*.g.i.cs",
59
+ "*.generated.cs"
60
+ ],
41
61
  "discovery": {
42
62
  "technologies": [
43
63
  "csharp"
@@ -58,7 +78,7 @@
58
78
  }
59
79
  ],
60
80
  "compatibility": {
61
- "groma": ">=0.3.0"
81
+ "groma": ">=0.5.0"
62
82
  }
63
83
  }
64
84
  }