@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.
- package/CLAUDE.md +10 -8
- package/README.md +2 -2
- package/bin/morph-spec.js +4 -3
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/MORPH.md +24 -23
- package/framework/docs/CLI.md +212 -15
- package/framework/evals/proof-rubrics/eval-de-modelo.md +16 -0
- package/framework/evals/rubrics/condensed.md +3 -1
- package/framework/evals/rubrics/test-coverage.md +8 -0
- package/framework/hooks/shared/phase-utils.js +1 -0
- package/framework/phases.json +1 -1
- package/framework/rules/morph-workflow.md +1 -1
- package/framework/schemas/config.schema.json +7 -0
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +21 -10
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
- package/framework/skills/core/morph-implement/SKILL.md +20 -3
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +41 -3
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +11 -2
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/STANDARDS.json +2 -2
- package/framework/standards/ai-agents/prompt-sources.md +4 -3
- package/framework/standards/ai-agents/testing-ai.md +14 -13
- package/framework/templates/dotnet/ai-kit/README.md +22 -11
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +23 -10
- package/package.json +1 -1
- package/src/commands/project/doctor.js +51 -2
- package/src/commands/tasks/dag.js +13 -1
- package/src/commands/validation/verify.js +59 -17
- package/src/core/paths/output-schema.js +7 -0
- package/src/lib/detectors/index.js +225 -217
- package/src/lib/risk-detector.js +38 -18
- package/src/lib/specs/done-criteria-check.js +200 -0
- package/src/lib/specs/usecase-check.js +129 -5
- package/src/lib/tasks/dotnet-test-introspection.js +632 -0
- package/src/lib/tasks/eval-node.js +3 -3
- package/src/lib/tasks/test-nodes.js +190 -20
- package/src/lib/tasks/test-runner.js +210 -17
- package/src/lib/tasks/test-scope.js +597 -0
- package/src/lib/validators/traceability-check.js +9 -2
- package/src/lib/validators/verify-runner.js +190 -27
- 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
|
|
393
|
-
*
|
|
394
|
-
*
|
|
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}
|