@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.
Files changed (32) 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 +47 -24
  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/standards/ai-agents/rag-custom-pgvector.md +7 -3
  23. package/framework/templates/dotnet/ai-kit/README.md +12 -7
  24. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaPlan.cs +12 -6
  25. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +16 -11
  26. package/package.json +1 -1
  27. package/src/commands/validation/verify.js +21 -8
  28. package/src/lib/tasks/dotnet-test-introspection.js +134 -19
  29. package/src/lib/tasks/eval-node.js +3 -3
  30. package/src/lib/tasks/test-runner.js +88 -15
  31. package/src/lib/tasks/test-scope.js +47 -17
  32. 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}`): com
34
- este piso, o no de teste deste projeto roda INTEIRO. A mesma opcao com valor
35
- 1 na linha de comando NAO sobrescreve o valor do projeto (medido: o recorte
36
- verde sai 9 do mesmo jeito), entao o loop por task nao estreita este projeto.
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 do framework
51
- escrito para VSTest (que procure uma linha COMECANDO em
52
- "Aprovado!"/"Passed!" e contendo "Total de testes:"/"Total:") nao consegue
53
- contar os testes deste projeto. O conserto do parser e da feature
54
- `verify-por-stack`; aqui isto e registro da fronteira, nao conserto — o
55
- piso acima ja garante que uma descoberta parcial saia com codigo 9, que e
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polymorphism-tech/morph-spec",
3
- "version": "8.36.0",
3
+ "version": "8.37.1",
4
4
  "description": "MORPH-SPEC: NLH (Natural Language Harness) for spec-driven development with Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -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) {