@polymorphism-tech/morph-spec 8.36.0 → 8.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/CLAUDE.md +5 -5
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +2 -2
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/docs/CLI.md +46 -23
  8. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  9. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  10. package/framework/skills/core/morph-apply/SKILL.md +1 -1
  11. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  12. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  13. package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
  14. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  15. package/framework/skills/core/morph-media/SKILL.md +1 -1
  16. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  17. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  18. package/framework/skills/core/morph-review/SKILL.md +2 -2
  19. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  20. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  21. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  22. package/framework/templates/dotnet/ai-kit/README.md +12 -7
  23. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +16 -11
  24. package/package.json +1 -1
  25. package/src/commands/validation/verify.js +21 -8
  26. package/src/lib/tasks/dotnet-test-introspection.js +134 -19
  27. package/src/lib/tasks/eval-node.js +3 -3
  28. package/src/lib/tasks/test-runner.js +88 -15
  29. package/src/lib/tasks/test-scope.js +47 -17
  30. package/src/lib/validators/verify-runner.js +46 -15
@@ -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. Task-scope `--filter` is unaffected: that stamp
274
- // already says `scope: 'task:{id}'`, and `taskVerifications` never claimed
275
- // to cover more than the task in the first place. Checked AFTER `--no-persist`
276
- // (via the `!options.noPersist` guard) so an explicit `--no-persist` keeps its
277
- // own reason instead of being reported as 'filtered' — the flags are both
278
- // "don't write", but decidePersistence() already owns the former's reason.
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
- : (!options.noPersist && !taskId && options.filter)
282
- ? { persist: false, reason: 'filtered' }
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
- * 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.
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 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;
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
- return files.some((p) => pattern.test(readText(p) ?? ''));
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 exits 9.
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 imposesMinimumExpected(csprojAbs, rootAbs) {
248
- return msbuildDeclares(csprojAbs, rootAbs, FLOOR_PROPERTY) || testconfigDeclares(csprojAbs, /minimum-?expected-?tests/i);
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 exit 0 (zero tests exit 8);
257
- * with 8 ignored, zero tests exit 0 and the proof is gone — the final review of
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, rootAbs) {
261
- return msbuildDeclares(csprojAbs, rootAbs, IGNORE_EXIT_MARKER)
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, rootAbs) {
275
- return msbuildDeclares(csprojAbs, rootAbs, CASE_FILTER_PROPERTY);
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 summary wins when it is readable; otherwise the caller falls back to the
393
- * `dotnet` extractor, because an eval IS `dotnet test`. **No new extractor is
394
- * invented**, and an unreadable summary yields null, never 0.
392
+ * The ONLY source of an eval node's count. The `dotnet` extractor reads the same
393
+ * run but counts xunit cases — another unit (#110) — so an unreadable summary
394
+ * yields null: UNKNOWN, never 0, and never the extractor's number.
395
395
  *
396
396
  * @param {object|null} summary
397
397
  * @returns {number|null}
@@ -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
- * Sum every `Total:`/`Aprovado:`/`Com falha:` summary `dotnet test` printed.
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
- * project, and reading a single line would report half a suite as the whole of
419
- * it — which, against a recorded baseline, is an invented count drop.
420
- * `countCompletedRuns()` above counts the same lines for a different question
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').filter((l) => /^\s*(?:Aprovado|Passed|Com falha|Failed|Reprovado|Erro)!/i.test(l));
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
- sum.passed += passed ? Number(passed[1]) : 0;
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. No new extractor is
536
- // invented here: the authoritative number for an eval node is the `scenarios`
537
- // field of the fixture's summary FILE, which this function cannot see (it only
538
- // gets the output). `verify-runner` prefers that file and falls back to this —
539
- // and both paths keep `null` meaning UNKNOWN, never zero.
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, ignoresExitCode, imposesMinimumExpected,
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` = MTP exits 8 on zero tests,
70
- * so exit 0 is proof. null unless narrowed.
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. So a project that imposes it runs whole
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 a narrowed run's proof that tests ran is exit 0 (zero
86
- * tests exit 8). Whatever makes MTP ignore an exit code takes that proof away —
87
- * zero tests then exit 0 — so the node runs whole. The COMMAND is read by
88
- * FLOOR_OR_IGNORE; these two are the project (ignoresExitCode) and the environment.
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: a floor exits 9 on a green
323
- // recorte (MTP, or MTP through the VSTest bridge), and a declared case filter
324
- // is REPLACED by an appended --filter (`Category!=Eval` silently gone).
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
- if (projects.some((p) => imposesMinimumExpected(p, rootAbs))) return whole(MIN_EXPECTED);
327
- if (projects.some((p) => ignoresExitCode(p, rootAbs))) return whole(IGNORE_EXIT_PROJECT);
328
- if (projects.some((p) => declaresCaseFilter(p, rootAbs))) return whole(CASE_FILTER);
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) out.push(`${link} ${selectorArgs(mode, mine[0].framework, classes)}`);
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) {
@@ -24,14 +24,14 @@
24
24
  */
25
25
 
26
26
  import { execFileSync } from 'child_process';
27
- import { existsSync, readFileSync } from 'fs';
27
+ import { existsSync, readFileSync, rmSync } from 'fs';
28
28
  import { join, resolve } from 'path';
29
29
 
30
30
  import { runBuildCheck, getBuildSteps } from '../../../framework/hooks/shared/build-check.js';
31
31
  import { runE2eCheck } from '../../../framework/hooks/shared/e2e-check.js';
32
32
  import { buildComposeEnv } from '../e2e/compose-env.js';
33
33
  import {
34
- runTestSuite, parseTestCount, compareTestCount, resolveTestTimeout, isZeroTestRun,
34
+ runTestSuite, parseTestCount, compareTestCount, resolveTestTimeout, isZeroTestRun, mtpSummaryTotals,
35
35
  } from '../tasks/test-runner.js';
36
36
  import {
37
37
  resolveTestNodes, parseTestFilter, nodeMatchesFilter, recordTestBaselines, morphConfigPath,
@@ -56,6 +56,7 @@ const OUTPUT_TAIL_LINES = 20;
56
56
 
57
57
  /** A narrowed run that proved ZERO tests ran (#87) — wording pinned by the plan. */
58
58
  const NARROWED_ZERO_TESTS = 'narrowed-zero-tests: o recorte executou 0 testes';
59
+ const NARROWED_ZERO_TESTS_LINK = 'narrowed-zero-tests: um link do recorte executou 0 testes (bloco MTP com total: 0)';
59
60
 
60
61
  /** A narrowed run that exited green with no count anyone could read (#87): null is UNKNOWN, and unknown is never green. */
61
62
  const NARROWED_COUNT_UNKNOWN = 'narrowed-count-unknown: o recorte saiu verde sem contagem legível — '
@@ -469,14 +470,27 @@ async function runOneTestNode({
469
470
  const evalChannel = node.runner === EVAL_RUNNER
470
471
  ? evalNodeEnv(node, projectPath, { refresh: refreshCache })
471
472
  : null;
473
+ // The summary path is fixed per node id and pid, and nothing else removes the
474
+ // file: one left there by an earlier run (an earlier verify in the same
475
+ // process, a recycled pid) was read as THIS run's, handing a harness that
476
+ // wrote nothing the old scenario count — and hiding an empty run behind it.
477
+ if (evalChannel) discardFile(evalChannel.summaryPath);
472
478
 
473
479
  const start = Date.now();
474
480
  const result = await _runTestSuite(projectPath, {
475
481
  timeout, node, ...(evalChannel ? { env: evalChannel.env } : {}),
476
482
  });
477
483
  const entry = judgeRun({ node, result, durationMs: Date.now() - start });
484
+ if (!evalChannel) return entry;
478
485
 
479
- return evalChannel ? judgeEvalRun(entry, node, evalChannel.summaryPath) : entry;
486
+ const judged = judgeEvalRun(entry, node, evalChannel.summaryPath);
487
+ discardFile(evalChannel.summaryPath);
488
+ return judged;
489
+ }
490
+
491
+ /** Remove a scratch file the CLI owns; a file that is not there (or will not go) changes nothing. */
492
+ function discardFile(path) {
493
+ try { rmSync(path, { force: true }); } catch { /* best effort — the next run removes it first */ }
480
494
  }
481
495
 
482
496
  /**
@@ -484,10 +498,12 @@ async function runOneTestNode({
484
498
  * code said.
485
499
  *
486
500
  * Two things the exit code cannot see:
487
- * - the scenario COUNT, which is what `expectedCount` is compared against.
488
- * The summary wins when readable; otherwise the `dotnet` extractor's number
489
- * stands, because an eval IS `dotnet test`. **null stays UNKNOWN, never 0** —
490
- * an unreadable summary must not manufacture a count drop;
501
+ * - the scenario COUNT, which is what `expectedCount` is compared against and
502
+ * what `--record-baseline` writes. Read ONLY from the summary: the `dotnet`
503
+ * extractor counts xunit cases, another unit — compared with (or recorded as)
504
+ * a scenario baseline it failed green runs as drops (#110, #117 review). No
505
+ * readable summary → **null, UNKNOWN, never 0** — an unreadable summary must
506
+ * not manufacture a count drop;
491
507
  * - a suite that evaluated ZERO scenarios and exited 0. Nothing failed, because
492
508
  * nothing ran: the guardrail-inert shape this repository has already paid to
493
509
  * learn to detect.
@@ -499,8 +515,7 @@ function judgeEvalRun(entry, node, summaryPath) {
499
515
  const cold = evalColdCacheWarning(summary);
500
516
  if (cold) warnings.push(cold);
501
517
 
502
- const fromSummary = evalScenarioCount(summary);
503
- const count = fromSummary ?? entry.count;
518
+ const count = evalScenarioCount(summary);
504
519
  const verdict = compareTestCount(count, node.expectedCount);
505
520
  if (verdict.warning) warnings.push(verdict.warning);
506
521
 
@@ -645,7 +660,12 @@ function judgeRun({ node, result, durationMs }) {
645
660
  return judgeNarrowedRun({ entry, node, result, durationMs, count });
646
661
  }
647
662
 
648
- const verdict = compareTestCount(count, node.expectedCount);
663
+ // An eval node's unit is the SCENARIO (#110 part 2): its baseline is the
664
+ // summary's scenario count, and `judgeEvalRun` is the one that compares it.
665
+ // Comparing here too, with the extractor's count of xunit cases, failed a
666
+ // 24/24 run as "esperado 24, executado 2" — and `judgeEvalRun` only turns a
667
+ // pass into a fail, never the reverse.
668
+ const verdict = compareTestCount(count, node.runner === EVAL_RUNNER ? null : node.expectedCount);
649
669
  const warnings = verdict.warning ? [verdict.warning] : [];
650
670
 
651
671
  // A drop is the one count verdict that fails a node by itself: tests that
@@ -689,16 +709,24 @@ function judgeRun({ node, result, durationMs }) {
689
709
  * must never buy. What counts as proof is the selection's `zeroGuard`
690
710
  * (test-scope.js):
691
711
  * `count` only a parsed count > 0. **null is UNKNOWN, never green**;
692
- * `mtp-exit-8` MTP exits 8 on zero tests, so exit 0 IS the proof.
693
- * A parsed 0 is refused under either guard: it is positive evidence.
712
+ * `mtp-exit-8` a parsed count > 0 proves it (the MTP summary is read in
713
+ * pt-BR and en-US, #115); with no readable summary (another
714
+ * locale), exit 0 does — MTP exits 8 on zero tests.
715
+ * A parsed 0 is refused under either guard: it is positive evidence. It is
716
+ * also what closes limit (f): with exit 8 IGNORED, MTP exits 0 over a
717
+ * summary that says `total: 0` (measured, mtp-ignore-exit-code-8-zero-tests.txt).
718
+ * In a narrowed `&&` chain the SUM would hide that link behind another's
719
+ * count, so under `mtp-exit-8` every MTP block is checked on its own — MTP
720
+ * prints one block per invocation (measured), and a `total: 0` block is red.
694
721
  *
695
722
  * A FAILED run is renamed `narrowed-zero-tests (…)` only when it carries the
696
723
  * runner's zero-test signature AND no count proves tests ran. A multi-project
697
724
  * VSTest output may print "No test matches…" for one project while another
698
725
  * ran and failed; that failure is the lead, never a zero. Anything else,
699
- * exit 9 included, keeps the executor's own raw reason: projects that impose
700
- * `--minimum-expected-tests` are never narrowed, so a 9 here is not ours to
701
- * reinterpret.
726
+ * exit 9 included, keeps the executor's own raw reason: a project that imposes
727
+ * `--minimum-expected-tests` is narrowed only with its floor lowered to 1 by
728
+ * `-p:` (#114, test-scope.js), where a run with tests cannot exit 9 — so a 9
729
+ * here is not ours to reinterpret.
702
730
  *
703
731
  * Every red leads its output with the verdict, like a timeout does: the
704
732
  * feedback line reads `output` before `reason`, and a tail alone would bury it.
@@ -712,6 +740,9 @@ function judgeNarrowedRun({ entry, node, result, durationMs, count }) {
712
740
 
713
741
  if (result.passed) {
714
742
  if (count === 0) return red(NARROWED_ZERO_TESTS);
743
+ if (entry.selection.zeroGuard === 'mtp-exit-8' && mtpSummaryTotals(result.output).includes(0)) {
744
+ return red(NARROWED_ZERO_TESTS_LINK);
745
+ }
715
746
  if (ranTests) return green();
716
747
  if (entry.selection.zeroGuard === 'mtp-exit-8' && result.exitCode === 0) return green();
717
748
  return red(NARROWED_COUNT_UNKNOWN);