@polymorphism-tech/morph-spec 8.35.1 → 8.37.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 (51) hide show
  1. package/CLAUDE.md +10 -8
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +4 -3
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/MORPH.md +24 -23
  8. package/framework/docs/CLI.md +212 -15
  9. package/framework/evals/proof-rubrics/eval-de-modelo.md +16 -0
  10. package/framework/evals/rubrics/condensed.md +3 -1
  11. package/framework/evals/rubrics/test-coverage.md +8 -0
  12. package/framework/hooks/shared/phase-utils.js +1 -0
  13. package/framework/phases.json +1 -1
  14. package/framework/rules/morph-workflow.md +1 -1
  15. package/framework/schemas/config.schema.json +7 -0
  16. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  17. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  18. package/framework/skills/core/morph-apply/SKILL.md +21 -10
  19. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  20. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  21. package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
  22. package/framework/skills/core/morph-implement/SKILL.md +20 -3
  23. package/framework/skills/core/morph-media/SKILL.md +1 -1
  24. package/framework/skills/core/morph-plan/SKILL.md +41 -3
  25. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  26. package/framework/skills/core/morph-review/SKILL.md +11 -2
  27. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  28. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  29. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  30. package/framework/standards/STANDARDS.json +2 -2
  31. package/framework/standards/ai-agents/prompt-sources.md +4 -3
  32. package/framework/standards/ai-agents/testing-ai.md +14 -13
  33. package/framework/templates/dotnet/ai-kit/README.md +22 -11
  34. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +23 -10
  35. package/package.json +1 -1
  36. package/src/commands/project/doctor.js +51 -2
  37. package/src/commands/tasks/dag.js +13 -1
  38. package/src/commands/validation/verify.js +59 -17
  39. package/src/core/paths/output-schema.js +7 -0
  40. package/src/lib/detectors/index.js +225 -217
  41. package/src/lib/risk-detector.js +38 -18
  42. package/src/lib/specs/done-criteria-check.js +200 -0
  43. package/src/lib/specs/usecase-check.js +129 -5
  44. package/src/lib/tasks/dotnet-test-introspection.js +632 -0
  45. package/src/lib/tasks/eval-node.js +3 -3
  46. package/src/lib/tasks/test-nodes.js +190 -20
  47. package/src/lib/tasks/test-runner.js +210 -17
  48. package/src/lib/tasks/test-scope.js +597 -0
  49. package/src/lib/validators/traceability-check.js +9 -2
  50. package/src/lib/validators/verify-runner.js +190 -27
  51. package/src/lib/worktree/docker-audit.js +5 -1
@@ -0,0 +1,632 @@
1
+ /**
2
+ * .NET test-project introspection for the task-scope recorte (issue #87): which
3
+ * test project owns a `.cs` file and with which framework, which project-level
4
+ * settings bind every run, which test mode the SDK picks, and which types a C#
5
+ * file declares. Split out of test-scope.js, which decides; this only reads.
6
+ *
7
+ * Every answer errs toward "run the node whole". A wrong answer that NARROWS runs
8
+ * fewer tests and still comes out green — the one failure issue #87 must never buy.
9
+ *
10
+ * The C# scan is NOT a parser. What narrowing relies on is narrower than "the
11
+ * scan is right" and is stated exactly in csharpClasses: every type-keyword
12
+ * token of the RAW text (nothing blanked, no context rule) must be a declaration
13
+ * the scanner NAMED. Type names are BARE and the selectors namespace-free
14
+ * (`*Type*` / `FullyQualifiedName~Type`), so no scope reading can misname one.
15
+ * What that does NOT cover is listed there too.
16
+ *
17
+ * Pure: reads files, never spawns a process.
18
+ *
19
+ * @module dotnet-test-introspection
20
+ */
21
+
22
+ import { existsSync, readFileSync, readdirSync } from 'fs';
23
+ import { dirname, join, resolve } from 'path';
24
+
25
+ import { isInside } from './test-runner.js';
26
+ import { jsoncToJson } from '../ai/comment-stripper.js';
27
+
28
+ const MTP_RUNNER = 'Microsoft.Testing.Platform';
29
+ const FLOOR_PROPERTY = /<TestingPlatformCommandLineArguments\b[^>]*>[^<]*--minimum-expected-tests/i;
30
+ /**
31
+ * `--ignore-exit-code`, or the env var MTP reads for the same thing, named ANYWHERE in an
32
+ * MSBuild file that binds the project — not only inside `<TestingPlatformCommandLineArguments>`:
33
+ * a property indirection (`<MtpExtraArgs>--ignore-exit-code 8</MtpExtraArgs>` referenced as
34
+ * `$(MtpExtraArgs)`) carries it just as well (re-review N4, probed). Errs wide.
35
+ */
36
+ const IGNORE_EXIT_MARKER = /--ignore-exit-code|TESTINGPLATFORM_EXITCODE_IGNORE/i;
37
+ const CASE_FILTER_PROPERTY = /<VSTestTestCaseFilter\b[^>]*>/i;
38
+ const TYPE_HEADER = /\b(?:class|struct|record|interface)\b/;
39
+ /** A C# identifier's first character: a letter, an Nl letter (`Ⅱ`), `_`, `@` (verbatim) or `\` (a unicode escape). */
40
+ const NAME_START = '[\\p{L}\\p{Nl}_@\\\\]';
41
+ /**
42
+ * TRIPWIRE: every type-declaration keyword wherever it sits — applied to the RAW
43
+ * text (nothing blanked) and to the blanked code alike, with NO context rule:
44
+ * round 5 showed that any exclusion decided by what precedes the keyword (the
45
+ * `:` of a generic constraint, a `where T :` shape) can be forged by a comment in
46
+ * the raw text or by string content a cut exposes in the code. `record class`/
47
+ * `record struct` count once. What follows the keyword, after optional whitespace,
48
+ * is a name start (NAME_START), a comment or preprocessor line standing between
49
+ * the keyword and its name (`class/**\/BTests` is legal), OR a quote — a quote is
50
+ * what the RAW text shows where the blanked code shows the spaces of a string the
51
+ * keyword is followed by (`"; class " : " Fake"` exposed as code): every keyword
52
+ * the code counts is a keyword the raw text counts, by offset.
53
+ * A constraint keyword is therefore counted too whenever a name-shaped token
54
+ * follows it (`where T : class where U : …`, `: class // note`): it is not named,
55
+ * so the file runs whole. `class,` / `class\n{` / `class?` are not counted at all.
56
+ */
57
+ const TYPE_KEYWORD = new RegExp(`\\b(?:record\\s+(?:class|struct)|class|record|struct|interface)\\b(?=\\s*(?:${NAME_START}|["']|\\/[/*]|#))`, 'gu');
58
+ /** A type declaration the scanner can NAME: after `; { } ]`, a newline or the file start; attributes; modifiers; a NAME_START name. */
59
+ const TYPE_DECL = new RegExp(`(?:^|[;{}\\]\\n])[ \\t]*(?:\\[[^\\]\\n]*\\][ \\t]*)*(?:(?:public|internal|private|protected|sealed|static|partial|abstract|unsafe|file|new|readonly|ref)\\s+)*(record\\s+(?:class|struct)|class|record|struct|interface)\\s+(${NAME_START}[^\\s:<({;,=]*)`, 'gu');
60
+ const IDENT = '[\\p{L}_][\\p{L}\\p{N}_]*';
61
+ const QUALIFIER = `(?:${IDENT}\\s*(?:\\.|::)\\s*)*`;
62
+ /** An attribute's optional arguments: one nested level of parentheses, no `]`. */
63
+ const ATTRIBUTE_ARGS = '(?:\\((?:[^()\\]]|\\([^()\\]]*\\))*\\))?';
64
+ /** Any other attribute of the same section: a name, optionally with arguments. */
65
+ const OTHER_ATTRIBUTE = `${QUALIFIER}${IDENT}\\s*${ATTRIBUTE_ARGS}`;
66
+ const TEST_ATTRIBUTE_NAME = '(?:Fact|Theory|Test|TestMethod|DataTestMethod|TestCase|TestCaseSource)(?:Attribute)?';
67
+ /**
68
+ * A test attribute, read on the BLANKED code of a test-project file (hasTestAttribute):
69
+ * a WHOLE name — `Fact`, `Theory`, `Test`, `TestMethod`, `DataTestMethod`, `TestCase`,
70
+ * `TestCaseSource`, optionally `…Attribute` and qualified (`Xunit.Fact`) — inside an
71
+ * attribute section that CLOSES as one: `[` where a declaration may start (line start,
72
+ * after `;` `{` `}` `]` or a `*\/`), an optional target, other attributes before it
73
+ * separated by `,`, the test name with its optional arguments, any further attributes,
74
+ * then the `]` — followed by a declaration start (`[`, a name, a comment, a directive)
75
+ * or the end of the input.
76
+ *
77
+ * A false POSITIVE is the unsafe direction here: a helper taken for a test file
78
+ * narrows to its own name, and a test whose name contains it makes the run green
79
+ * while the tests that USE the helper never run. The final review of branch B found
80
+ * exactly that with the old prefix match (`, TestContext` / `, Factory` / `, TheoryData`
81
+ * after any `,`), and its re-review (N1) with a test name merely FOLLOWED by `(` or `,`:
82
+ * a C# 12 collection laid out with `[` alone on its line (`[\n Env.Dev,\n Env.Test,\n];`,
83
+ * a row `[Env.Test, Env.Dev],`, `[\n Test("a"),\n];`). Requiring the section to close
84
+ * rejects all three; what still matches is stated in csharpClasses. A false NEGATIVE
85
+ * only runs the node whole (a custom attribute such as `[SkippableFact]`, a test
86
+ * attribute after an argument holding `]`).
87
+ */
88
+ export const CS_TEST_ATTRIBUTE = new RegExp(
89
+ '(?:^|[;{}\\]]|\\*\\/)[ \\t]*\\['
90
+ + `\\s*(?:${IDENT}\\s*:(?!:)\\s*)?`
91
+ + `(?:${OTHER_ATTRIBUTE}\\s*,\\s*)*`
92
+ + `${QUALIFIER}${TEST_ATTRIBUTE_NAME}(?![\\p{L}\\p{N}_])\\s*${ATTRIBUTE_ARGS}`
93
+ + `(?:\\s*,\\s*${OTHER_ATTRIBUTE})*`
94
+ + '\\s*\\]\\s*(?:[\\[\\p{L}_@/#]|(?![\\s\\S]))',
95
+ 'mu',
96
+ );
97
+
98
+ /**
99
+ * File text with a leading UTF-8 BOM stripped (VS and `charset=utf-8-bom` write
100
+ * one) and every C# new-line character mapped 1:1 to `\n` — CR, NEL (U+0085),
101
+ * LS (U+2028), PS (U+2029): JS `\s` excludes NEL, so `class<NEL>Name` was
102
+ * invisible to both regexes (compiled and discovered by xunit); a CR-only file had
103
+ * no line ends at all. U+001A (Ctrl-Z) is mapped 1:1 to a space: Roslyn reads it
104
+ * as whitespace and JS `\s` does not, so `class<SUB>Name` hid a declaration the
105
+ * same way (B3 carry-over: compiled, green while skipping the class). Length is kept (CRLF becomes `\n\n`). null when unreadable.
106
+ */
107
+ const readText = (path) => {
108
+ try { return readFileSync(path, 'utf8').replace(/\x1A/g, ' ').replace(/^\uFEFF/, '').replace(/[\r\u0085\u2028\u2029]/g, '\n'); } catch { return null; }
109
+ };
110
+
111
+ /** Which test framework a csproj references, by package (or MSBuild SDK) name. */
112
+ function testFrameworkOf(csprojText) {
113
+ const refs = [...csprojText.matchAll(/(?:Include|Sdk)\s*=\s*"([^"/]+)/gi)].map((m) => m[1].toLowerCase());
114
+ if (refs.some((r) => r.startsWith('xunit.v3'))) return 'xunit.v3';
115
+ if (refs.some((r) => r.startsWith('mstest'))) return 'mstest';
116
+ if (refs.some((r) => r === 'nunit')) return 'nunit';
117
+ if (refs.some((r) => r === 'xunit')) return 'xunit';
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * A test project: named `*Tests.csproj`/`.fsproj`/`.vbproj`, OR referencing a
123
+ * test framework / `Microsoft.NET.Test.Sdk` in the project file itself.
124
+ */
125
+ export function isDotnetTestProject(projectAbs) {
126
+ const text = readText(projectAbs) ?? '';
127
+ return /Tests\.(?:cs|fs|vb)proj$/i.test(projectAbs) || testFrameworkOf(text) !== null || /Microsoft\.NET\.Test\.Sdk/i.test(text);
128
+ }
129
+
130
+ /**
131
+ * The test project owning a `.cs` file — nearest `.csproj` walking up, as MSBuild
132
+ * globs it — or null when the file is not a test file (see isDotnetTestProject).
133
+ *
134
+ * @returns {{ csproj: string, framework: 'xunit.v3'|'mstest'|'nunit'|'xunit'|null }|null}
135
+ */
136
+ export function dotnetTestProjectOf(fileAbs, rootAbs) {
137
+ const csproj = nearestCsprojOf(fileAbs, rootAbs);
138
+ if (csproj === null || !isDotnetTestProject(csproj)) return null;
139
+ return { csproj, framework: testFrameworkOf(readText(csproj) ?? '') };
140
+ }
141
+
142
+ /** Nearest file matching `projectName` walking up from a file's directory, inside the root; null when none. */
143
+ function nearestProjectFile(fileAbs, rootAbs, projectName) {
144
+ for (let dir = dirname(fileAbs); isInside(dir, rootAbs); dir = dirname(dir)) {
145
+ let names = [];
146
+ try { names = readdirSync(dir).filter((n) => projectName.test(n)).sort(); } catch { /* unreadable dir */ }
147
+ if (names.length > 0) return join(dir, names[0]);
148
+ if (dirname(dir) === dir) break;
149
+ }
150
+ return null;
151
+ }
152
+
153
+ /**
154
+ * The `.csproj` that compiles a `.cs` file — nearest one walking up, as MSBuild
155
+ * globs it — test project or not; null when there is none inside the root.
156
+ * The one door for "which project owns this file": link ownership in test-scope
157
+ * asks it about every touched `.cs`, recognised as a test file or not.
158
+ */
159
+ export function nearestCsprojOf(fileAbs, rootAbs) {
160
+ if (!/\.cs$/i.test(fileAbs)) return null;
161
+ return nearestProjectFile(fileAbs, rootAbs, /\.csproj$/i);
162
+ }
163
+
164
+ /**
165
+ * The project — `.csproj`, `.fsproj` or `.vbproj` — nearest to ANY file walking
166
+ * up, or null inside the root. Asked about the .NET sources the C# scan cannot
167
+ * read (a bUnit `.razor`, an F# `.fs`), which nearestCsprojOf does not answer.
168
+ */
169
+ export function nearestProjectOf(fileAbs, rootAbs) {
170
+ return nearestProjectFile(fileAbs, rootAbs, /\.(?:cs|fs|vb)proj$/i);
171
+ }
172
+
173
+ /** .NET sources the C# scan cannot read: Razor (`.razor`, `.cshtml`), F# (`.fs`, `.fsi`, `.fsx`), VB (`.vb`). */
174
+ const NON_CSHARP_SOURCE = /\.(?:razor|cshtml|fs|fsi|fsx|vb)$/i;
175
+
176
+ /** A .NET source other than C#: the recorte has no type scan, hence no selector, for it. */
177
+ export const isNonCSharpSource = (path) => NON_CSHARP_SOURCE.test(String(path));
178
+
179
+ /** Any .NET source a project compiles — C# or one of the others. */
180
+ export const isDotnetSource = (path) => /\.cs$/i.test(String(path)) || isNonCSharpSource(path);
181
+
182
+ const MARKER_TAIL = '\\s*(?:[\\p{L}_][\\p{L}\\p{N}_]*\\s*(?:\\.|::)\\s*)*(?:Fact|Theory|Test|DataTestMethod)';
183
+ /** Per language, the LOOSE test marker readsLikeTestSource looks for in the raw text. */
184
+ const LOOSE_TEST_MARKERS = [
185
+ [/\.(?:cs|razor|cshtml)$/i, new RegExp(`[[,]${MARKER_TAIL}`, 'u')], // C#, Razor: `[Fact`, `, Theory`, `[Xunit.Fact(`
186
+ [/\.(?:fs|fsi|fsx)$/i, new RegExp(`(?:\\[<|;)${MARKER_TAIL}`, 'u')], // F#: `[<Fact>]`, `[<Trait(…); Fact>]`
187
+ [/\.vb$/i, new RegExp(`[<,]${MARKER_TAIL}`, 'u')], // VB: `<Fact>`, `<Trait(…), Fact>`
188
+ ];
189
+
190
+ /**
191
+ * Does a file the recogniser did NOT classify READ like a test, in its raw text
192
+ * (comments and strings included)? The question test-scope asks before narrowing
193
+ * PAST a touched file, so its errors cost the OPPOSITE of CS_TEST_ATTRIBUTE's: a
194
+ * false positive runs a node whole (time), a false negative skips a touched test.
195
+ * So it errs wide on purpose — a name PREFIX is enough (`[TestContext` counts).
196
+ * false for any extension it has no marker for.
197
+ */
198
+ export function readsLikeTestSource(fileAbs) {
199
+ const marker = LOOSE_TEST_MARKERS.find(([ext]) => ext.test(fileAbs))?.[1];
200
+ return marker ? marker.test(readText(fileAbs) ?? '') : false;
201
+ }
202
+
203
+ /**
204
+ * `.NET` test mode for a cwd: nearest `global.json` walking UP (as the SDK
205
+ * resolves it — from the CWD, never from the `.sln` argument), read as JSONC.
206
+ *
207
+ * @param {string} absCwd
208
+ * @returns {'mtp'|'vstest'|null} `mtp` when `test.runner` is Microsoft.Testing.Platform;
209
+ * `vstest` when readable without it (or absent: the SDK default); null when a
210
+ * global.json exists but cannot be read — never a guess.
211
+ */
212
+ export function dotnetTestMode(absCwd) {
213
+ for (let dir = resolve(absCwd); ; dir = dirname(dir)) {
214
+ const file = join(dir, 'global.json');
215
+ if (existsSync(file)) {
216
+ try {
217
+ const parsed = JSON.parse(jsoncToJson(readFileSync(file, 'utf8')));
218
+ return parsed?.test?.runner === MTP_RUNNER ? 'mtp' : 'vstest';
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+ if (dirname(dir) === dir) return 'vstest';
224
+ }
225
+ }
226
+
227
+ /** Read on EVERY level from the project's folder up (bindingMsbuildFiles). */
228
+ const DIRECTORY_FILES = ['Directory.Build.props', 'Directory.Build.targets', 'Directory.Build.rsp', 'Directory.Packages.props'];
229
+
230
+ /**
231
+ * The MSBuild files that bind a project, as far as this module reads them: the csproj,
232
+ * the `<csproj>.user` Microsoft.Common.targets imports beside it, and every
233
+ * DIRECTORY_FILES walking up to the FILESYSTEM root — MSBuild's lookup does not stop at
234
+ * the repository, and a Directory.Build.targets above it binds the project just the same
235
+ * (#114 review F3). Every level is read, not only the first file MSBuild would import:
236
+ * that errs wide. Not read: an `<Import Project=…>` (floorOverride refuses a csproj that
237
+ * has one), a NuGet package's `build/*.targets`, the SDK's own imports.
238
+ */
239
+ function bindingMsbuildFiles(csprojAbs) {
240
+ const files = [csprojAbs, `${csprojAbs}.user`];
241
+ for (let dir = dirname(csprojAbs); ; dir = dirname(dir)) {
242
+ files.push(...DIRECTORY_FILES.map((name) => join(dir, name)));
243
+ if (dirname(dir) === dir) return files;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Does an MSBuild file that binds this project (bindingMsbuildFiles) match `pattern`?
249
+ * It errs wide: a false positive costs a whole run, a false negative a red (or a
250
+ * green) that no test earned.
251
+ */
252
+ function msbuildDeclares(csprojAbs, pattern) {
253
+ return bindingMsbuildFiles(csprojAbs).some((p) => pattern.test(readText(p) ?? ''));
254
+ }
255
+
256
+ /**
257
+ * The project imposes `--minimum-expected-tests` on every run: in the MSBuild
258
+ * property (attributes such as `Condition=` allowed), or in a `*testconfig.json`
259
+ * beside the csproj. Measured: the CLI flag cannot override it, so a green recorte
260
+ * exits 9 — unless floorOverride can lower it (#114).
261
+ */
262
+ export function imposesMinimumExpected(csprojAbs) {
263
+ return msbuildDeclares(csprojAbs, FLOOR_PROPERTY) || testconfigDeclares(csprojAbs, /minimum-?expected-?tests/i);
264
+ }
265
+
266
+ const ARGS_NAME = 'TestingPlatformCommandLineArguments';
267
+ const ARGS_ELEMENT = /<TestingPlatformCommandLineArguments\b([^>]*)>([^<]*)<\/TestingPlatformCommandLineArguments\s*>/gi;
268
+ const ARGS_OPENING = /<TestingPlatformCommandLineArguments\b/gi;
269
+ /** Named anywhere: an element, a `$(…)` read, a `-p:` in a Directory.Build.rsp. */
270
+ const ARGS_MENTION = /TestingPlatformCommandLineArguments/i;
271
+ const IMPORT_ELEMENT = /<Import\b/i;
272
+ /**
273
+ * A tag — or a comment, CDATA section, declaration or processing instruction, which
274
+ * enclosingElements skips. An attribute value may hold `>` (`Condition="'$(A)' > '1'"`),
275
+ * so a quoted value is consumed whole.
276
+ */
277
+ const XML_TOKEN = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<[!?][^>]*>|<(\/?)([A-Za-z_][\w.:-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
278
+ /**
279
+ * What a value cannot carry and still be re-passed as ONE double-quoted `-p:`
280
+ * argument meaning the same thing: MSBuild expansion and escapes (`$ @ %`), its
281
+ * property separators (`; ,`), an XML entity (`&`), and shell quoting (`" ' \` \ !`).
282
+ */
283
+ const UNSAFE_IN_PROPERTY = /[$@%;,&"'`\\!]/;
284
+ /** Read after ASCII whitespace is collapsed: a no-break space between flag and number matches nothing (review F7). */
285
+ const FLOOR_ARGUMENT = /(^| )--minimum-expected-tests \d+(?= |$)/;
286
+
287
+ /**
288
+ * The elements enclosing the opening tag of `name`, outermost first — or null when the
289
+ * tags do not balance. Fails SAFE: a document it cannot walk is null, and the node runs
290
+ * whole.
291
+ */
292
+ function enclosingElements(text, name) {
293
+ const stack = [];
294
+ let enclosing = null;
295
+ for (const [, closing, tag, attributes, selfClosing] of text.matchAll(XML_TOKEN)) {
296
+ if (tag === undefined) continue;
297
+ if (closing) {
298
+ if (stack.pop()?.tag.toLowerCase() !== tag.toLowerCase()) return null;
299
+ continue;
300
+ }
301
+ if (tag.toLowerCase() === name.toLowerCase()) enclosing = [...stack];
302
+ if (!selfClosing) stack.push({ tag, attributes });
303
+ }
304
+ return stack.length === 0 ? enclosing : null;
305
+ }
306
+
307
+ /**
308
+ * The element is set on EVERY evaluation, by nothing but itself: its parent is a
309
+ * `<PropertyGroup>` with no attribute, directly under `<Project>` — never a `<Target>`, a
310
+ * `<Choose>/<When>`, an `<ItemDefinitionGroup>` or a conditional group (#114 review F1:
311
+ * the element's OWN attributes were the only ones read, and a false `Condition` on its
312
+ * group got the value injected into a run MSBuild would never give it). `<Project>` does
313
+ * not mark it `TreatAsLocalProperty` (the csproj would beat the global `-p:` and a green
314
+ * recorte exit 9, review F5), and the csproj imports nothing that could append to it.
315
+ */
316
+ function setUnconditionally(text) {
317
+ if (IMPORT_ELEMENT.test(text)) return false;
318
+ const enclosing = enclosingElements(text, ARGS_NAME);
319
+ if (enclosing?.length !== 2) return false;
320
+ const [project, group] = enclosing;
321
+ return /^Project$/i.test(project.tag) && !/TreatAsLocalProperty/i.test(project.attributes)
322
+ && /^PropertyGroup$/i.test(group.tag) && group.attributes.trim() === '';
323
+ }
324
+
325
+ /**
326
+ * The value for `-p:TestingPlatformCommandLineArguments=` that lowers the project's
327
+ * floor to 1 and keeps every other argument (#114) — or null, and the node runs whole.
328
+ *
329
+ * Measured (test/fixtures/test-output/): the CLI flag does NOT override the project's
330
+ * floor — a green recorte still exits 9 —, the global MSBuild property does — green
331
+ * exits 0, zero tests still exit 8 — and it replaces the value WHOLE, so the rest is
332
+ * re-passed verbatim (mtp-minimum-expected-override-multiarg.txt). No rebuild: six
333
+ * runs alternating with and without it kept the test DLL's mtime, ~3 s each.
334
+ *
335
+ * So the value has to be exactly the one MSBuild would use, read from ONE place:
336
+ * - ONE `<TestingPlatformCommandLineArguments>` in the csproj itself, set on every
337
+ * evaluation (setUnconditionally) — no attribute of its own, no second opening
338
+ * (another element, or one in an XML comment: the count is over the raw text);
339
+ * - the property named in no other file that binds the project (bindingMsbuildFiles:
340
+ * the `.user` file, Directory.Build.* up to the filesystem root) — one could append
341
+ * to it or replace it;
342
+ * - no floor in a `*testconfig.json` beside it (`-p:` does not reach that file);
343
+ * - exactly one literal `--minimum-expected-tests N`, and nothing UNSAFE_IN_PROPERTY.
344
+ * Out of reach: a NuGet package's build targets appending to the property. The override
345
+ * drops that argument during the recorte, and one like xunit.v3's `--explicit` changes
346
+ * WHICH tests run — a limit framework/docs/CLI.md states.
347
+ *
348
+ * @returns {string|null}
349
+ */
350
+ export function floorOverride(csprojAbs) {
351
+ const text = readText(csprojAbs);
352
+ if (text === null) return null;
353
+ const elements = [...text.matchAll(ARGS_ELEMENT)];
354
+ if (elements.length !== 1 || (text.match(ARGS_OPENING) ?? []).length !== 1) return null;
355
+ const [, attributes, raw] = elements[0];
356
+ if (attributes.trim() !== '' || !setUnconditionally(text)) return null;
357
+ if (bindingMsbuildFiles(csprojAbs).slice(1).some((p) => ARGS_MENTION.test(readText(p) ?? ''))) return null;
358
+ if (testconfigDeclares(csprojAbs, /minimum-?expected-?tests/i)) return null;
359
+ const value = raw.replace(/[ \t\r\n]+/g, ' ').trim();
360
+ if (UNSAFE_IN_PROPERTY.test(value)) return null;
361
+ if ((value.match(/--minimum-expected-tests/g) ?? []).length !== 1 || !FLOOR_ARGUMENT.test(value)) return null;
362
+ return value.replace(FLOOR_ARGUMENT, '$1--minimum-expected-tests 1');
363
+ }
364
+
365
+ /**
366
+ * The project can make MTP IGNORE an exit code on every run: `--ignore-exit-code` or
367
+ * the `TESTINGPLATFORM_EXITCODE_IGNORE` variable named anywhere in an MSBuild file that
368
+ * binds the project — inside `<TestingPlatformCommandLineArguments>` or in a property
369
+ * it references through `$(…)` — or either one in a `*testconfig.json` beside the csproj.
370
+ * Under MTP a narrowed run's proof that tests ran is the summary's count and — when
371
+ * the summary is not readable (#115: pt-BR and en-US are) — exit 0 (zero tests exit
372
+ * 8); with 8 ignored, zero tests exit 0 and that proof is gone — the final review of
373
+ * branch B probed it end to end. Errs wide, like the floor.
374
+ */
375
+ export function ignoresExitCode(csprojAbs) {
376
+ return msbuildDeclares(csprojAbs, IGNORE_EXIT_MARKER)
377
+ || testconfigDeclares(csprojAbs, /ignore-?exit-?code|exit-?code-?ignore|TESTINGPLATFORM_EXITCODE_IGNORE/i);
378
+ }
379
+
380
+ /** Does a `*testconfig.json` beside the csproj (MTP reads it from the project) match `pattern`? */
381
+ function testconfigDeclares(csprojAbs, pattern) {
382
+ const projectDir = dirname(csprojAbs);
383
+ let names = [];
384
+ try { names = readdirSync(projectDir).filter((n) => /testconfig\.json$/i.test(n)); } catch { /* none */ }
385
+ return names.some((n) => pattern.test(readText(join(projectDir, n)) ?? ''));
386
+ }
387
+
388
+ /** The project declares `<VSTestTestCaseFilter>` — an appended `--filter` would silently REPLACE it. */
389
+ export function declaresCaseFilter(csprojAbs) {
390
+ return msbuildDeclares(csprojAbs, CASE_FILTER_PROPERTY);
391
+ }
392
+
393
+ const lineEnd = (text, i) => { const e = text.indexOf('\n', i); return e < 0 ? text.length : e; };
394
+
395
+ /** End (exclusive) of a regular string or char literal opened at `start`; -1 if the line ends first. */
396
+ function closeOnLine(text, start, quote) {
397
+ for (let j = start + 1; j < text.length && text[j] !== '\n'; j++) {
398
+ if (text[j] === '\\') j++;
399
+ else if (text[j] === quote) return j + 1;
400
+ }
401
+ return -1;
402
+ }
403
+
404
+ /**
405
+ * Does an interpolated literal's content end inside a hole? A `"` inside a hole
406
+ * (`$"{d["k"]}"`) ends the literal as the scanner reads it, so what it took for
407
+ * the literal stops with the hole still open. `{{`/`}}` outside a hole are escapes.
408
+ */
409
+ function holeLeftOpen(content) {
410
+ let depth = 0;
411
+ for (let k = 0; k < content.length; k++) {
412
+ const c = content[k];
413
+ if (depth === 0 && (c === '{' || c === '}') && content[k + 1] === c) { k++; continue; }
414
+ if (c === '{') depth++;
415
+ else if (c === '}' && depth > 0) depth--;
416
+ }
417
+ return depth > 0;
418
+ }
419
+
420
+ /**
421
+ * C# source with every comment, preprocessor line and string/char literal
422
+ * blanked — newlines and length kept — or null when the file cannot be read this way:
423
+ * - a literal or comment never ends;
424
+ * - an interpolated `$"…"`/`$@"…"`/`@$"…"` literal is cut by a quote inside a hole
425
+ * (its content ends with the hole open). Blanking the part before the cut would
426
+ * leave the rest of the hole as "code", with braces that can cancel out.
427
+ *
428
+ * Raw strings (`"""…"""`, 3+ quotes, with or without `$`/`$$`) end at the same run
429
+ * of quotes; a quote inside a raw interpolation hole is NOT checked. Verbatim `@"…"`
430
+ * ends at a lone `"`. Regular and char literals cannot cross a line.
431
+ *
432
+ * This is a heuristic lexer and it CAN misread (known: `holeLeftOpen` counts the
433
+ * braces of a char literal such as `'}'`, so `$"{(c == '}' ? "a" : "/*")}"` is cut
434
+ * at the inner quote and `"/*"` becomes code). Nothing in narrowing relies on the
435
+ * blanking being right: csharpClasses compares the declarations it names against
436
+ * the type keywords of the RAW text, and any declaration a misread hides makes the
437
+ * file unscannable.
438
+ *
439
+ * @param {string} text
440
+ * @returns {string|null}
441
+ */
442
+ export function blankCSharpLiterals(text) {
443
+ const out = text.split('');
444
+ const blank = (from, to) => { for (let k = from; k < to; k++) if (out[k] !== '\n') out[k] = ' '; };
445
+ for (let i = 0; i < text.length;) {
446
+ const c = text[i];
447
+ let end = -1;
448
+ if (c === '#' && /^[ \t]*$/.test(text.slice(text.lastIndexOf('\n', i - 1) + 1, i))) {
449
+ end = lineEnd(text, i);
450
+ } else if (c === '/' && text[i + 1] === '/') {
451
+ end = lineEnd(text, i);
452
+ } else if (c === '/' && text[i + 1] === '*') {
453
+ const close = text.indexOf('*/', i + 2);
454
+ if (close < 0) return null;
455
+ end = close + 2;
456
+ } else if (c === '"' && (text[i - 1] === '@' || (text[i - 1] === '$' && text[i - 2] === '@'))) {
457
+ let j = i + 1;
458
+ while (j < text.length && !(text[j] === '"' && text[j + 1] !== '"')) j += text[j] === '"' ? 2 : 1;
459
+ if (j >= text.length) return null;
460
+ end = j + 1;
461
+ const interpolated = text[i - 1] === '$' || text[i - 2] === '$';
462
+ if (interpolated && holeLeftOpen(text.slice(i + 1, end - 1))) return null;
463
+ } else if (c === '"') {
464
+ let quotes = 0;
465
+ while (text[i + quotes] === '"') quotes++;
466
+ if (quotes >= 3) {
467
+ const close = text.indexOf('"'.repeat(quotes), i + quotes);
468
+ if (close < 0) return null;
469
+ end = close + quotes;
470
+ } else {
471
+ end = closeOnLine(text, i, '"');
472
+ if (end < 0) return null;
473
+ if (text[i - 1] === '$' && holeLeftOpen(text.slice(i + 1, end - 1))) return null;
474
+ }
475
+ } else if (c === "'") {
476
+ end = closeOnLine(text, i, "'");
477
+ if (end < 0) return null;
478
+ }
479
+ if (end < 0) { i++; continue; }
480
+ blank(i, end);
481
+ i = end;
482
+ }
483
+ return out.join('');
484
+ }
485
+
486
+ /** Type-keyword tokens in a text (raw or blanked) — every one; no context rule (see TYPE_KEYWORD). */
487
+ const typeKeywordCount = (text) => [...text.matchAll(TYPE_KEYWORD)].length;
488
+
489
+ /**
490
+ * The types of a C# file, read FAIL-SAFE.
491
+ *
492
+ * `types` lists the BARE name of EVERY type declaration (class, record, struct,
493
+ * interface) at ANY depth, in source order, with no namespace. That list is what
494
+ * narrowing selects on, namespace-free (`--filter-class "*T*"`, measured, or the
495
+ * substring `FullyQualifiedName~T`): any namespace, `Outer+Inner`, `Name`1` — a
496
+ * selection that can only run MORE tests than the file's, never fewer.
497
+ *
498
+ * THE GUARANTEE (what `scannable: true` means), and why it holds:
499
+ * every type-keyword token of the RAW text is a declaration in `types`.
500
+ * - R = keyword tokens of the raw text (TYPE_KEYWORD, no context rule); C = the
501
+ * same on the blanked code; D = the declarations named (TYPE_DECL on the code).
502
+ * `scannable` requires |R| = |D| and |C| = |D|. By construction D ⊆ C ⊆ R as
503
+ * sets of offsets: blanking keeps length and only replaces characters by spaces,
504
+ * a blanked region starts at `"` `'` `/` `#` and ends at `"` `'` `*` `/` or a
505
+ * line end (never inside an identifier), and the raw lookahead accepts every
506
+ * character the code lookahead can be looking past (a name, a comment, `#`, a
507
+ * quote). So |R| = |D| forces R = D. A real declaration's keyword is always in R
508
+ * (it is followed by its name, or by a comment/directive before the name) —
509
+ * UNDER ONE ASSUMPTION: every character Roslyn reads as whitespace or a new-line
510
+ * between a keyword and its name is whitespace to JS `\s` once readText has run.
511
+ * Roslyn's whitespace is Unicode Zs, TAB, VT, FF and U+001A; its new-lines are
512
+ * CR, LF, NEL, LS and PS. JS `\s` already covers Zs, TAB, VT, FF and LF, and
513
+ * readText maps CR/NEL/LS/PS to `\n` and U+001A to a space. A separator outside
514
+ * that shared set would hide a declaration from R; none is known, and that is a
515
+ * statement about the two lexers as read, not a proof.
516
+ * Hence: a declaration swallowed by ANY misread — `"/*"` taken for a comment
517
+ * opener, a quote inside an interpolation hole, a char literal — is still in R
518
+ * and cannot be compensated by anything (a fake the code exposes is in R too).
519
+ * - `abstract` lists the abstract types: their tests run under the names of DERIVED
520
+ * classes, possibly in files the task did not touch, so narrowing refuses them.
521
+ * - `hasTestAttribute`: a test attribute (CS_TEST_ATTRIBUTE: a whole name inside an
522
+ * attribute section) is present in the blanked code (a file without one is a
523
+ * helper/fake/fixture: the tests using it are elsewhere).
524
+ * - kept fail-safes: braces balance and blankCSharpLiterals returning null → unscannable.
525
+ *
526
+ * NOT guaranteed (stated, not hidden):
527
+ * - narrowing itself: keyword-shaped text in a real comment or string (`// the
528
+ * class under test`), a constraint keyword followed by a name-shaped token
529
+ * (`where T : class where …`, `: class // note`), `record with`, a directive
530
+ * splitting a name — all run the node whole. A cost in time, never a skipped test;
531
+ * - which scope a type was read in (`names`, the top-level subset, is
532
+ * informational only — nothing in narrowing depends on it);
533
+ * - a NON-abstract class whose tests are inherited by derived classes declared in
534
+ * OTHER files: the touched class runs; the derived copies only if their names
535
+ * contain it;
536
+ * - a test attribute reached through an alias (`using F = Xunit.FactAttribute; [F]`)
537
+ * or a custom one (`[SkippableFact]`) is not seen: the file counts as a helper and
538
+ * the node runs whole (safe, slower);
539
+ * - `hasTestAttribute` is still a heuristic, and ITS false positive is the unsafe
540
+ * side. Since the re-review (N1) the section must close; what still matches in a
541
+ * helper — probed — is a LIST PATTERN opening a line (or after `;` `{` `}` `]`)
542
+ * whose element is named exactly `Test`/`Fact`/… (qualified or not) and whose `]`
543
+ * is followed by a combinator: `[Env.Test] or [Env.Dev] => true`. Such a helper is
544
+ * taken for a test file. (`[Env.Test] => 1` does not match: `=` starts no declaration.)
545
+ *
546
+ * @param {string} csFileAbs
547
+ * @returns {{ names: string[], types: string[], abstract: string[], hasTestClass: boolean, hasTestAttribute: boolean, scannable: boolean, why: string|null }}
548
+ * `names`: top-level `class` names (classNamesOf). `abstract`: the abstract
549
+ * types. `hasTestClass`: some plain `class` is declared (records, structs and
550
+ * interfaces hold no tests). `hasTestAttribute`: see above. `why`: the reason
551
+ * when `scannable` is false.
552
+ */
553
+ export function csharpClasses(csFileAbs) {
554
+ const unscannable = (why) => ({ names: [], types: [], abstract: [], hasTestClass: false, hasTestAttribute: false, scannable: false, why });
555
+ const text = readText(csFileAbs);
556
+ if (text === null) return { names: [], types: [], abstract: [], hasTestClass: false, hasTestAttribute: false, scannable: true, why: null };
557
+ const code = blankCSharpLiterals(text);
558
+ if (code === null) return unscannable('a literal or comment never ends, or an interpolation hole is cut by an inner quote');
559
+
560
+ const decls = [...code.matchAll(TYPE_DECL)].map((m) => ({
561
+ at: m.index + m[0].length - m[2].length,
562
+ kind: m[1].split(/\s+/)[0],
563
+ name: m[2],
564
+ abstract: /\babstract\b/.test(m[0]),
565
+ gap: m[0].slice(0, m[0].length - m[2].length).match(/\s*$/)[0].length,
566
+ }));
567
+ // A preprocessor line between a keyword and its name (`class\n#if X\nA\n#else\nB…`)
568
+ // makes the compiled name depend on a symbol this scan cannot see. Blanking keeps
569
+ // length, so the raw text at the same offsets shows the directive.
570
+ const split = decls.find((d) => /(^|\n)[ \t]*#/.test(text.slice(d.at - d.gap, d.at)));
571
+ if (split) return unscannable(`a preprocessor directive stands between a type keyword and the name "${split.name}"`);
572
+ // THE NET (R2, tightened in round 5): the raw text is the lexer-independent
573
+ // truth about how many type keywords the file carries. Compare it — and the
574
+ // blanked code — against what was actually named (see the JSDoc for why
575
+ // equality of counts is equality of sets).
576
+ const rawKeywords = typeKeywordCount(text);
577
+ if (rawKeywords !== decls.length) {
578
+ return unscannable(`${rawKeywords} type keyword(s) in the file but ${decls.length} declaration(s) recognised`);
579
+ }
580
+ const codeKeywords = typeKeywordCount(code);
581
+ if (codeKeywords !== decls.length) {
582
+ return unscannable(`${codeKeywords} type keyword(s) in the code but ${decls.length} declaration(s) recognised`);
583
+ }
584
+
585
+ const names = [];
586
+ const types = [];
587
+ const abstractTypes = [];
588
+ const scopes = [];
589
+ let hasTestClass = false;
590
+ let header = 0;
591
+ for (let i = 0, d = 0; i < code.length; i++) {
592
+ for (; d < decls.length && decls[d].at === i; d++) {
593
+ types.push(decls[d].name);
594
+ if (decls[d].abstract) abstractTypes.push(decls[d].name);
595
+ if (decls[d].kind === 'class') {
596
+ hasTestClass = true;
597
+ if (!scopes.some((s) => s.type)) names.push(decls[d].name);
598
+ }
599
+ }
600
+ const c = code[i];
601
+ if (c === '{') {
602
+ scopes.push({ type: TYPE_HEADER.test(code.slice(header, i)) });
603
+ } else if (c === '}' && scopes.pop() === undefined) {
604
+ return unscannable('a `}` closes nothing');
605
+ }
606
+ if (c === '{' || c === '}' || c === ';') header = i + 1;
607
+ }
608
+ if (scopes.length > 0) return unscannable('braces left open at end of file');
609
+ return {
610
+ names: [...new Set(names)],
611
+ types: [...new Set(types)],
612
+ abstract: [...new Set(abstractTypes)],
613
+ hasTestClass,
614
+ hasTestAttribute: CS_TEST_ATTRIBUTE.test(code),
615
+ scannable: true,
616
+ why: null,
617
+ };
618
+ }
619
+
620
+ /**
621
+ * BARE names of the TOP-LEVEL `class` declarations in a C# file — no namespace
622
+ * (the selectors are namespace-free by construction, see csharpClasses). Records
623
+ * are not test classes. Nested classes are left out. `[]` also when the file
624
+ * cannot be scanned safely.
625
+ *
626
+ * @param {string} csFileAbs
627
+ * @returns {string[]} unvalidated — narrowNode refuses names outside `[A-Za-z0-9_.]`
628
+ */
629
+ export function classNamesOf(csFileAbs) {
630
+ const { names, scannable } = csharpClasses(csFileAbs);
631
+ return scannable ? names : [];
632
+ }
@@ -389,9 +389,9 @@ export function readEvalSummary(path) {
389
389
  /**
390
390
  * How many scenarios an eval run evaluated.
391
391
  *
392
- * The summary wins when it is readable; otherwise the caller falls back to the
393
- * `dotnet` extractor, because an eval IS `dotnet test`. **No new extractor is
394
- * invented**, and an unreadable summary yields null, never 0.
392
+ * The ONLY source of an eval node's count. The `dotnet` extractor reads the same
393
+ * run but counts xunit cases — another unit (#110) — so an unreadable summary
394
+ * yields null: UNKNOWN, never 0, and never the extractor's number.
395
395
  *
396
396
  * @param {object|null} summary
397
397
  * @returns {number|null}