@polymorphism-tech/morph-spec 8.35.0 → 8.36.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 (55) hide show
  1. package/CLAUDE.md +11 -9
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +2 -1
  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 +187 -13
  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/claude-code/core/worktree-env-inject.js +43 -0
  13. package/framework/hooks/shared/payload-utils.js +15 -2
  14. package/framework/hooks/shared/phase-utils.js +1 -0
  15. package/framework/phases.json +1 -1
  16. package/framework/rules/morph-workflow.md +1 -1
  17. package/framework/schemas/config.schema.json +7 -0
  18. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  19. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  20. package/framework/skills/core/morph-apply/SKILL.md +21 -10
  21. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  22. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  23. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  24. package/framework/skills/core/morph-implement/SKILL.md +20 -3
  25. package/framework/skills/core/morph-media/SKILL.md +1 -1
  26. package/framework/skills/core/morph-plan/SKILL.md +41 -3
  27. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  28. package/framework/skills/core/morph-review/SKILL.md +11 -2
  29. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  30. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  31. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  32. package/framework/standards/STANDARDS.json +2 -2
  33. package/framework/standards/ai-agents/prompt-sources.md +4 -3
  34. package/framework/standards/ai-agents/testing-ai.md +14 -13
  35. package/framework/templates/dotnet/ai-kit/README.md +14 -8
  36. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +11 -3
  37. package/package.json +1 -1
  38. package/src/commands/project/doctor.js +59 -3
  39. package/src/commands/project/worktree.js +68 -3
  40. package/src/commands/state/finish.js +48 -5
  41. package/src/commands/tasks/dag.js +13 -1
  42. package/src/commands/validation/verify.js +47 -18
  43. package/src/core/paths/output-schema.js +7 -0
  44. package/src/lib/detectors/index.js +225 -217
  45. package/src/lib/git/pr-files.js +56 -0
  46. package/src/lib/risk-detector.js +38 -18
  47. package/src/lib/specs/done-criteria-check.js +200 -0
  48. package/src/lib/specs/usecase-check.js +129 -5
  49. package/src/lib/tasks/dotnet-test-introspection.js +517 -0
  50. package/src/lib/tasks/test-nodes.js +190 -20
  51. package/src/lib/tasks/test-runner.js +122 -2
  52. package/src/lib/tasks/test-scope.js +567 -0
  53. package/src/lib/validators/traceability-check.js +9 -2
  54. package/src/lib/validators/verify-runner.js +150 -18
  55. package/src/lib/worktree/docker-audit.js +5 -1
@@ -0,0 +1,517 @@
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
+ /**
228
+ * Does an MSBuild file that binds this project — the csproj, or any
229
+ * `Directory.Build.props/.targets` up to the project root — match `pattern`?
230
+ * `<Import Project=…>` is not followed (deferred). It errs wide: a false positive
231
+ * costs a whole run, a false negative a red (or a green) that no test earned.
232
+ */
233
+ function msbuildDeclares(csprojAbs, rootAbs, pattern) {
234
+ const files = [csprojAbs];
235
+ for (let dir = dirname(csprojAbs); isInside(dir, rootAbs); dir = dirname(dir)) {
236
+ files.push(join(dir, 'Directory.Build.props'), join(dir, 'Directory.Build.targets'));
237
+ if (dirname(dir) === dir) break;
238
+ }
239
+ return files.some((p) => pattern.test(readText(p) ?? ''));
240
+ }
241
+
242
+ /**
243
+ * The project imposes `--minimum-expected-tests` on every run: in the MSBuild
244
+ * property (attributes such as `Condition=` allowed), or in a `*testconfig.json`
245
+ * beside the csproj. Measured: the CLI cannot override it, so a green recorte exits 9.
246
+ */
247
+ export function imposesMinimumExpected(csprojAbs, rootAbs) {
248
+ return msbuildDeclares(csprojAbs, rootAbs, FLOOR_PROPERTY) || testconfigDeclares(csprojAbs, /minimum-?expected-?tests/i);
249
+ }
250
+
251
+ /**
252
+ * The project can make MTP IGNORE an exit code on every run: `--ignore-exit-code` or
253
+ * the `TESTINGPLATFORM_EXITCODE_IGNORE` variable named anywhere in an MSBuild file that
254
+ * binds the project — inside `<TestingPlatformCommandLineArguments>` or in a property
255
+ * it references through `$(…)` — or either one in a `*testconfig.json` beside the csproj.
256
+ * Under MTP a narrowed run's proof that tests ran is exit 0 (zero tests exit 8);
257
+ * with 8 ignored, zero tests exit 0 and the proof is gone — the final review of
258
+ * branch B probed it end to end. Errs wide, like the floor.
259
+ */
260
+ export function ignoresExitCode(csprojAbs, rootAbs) {
261
+ return msbuildDeclares(csprojAbs, rootAbs, IGNORE_EXIT_MARKER)
262
+ || testconfigDeclares(csprojAbs, /ignore-?exit-?code|exit-?code-?ignore|TESTINGPLATFORM_EXITCODE_IGNORE/i);
263
+ }
264
+
265
+ /** Does a `*testconfig.json` beside the csproj (MTP reads it from the project) match `pattern`? */
266
+ function testconfigDeclares(csprojAbs, pattern) {
267
+ const projectDir = dirname(csprojAbs);
268
+ let names = [];
269
+ try { names = readdirSync(projectDir).filter((n) => /testconfig\.json$/i.test(n)); } catch { /* none */ }
270
+ return names.some((n) => pattern.test(readText(join(projectDir, n)) ?? ''));
271
+ }
272
+
273
+ /** The project declares `<VSTestTestCaseFilter>` — an appended `--filter` would silently REPLACE it. */
274
+ export function declaresCaseFilter(csprojAbs, rootAbs) {
275
+ return msbuildDeclares(csprojAbs, rootAbs, CASE_FILTER_PROPERTY);
276
+ }
277
+
278
+ const lineEnd = (text, i) => { const e = text.indexOf('\n', i); return e < 0 ? text.length : e; };
279
+
280
+ /** End (exclusive) of a regular string or char literal opened at `start`; -1 if the line ends first. */
281
+ function closeOnLine(text, start, quote) {
282
+ for (let j = start + 1; j < text.length && text[j] !== '\n'; j++) {
283
+ if (text[j] === '\\') j++;
284
+ else if (text[j] === quote) return j + 1;
285
+ }
286
+ return -1;
287
+ }
288
+
289
+ /**
290
+ * Does an interpolated literal's content end inside a hole? A `"` inside a hole
291
+ * (`$"{d["k"]}"`) ends the literal as the scanner reads it, so what it took for
292
+ * the literal stops with the hole still open. `{{`/`}}` outside a hole are escapes.
293
+ */
294
+ function holeLeftOpen(content) {
295
+ let depth = 0;
296
+ for (let k = 0; k < content.length; k++) {
297
+ const c = content[k];
298
+ if (depth === 0 && (c === '{' || c === '}') && content[k + 1] === c) { k++; continue; }
299
+ if (c === '{') depth++;
300
+ else if (c === '}' && depth > 0) depth--;
301
+ }
302
+ return depth > 0;
303
+ }
304
+
305
+ /**
306
+ * C# source with every comment, preprocessor line and string/char literal
307
+ * blanked — newlines and length kept — or null when the file cannot be read this way:
308
+ * - a literal or comment never ends;
309
+ * - an interpolated `$"…"`/`$@"…"`/`@$"…"` literal is cut by a quote inside a hole
310
+ * (its content ends with the hole open). Blanking the part before the cut would
311
+ * leave the rest of the hole as "code", with braces that can cancel out.
312
+ *
313
+ * Raw strings (`"""…"""`, 3+ quotes, with or without `$`/`$$`) end at the same run
314
+ * of quotes; a quote inside a raw interpolation hole is NOT checked. Verbatim `@"…"`
315
+ * ends at a lone `"`. Regular and char literals cannot cross a line.
316
+ *
317
+ * This is a heuristic lexer and it CAN misread (known: `holeLeftOpen` counts the
318
+ * braces of a char literal such as `'}'`, so `$"{(c == '}' ? "a" : "/*")}"` is cut
319
+ * at the inner quote and `"/*"` becomes code). Nothing in narrowing relies on the
320
+ * blanking being right: csharpClasses compares the declarations it names against
321
+ * the type keywords of the RAW text, and any declaration a misread hides makes the
322
+ * file unscannable.
323
+ *
324
+ * @param {string} text
325
+ * @returns {string|null}
326
+ */
327
+ export function blankCSharpLiterals(text) {
328
+ const out = text.split('');
329
+ const blank = (from, to) => { for (let k = from; k < to; k++) if (out[k] !== '\n') out[k] = ' '; };
330
+ for (let i = 0; i < text.length;) {
331
+ const c = text[i];
332
+ let end = -1;
333
+ if (c === '#' && /^[ \t]*$/.test(text.slice(text.lastIndexOf('\n', i - 1) + 1, i))) {
334
+ end = lineEnd(text, i);
335
+ } else if (c === '/' && text[i + 1] === '/') {
336
+ end = lineEnd(text, i);
337
+ } else if (c === '/' && text[i + 1] === '*') {
338
+ const close = text.indexOf('*/', i + 2);
339
+ if (close < 0) return null;
340
+ end = close + 2;
341
+ } else if (c === '"' && (text[i - 1] === '@' || (text[i - 1] === '$' && text[i - 2] === '@'))) {
342
+ let j = i + 1;
343
+ while (j < text.length && !(text[j] === '"' && text[j + 1] !== '"')) j += text[j] === '"' ? 2 : 1;
344
+ if (j >= text.length) return null;
345
+ end = j + 1;
346
+ const interpolated = text[i - 1] === '$' || text[i - 2] === '$';
347
+ if (interpolated && holeLeftOpen(text.slice(i + 1, end - 1))) return null;
348
+ } else if (c === '"') {
349
+ let quotes = 0;
350
+ while (text[i + quotes] === '"') quotes++;
351
+ if (quotes >= 3) {
352
+ const close = text.indexOf('"'.repeat(quotes), i + quotes);
353
+ if (close < 0) return null;
354
+ end = close + quotes;
355
+ } else {
356
+ end = closeOnLine(text, i, '"');
357
+ if (end < 0) return null;
358
+ if (text[i - 1] === '$' && holeLeftOpen(text.slice(i + 1, end - 1))) return null;
359
+ }
360
+ } else if (c === "'") {
361
+ end = closeOnLine(text, i, "'");
362
+ if (end < 0) return null;
363
+ }
364
+ if (end < 0) { i++; continue; }
365
+ blank(i, end);
366
+ i = end;
367
+ }
368
+ return out.join('');
369
+ }
370
+
371
+ /** Type-keyword tokens in a text (raw or blanked) — every one; no context rule (see TYPE_KEYWORD). */
372
+ const typeKeywordCount = (text) => [...text.matchAll(TYPE_KEYWORD)].length;
373
+
374
+ /**
375
+ * The types of a C# file, read FAIL-SAFE.
376
+ *
377
+ * `types` lists the BARE name of EVERY type declaration (class, record, struct,
378
+ * interface) at ANY depth, in source order, with no namespace. That list is what
379
+ * narrowing selects on, namespace-free (`--filter-class "*T*"`, measured, or the
380
+ * substring `FullyQualifiedName~T`): any namespace, `Outer+Inner`, `Name`1` — a
381
+ * selection that can only run MORE tests than the file's, never fewer.
382
+ *
383
+ * THE GUARANTEE (what `scannable: true` means), and why it holds:
384
+ * every type-keyword token of the RAW text is a declaration in `types`.
385
+ * - R = keyword tokens of the raw text (TYPE_KEYWORD, no context rule); C = the
386
+ * same on the blanked code; D = the declarations named (TYPE_DECL on the code).
387
+ * `scannable` requires |R| = |D| and |C| = |D|. By construction D ⊆ C ⊆ R as
388
+ * sets of offsets: blanking keeps length and only replaces characters by spaces,
389
+ * a blanked region starts at `"` `'` `/` `#` and ends at `"` `'` `*` `/` or a
390
+ * line end (never inside an identifier), and the raw lookahead accepts every
391
+ * character the code lookahead can be looking past (a name, a comment, `#`, a
392
+ * quote). So |R| = |D| forces R = D. A real declaration's keyword is always in R
393
+ * (it is followed by its name, or by a comment/directive before the name) —
394
+ * UNDER ONE ASSUMPTION: every character Roslyn reads as whitespace or a new-line
395
+ * between a keyword and its name is whitespace to JS `\s` once readText has run.
396
+ * Roslyn's whitespace is Unicode Zs, TAB, VT, FF and U+001A; its new-lines are
397
+ * CR, LF, NEL, LS and PS. JS `\s` already covers Zs, TAB, VT, FF and LF, and
398
+ * readText maps CR/NEL/LS/PS to `\n` and U+001A to a space. A separator outside
399
+ * that shared set would hide a declaration from R; none is known, and that is a
400
+ * statement about the two lexers as read, not a proof.
401
+ * Hence: a declaration swallowed by ANY misread — `"/*"` taken for a comment
402
+ * opener, a quote inside an interpolation hole, a char literal — is still in R
403
+ * and cannot be compensated by anything (a fake the code exposes is in R too).
404
+ * - `abstract` lists the abstract types: their tests run under the names of DERIVED
405
+ * classes, possibly in files the task did not touch, so narrowing refuses them.
406
+ * - `hasTestAttribute`: a test attribute (CS_TEST_ATTRIBUTE: a whole name inside an
407
+ * attribute section) is present in the blanked code (a file without one is a
408
+ * helper/fake/fixture: the tests using it are elsewhere).
409
+ * - kept fail-safes: braces balance and blankCSharpLiterals returning null → unscannable.
410
+ *
411
+ * NOT guaranteed (stated, not hidden):
412
+ * - narrowing itself: keyword-shaped text in a real comment or string (`// the
413
+ * class under test`), a constraint keyword followed by a name-shaped token
414
+ * (`where T : class where …`, `: class // note`), `record with`, a directive
415
+ * splitting a name — all run the node whole. A cost in time, never a skipped test;
416
+ * - which scope a type was read in (`names`, the top-level subset, is
417
+ * informational only — nothing in narrowing depends on it);
418
+ * - a NON-abstract class whose tests are inherited by derived classes declared in
419
+ * OTHER files: the touched class runs; the derived copies only if their names
420
+ * contain it;
421
+ * - a test attribute reached through an alias (`using F = Xunit.FactAttribute; [F]`)
422
+ * or a custom one (`[SkippableFact]`) is not seen: the file counts as a helper and
423
+ * the node runs whole (safe, slower);
424
+ * - `hasTestAttribute` is still a heuristic, and ITS false positive is the unsafe
425
+ * side. Since the re-review (N1) the section must close; what still matches in a
426
+ * helper — probed — is a LIST PATTERN opening a line (or after `;` `{` `}` `]`)
427
+ * whose element is named exactly `Test`/`Fact`/… (qualified or not) and whose `]`
428
+ * is followed by a combinator: `[Env.Test] or [Env.Dev] => true`. Such a helper is
429
+ * taken for a test file. (`[Env.Test] => 1` does not match: `=` starts no declaration.)
430
+ *
431
+ * @param {string} csFileAbs
432
+ * @returns {{ names: string[], types: string[], abstract: string[], hasTestClass: boolean, hasTestAttribute: boolean, scannable: boolean, why: string|null }}
433
+ * `names`: top-level `class` names (classNamesOf). `abstract`: the abstract
434
+ * types. `hasTestClass`: some plain `class` is declared (records, structs and
435
+ * interfaces hold no tests). `hasTestAttribute`: see above. `why`: the reason
436
+ * when `scannable` is false.
437
+ */
438
+ export function csharpClasses(csFileAbs) {
439
+ const unscannable = (why) => ({ names: [], types: [], abstract: [], hasTestClass: false, hasTestAttribute: false, scannable: false, why });
440
+ const text = readText(csFileAbs);
441
+ if (text === null) return { names: [], types: [], abstract: [], hasTestClass: false, hasTestAttribute: false, scannable: true, why: null };
442
+ const code = blankCSharpLiterals(text);
443
+ if (code === null) return unscannable('a literal or comment never ends, or an interpolation hole is cut by an inner quote');
444
+
445
+ const decls = [...code.matchAll(TYPE_DECL)].map((m) => ({
446
+ at: m.index + m[0].length - m[2].length,
447
+ kind: m[1].split(/\s+/)[0],
448
+ name: m[2],
449
+ abstract: /\babstract\b/.test(m[0]),
450
+ gap: m[0].slice(0, m[0].length - m[2].length).match(/\s*$/)[0].length,
451
+ }));
452
+ // A preprocessor line between a keyword and its name (`class\n#if X\nA\n#else\nB…`)
453
+ // makes the compiled name depend on a symbol this scan cannot see. Blanking keeps
454
+ // length, so the raw text at the same offsets shows the directive.
455
+ const split = decls.find((d) => /(^|\n)[ \t]*#/.test(text.slice(d.at - d.gap, d.at)));
456
+ if (split) return unscannable(`a preprocessor directive stands between a type keyword and the name "${split.name}"`);
457
+ // THE NET (R2, tightened in round 5): the raw text is the lexer-independent
458
+ // truth about how many type keywords the file carries. Compare it — and the
459
+ // blanked code — against what was actually named (see the JSDoc for why
460
+ // equality of counts is equality of sets).
461
+ const rawKeywords = typeKeywordCount(text);
462
+ if (rawKeywords !== decls.length) {
463
+ return unscannable(`${rawKeywords} type keyword(s) in the file but ${decls.length} declaration(s) recognised`);
464
+ }
465
+ const codeKeywords = typeKeywordCount(code);
466
+ if (codeKeywords !== decls.length) {
467
+ return unscannable(`${codeKeywords} type keyword(s) in the code but ${decls.length} declaration(s) recognised`);
468
+ }
469
+
470
+ const names = [];
471
+ const types = [];
472
+ const abstractTypes = [];
473
+ const scopes = [];
474
+ let hasTestClass = false;
475
+ let header = 0;
476
+ for (let i = 0, d = 0; i < code.length; i++) {
477
+ for (; d < decls.length && decls[d].at === i; d++) {
478
+ types.push(decls[d].name);
479
+ if (decls[d].abstract) abstractTypes.push(decls[d].name);
480
+ if (decls[d].kind === 'class') {
481
+ hasTestClass = true;
482
+ if (!scopes.some((s) => s.type)) names.push(decls[d].name);
483
+ }
484
+ }
485
+ const c = code[i];
486
+ if (c === '{') {
487
+ scopes.push({ type: TYPE_HEADER.test(code.slice(header, i)) });
488
+ } else if (c === '}' && scopes.pop() === undefined) {
489
+ return unscannable('a `}` closes nothing');
490
+ }
491
+ if (c === '{' || c === '}' || c === ';') header = i + 1;
492
+ }
493
+ if (scopes.length > 0) return unscannable('braces left open at end of file');
494
+ return {
495
+ names: [...new Set(names)],
496
+ types: [...new Set(types)],
497
+ abstract: [...new Set(abstractTypes)],
498
+ hasTestClass,
499
+ hasTestAttribute: CS_TEST_ATTRIBUTE.test(code),
500
+ scannable: true,
501
+ why: null,
502
+ };
503
+ }
504
+
505
+ /**
506
+ * BARE names of the TOP-LEVEL `class` declarations in a C# file — no namespace
507
+ * (the selectors are namespace-free by construction, see csharpClasses). Records
508
+ * are not test classes. Nested classes are left out. `[]` also when the file
509
+ * cannot be scanned safely.
510
+ *
511
+ * @param {string} csFileAbs
512
+ * @returns {string[]} unvalidated — narrowNode refuses names outside `[A-Za-z0-9_.]`
513
+ */
514
+ export function classNamesOf(csFileAbs) {
515
+ const { names, scannable } = csharpClasses(csFileAbs);
516
+ return scannable ? names : [];
517
+ }