@polymorphism-tech/morph-spec 8.36.0 → 8.37.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +5 -5
- package/README.md +2 -2
- package/bin/morph-spec.js +2 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/docs/CLI.md +47 -24
- 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 +1 -1
- 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 +1 -1
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +2 -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/ai-agents/rag-custom-pgvector.md +7 -3
- package/framework/templates/dotnet/ai-kit/README.md +12 -7
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaPlan.cs +12 -6
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +16 -11
- package/package.json +1 -1
- package/src/commands/validation/verify.js +21 -8
- package/src/lib/tasks/dotnet-test-introspection.js +134 -19
- package/src/lib/tasks/eval-node.js +3 -3
- package/src/lib/tasks/test-runner.js +88 -15
- package/src/lib/tasks/test-scope.js +47 -17
- package/src/lib/validators/verify-runner.js +46 -15
|
@@ -30,10 +30,15 @@
|
|
|
30
30
|
de workflow. Uma unica verdade: nenhum chamador repassa a flag na linha de
|
|
31
31
|
comando, porque repassa-la duplicaria a opcao.
|
|
32
32
|
|
|
33
|
-
Consequencia para o recorte por task (`morph-spec verify {f} {task}`):
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
Consequencia para o recorte por task (`morph-spec verify {f} {task}`): a
|
|
34
|
+
mesma opcao com valor 1 na linha de comando NAO sobrescreve o valor do
|
|
35
|
+
projeto (medido: o recorte verde sai 9 do mesmo jeito), mas a propriedade
|
|
36
|
+
global do MSBuild sim. O recorte estreita este projeto repassando
|
|
37
|
+
"-p:TestingPlatformCommandLineArguments=" com ESTE valor e o piso em 1
|
|
38
|
+
(issue #114: verde sai 0, zero testes segue saindo 8, nada recompila). Por
|
|
39
|
+
isso o piso fica NESTE elemento, literal: sem Condition, sem $(...), sem
|
|
40
|
+
outra declaracao da propriedade em Directory.Build.* — em qualquer outra
|
|
41
|
+
forma o recorte volta a rodar o no inteiro.
|
|
37
42
|
|
|
38
43
|
O numero e o total de CASOS EXECUTADOS (275), nao de atributos nem de casos
|
|
39
44
|
DESCOBERTOS (276): os [Theory] expandem, e um caso IGNORADO nao conta para o piso — a
|
|
@@ -47,13 +52,13 @@
|
|
|
47
52
|
BLOCO DE 6 LINHAS, o veredito NAO comeca a linha ("Resumo da execucao de
|
|
48
53
|
teste: Aprovado!" / "Test run summary: Passed!") e a contagem vem em linha
|
|
49
54
|
propria, indentada, como `total:` MINUSCULO — nada disso e o sumario do
|
|
50
|
-
VSTest. Medido nos dois locales em 2026-09-08. Um parser
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
o eixo que ESTE arquivo controla.
|
|
55
|
+
VSTest. Medido nos dois locales em 2026-09-08. Um parser escrito so para
|
|
56
|
+
VSTest (que procure uma linha COMECANDO em "Aprovado!"/"Passed!" e
|
|
57
|
+
contendo "Total de testes:"/"Total:") nao consegue contar os testes deste
|
|
58
|
+
projeto; o `verify` do morph-spec le este bloco nos dois locales desde a
|
|
59
|
+
issue #115 (antes, a contagem deste projeto voltava desconhecida). O piso
|
|
60
|
+
acima garante por conta propria que uma descoberta parcial saia com
|
|
61
|
+
codigo 9 — o eixo que ESTE arquivo controla.
|
|
57
62
|
-->
|
|
58
63
|
<TestingPlatformCommandLineArguments>--minimum-expected-tests 275</TestingPlatformCommandLineArguments>
|
|
59
64
|
</PropertyGroup>
|
package/package.json
CHANGED
|
@@ -188,6 +188,12 @@ function evalsStatusOf(result) {
|
|
|
188
188
|
return 'skip';
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/**
|
|
192
|
+
* The options that make a FEATURE-scope run partial, each with the `reason` it
|
|
193
|
+
* reports when it keeps the stamp from being written — first match wins.
|
|
194
|
+
*/
|
|
195
|
+
const PARTIAL_RUN_FLAGS = [['filter', 'filtered'], ['skipTests', 'skip-tests'], ['skipBuild', 'skip-build']];
|
|
196
|
+
|
|
191
197
|
/**
|
|
192
198
|
* `morph-spec verify <feature> [task]` — the deterministic code-before-judge
|
|
193
199
|
* gate: build + tests + validators, BEFORE the LLM eval. See verify-runner.js
|
|
@@ -270,16 +276,23 @@ export async function verifyCommand(featureName, taskId, options = {}) {
|
|
|
270
276
|
// run is narrowed to a subset of nodes, but the stamp it would write carries
|
|
271
277
|
// `scope: 'feature'` — the one scope risk-detector.js trusts unconditionally.
|
|
272
278
|
// A partial run must not stand in for the proof a gate asks for, same as
|
|
273
|
-
// `--record-baseline` above.
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
+
// `--record-baseline` above. `--skip-tests` and `--skip-build` are the same
|
|
280
|
+
// partial run through another door (#113): a skipped node never reddens the
|
|
281
|
+
// rollup, so the stamp said `pass` over a suite (or a build) that never ran,
|
|
282
|
+
// and no gate rule reads a skipped `tests`/`build`. Task scope is unaffected:
|
|
283
|
+
// that stamp already says `scope: 'task:{id}'` (which pauses the ship gate on
|
|
284
|
+
// its own), and `taskVerifications` never claimed to cover more than the
|
|
285
|
+
// task. Checked AFTER `--no-persist` (via the `!options.noPersist` guard) so
|
|
286
|
+
// an explicit `--no-persist` keeps its own reason instead of being reported
|
|
287
|
+
// as one of these — the flags are all "don't write", but decidePersistence()
|
|
288
|
+
// already owns the former's reason.
|
|
289
|
+
const partialFeatureRun = (!options.noPersist && !taskId)
|
|
290
|
+
? PARTIAL_RUN_FLAGS.find(([flag]) => options[flag])?.[1] ?? null
|
|
291
|
+
: null;
|
|
279
292
|
const { persist, reason } = options.recordBaseline
|
|
280
293
|
? { persist: false, reason: 'record-baseline' }
|
|
281
|
-
:
|
|
282
|
-
? { persist: false, reason:
|
|
294
|
+
: partialFeatureRun
|
|
295
|
+
? { persist: false, reason: partialFeatureRun }
|
|
283
296
|
: decidePersistence({ cwd: process.cwd(), featureName, noPersist: options.noPersist });
|
|
284
297
|
|
|
285
298
|
let compact = null;
|
|
@@ -224,28 +224,142 @@ export function dotnetTestMode(absCwd) {
|
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
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
|
+
|
|
227
230
|
/**
|
|
228
|
-
*
|
|
229
|
-
* `
|
|
230
|
-
*
|
|
231
|
-
*
|
|
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.
|
|
232
238
|
*/
|
|
233
|
-
function
|
|
234
|
-
const files = [csprojAbs];
|
|
235
|
-
for (let dir = dirname(csprojAbs);
|
|
236
|
-
files.push(
|
|
237
|
-
if (dirname(dir) === dir)
|
|
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;
|
|
238
244
|
}
|
|
239
|
-
|
|
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) ?? ''));
|
|
240
254
|
}
|
|
241
255
|
|
|
242
256
|
/**
|
|
243
257
|
* The project imposes `--minimum-expected-tests` on every run: in the MSBuild
|
|
244
258
|
* 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
|
|
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}
|
|
246
349
|
*/
|
|
247
|
-
export function
|
|
248
|
-
|
|
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');
|
|
249
363
|
}
|
|
250
364
|
|
|
251
365
|
/**
|
|
@@ -253,12 +367,13 @@ export function imposesMinimumExpected(csprojAbs, rootAbs) {
|
|
|
253
367
|
* the `TESTINGPLATFORM_EXITCODE_IGNORE` variable named anywhere in an MSBuild file that
|
|
254
368
|
* binds the project — inside `<TestingPlatformCommandLineArguments>` or in a property
|
|
255
369
|
* 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
|
|
257
|
-
*
|
|
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
|
|
258
373
|
* branch B probed it end to end. Errs wide, like the floor.
|
|
259
374
|
*/
|
|
260
|
-
export function ignoresExitCode(csprojAbs
|
|
261
|
-
return msbuildDeclares(csprojAbs,
|
|
375
|
+
export function ignoresExitCode(csprojAbs) {
|
|
376
|
+
return msbuildDeclares(csprojAbs, IGNORE_EXIT_MARKER)
|
|
262
377
|
|| testconfigDeclares(csprojAbs, /ignore-?exit-?code|exit-?code-?ignore|TESTINGPLATFORM_EXITCODE_IGNORE/i);
|
|
263
378
|
}
|
|
264
379
|
|
|
@@ -271,8 +386,8 @@ function testconfigDeclares(csprojAbs, pattern) {
|
|
|
271
386
|
}
|
|
272
387
|
|
|
273
388
|
/** The project declares `<VSTestTestCaseFilter>` — an appended `--filter` would silently REPLACE it. */
|
|
274
|
-
export function declaresCaseFilter(csprojAbs
|
|
275
|
-
return msbuildDeclares(csprojAbs,
|
|
389
|
+
export function declaresCaseFilter(csprojAbs) {
|
|
390
|
+
return msbuildDeclares(csprojAbs, CASE_FILTER_PROPERTY);
|
|
276
391
|
}
|
|
277
392
|
|
|
278
393
|
const lineEnd = (text, i) => { const e = text.indexOf('\n', i); return e < 0 ? text.length : e; };
|
|
@@ -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}
|
|
@@ -411,34 +411,106 @@ function runTestPrebuild({ node, cwd, exec, timeout, projectPath }) {
|
|
|
411
411
|
// How many tests actually ran (T4 / UC-08)
|
|
412
412
|
// ============================================================================
|
|
413
413
|
|
|
414
|
+
/** VSTest: one summary line per project, STARTING with the verdict. */
|
|
415
|
+
const VSTEST_SUMMARY = /^\s*(?:Aprovado|Passed|Com falha|Failed|Reprovado|Erro)!/i;
|
|
414
416
|
/**
|
|
415
|
-
*
|
|
417
|
+
* Microsoft.Testing.Platform: the header line, in the two locales MEASURED (pt-BR and
|
|
418
|
+
* en-US). `\S+` spans `ção` — and a mis-decoded version of it — without naming a byte.
|
|
419
|
+
*/
|
|
420
|
+
const MTP_SUMMARY_HEADER = /^(?:Test run summary|Resumo da execu\S+ de teste):/;
|
|
421
|
+
const MTP_TOTAL = /^\s+total:\s*(\d+)\s*$/;
|
|
422
|
+
const MTP_FAILED = /^\s+(?:failed|falhou):\s*(\d+)\s*$/;
|
|
423
|
+
const MTP_PASSED = /^\s+(?:succeeded|conclu\S*\s+com\s+\S*xito):\s*(\d+)\s*$/;
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The indented block under an MTP summary header. It ends at the first line that
|
|
427
|
+
* starts at column 0; a block with no `total:` line yields `total: null`.
|
|
428
|
+
*/
|
|
429
|
+
function mtpSummaryBlock(lines, from) {
|
|
430
|
+
const block = { total: null, passed: 0, failed: 0 };
|
|
431
|
+
for (let i = from; i < lines.length && /^(?:\s|$)/.test(lines[i]); i++) {
|
|
432
|
+
const line = lines[i];
|
|
433
|
+
let m;
|
|
434
|
+
if ((m = MTP_TOTAL.exec(line))) block.total = Number(m[1]);
|
|
435
|
+
else if ((m = MTP_FAILED.exec(line))) block.failed = Number(m[1]);
|
|
436
|
+
else if ((m = MTP_PASSED.exec(line))) block.passed = Number(m[1]);
|
|
437
|
+
}
|
|
438
|
+
return block;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Sum every summary `dotnet test` printed, in either of its two formats.
|
|
443
|
+
*
|
|
444
|
+
* - **VSTest**: a line per project that starts with the verdict —
|
|
445
|
+
* `Aprovado! - Com falha: 0, Aprovado: 57, Ignorado: 1, Total: 58, …`.
|
|
446
|
+
* - **Microsoft.Testing.Platform** (xunit.v3 4.x under `global.json`
|
|
447
|
+
* `test.runner`, #115): the verdict does NOT start the line —
|
|
448
|
+
* `Test run summary: Passed!` / `Resumo da execução de teste: Aprovado!` —
|
|
449
|
+
* and the count comes in an indented block below it, with a lowercase
|
|
450
|
+
* `total:`, `failed:`/`falhou:` and `succeeded:`/`concluído com êxito:`.
|
|
451
|
+
* Measured in both locales (test/fixtures/test-output/mtp-*.txt). ONE block
|
|
452
|
+
* per `dotnet test` invocation: a solution of two projects prints a single
|
|
453
|
+
* aggregated block (mtp-solution-fail-skip.txt: `total: 6` = 4 + 2), so
|
|
454
|
+
* blocks add up only across invocations. `total` counts skipped cases, as
|
|
455
|
+
* VSTest's `Total:` does. Another locale is not read: UNKNOWN, never 0.
|
|
416
456
|
*
|
|
417
457
|
* SUM, not "the last one": a chained `a && b` command prints one summary per
|
|
418
|
-
*
|
|
419
|
-
* it — which, against a recorded baseline, is an invented count drop.
|
|
420
|
-
* `countCompletedRuns()` above counts the
|
|
458
|
+
* invocation, and reading a single one would report half a suite as the whole
|
|
459
|
+
* of it — which, against a recorded baseline, is an invented count drop.
|
|
460
|
+
* `countCompletedRuns()` above counts the VSTest lines for a different question
|
|
421
461
|
* (WHICH link was running when the clock died); this one reads their numbers.
|
|
422
462
|
*/
|
|
423
463
|
function parseDotnetCount(output) {
|
|
424
|
-
const lines = output.split('\n')
|
|
464
|
+
const lines = output.split('\n');
|
|
425
465
|
let seen = false;
|
|
426
466
|
const sum = { total: 0, passed: 0, failed: 0 };
|
|
467
|
+
const add = (total, passed, failed) => {
|
|
468
|
+
seen = true;
|
|
469
|
+
sum.total += total;
|
|
470
|
+
sum.passed += passed;
|
|
471
|
+
sum.failed += failed;
|
|
472
|
+
};
|
|
427
473
|
|
|
428
|
-
for (const line of lines) {
|
|
474
|
+
for (const line of lines.filter((l) => VSTEST_SUMMARY.test(l))) {
|
|
429
475
|
const total = /\b(?:Total de testes|Total)\s*:\s*(\d+)/i.exec(line);
|
|
430
476
|
if (!total) continue;
|
|
431
|
-
seen = true;
|
|
432
|
-
sum.total += Number(total[1]);
|
|
433
477
|
const passed = /\b(?:Aprovado|Passed)\s*:\s*(\d+)/i.exec(line);
|
|
434
478
|
const failed = /\b(?:Com falha|Failed|Reprovado)\s*:\s*(\d+)/i.exec(line);
|
|
435
|
-
|
|
436
|
-
sum.failed += failed ? Number(failed[1]) : 0;
|
|
479
|
+
add(Number(total[1]), passed ? Number(passed[1]) : 0, failed ? Number(failed[1]) : 0);
|
|
437
480
|
}
|
|
438
481
|
|
|
482
|
+
lines.forEach((line, i) => {
|
|
483
|
+
if (!MTP_SUMMARY_HEADER.test(line)) return;
|
|
484
|
+
const block = mtpSummaryBlock(lines, i + 1);
|
|
485
|
+
if (block.total !== null) add(block.total, block.passed, block.failed);
|
|
486
|
+
});
|
|
487
|
+
|
|
439
488
|
return seen ? sum : null;
|
|
440
489
|
}
|
|
441
490
|
|
|
491
|
+
/**
|
|
492
|
+
* The `total` of every MTP summary block in `output`, ANSI stripped — one per
|
|
493
|
+
* `dotnet test` invocation (measured: a solution prints ONE aggregated block).
|
|
494
|
+
* `[]` when none is readable. Read by `judgeNarrowedRun` (verify-runner.js): in a
|
|
495
|
+
* narrowed `&&` chain the SUM can be > 0 while one link ran nothing — its exit 8
|
|
496
|
+
* ignored by a path the scanner cannot read — and that link's own block says
|
|
497
|
+
* `total: 0`.
|
|
498
|
+
*
|
|
499
|
+
* @param {string|null|undefined} output
|
|
500
|
+
* @returns {number[]}
|
|
501
|
+
*/
|
|
502
|
+
export function mtpSummaryTotals(output) {
|
|
503
|
+
if (typeof output !== 'string' || !output) return [];
|
|
504
|
+
const lines = stripAnsi(output).split('\n');
|
|
505
|
+
const totals = [];
|
|
506
|
+
lines.forEach((line, i) => {
|
|
507
|
+
if (!MTP_SUMMARY_HEADER.test(line)) return;
|
|
508
|
+
const { total } = mtpSummaryBlock(lines, i + 1);
|
|
509
|
+
if (total !== null) totals.push(total);
|
|
510
|
+
});
|
|
511
|
+
return totals;
|
|
512
|
+
}
|
|
513
|
+
|
|
442
514
|
/** Last value of a `# key N` (TAP) or `ℹ key N` (spec reporter) summary line. */
|
|
443
515
|
function lastSummaryValue(output, key) {
|
|
444
516
|
const re = new RegExp(`^\\s*[#ℹ]\\s*${key}\\s+(\\d+)\\s*$`, 'gim');
|
|
@@ -532,11 +604,12 @@ function parsePlaywrightCount(output) {
|
|
|
532
604
|
/** One extractor per runner. An absent entry is an honest "I cannot read this". */
|
|
533
605
|
const COUNT_EXTRACTORS = {
|
|
534
606
|
dotnet: parseDotnetCount,
|
|
535
|
-
// An eval IS `dotnet test`, so it reads the same summary
|
|
536
|
-
//
|
|
537
|
-
// field of the fixture's summary FILE, which this
|
|
538
|
-
//
|
|
539
|
-
//
|
|
607
|
+
// An eval IS `dotnet test`, so it reads the same summary — but that number
|
|
608
|
+
// counts xunit cases, not scenarios. What an eval node is judged and recorded
|
|
609
|
+
// by is the `scenarios` field of the fixture's summary FILE, which this
|
|
610
|
+
// function cannot see: `judgeEvalRun` replaces this count with it, or with
|
|
611
|
+
// `null` (UNKNOWN, never zero) when the file is unreadable, and `judgeRun`
|
|
612
|
+
// never compares an eval node against `expectedCount` (#110).
|
|
540
613
|
evals: parseDotnetCount,
|
|
541
614
|
'node-test': parseNodeTestCount,
|
|
542
615
|
vitest: parseVitestCount,
|
|
@@ -31,8 +31,8 @@ import { canonicalPath, extractCsprojTargets, isInside } from './test-runner.js'
|
|
|
31
31
|
import { normalizePath } from '../validators/traceability-check.js';
|
|
32
32
|
import { samePathCase } from '../worktree/docker-audit.js';
|
|
33
33
|
import {
|
|
34
|
-
csharpClasses, declaresCaseFilter, dotnetTestMode, dotnetTestProjectOf,
|
|
35
|
-
isDotnetSource, isDotnetTestProject, nearestCsprojOf, nearestProjectOf, readsLikeTestSource,
|
|
34
|
+
csharpClasses, declaresCaseFilter, dotnetTestMode, dotnetTestProjectOf, floorOverride, ignoresExitCode,
|
|
35
|
+
imposesMinimumExpected, isDotnetSource, isDotnetTestProject, nearestCsprojOf, nearestProjectOf, readsLikeTestSource,
|
|
36
36
|
} from './dotnet-test-introspection.js';
|
|
37
37
|
|
|
38
38
|
// Part of this module's API (task B3); implemented beside the rest of the .NET introspection.
|
|
@@ -66,8 +66,9 @@ export { dotnetTestMode, classNamesOf } from './dotnet-test-introspection.js';
|
|
|
66
66
|
* unless narrowed.
|
|
67
67
|
* @property {'count'|'mtp-exit-8'|null} zeroGuard - How a narrowed run proves tests
|
|
68
68
|
* ran: `count` = only a parsed count > 0 proves it (VSTest exits 0 on an empty
|
|
69
|
-
* filter; vitest/jest print a string); `mtp-exit-8` =
|
|
70
|
-
*
|
|
69
|
+
* filter; vitest/jest print a string); `mtp-exit-8` = a parsed count > 0 proves it
|
|
70
|
+
* (the MTP summary, pt-BR/en-US, #115) and, with no readable summary, exit 0 does —
|
|
71
|
+
* MTP exits 8 on zero tests. A parsed 0 is red under both. null unless narrowed.
|
|
71
72
|
*/
|
|
72
73
|
|
|
73
74
|
const NO_TEST_FILE = 'no test file from task outputs in this node — running it whole';
|
|
@@ -75,17 +76,26 @@ const SHAPE = 'command shape not recognised for narrowing — running it whole';
|
|
|
75
76
|
/**
|
|
76
77
|
* Measured (test/fixtures/test-output/mtp-minimum-expected-cli-override-ignored.txt):
|
|
77
78
|
* `--minimum-expected-tests 1` on the CLI does NOT override the project's floor —
|
|
78
|
-
* a green narrowed run still exits 9.
|
|
79
|
+
* a green narrowed run still exits 9. The global `-p:` property does, and since
|
|
80
|
+
* #114 a floor floorOverride can re-pass is lowered to 1 for the recorte; every
|
|
81
|
+
* other floor (VSTest, set elsewhere or conditionally) runs whole with this reason
|
|
79
82
|
* rather than produce a red that no test caused. Wording pinned by the plan.
|
|
80
83
|
*/
|
|
81
84
|
const MIN_EXPECTED = 'projeto impõe --minimum-expected-tests; recorte desligado';
|
|
85
|
+
/** #114 review F6: `-p:` is global to ONE `dotnet test`, so it would replace the arguments of EVERY project the link runs. */
|
|
86
|
+
const FLOOR_SHARED = 'a link runs several test projects and one has a floor — its -p: would replace the others\' arguments too; running it whole';
|
|
87
|
+
/** #114 review F2: the recorte's `-p:` comes last, and the last one wins — the command's own value would be gone. */
|
|
88
|
+
const COMMAND_SETS_ARGS = 'command sets TestingPlatformCommandLineArguments itself — the recorte\'s -p: would replace that value; running it whole';
|
|
82
89
|
const FILTER_TAKEN = 'command already carries a test filter — a second one would replace it; running it whole';
|
|
83
90
|
const CASE_FILTER = 'project declares <VSTestTestCaseFilter> — an appended --filter would replace it; running it whole';
|
|
84
91
|
/**
|
|
85
|
-
* Final review F3: under MTP
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
92
|
+
* Final review F3: under MTP, when the summary is NOT readable (a locale other than
|
|
93
|
+
* pt-BR/en-US, #115), a narrowed run's proof that tests ran is exit 0 (zero tests
|
|
94
|
+
* exit 8). Whatever makes MTP ignore an exit code takes that proof away — zero tests
|
|
95
|
+
* then exit 0 — so the node runs whole, readable summary or not: this is decided
|
|
96
|
+
* BEFORE the run, which is when nobody knows yet whether its summary will read.
|
|
97
|
+
* The COMMAND is read by FLOOR_OR_IGNORE; these two are the project
|
|
98
|
+
* (ignoresExitCode) and the environment.
|
|
89
99
|
*/
|
|
90
100
|
const IGNORE_EXIT_PROJECT = 'project makes MTP ignore an exit code (--ignore-exit-code / TESTINGPLATFORM_EXITCODE_IGNORE in its MSBuild files or testconfig) — exit 0 would no longer prove that tests ran; running it whole';
|
|
91
101
|
const IGNORE_EXIT_ENV = 'TESTINGPLATFORM_EXITCODE_IGNORE is set in the environment — MTP may exit 0 on zero tests, so exit 0 would no longer prove that tests ran; running it whole';
|
|
@@ -319,13 +329,29 @@ function narrowDotnet(command, owned, cwdAbs, rootAbs) {
|
|
|
319
329
|
if (mode === null) return whole('global.json unreadable — cannot tell VSTest from MTP; running it whole');
|
|
320
330
|
|
|
321
331
|
const files = owned.map((abs) => ({ abs, inside: isInside(abs, cwdAbs), ...dotnetTestProjectOf(abs, rootAbs), ...csharpClasses(abs) }));
|
|
322
|
-
// The PROJECT binds the run, in either mode
|
|
323
|
-
// recorte
|
|
324
|
-
//
|
|
332
|
+
// The PROJECT binds the run, in either mode. A floor exits 9 on a green
|
|
333
|
+
// recorte: under MTP it is lowered to 1 by a `-p:` that re-passes the rest of
|
|
334
|
+
// the value when floorOverride can read it from ONE place (#114); otherwise —
|
|
335
|
+
// VSTest (the MTP bridge, not measured), a floor set elsewhere or conditionally
|
|
336
|
+
// — the node runs whole. A declared case filter is REPLACED by an appended
|
|
337
|
+
// --filter (`Category!=Eval` silently gone).
|
|
325
338
|
const projects = [...new Set(files.map((f) => f.csproj))];
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
339
|
+
const floors = new Map();
|
|
340
|
+
for (const project of projects.filter((p) => imposesMinimumExpected(p))) {
|
|
341
|
+
const value = mode === 'mtp' ? floorOverride(project) : null;
|
|
342
|
+
if (value === null) return whole(MIN_EXPECTED);
|
|
343
|
+
if (links.some((l) => /TestingPlatformCommandLineArguments/i.test(l))) return whole(COMMAND_SETS_ARGS);
|
|
344
|
+
floors.set(project, value);
|
|
345
|
+
}
|
|
346
|
+
// `-p:` is global to ONE `dotnet test` invocation: it lowers the floor of the project
|
|
347
|
+
// it was read from and REPLACES the arguments of every other project the link runs.
|
|
348
|
+
const floorArg = (owners, projectCount) => {
|
|
349
|
+
const value = owners.map((c) => floors.get(c)).find((v) => v !== undefined);
|
|
350
|
+
if (value === undefined) return '';
|
|
351
|
+
return projectCount === 1 ? ` "-p:TestingPlatformCommandLineArguments=${value}"` : null;
|
|
352
|
+
};
|
|
353
|
+
if (projects.some((p) => ignoresExitCode(p))) return whole(IGNORE_EXIT_PROJECT);
|
|
354
|
+
if (projects.some((p) => declaresCaseFilter(p))) return whole(CASE_FILTER);
|
|
329
355
|
if (mode === 'mtp') {
|
|
330
356
|
const other = files.find((f) => !MTP_FRAMEWORKS.has(f.framework));
|
|
331
357
|
if (other) return whole(`MTP test project uses a framework with no known class filter (${other.framework ?? 'unknown'}) — running it whole`);
|
|
@@ -367,7 +393,11 @@ function narrowDotnet(command, owned, cwdAbs, rootAbs) {
|
|
|
367
393
|
if (targets.length > 0) {
|
|
368
394
|
const mine = files.filter((f) => targets.some((t) => samePath(t, f.csproj)));
|
|
369
395
|
const classes = classesOf(mine);
|
|
370
|
-
if (classes.length > 0)
|
|
396
|
+
if (classes.length > 0) {
|
|
397
|
+
const floor = floorArg([...new Set(mine.map((f) => f.csproj))], targets.length);
|
|
398
|
+
if (floor === null) return whole(FLOOR_SHARED);
|
|
399
|
+
out.push(`${link} ${selectorArgs(mode, mine[0].framework, classes)}${floor}`);
|
|
400
|
+
}
|
|
371
401
|
continue; // a link whose project owns none of the files is dropped
|
|
372
402
|
}
|
|
373
403
|
const flags = retargetFlags(link, mode, cwdAbs);
|
|
@@ -380,7 +410,7 @@ function narrowDotnet(command, owned, cwdAbs, rootAbs) {
|
|
|
380
410
|
const mine = files.filter((f) => f.csproj === csproj && f.inside);
|
|
381
411
|
const classes = classesOf(mine);
|
|
382
412
|
const target = mode === 'mtp' ? `--project "${rel}"` : `"${rel}"`;
|
|
383
|
-
if (classes.length > 0) out.push(`dotnet test ${target}${flags} ${selectorArgs(mode, mine[0].framework, classes)}`);
|
|
413
|
+
if (classes.length > 0) out.push(`dotnet test ${target}${flags} ${selectorArgs(mode, mine[0].framework, classes)}${floorArg([csproj], 1)}`);
|
|
384
414
|
}
|
|
385
415
|
}
|
|
386
416
|
if (out.length === 0) {
|