@polymorphism-tech/morph-spec 8.35.1 → 8.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +10 -8
- package/README.md +2 -2
- package/bin/morph-spec.js +4 -3
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/MORPH.md +24 -23
- package/framework/docs/CLI.md +212 -15
- package/framework/evals/proof-rubrics/eval-de-modelo.md +16 -0
- package/framework/evals/rubrics/condensed.md +3 -1
- package/framework/evals/rubrics/test-coverage.md +8 -0
- package/framework/hooks/shared/phase-utils.js +1 -0
- package/framework/phases.json +1 -1
- package/framework/rules/morph-workflow.md +1 -1
- package/framework/schemas/config.schema.json +7 -0
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +21 -10
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
- package/framework/skills/core/morph-implement/SKILL.md +20 -3
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +41 -3
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +11 -2
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/STANDARDS.json +2 -2
- package/framework/standards/ai-agents/prompt-sources.md +4 -3
- package/framework/standards/ai-agents/testing-ai.md +14 -13
- package/framework/templates/dotnet/ai-kit/README.md +22 -11
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +23 -10
- package/package.json +1 -1
- package/src/commands/project/doctor.js +51 -2
- package/src/commands/tasks/dag.js +13 -1
- package/src/commands/validation/verify.js +59 -17
- package/src/core/paths/output-schema.js +7 -0
- package/src/lib/detectors/index.js +225 -217
- package/src/lib/risk-detector.js +38 -18
- package/src/lib/specs/done-criteria-check.js +200 -0
- package/src/lib/specs/usecase-check.js +129 -5
- package/src/lib/tasks/dotnet-test-introspection.js +632 -0
- package/src/lib/tasks/eval-node.js +3 -3
- package/src/lib/tasks/test-nodes.js +190 -20
- package/src/lib/tasks/test-runner.js +210 -17
- package/src/lib/tasks/test-scope.js +597 -0
- package/src/lib/validators/traceability-check.js +9 -2
- package/src/lib/validators/verify-runner.js +190 -27
- package/src/lib/worktree/docker-audit.js +5 -1
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* - runBuildCheck() from framework/hooks/shared/build-check.js
|
|
16
16
|
* - runTestSuite() from src/lib/tasks/test-runner.js (one node)
|
|
17
17
|
* - resolveTestNodes() from src/lib/tasks/test-nodes.js (the LIST of suites)
|
|
18
|
+
* - applyTaskScope() from src/lib/tasks/test-scope.js (the task-scoped cut, #87)
|
|
18
19
|
* - runValidation() from ./validation-runner.js
|
|
19
20
|
* - runE2eCheck() from framework/hooks/shared/e2e-check.js
|
|
20
21
|
* - loadRubric() + aggregateScores() from ./score-aggregator.js
|
|
@@ -23,16 +24,19 @@
|
|
|
23
24
|
*/
|
|
24
25
|
|
|
25
26
|
import { execFileSync } from 'child_process';
|
|
26
|
-
import { existsSync, readFileSync } from 'fs';
|
|
27
|
+
import { existsSync, readFileSync, rmSync } from 'fs';
|
|
27
28
|
import { join, resolve } from 'path';
|
|
28
29
|
|
|
29
30
|
import { runBuildCheck, getBuildSteps } from '../../../framework/hooks/shared/build-check.js';
|
|
30
31
|
import { runE2eCheck } from '../../../framework/hooks/shared/e2e-check.js';
|
|
31
32
|
import { buildComposeEnv } from '../e2e/compose-env.js';
|
|
32
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
runTestSuite, parseTestCount, compareTestCount, resolveTestTimeout, isZeroTestRun, mtpSummaryTotals,
|
|
35
|
+
} from '../tasks/test-runner.js';
|
|
33
36
|
import {
|
|
34
37
|
resolveTestNodes, parseTestFilter, nodeMatchesFilter, recordTestBaselines, morphConfigPath,
|
|
35
38
|
} from '../tasks/test-nodes.js';
|
|
39
|
+
import { applyTaskScope } from '../tasks/test-scope.js';
|
|
36
40
|
import { runLinuxSuite, checkLinuxPreconditions } from '../tasks/linux-runner.js';
|
|
37
41
|
import {
|
|
38
42
|
EVAL_RUNNER, evalPreflight, evalNodeEnv, readEvalSummary, evalScenarioCount,
|
|
@@ -50,6 +54,14 @@ const DEFAULT_BUILD_TIMEOUT = 120_000;
|
|
|
50
54
|
/** Number of trailing output lines kept for feedback/reporting. */
|
|
51
55
|
const OUTPUT_TAIL_LINES = 20;
|
|
52
56
|
|
|
57
|
+
/** A narrowed run that proved ZERO tests ran (#87) — wording pinned by the plan. */
|
|
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)';
|
|
60
|
+
|
|
61
|
+
/** A narrowed run that exited green with no count anyone could read (#87): null is UNKNOWN, and unknown is never green. */
|
|
62
|
+
const NARROWED_COUNT_UNKNOWN = 'narrowed-count-unknown: o recorte saiu verde sem contagem legível — '
|
|
63
|
+
+ 'nada prova que algum teste rodou (desconhecido nunca vira verde)';
|
|
64
|
+
|
|
53
65
|
/**
|
|
54
66
|
* The caller asked for something impossible — not a red verification, a wrong
|
|
55
67
|
* invocation. The distinction is the exit code: `verify` answers 1 for "the
|
|
@@ -86,6 +98,8 @@ export class VerifyUsageError extends Error {
|
|
|
86
98
|
* @param {boolean} [options.linux] - Run the test nodes inside a Linux container, on a COPY
|
|
87
99
|
* of the tree (see linux-runner.js for why a bind mount would prove nothing)
|
|
88
100
|
* @param {string} [options.linuxImage] - Image override for --linux
|
|
101
|
+
* @param {boolean} [options.full] - Task scope only: switch the recorte off and run every
|
|
102
|
+
* node whole (#87). A documented no-op at feature scope, which never narrows anything.
|
|
89
103
|
* @param {Function} [options._runBuildCheck] - Injectable override for runBuildCheck (testing)
|
|
90
104
|
* @param {Function} [options._runTestSuite] - Injectable override for runTestSuite (testing)
|
|
91
105
|
* @param {Function} [options._runValidation] - Injectable override for runValidation (testing)
|
|
@@ -105,6 +119,7 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
|
|
|
105
119
|
refreshCache = false,
|
|
106
120
|
linux = false,
|
|
107
121
|
linuxImage = null,
|
|
122
|
+
full = false,
|
|
108
123
|
_runLinuxSuite = runLinuxSuite,
|
|
109
124
|
_exec = undefined,
|
|
110
125
|
_runBuildCheck = runBuildCheck,
|
|
@@ -130,13 +145,18 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
|
|
|
130
145
|
);
|
|
131
146
|
}
|
|
132
147
|
|
|
148
|
+
// The task is read ONCE: its group picks the rubric and the e2e
|
|
149
|
+
// applicability and, with its outputs, the test scope (#87). One reader of
|
|
150
|
+
// tasks.json for verify, so the three can never disagree about the task.
|
|
151
|
+
const task = taskId ? readTask(projectPath, featureName, taskId) : null;
|
|
152
|
+
|
|
133
153
|
// `--record-baseline` is judged in the same breath, and for the same reason:
|
|
134
154
|
// discovering that there is nowhere to write AFTER running the whole fleet
|
|
135
155
|
// would charge the user a full suite for a usage error. `config.json` is
|
|
136
156
|
// never created here — this repository deliberately has none, and inventing
|
|
137
157
|
// one would break `doctor --full`.
|
|
138
158
|
if (recordBaseline) {
|
|
139
|
-
const problem = baselineTargetProblem(projectPath, testPlan, { skipTests });
|
|
159
|
+
const problem = baselineTargetProblem(projectPath, testPlan, { skipTests, taskId });
|
|
140
160
|
if (problem) throw new VerifyUsageError(problem);
|
|
141
161
|
}
|
|
142
162
|
|
|
@@ -175,6 +195,16 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
|
|
|
175
195
|
}
|
|
176
196
|
}
|
|
177
197
|
|
|
198
|
+
// #87: at task scope the task's group picks the nodes and its outputs narrow
|
|
199
|
+
// the command inside each; test-scope.js DECIDES, this runner executes. The
|
|
200
|
+
// scoping happens ONCE, here, before runTestsNode, so the host loop and the
|
|
201
|
+
// `--linux` container receive the SAME scoped nodes (the container script is
|
|
202
|
+
// written from `node.command`) and preDecideRun/judgeRun stay the single copy
|
|
203
|
+
// of each rule for both. `--filter` was judged above on the ORIGINAL ids: a
|
|
204
|
+
// typo is a typo whatever the task. Feature scope gets the nodes back
|
|
205
|
+
// unchanged, because Gate 3 always runs every suite, whole.
|
|
206
|
+
const scoped = applyTaskScope({ projectPath, nodes: testPlan.nodes, task, taskId, filter, full });
|
|
207
|
+
|
|
178
208
|
const nodes = {};
|
|
179
209
|
const feedback = [];
|
|
180
210
|
|
|
@@ -190,7 +220,7 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
|
|
|
190
220
|
const explicitTimeout = Number.isFinite(options.timeout) ? options.timeout : undefined;
|
|
191
221
|
nodes.tests = await runTestsNode({
|
|
192
222
|
projectPath,
|
|
193
|
-
testPlan,
|
|
223
|
+
testPlan: { ...testPlan, nodes: scoped.nodes },
|
|
194
224
|
filter,
|
|
195
225
|
skipTests,
|
|
196
226
|
buildNode: nodes.build,
|
|
@@ -203,10 +233,14 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
|
|
|
203
233
|
_runLinuxSuite,
|
|
204
234
|
_exec,
|
|
205
235
|
});
|
|
236
|
+
// How the nodes were chosen, for whoever reads --json. It lives on the rollup
|
|
237
|
+
// and nowhere else: nodeStatuses() (verify.js) persists status strings only,
|
|
238
|
+
// so no part of the selection ever reaches feature.json.
|
|
239
|
+
nodes.tests.scope = scoped.summary;
|
|
206
240
|
appendTestsFeedback(feedback, nodes.tests);
|
|
207
241
|
|
|
208
242
|
// 3. validators — ALWAYS runs, even on a red build (free static feedback).
|
|
209
|
-
const rubricId = resolveRubricId({
|
|
243
|
+
const rubricId = resolveRubricId({ task, rubricOverride, projectPath });
|
|
210
244
|
nodes.validators = await runValidatorsNode({ projectPath, featureName, rubricId, _runValidation });
|
|
211
245
|
appendValidatorsFeedback(feedback, nodes.validators);
|
|
212
246
|
|
|
@@ -217,6 +251,7 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
|
|
|
217
251
|
projectPath,
|
|
218
252
|
featureName,
|
|
219
253
|
taskId,
|
|
254
|
+
task,
|
|
220
255
|
skipE2e,
|
|
221
256
|
freshE2e,
|
|
222
257
|
buildNode: nodes.build,
|
|
@@ -356,7 +391,8 @@ async function runTestsNode({
|
|
|
356
391
|
* Run the executable nodes inside ONE Linux container and judge them with the
|
|
357
392
|
* SAME rules the local path uses.
|
|
358
393
|
*
|
|
359
|
-
* The pre-decisions (invalid config, `--filter`,
|
|
394
|
+
* The pre-decisions (invalid config, `--filter`, a node outside the task
|
|
395
|
+
* scope, an e2e node at task scope)
|
|
360
396
|
* happen HERE, before the container exists: a node that must not run must not
|
|
361
397
|
* be written into the container script either, or the flag would quietly run
|
|
362
398
|
* what the user filtered out. What the container returns — an exit code and an
|
|
@@ -434,14 +470,27 @@ async function runOneTestNode({
|
|
|
434
470
|
const evalChannel = node.runner === EVAL_RUNNER
|
|
435
471
|
? evalNodeEnv(node, projectPath, { refresh: refreshCache })
|
|
436
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);
|
|
437
478
|
|
|
438
479
|
const start = Date.now();
|
|
439
480
|
const result = await _runTestSuite(projectPath, {
|
|
440
481
|
timeout, node, ...(evalChannel ? { env: evalChannel.env } : {}),
|
|
441
482
|
});
|
|
442
483
|
const entry = judgeRun({ node, result, durationMs: Date.now() - start });
|
|
484
|
+
if (!evalChannel) return entry;
|
|
443
485
|
|
|
444
|
-
|
|
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 */ }
|
|
445
494
|
}
|
|
446
495
|
|
|
447
496
|
/**
|
|
@@ -449,10 +498,12 @@ async function runOneTestNode({
|
|
|
449
498
|
* code said.
|
|
450
499
|
*
|
|
451
500
|
* Two things the exit code cannot see:
|
|
452
|
-
* - the scenario COUNT, which is what `expectedCount` is compared against
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
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;
|
|
456
507
|
* - a suite that evaluated ZERO scenarios and exited 0. Nothing failed, because
|
|
457
508
|
* nothing ran: the guardrail-inert shape this repository has already paid to
|
|
458
509
|
* learn to detect.
|
|
@@ -464,8 +515,7 @@ function judgeEvalRun(entry, node, summaryPath) {
|
|
|
464
515
|
const cold = evalColdCacheWarning(summary);
|
|
465
516
|
if (cold) warnings.push(cold);
|
|
466
517
|
|
|
467
|
-
const
|
|
468
|
-
const count = fromSummary ?? entry.count;
|
|
518
|
+
const count = evalScenarioCount(summary);
|
|
469
519
|
const verdict = compareTestCount(count, node.expectedCount);
|
|
470
520
|
if (verdict.warning) warnings.push(verdict.warning);
|
|
471
521
|
|
|
@@ -508,6 +558,11 @@ function emptyRunEntry(node) {
|
|
|
508
558
|
reason: null,
|
|
509
559
|
warnings: [],
|
|
510
560
|
output: '',
|
|
561
|
+
// What the task-scope recorte did to this node (#87): `command` above is
|
|
562
|
+
// what actually ran, `selection.declaredCommand` what was declared. null at
|
|
563
|
+
// feature scope, where applyTaskScope leaves the key ABSENT; absent is read
|
|
564
|
+
// as null here so no reader has to tell the two apart.
|
|
565
|
+
selection: node.selection ?? null,
|
|
511
566
|
};
|
|
512
567
|
}
|
|
513
568
|
|
|
@@ -537,6 +592,14 @@ function preDecideRun({ node, filter, taskId, projectPath = '.', refreshCache =
|
|
|
537
592
|
return { ...entry, reason: `filtered out (--filter ${filter.raw})` };
|
|
538
593
|
}
|
|
539
594
|
|
|
595
|
+
// The task's group scoped this node out (#87). AFTER the filter on purpose:
|
|
596
|
+
// a node the user filtered out reports the filter, never a scope it was not
|
|
597
|
+
// judged by. BEFORE the e2e and eval checks: a node outside the task scope
|
|
598
|
+
// has nothing left to decide.
|
|
599
|
+
if (node.selection?.mode === 'out-of-scope') {
|
|
600
|
+
return { ...entry, reason: node.selection.reason };
|
|
601
|
+
}
|
|
602
|
+
|
|
540
603
|
if (node.e2e && taskId) {
|
|
541
604
|
return { ...entry, reason: 'e2e node runs at feature scope only' };
|
|
542
605
|
}
|
|
@@ -590,7 +653,19 @@ function judgeRun({ node, result, durationMs }) {
|
|
|
590
653
|
// is chosen for the failures, not for the summary.
|
|
591
654
|
const parsed = parseTestCount(result.output, node.runner);
|
|
592
655
|
const count = parsed ? parsed.total : null;
|
|
593
|
-
|
|
656
|
+
|
|
657
|
+
// A narrowed run is PARTIAL by construction (#87): it has a verdict of its
|
|
658
|
+
// own, and the count rule below never sees it.
|
|
659
|
+
if (entry.selection?.mode === 'narrowed') {
|
|
660
|
+
return judgeNarrowedRun({ entry, node, result, durationMs, count });
|
|
661
|
+
}
|
|
662
|
+
|
|
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);
|
|
594
669
|
const warnings = verdict.warning ? [verdict.warning] : [];
|
|
595
670
|
|
|
596
671
|
// A drop is the one count verdict that fails a node by itself: tests that
|
|
@@ -618,6 +693,68 @@ function judgeRun({ node, result, durationMs }) {
|
|
|
618
693
|
};
|
|
619
694
|
}
|
|
620
695
|
|
|
696
|
+
/**
|
|
697
|
+
* The verdict of a NARROWED run (#87): what `judgeRun` hands over instead of
|
|
698
|
+
* its count rule. Reached from the host loop and from `--linux` alike, because
|
|
699
|
+
* both go through `judgeRun`.
|
|
700
|
+
*
|
|
701
|
+
* Two things change, and both follow from "partial by construction":
|
|
702
|
+
*
|
|
703
|
+
* - NO `compareTestCount`. `expectedCount` is the WHOLE suite's baseline: a
|
|
704
|
+
* recorte running 12 of 3.082 is not a drop, and a superset selection
|
|
705
|
+
* running more than the baseline is not a rise. Either comparison would
|
|
706
|
+
* report something about the suite that this run never measured.
|
|
707
|
+
* - a pass needs PROOF that tests ran. A filter that matches nothing exits 0
|
|
708
|
+
* under VSTest, and "zero tests, all green" is exactly the verdict issue #87
|
|
709
|
+
* must never buy. What counts as proof is the selection's `zeroGuard`
|
|
710
|
+
* (test-scope.js):
|
|
711
|
+
* `count` only a parsed count > 0. **null is UNKNOWN, never green**;
|
|
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.
|
|
721
|
+
*
|
|
722
|
+
* A FAILED run is renamed `narrowed-zero-tests (…)` only when it carries the
|
|
723
|
+
* runner's zero-test signature AND no count proves tests ran. A multi-project
|
|
724
|
+
* VSTest output may print "No test matches…" for one project while another
|
|
725
|
+
* ran and failed; that failure is the lead, never a zero. Anything else,
|
|
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.
|
|
730
|
+
*
|
|
731
|
+
* Every red leads its output with the verdict, like a timeout does: the
|
|
732
|
+
* feedback line reads `output` before `reason`, and a tail alone would bury it.
|
|
733
|
+
*/
|
|
734
|
+
function judgeNarrowedRun({ entry, node, result, durationMs, count }) {
|
|
735
|
+
const tail = tailLines(result.output, OUTPUT_TAIL_LINES);
|
|
736
|
+
const base = { ...entry, durationMs, count, warnings: [] };
|
|
737
|
+
const red = (reason) => ({ ...base, status: 'fail', reason, output: tail ? `${reason}\n${tail}` : reason });
|
|
738
|
+
const green = () => ({ ...base, status: 'pass', reason: result.reason ?? null, output: '' });
|
|
739
|
+
const ranTests = Number.isInteger(count) && count > 0;
|
|
740
|
+
|
|
741
|
+
if (result.passed) {
|
|
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
|
+
}
|
|
746
|
+
if (ranTests) return green();
|
|
747
|
+
if (entry.selection.zeroGuard === 'mtp-exit-8' && result.exitCode === 0) return green();
|
|
748
|
+
return red(NARROWED_COUNT_UNKNOWN);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (!ranTests && isZeroTestRun(result.output, node.runner, result.exitCode)) {
|
|
752
|
+
return red(`narrowed-zero-tests (${node.runner}, exit ${result.exitCode ?? 'desconhecido'}): o recorte executou 0 testes`);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
return { ...base, status: 'fail', reason: result.reason ?? null, output: tail };
|
|
756
|
+
}
|
|
757
|
+
|
|
621
758
|
/**
|
|
622
759
|
* Aggregate the per-node runs into the single node `verify` publishes.
|
|
623
760
|
*
|
|
@@ -747,12 +884,22 @@ function refreshCacheProblem(projectPath, testPlan, filter, { skipTests }) {
|
|
|
747
884
|
* never created — the framework repository has none on purpose (creating it
|
|
748
885
|
* breaks `doctor --full`), so the honest answer is a named usage error.
|
|
749
886
|
*
|
|
887
|
+
* A TASK id is refused first (#87): at task scope the runner narrows the nodes
|
|
888
|
+
* AND the commands, so what it measures is a recorte, while `expectedCount` is
|
|
889
|
+
* the whole fleet's number. `recordTestBaselines` refuses a measurement that
|
|
890
|
+
* carries a selection (the second barrier), but paying for the recorte to
|
|
891
|
+
* learn that is the ordering this function exists to prevent.
|
|
892
|
+
*
|
|
750
893
|
* @param {string} projectPath
|
|
751
894
|
* @param {{ nodes: Array, source: string }} testPlan
|
|
752
|
-
* @param {{ skipTests: boolean }} opts
|
|
895
|
+
* @param {{ skipTests: boolean, taskId?: string|null }} opts
|
|
753
896
|
* @returns {string|null}
|
|
754
897
|
*/
|
|
755
|
-
function baselineTargetProblem(projectPath, testPlan, { skipTests }) {
|
|
898
|
+
function baselineTargetProblem(projectPath, testPlan, { skipTests, taskId = null }) {
|
|
899
|
+
if (taskId) {
|
|
900
|
+
return `--record-baseline é manutenção da frota: rode sem {task} (recebeu "${taskId}") — `
|
|
901
|
+
+ 'no escopo de task o verify recorta nós e testes, e baseline de recorte não é baseline';
|
|
902
|
+
}
|
|
756
903
|
if (skipTests) {
|
|
757
904
|
return '--record-baseline com --skip-tests não mede nada — uma baseline vem de uma execução, não de uma omissão';
|
|
758
905
|
}
|
|
@@ -796,9 +943,11 @@ function recordBaselineFrom(projectPath, testsNode) {
|
|
|
796
943
|
);
|
|
797
944
|
}
|
|
798
945
|
|
|
946
|
+
// The selection travels with the count so the WRITER can refuse a recorte
|
|
947
|
+
// (#87) — the second barrier behind baselineTargetProblem's usage error.
|
|
799
948
|
return recordTestBaselines(
|
|
800
949
|
projectPath,
|
|
801
|
-
runs.map((r) => ({ id: r.id, count: r.count }))
|
|
950
|
+
runs.map((r) => ({ id: r.id, count: r.count, selection: r.selection ?? null }))
|
|
802
951
|
);
|
|
803
952
|
}
|
|
804
953
|
|
|
@@ -867,7 +1016,7 @@ function appendValidatorsFeedback(feedback, validatorsNode) {
|
|
|
867
1016
|
// E2E node
|
|
868
1017
|
// ============================================================================
|
|
869
1018
|
|
|
870
|
-
async function runE2eNode({ projectPath, featureName, taskId, skipE2e, freshE2e, buildNode, _runE2eCheck }) {
|
|
1019
|
+
async function runE2eNode({ projectPath, featureName, taskId, task = null, skipE2e, freshE2e, buildNode, _runE2eCheck }) {
|
|
871
1020
|
if (buildNode.status === 'fail') {
|
|
872
1021
|
return {
|
|
873
1022
|
status: 'skip',
|
|
@@ -879,7 +1028,7 @@ async function runE2eNode({ projectPath, featureName, taskId, skipE2e, freshE2e,
|
|
|
879
1028
|
}
|
|
880
1029
|
|
|
881
1030
|
const config = readConfig(projectPath);
|
|
882
|
-
const taskGroup =
|
|
1031
|
+
const taskGroup = task?.group ?? null;
|
|
883
1032
|
const gitCommonDir = resolveGitCommonDir(projectPath);
|
|
884
1033
|
|
|
885
1034
|
const start = Date.now();
|
|
@@ -931,14 +1080,11 @@ function appendE2eFeedback(feedback, e2eNode) {
|
|
|
931
1080
|
// Rubric selection — flag > task group > config > default
|
|
932
1081
|
// ============================================================================
|
|
933
1082
|
|
|
934
|
-
function resolveRubricId({ projectPath,
|
|
1083
|
+
function resolveRubricId({ projectPath, task, rubricOverride }) {
|
|
935
1084
|
if (rubricOverride) return rubricOverride;
|
|
936
1085
|
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
const mapped = mapGroupToRubric(group);
|
|
940
|
-
if (mapped) return mapped;
|
|
941
|
-
}
|
|
1086
|
+
const mapped = mapGroupToRubric(task?.group);
|
|
1087
|
+
if (mapped) return mapped;
|
|
942
1088
|
|
|
943
1089
|
const configRubric = readConfigRubric(projectPath);
|
|
944
1090
|
if (configRubric) return configRubric;
|
|
@@ -959,13 +1105,30 @@ function mapGroupToRubric(group) {
|
|
|
959
1105
|
return null;
|
|
960
1106
|
}
|
|
961
1107
|
|
|
962
|
-
|
|
1108
|
+
/**
|
|
1109
|
+
* The task as verify needs it, or null when tasks.json or the task is missing.
|
|
1110
|
+
*
|
|
1111
|
+
* The ONE reader of tasks.json for verify (#87): the group picks the rubric and
|
|
1112
|
+
* the e2e applicability, group + outputs pick the test scope, and the command
|
|
1113
|
+
* uses the null to refuse an unknown task id. Two readers is how a rubric and
|
|
1114
|
+
* a scope start disagreeing about the same task.
|
|
1115
|
+
*
|
|
1116
|
+
* @param {string} projectPath
|
|
1117
|
+
* @param {string} featureName
|
|
1118
|
+
* @param {string} taskId
|
|
1119
|
+
* @returns {{ group: string|null, outputs: string[] }|null}
|
|
1120
|
+
*/
|
|
1121
|
+
export function readTask(projectPath, featureName, taskId) {
|
|
963
1122
|
try {
|
|
964
1123
|
const tasksPath = getAbsoluteOutputPath(projectPath, featureName, 'tasks');
|
|
965
1124
|
if (!existsSync(tasksPath)) return null;
|
|
966
1125
|
const tasksJson = JSON.parse(readFileSync(tasksPath, 'utf8'));
|
|
967
1126
|
const task = (tasksJson.tasks || []).find((t) => t.id === taskId);
|
|
968
|
-
|
|
1127
|
+
if (!task) return null;
|
|
1128
|
+
return {
|
|
1129
|
+
group: task.group ?? null,
|
|
1130
|
+
outputs: Array.isArray(task.outputs) ? task.outputs : [],
|
|
1131
|
+
};
|
|
969
1132
|
} catch {
|
|
970
1133
|
return null;
|
|
971
1134
|
}
|
|
@@ -72,8 +72,12 @@ function resolveGitCommonDir(cwd) {
|
|
|
72
72
|
* Path comparison that matches the host's own rules. Windows paths differ in
|
|
73
73
|
* drive-letter case between what git reports and what docker echoes back;
|
|
74
74
|
* treating those as different paths would make every stack look foreign.
|
|
75
|
+
*
|
|
76
|
+
* Exported as the ONE copy of that rule: `doctor.js` compares a declared test
|
|
77
|
+
* node `cwd` with a discovered `package.json` folder through it (#87 F5), and
|
|
78
|
+
* `test-scope.js` takes only the CASE of a canonical path through it (#87 N3).
|
|
75
79
|
*/
|
|
76
|
-
const samePathCase = process.platform === 'win32' ? (s) => s.toLowerCase() : (s) => s;
|
|
80
|
+
export const samePathCase = process.platform === 'win32' ? (s) => s.toLowerCase() : (s) => s;
|
|
77
81
|
const norm = (p) => samePathCase(resolve(String(p)).replace(/\\/g, '/'));
|
|
78
82
|
|
|
79
83
|
/**
|