flagrm 0.1.0 → 0.2.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.
@@ -0,0 +1,429 @@
1
+ /**
2
+ * Follow a flag's value through C# code: the bool locals, fields and
3
+ * properties that hold `IsEnabled(<flag>)`, the methods that only return it
4
+ * (or a ternary on it), and the parameters it is passed to. Each is a name
5
+ * the removal has to get rid of, and that `verify` should check. Tests that
6
+ * call such a method with a `true`/`false` literal for the parameter are
7
+ * parameterized over the flag.
8
+ *
9
+ * Like the rest of discovery this is lexical (comments and strings masked,
10
+ * brackets matched), not a C# parser: it finds the common shapes and misses
11
+ * unusual ones, which the agent still records with `--name`.
12
+ */
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { computeCodeMask, escapeRegExp, isTestPath, methodNamePattern } from "../../core/scan.js";
16
+ import { SourceText } from "../../core/source-text.js";
17
+ import { matchBracket, toLf } from "../../core/text-utils.js";
18
+ /** Keywords that can precede `name(` without it being a declaration or a call of `name`. */
19
+ const KEYWORDS = new Set("new return await throw else case in is as yield out ref typeof nameof sizeof default using if while for foreach switch lock catch when var get set init and or not".split(" "));
20
+ export function dotnetFlow(ctx, flags, evalMethods) {
21
+ const files = readCsFiles(ctx);
22
+ const code = files.filter((f) => !f.isTest);
23
+ const names = [];
24
+ const args = [];
25
+ const add = (name) => {
26
+ if (evalMethods.includes(name.name) || KEYWORDS.has(name.name))
27
+ return;
28
+ const same = (n) => n.flag === name.flag && n.name === name.name && n.kind === name.kind && n.file === name.file;
29
+ if (names.some(same))
30
+ return;
31
+ names.push({ ...name, project: ctx.name });
32
+ };
33
+ const declarations = methodDeclarations(code);
34
+ const ternaries = new Set();
35
+ const flagOfArgs = flagMatcher(flags);
36
+ const evaluators = evalMethods.length
37
+ ? [
38
+ {
39
+ pattern: new RegExp(`\\b(?:${evalMethods.map(methodNamePattern).join("|")})\\s*(?:<[^<>()]*>)?\\s*\\(`, "g"),
40
+ flagOf: (a) => flagOfArgs(firstArgument(a)),
41
+ },
42
+ ]
43
+ : [];
44
+ // Pass 1: the eval calls. Pass 2: calls of the wrapper methods pass 1 found.
45
+ for (let pass = 0; pass < 2; pass++) {
46
+ for (const f of code) {
47
+ for (const ev of evaluators) {
48
+ if (!ev.onlyIn || ev.onlyIn === f.file)
49
+ findEvaluations(f, ev, add, args, ternaries);
50
+ }
51
+ }
52
+ // A method returning a ternary on the flag (`? "collections" : "shared folders"`) returns another
53
+ // value, not the flag's: a local holding its result stays after the removal.
54
+ const methods = names.filter((n) => n.kind === "method" && !ternaries.has(n.name));
55
+ if (pass === 1 || methods.length === 0)
56
+ break;
57
+ evaluators.length = 0;
58
+ for (const m of methods)
59
+ evaluators.push(...wrapperCalls(m, declarations));
60
+ }
61
+ // An instance method is recorded by its bare name: when another method has the same name, that name
62
+ // would match the other one's calls too.
63
+ for (const n of names) {
64
+ if (n.kind !== "method" || n.name.includes("."))
65
+ continue;
66
+ if ((declarations.get(n.name) ?? []).filter((d) => d.hasBody).length > 1)
67
+ n.ambiguous = true;
68
+ }
69
+ // Locals, fields and properties holding the value, passed on as an argument.
70
+ for (const f of code) {
71
+ for (const n of names.filter((x) => (x.kind === "local" || x.kind === "field" || x.kind === "property") && x.file === f.file)) {
72
+ for (const m of f.text.matchAll(new RegExp(`(?<![\\w$.])(?:this\\s*\\.\\s*)?${escapeRegExp(n.name)}(?![\\w$])`, "g"))) {
73
+ if (!f.mask[m.index])
74
+ continue;
75
+ const arg = wholeArgument(f, m.index, m.index + m[0].length);
76
+ if (arg)
77
+ args.push({ flag: n.flag, ...arg });
78
+ }
79
+ }
80
+ }
81
+ // The parameter each argument lands in. Only the callee's name is known, so every declaration of that
82
+ // name with a bool parameter there is a candidate: when they name it differently (`GetName(bool
83
+ // useVfo1Terminology)`, `Organization.GetName(bool includeDomain)`), which one the call reaches is
84
+ // unknown, so each is only suggested and its tests aren't reported.
85
+ const parameters = [];
86
+ for (const a of args) {
87
+ const candidates = [];
88
+ for (const d of declarations.get(a.callee) ?? []) {
89
+ const offset = a.viaDot && d.extension ? 1 : 0;
90
+ const p = a.label ? d.params.find((x) => x.name === a.label) : d.params[a.index + offset];
91
+ if (p && /^bool\??$/.test(p.type))
92
+ candidates.push({ d, p });
93
+ }
94
+ const ambiguous = new Set(candidates.map((c) => c.p.name)).size > 1;
95
+ for (const { d, p } of candidates) {
96
+ add({
97
+ flag: a.flag,
98
+ name: p.name,
99
+ kind: "parameter",
100
+ file: d.file,
101
+ line: d.line,
102
+ ...(ambiguous ? { ambiguous } : {}),
103
+ });
104
+ const index = d.params.indexOf(p);
105
+ if (!ambiguous && !parameters.some((x) => x.method === a.callee && x.index === index)) {
106
+ parameters.push({ flag: a.flag, method: a.callee, index, name: p.name, extension: d.extension });
107
+ }
108
+ }
109
+ }
110
+ const tests = [];
111
+ for (const f of files.filter((x) => x.isTest)) {
112
+ for (const p of parameters) {
113
+ for (const m of f.text.matchAll(new RegExp(`\\b${escapeRegExp(p.method)}\\s*(?:<[^<>()]*>)?\\s*\\(`, "g"))) {
114
+ if (!f.mask[m.index])
115
+ continue;
116
+ const open = m.index + m[0].length - 1;
117
+ const close = matchBracket(f.text, f.mask, open, "(", ")");
118
+ if (close === -1)
119
+ continue;
120
+ const list = splitTopLevel(f.text.slice(open + 1, close));
121
+ const viaDot = /\.\s*$/.test(f.text.slice(Math.max(0, m.index - 20), m.index));
122
+ const offset = viaDot && p.extension ? 1 : 0;
123
+ const named = list.find((a) => new RegExp(`^${escapeRegExp(p.name)}\\s*:`).test(a));
124
+ const value = (named ? named.slice(named.indexOf(":") + 1) : list[p.index - offset])?.trim();
125
+ if (value !== "true" && value !== "false")
126
+ continue;
127
+ tests.push({
128
+ flag: p.flag,
129
+ project: ctx.name,
130
+ file: f.file,
131
+ line: f.source.position(m.index).line,
132
+ method: p.method,
133
+ parameter: p.name,
134
+ value: value === "true",
135
+ });
136
+ }
137
+ }
138
+ }
139
+ return { names, tests };
140
+ }
141
+ function readCsFiles(ctx) {
142
+ const out = [];
143
+ for (const file of ctx.files) {
144
+ if (!file.endsWith(".cs"))
145
+ continue;
146
+ let text;
147
+ try {
148
+ text = toLf(fs.readFileSync(file, "utf8"));
149
+ }
150
+ catch {
151
+ continue;
152
+ }
153
+ out.push({
154
+ file,
155
+ text,
156
+ mask: computeCodeMask(text),
157
+ source: new SourceText(file, text),
158
+ isTest: isTestPath(path.relative(ctx.root, file)),
159
+ });
160
+ }
161
+ return out;
162
+ }
163
+ /** Which flag an eval call's argument names: its literal (`"NewCheckout"`) or a definition (`FeatureFlags.NewCheckout`). */
164
+ function flagMatcher(flags) {
165
+ const tests = flags.map((f) => {
166
+ const parts = [`(['"])${escapeRegExp(f.flag)}\\1`];
167
+ for (const alias of f.aliases) {
168
+ parts.push(`(?<![\\w$])${alias.split(".").map(escapeRegExp).join("\\s*\\.\\s*")}(?![\\w$])`);
169
+ }
170
+ return { flag: f.flag, pattern: new RegExp(parts.join("|")) };
171
+ });
172
+ return (arg) => tests.find((t) => t.pattern.test(arg))?.flag;
173
+ }
174
+ function firstArgument(args) {
175
+ return splitTopLevel(args)[0] ?? "";
176
+ }
177
+ /**
178
+ * Generic type arguments right after a name (`Dictionary<string, bool>`, one
179
+ * level nested): their commas don't split. A `<` or `>` anywhere else is an
180
+ * operator (`a < b`, `x => x.Id`), not a bracket.
181
+ */
182
+ const TYPE_ARGS = /^<(?:[\w\s.,?[\]]|<[\w\s.,?[\]]*>)*>/;
183
+ /** Split an argument or parameter list at its top-level commas. */
184
+ function splitTopLevel(list) {
185
+ const out = [];
186
+ let depth = 0;
187
+ let start = 0;
188
+ let quote;
189
+ for (let i = 0; i < list.length; i++) {
190
+ const c = list[i];
191
+ if (quote) {
192
+ if (c === "\\")
193
+ i++;
194
+ else if (c === quote)
195
+ quote = undefined;
196
+ }
197
+ else if (c === '"' || c === "'")
198
+ quote = c;
199
+ else if (c === "<" && /\w/.test(list[i - 1] ?? "")) {
200
+ const args = TYPE_ARGS.exec(list.slice(i));
201
+ if (args)
202
+ i += args[0].length - 1;
203
+ }
204
+ else if ("([{".includes(c))
205
+ depth++;
206
+ else if (")]}".includes(c))
207
+ depth--;
208
+ else if (c === "," && depth === 0) {
209
+ out.push(list.slice(start, i));
210
+ start = i + 1;
211
+ }
212
+ }
213
+ if (list.trim())
214
+ out.push(list.slice(start));
215
+ return out.map((s) => s.trim());
216
+ }
217
+ /** The receiver chain and `await` before a call: `await _featureService.` in `await _featureService.IsEnabledAsync(`. */
218
+ const RECEIVER = /(?:\bawait\s+)?(?:(?:this|base|@?\w+)(?:\(\))?\s*[?!]?\.\s*)*$/;
219
+ /**
220
+ * Each call of `ev` that evaluates a flag, and what its value goes into: a
221
+ * local, field or property assigned only that value, a method returning it
222
+ * (or a ternary on it) and nothing else, or an argument of another call.
223
+ */
224
+ function findEvaluations(f, ev, add, args, ternaries) {
225
+ const { text, mask } = f;
226
+ for (const m of text.matchAll(ev.pattern)) {
227
+ if (!mask[m.index])
228
+ continue;
229
+ const open = m.index + m[0].length - 1;
230
+ const close = matchBracket(text, mask, open, "(", ")");
231
+ if (close === -1)
232
+ continue;
233
+ const flag = ev.flagOf(text.slice(open + 1, close));
234
+ if (!flag)
235
+ continue;
236
+ const start = m.index - (RECEIVER.exec(text.slice(Math.max(0, m.index - 200), m.index))?.[0].length ?? 0);
237
+ const line = f.source.position(m.index).line;
238
+ const arg = wholeArgument(f, start, close + 1);
239
+ if (arg) {
240
+ args.push({ flag, ...arg });
241
+ continue;
242
+ }
243
+ const boundary = statementStart(text, mask, start);
244
+ if (boundary === undefined)
245
+ continue;
246
+ const head = text.slice(boundary + 1, start);
247
+ const next = nextCode(text, mask, close + 1);
248
+ const holdsOnly = text[next] === ";";
249
+ const valueOrTernary = holdsOnly || text[next] === "?";
250
+ let hit;
251
+ if (holdsOnly && (hit = /\b(?:var|bool\??)\s+(@?\w+)\s*=\s*$/.exec(head))) {
252
+ const field = /\b(?:private|public|protected|internal|readonly|static)\b/.test(head);
253
+ add({ flag, name: hit[1], kind: field ? "field" : "local", file: f.file, line });
254
+ }
255
+ else if (holdsOnly && (hit = /(?:^|[\s(])(this\s*\.\s*)?(@?\w+)\s*=\s*$/.exec(head))) {
256
+ const field = Boolean(hit[1]) || hit[2].startsWith("_");
257
+ add({ flag, name: hit[2], kind: field ? "field" : "local", file: f.file, line });
258
+ }
259
+ else if (holdsOnly && (hit = /\bbool\??\s+(\w+)\s*=>\s*$/.exec(head))) {
260
+ add({ flag, name: hit[1], kind: "property", file: f.file, line });
261
+ }
262
+ else if (valueOrTernary && (hit = /(\w+)\s*(?:<[^<>()]*>)?\s*\(([^()]*)\)\s*=>\s*$/.exec(head))) {
263
+ const name = methodName(text, mask, boundary, head, hit[1]);
264
+ if (!holdsOnly)
265
+ ternaries.add(name);
266
+ add({ flag, name, kind: "method", file: f.file, line });
267
+ }
268
+ else if (valueOrTernary && /^\s*return\s+$/.test(head) && text[boundary] === "{" && onlyStatement(f, close)) {
269
+ const signature = /(\w+)\s*(?:<[^<>()]*>)?\s*\(([^()]*)\)\s*$/.exec(text.slice(Math.max(0, boundary - 300), boundary));
270
+ const outer = statementStart(text, mask, boundary);
271
+ if (signature && outer !== undefined) {
272
+ const decl = text.slice(outer + 1, boundary);
273
+ const name = methodName(text, mask, outer, decl, signature[1]);
274
+ if (!holdsOnly)
275
+ ternaries.add(name);
276
+ add({ flag, name, kind: "method", file: f.file, line });
277
+ }
278
+ }
279
+ }
280
+ }
281
+ /**
282
+ * How a wrapper method found in pass 1 is called. A static one by
283
+ * `Class.Method(`, or by its bare name inside its own file. An instance one
284
+ * by its name, but only when exactly one method of that name has a body in
285
+ * the project: otherwise `x.Plural()` may be another class's `Plural`.
286
+ */
287
+ function wrapperCalls(method, declarations) {
288
+ const flagOf = () => method.flag;
289
+ const dot = method.name.lastIndexOf(".");
290
+ const simple = method.name.slice(dot + 1);
291
+ if (dot >= 0) {
292
+ const owner = method.name.slice(0, dot);
293
+ return [
294
+ { pattern: new RegExp(`\\b${escapeRegExp(owner)}\\s*\\.\\s*${escapeRegExp(simple)}\\s*\\(`, "g"), flagOf },
295
+ { pattern: new RegExp(`(?<![\\w$.])${escapeRegExp(simple)}\\s*\\(`, "g"), flagOf, onlyIn: method.file },
296
+ ];
297
+ }
298
+ const bodies = (declarations.get(simple) ?? []).filter((d) => d.hasBody);
299
+ if (bodies.length !== 1)
300
+ return [];
301
+ return [{ pattern: new RegExp(`\\b${escapeRegExp(simple)}\\s*\\(`, "g"), flagOf }];
302
+ }
303
+ /** A static method is recorded as `Class.Method`, which callers outside the class write; an instance method by name. */
304
+ function methodName(text, mask, at, declaration, name) {
305
+ if (!/\bstatic\b/.test(declaration))
306
+ return name;
307
+ let owner;
308
+ for (const m of text.slice(0, at).matchAll(/\b(?:class|struct|record)\s+(\w+)/g))
309
+ if (mask[m.index])
310
+ owner = m[1];
311
+ return owner ? `${owner}.${name}` : name;
312
+ }
313
+ /**
314
+ * Whether `[start, end)` is a whole argument of a call: what comes before it
315
+ * up to the enclosing `(` or `,` is at most a `name:` label, and a `,` or `)`
316
+ * follows it. Returns the callee, the argument's position and its label.
317
+ */
318
+ function wholeArgument(f, start, end) {
319
+ const { text, mask } = f;
320
+ const after = nextCode(text, mask, end);
321
+ if (text[after] !== "," && text[after] !== ")")
322
+ return undefined;
323
+ let depth = 0;
324
+ let index = 0;
325
+ let edge = -1;
326
+ let open = -1;
327
+ for (let i = start - 1; i >= 0; i--) {
328
+ if (!mask[i])
329
+ continue;
330
+ const c = text[i];
331
+ if (c === ")" || c === "]" || c === "}")
332
+ depth++;
333
+ else if (c === "(" || c === "[" || c === "{") {
334
+ if (depth === 0) {
335
+ open = i;
336
+ break;
337
+ }
338
+ depth--;
339
+ }
340
+ else if (c === "," && depth === 0) {
341
+ if (edge === -1)
342
+ edge = i;
343
+ index++;
344
+ }
345
+ else if (c === ";" && depth === 0)
346
+ return undefined;
347
+ }
348
+ if (open === -1 || text[open] !== "(")
349
+ return undefined;
350
+ const before = text.slice((edge === -1 ? open : edge) + 1, start);
351
+ const label = /^\s*(@?\w+)\s*:\s*$/.exec(before)?.[1];
352
+ if (!label && before.trim())
353
+ return undefined;
354
+ const callee = /(@?\w+)\s*(?:<[^<>()]*>)?\s*$/.exec(text.slice(Math.max(0, open - 200), open));
355
+ if (!callee || KEYWORDS.has(callee[1]))
356
+ return undefined;
357
+ const viaDot = /\.\s*$/.test(text.slice(Math.max(0, open - 200), open - callee[0].length + 1));
358
+ return { callee: callee[1], index, label, viaDot };
359
+ }
360
+ /** The `;`, `{` or `}` that ends the previous statement, outside brackets; undefined inside an argument list. */
361
+ function statementStart(text, mask, from) {
362
+ let depth = 0;
363
+ for (let i = from - 1; i >= 0; i--) {
364
+ if (!mask[i])
365
+ continue;
366
+ const c = text[i];
367
+ if (c === ")" || c === "]")
368
+ depth++;
369
+ else if (c === "(" || c === "[") {
370
+ if (depth === 0)
371
+ return undefined;
372
+ depth--;
373
+ }
374
+ else if (depth === 0 && (c === ";" || c === "{" || c === "}"))
375
+ return i;
376
+ }
377
+ return -1;
378
+ }
379
+ /** Index of the next code character that isn't whitespace. */
380
+ function nextCode(text, mask, from) {
381
+ let i = from;
382
+ while (i < text.length && (!mask[i] || /\s/.test(text[i])))
383
+ i++;
384
+ return i;
385
+ }
386
+ /** Whether the statement ending after `close` is the last one in its block (`{ return X(...); }`). */
387
+ function onlyStatement(f, close) {
388
+ let i = close + 1;
389
+ while (i < f.text.length && !(f.mask[i] && f.text[i] === ";"))
390
+ i++;
391
+ return f.text[nextCode(f.text, f.mask, i + 1)] === "}";
392
+ }
393
+ /** Method and constructor declarations by name, with their parameters. */
394
+ function methodDeclarations(files) {
395
+ const out = new Map();
396
+ for (const f of files) {
397
+ for (const m of f.text.matchAll(/([\w<>[\]?,.]+)\s+(@?\w+)\s*(?:<[^<>()]*>)?\s*\(/g)) {
398
+ if (!f.mask[m.index] || KEYWORDS.has(m[1]) || KEYWORDS.has(m[2]))
399
+ continue;
400
+ const open = m.index + m[0].length - 1;
401
+ const close = matchBracket(f.text, f.mask, open, "(", ")");
402
+ if (close === -1)
403
+ continue;
404
+ const after = /^\s*(\{|=>|;|where\b|:)/.exec(f.text.slice(close + 1, close + 40))?.[1];
405
+ if (!after)
406
+ continue;
407
+ const raw = splitTopLevel(f.text.slice(open + 1, close));
408
+ const params = raw.map((p) => {
409
+ const plain = p
410
+ .replace(/\[[^\]]*\]/g, "")
411
+ .replace(/=.*$/s, "")
412
+ .replace(/^\s*(?:this|ref|out|in|params|scoped)\s+/, "")
413
+ .trim();
414
+ const word = /(@?\w+)$/.exec(plain);
415
+ return { type: word ? plain.slice(0, word.index).trim() : plain, name: word?.[1] ?? "" };
416
+ });
417
+ const list = out.get(m[2]) ?? [];
418
+ list.push({
419
+ file: f.file,
420
+ line: f.source.position(m.index).line,
421
+ params,
422
+ extension: /^\s*this\s/.test(raw[0] ?? ""),
423
+ hasBody: after !== ";",
424
+ });
425
+ out.set(m[2], list);
426
+ }
427
+ }
428
+ return out;
429
+ }
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import { discoverInText } from "../../core/discover.js";
3
3
  import { BACKEND_GLOBS } from "../../core/scan.js";
4
+ import { dotnetFlow } from "./flow.js";
4
5
  import { dotnetUnusedDiagnostics } from "./unused.js";
5
6
  /** Discover candidate flag names (appsettings config keys, [FeatureGate] args, eval-call literals, *Flags constants/enums). */
6
7
  export function discoverDotnetFlags(ctx) {
@@ -22,6 +23,7 @@ export const dotnetAdapter = {
22
23
  id: "dotnet",
23
24
  defaultGlobs: BACKEND_GLOBS,
24
25
  discover: async (ctx) => discoverDotnetFlags(ctx),
26
+ flow: (ctx, flags) => dotnetFlow(ctx, flags, ctx.project.methods),
25
27
  // --no-incremental: an up-to-date project skips compiling and prints none of its
26
28
  // warnings, so the baseline's dead-code comparison would have nothing to subtract.
27
29
  defaultCommands: () => ({ build: "dotnet build --no-incremental", test: "dotnet test" }),
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Whether this machine can build and test a .NET solution: an installed SDK
3
+ * that satisfies global.json, `cargo` for projects whose build runs it, Docker
4
+ * for test projects that use Testcontainers. And which projects a solution
5
+ * filter has to leave out when one of the last two is missing.
6
+ */
7
+ import { spawnSync } from "node:child_process";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ // --- SDK -------------------------------------------------------------------------
12
+ /** global.json's SDK request, from the nearest global.json at or above `dir` (where `dotnet` looks). */
13
+ export function sdkRequest(dir) {
14
+ for (let d = path.resolve(dir);; d = path.dirname(d)) {
15
+ const file = path.join(d, "global.json");
16
+ if (fs.existsSync(file)) {
17
+ try {
18
+ const sdk = JSON.parse(fs.readFileSync(file, "utf8"))?.sdk;
19
+ if (typeof sdk?.version !== "string")
20
+ return undefined;
21
+ const rollForward = typeof sdk.rollForward === "string" ? sdk.rollForward : "latestPatch";
22
+ return { file, version: sdk.version, rollForward };
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }
28
+ if (path.dirname(d) === d)
29
+ return undefined;
30
+ }
31
+ }
32
+ /** `8.0.403` → [8, 0, 4, 3]: major, minor, feature band, patch. Undefined for anything else. */
33
+ function sdkParts(version) {
34
+ const m = /^(\d+)\.(\d+)\.(\d)(\d\d)(?:-|$)/.exec(version);
35
+ return m ? m.slice(1).map(Number) : undefined;
36
+ }
37
+ /**
38
+ * Whether an installed SDK satisfies the request under its rollForward policy:
39
+ * how many leading version parts must match the requested ones (`patch`: the
40
+ * feature band, `feature`: the minor, `minor`: the major, `major`: none), and
41
+ * the version must be at least the requested one. `disable` needs it exactly.
42
+ * The `latest*` policies accept the same SDKs as their plain forms; they only
43
+ * prefer a newer one.
44
+ */
45
+ export function satisfiesSdk(installed, requested, rollForward) {
46
+ const have = sdkParts(installed);
47
+ const want = sdkParts(requested);
48
+ if (!have || !want)
49
+ return installed === requested;
50
+ const policy = rollForward.replace(/^latest/, "").toLowerCase();
51
+ if (policy === "disable")
52
+ return installed === requested;
53
+ const fixed = { patch: 3, feature: 2, minor: 1, major: 0 }[policy] ?? 3;
54
+ for (let i = 0; i < fixed; i++)
55
+ if (have[i] !== want[i])
56
+ return false;
57
+ for (let i = 0; i < 4; i++)
58
+ if (have[i] !== want[i])
59
+ return have[i] > want[i];
60
+ return true;
61
+ }
62
+ /** Why `dir` can't build with the installed SDKs, or undefined when it can (or no global.json pins one). */
63
+ export function sdkProblem(dir, probes, root) {
64
+ const request = sdkRequest(dir);
65
+ if (!request)
66
+ return undefined;
67
+ const installed = probes.installedSdks();
68
+ const where = path.relative(root, request.file) || "global.json";
69
+ if (!installed)
70
+ return `\`dotnet\` is not installed; ${where} requests SDK ${request.version}`;
71
+ if (installed.some((v) => satisfiesSdk(v, request.version, request.rollForward)))
72
+ return undefined;
73
+ return (`${where} requests .NET SDK ${request.version} (rollForward: ${request.rollForward}); ` +
74
+ `installed: ${installed.join(", ") || "none"}. Install a matching SDK or change ${where}`);
75
+ }
76
+ /** The projects of a `.sln` (`Project(...) = "Name", "path", ...`) or `.slnx` (`<Project Path="..."/>`). */
77
+ export function solutionProjects(solution) {
78
+ const text = fs.readFileSync(solution, "utf8");
79
+ const entries = solution.endsWith(".slnx")
80
+ ? [...text.matchAll(/<Project\b[^>]*\bPath\s*=\s*"([^"]+)"/g)].map((m) => m[1])
81
+ : [...text.matchAll(/^Project\("[^"]*"\)\s*=\s*"[^"]*",\s*"([^"]+)"/gm)].map((m) => m[1]);
82
+ const dir = path.dirname(solution);
83
+ return entries
84
+ .filter((entry) => /\.\w*proj$/i.test(entry))
85
+ .map((entry) => ({ entry, file: path.resolve(dir, entry.replace(/\\/g, "/")) }));
86
+ }
87
+ function projectInfo(file) {
88
+ let text = "";
89
+ try {
90
+ text = fs.readFileSync(file, "utf8");
91
+ }
92
+ catch {
93
+ // A project the solution lists but that's missing: nothing to learn from it.
94
+ }
95
+ const dir = path.dirname(file);
96
+ return {
97
+ references: [...text.matchAll(/<ProjectReference\b[^>]*\bInclude\s*=\s*"([^"]+)"/g)].map((m) => path.resolve(dir, m[1].replace(/\\/g, "/"))),
98
+ cargo: /<Exec\b[^>]*\bCommand\s*=\s*"[^"]*\bcargo\b/i.test(text),
99
+ testcontainers: /<PackageReference\b[^>]*\bInclude\s*=\s*"Testcontainers\b/i.test(text),
100
+ };
101
+ }
102
+ /**
103
+ * Check a solution's projects against the machine: a project whose build
104
+ * runs `cargo` needs it on PATH, one using Testcontainers needs Docker. Each
105
+ * such project is left out, and so is every project that references one,
106
+ * directly or not (it can't build, or can't run, without it). With `only`
107
+ * (the project files a solution filter keeps), the other projects aren't built
108
+ * and so aren't checked or kept.
109
+ */
110
+ export function solutionSetup(solution, probes, root, only) {
111
+ const kept = only && new Set(only);
112
+ const projects = solutionProjects(solution).filter((p) => !kept || kept.has(p.file));
113
+ const info = new Map(projects.map((p) => [p.file, projectInfo(p.file)]));
114
+ const rel = (file) => path.relative(root, file).split(path.sep).join("/");
115
+ const problems = [];
116
+ const reasons = new Map();
117
+ const cargo = projects.filter((p) => info.get(p.file)?.cargo);
118
+ if (cargo.length && !probes.onPath("cargo")) {
119
+ problems.push(`\`cargo\` is not installed, and ${cargo.map((p) => rel(p.file)).join(", ")} ${cargo.length === 1 ? "runs" : "run"} it to build`);
120
+ for (const p of cargo)
121
+ reasons.set(p.file, "runs `cargo`, which is not installed");
122
+ }
123
+ const docker = projects.filter((p) => info.get(p.file)?.testcontainers);
124
+ if (docker.length && !probes.dockerRunning()) {
125
+ problems.push(`Docker is not running, and ${docker.map((p) => rel(p.file)).join(", ")} ${docker.length === 1 ? "uses" : "use"} Testcontainers`);
126
+ for (const p of docker)
127
+ reasons.set(p.file, "uses Testcontainers, and Docker is not running");
128
+ }
129
+ // Everything that references a left-out project, transitively.
130
+ for (let changed = true; changed;) {
131
+ changed = false;
132
+ for (const p of projects) {
133
+ if (reasons.has(p.file))
134
+ continue;
135
+ const blocked = info.get(p.file)?.references.find((r) => reasons.has(r));
136
+ if (blocked) {
137
+ reasons.set(p.file, `references ${path.basename(blocked)}`);
138
+ changed = true;
139
+ }
140
+ }
141
+ }
142
+ const leaveOut = projects
143
+ .filter((p) => reasons.has(p.file))
144
+ .map((p) => ({ entry: p.entry, reason: reasons.get(p.file) ?? "" }));
145
+ return { solution, problems, leaveOut, projects };
146
+ }
147
+ // --- probes ----------------------------------------------------------------------
148
+ export function realProbes(onPath) {
149
+ return {
150
+ installedSdks() {
151
+ // From a temp directory, so no global.json gets in the way of listing.
152
+ const r = spawnSync("dotnet", ["--list-sdks"], { cwd: os.tmpdir(), encoding: "utf8", timeout: 30_000 });
153
+ if (r.error || r.status !== 0)
154
+ return undefined;
155
+ return [...r.stdout.matchAll(/^(\S+)\s+\[/gm)].map((m) => m[1]);
156
+ },
157
+ onPath,
158
+ dockerRunning() {
159
+ const r = spawnSync("docker", ["info"], { stdio: "ignore", timeout: 20_000 });
160
+ return !r.error && r.status === 0;
161
+ },
162
+ };
163
+ }