@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.
Files changed (51) hide show
  1. package/CLAUDE.md +10 -8
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +4 -3
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/MORPH.md +24 -23
  8. package/framework/docs/CLI.md +212 -15
  9. package/framework/evals/proof-rubrics/eval-de-modelo.md +16 -0
  10. package/framework/evals/rubrics/condensed.md +3 -1
  11. package/framework/evals/rubrics/test-coverage.md +8 -0
  12. package/framework/hooks/shared/phase-utils.js +1 -0
  13. package/framework/phases.json +1 -1
  14. package/framework/rules/morph-workflow.md +1 -1
  15. package/framework/schemas/config.schema.json +7 -0
  16. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  17. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  18. package/framework/skills/core/morph-apply/SKILL.md +21 -10
  19. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  20. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  21. package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
  22. package/framework/skills/core/morph-implement/SKILL.md +20 -3
  23. package/framework/skills/core/morph-media/SKILL.md +1 -1
  24. package/framework/skills/core/morph-plan/SKILL.md +41 -3
  25. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  26. package/framework/skills/core/morph-review/SKILL.md +11 -2
  27. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  28. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  29. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  30. package/framework/standards/STANDARDS.json +2 -2
  31. package/framework/standards/ai-agents/prompt-sources.md +4 -3
  32. package/framework/standards/ai-agents/testing-ai.md +14 -13
  33. package/framework/templates/dotnet/ai-kit/README.md +22 -11
  34. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +23 -10
  35. package/package.json +1 -1
  36. package/src/commands/project/doctor.js +51 -2
  37. package/src/commands/tasks/dag.js +13 -1
  38. package/src/commands/validation/verify.js +59 -17
  39. package/src/core/paths/output-schema.js +7 -0
  40. package/src/lib/detectors/index.js +225 -217
  41. package/src/lib/risk-detector.js +38 -18
  42. package/src/lib/specs/done-criteria-check.js +200 -0
  43. package/src/lib/specs/usecase-check.js +129 -5
  44. package/src/lib/tasks/dotnet-test-introspection.js +632 -0
  45. package/src/lib/tasks/eval-node.js +3 -3
  46. package/src/lib/tasks/test-nodes.js +190 -20
  47. package/src/lib/tasks/test-runner.js +210 -17
  48. package/src/lib/tasks/test-scope.js +597 -0
  49. package/src/lib/validators/traceability-check.js +9 -2
  50. package/src/lib/validators/verify-runner.js +190 -27
  51. 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 { runTestSuite, parseTestCount, compareTestCount, resolveTestTimeout } from '../tasks/test-runner.js';
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({ projectPath, featureName, taskId, rubricOverride });
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`, an e2e node at task scope)
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
- 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 */ }
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
- * The summary wins when readable; otherwise the `dotnet` extractor's number
454
- * stands, because an eval IS `dotnet test`. **null stays UNKNOWN, never 0** —
455
- * 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;
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 fromSummary = evalScenarioCount(summary);
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
- const verdict = compareTestCount(count, node.expectedCount);
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 = taskId ? readTaskGroup(projectPath, featureName, taskId) : null;
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, featureName, taskId, rubricOverride }) {
1083
+ function resolveRubricId({ projectPath, task, rubricOverride }) {
935
1084
  if (rubricOverride) return rubricOverride;
936
1085
 
937
- if (taskId) {
938
- const group = readTaskGroup(projectPath, featureName, taskId);
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
- function readTaskGroup(projectPath, featureName, taskId) {
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
- return task?.group ?? null;
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
  /**