@polymorphism-tech/morph-spec 8.35.1 → 8.36.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 (50) hide show
  1. package/CLAUDE.md +9 -7
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +2 -1
  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 +184 -10
  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 +1 -1
  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 +14 -8
  34. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +11 -3
  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 +47 -18
  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 +517 -0
  45. package/src/lib/tasks/test-nodes.js +190 -20
  46. package/src/lib/tasks/test-runner.js +122 -2
  47. package/src/lib/tasks/test-scope.js +567 -0
  48. package/src/lib/validators/traceability-check.js +9 -2
  49. package/src/lib/validators/verify-runner.js +150 -18
  50. 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
@@ -29,10 +30,13 @@ import { join, resolve } from 'path';
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,
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,13 @@ 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
+
60
+ /** A narrowed run that exited green with no count anyone could read (#87): null is UNKNOWN, and unknown is never green. */
61
+ const NARROWED_COUNT_UNKNOWN = 'narrowed-count-unknown: o recorte saiu verde sem contagem legível — '
62
+ + 'nada prova que algum teste rodou (desconhecido nunca vira verde)';
63
+
53
64
  /**
54
65
  * The caller asked for something impossible — not a red verification, a wrong
55
66
  * invocation. The distinction is the exit code: `verify` answers 1 for "the
@@ -86,6 +97,8 @@ export class VerifyUsageError extends Error {
86
97
  * @param {boolean} [options.linux] - Run the test nodes inside a Linux container, on a COPY
87
98
  * of the tree (see linux-runner.js for why a bind mount would prove nothing)
88
99
  * @param {string} [options.linuxImage] - Image override for --linux
100
+ * @param {boolean} [options.full] - Task scope only: switch the recorte off and run every
101
+ * node whole (#87). A documented no-op at feature scope, which never narrows anything.
89
102
  * @param {Function} [options._runBuildCheck] - Injectable override for runBuildCheck (testing)
90
103
  * @param {Function} [options._runTestSuite] - Injectable override for runTestSuite (testing)
91
104
  * @param {Function} [options._runValidation] - Injectable override for runValidation (testing)
@@ -105,6 +118,7 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
105
118
  refreshCache = false,
106
119
  linux = false,
107
120
  linuxImage = null,
121
+ full = false,
108
122
  _runLinuxSuite = runLinuxSuite,
109
123
  _exec = undefined,
110
124
  _runBuildCheck = runBuildCheck,
@@ -130,13 +144,18 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
130
144
  );
131
145
  }
132
146
 
147
+ // The task is read ONCE: its group picks the rubric and the e2e
148
+ // applicability and, with its outputs, the test scope (#87). One reader of
149
+ // tasks.json for verify, so the three can never disagree about the task.
150
+ const task = taskId ? readTask(projectPath, featureName, taskId) : null;
151
+
133
152
  // `--record-baseline` is judged in the same breath, and for the same reason:
134
153
  // discovering that there is nowhere to write AFTER running the whole fleet
135
154
  // would charge the user a full suite for a usage error. `config.json` is
136
155
  // never created here — this repository deliberately has none, and inventing
137
156
  // one would break `doctor --full`.
138
157
  if (recordBaseline) {
139
- const problem = baselineTargetProblem(projectPath, testPlan, { skipTests });
158
+ const problem = baselineTargetProblem(projectPath, testPlan, { skipTests, taskId });
140
159
  if (problem) throw new VerifyUsageError(problem);
141
160
  }
142
161
 
@@ -175,6 +194,16 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
175
194
  }
176
195
  }
177
196
 
197
+ // #87: at task scope the task's group picks the nodes and its outputs narrow
198
+ // the command inside each; test-scope.js DECIDES, this runner executes. The
199
+ // scoping happens ONCE, here, before runTestsNode, so the host loop and the
200
+ // `--linux` container receive the SAME scoped nodes (the container script is
201
+ // written from `node.command`) and preDecideRun/judgeRun stay the single copy
202
+ // of each rule for both. `--filter` was judged above on the ORIGINAL ids: a
203
+ // typo is a typo whatever the task. Feature scope gets the nodes back
204
+ // unchanged, because Gate 3 always runs every suite, whole.
205
+ const scoped = applyTaskScope({ projectPath, nodes: testPlan.nodes, task, taskId, filter, full });
206
+
178
207
  const nodes = {};
179
208
  const feedback = [];
180
209
 
@@ -190,7 +219,7 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
190
219
  const explicitTimeout = Number.isFinite(options.timeout) ? options.timeout : undefined;
191
220
  nodes.tests = await runTestsNode({
192
221
  projectPath,
193
- testPlan,
222
+ testPlan: { ...testPlan, nodes: scoped.nodes },
194
223
  filter,
195
224
  skipTests,
196
225
  buildNode: nodes.build,
@@ -203,10 +232,14 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
203
232
  _runLinuxSuite,
204
233
  _exec,
205
234
  });
235
+ // How the nodes were chosen, for whoever reads --json. It lives on the rollup
236
+ // and nowhere else: nodeStatuses() (verify.js) persists status strings only,
237
+ // so no part of the selection ever reaches feature.json.
238
+ nodes.tests.scope = scoped.summary;
206
239
  appendTestsFeedback(feedback, nodes.tests);
207
240
 
208
241
  // 3. validators — ALWAYS runs, even on a red build (free static feedback).
209
- const rubricId = resolveRubricId({ projectPath, featureName, taskId, rubricOverride });
242
+ const rubricId = resolveRubricId({ task, rubricOverride, projectPath });
210
243
  nodes.validators = await runValidatorsNode({ projectPath, featureName, rubricId, _runValidation });
211
244
  appendValidatorsFeedback(feedback, nodes.validators);
212
245
 
@@ -217,6 +250,7 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
217
250
  projectPath,
218
251
  featureName,
219
252
  taskId,
253
+ task,
220
254
  skipE2e,
221
255
  freshE2e,
222
256
  buildNode: nodes.build,
@@ -356,7 +390,8 @@ async function runTestsNode({
356
390
  * Run the executable nodes inside ONE Linux container and judge them with the
357
391
  * SAME rules the local path uses.
358
392
  *
359
- * The pre-decisions (invalid config, `--filter`, an e2e node at task scope)
393
+ * The pre-decisions (invalid config, `--filter`, a node outside the task
394
+ * scope, an e2e node at task scope)
360
395
  * happen HERE, before the container exists: a node that must not run must not
361
396
  * be written into the container script either, or the flag would quietly run
362
397
  * what the user filtered out. What the container returns — an exit code and an
@@ -508,6 +543,11 @@ function emptyRunEntry(node) {
508
543
  reason: null,
509
544
  warnings: [],
510
545
  output: '',
546
+ // What the task-scope recorte did to this node (#87): `command` above is
547
+ // what actually ran, `selection.declaredCommand` what was declared. null at
548
+ // feature scope, where applyTaskScope leaves the key ABSENT; absent is read
549
+ // as null here so no reader has to tell the two apart.
550
+ selection: node.selection ?? null,
511
551
  };
512
552
  }
513
553
 
@@ -537,6 +577,14 @@ function preDecideRun({ node, filter, taskId, projectPath = '.', refreshCache =
537
577
  return { ...entry, reason: `filtered out (--filter ${filter.raw})` };
538
578
  }
539
579
 
580
+ // The task's group scoped this node out (#87). AFTER the filter on purpose:
581
+ // a node the user filtered out reports the filter, never a scope it was not
582
+ // judged by. BEFORE the e2e and eval checks: a node outside the task scope
583
+ // has nothing left to decide.
584
+ if (node.selection?.mode === 'out-of-scope') {
585
+ return { ...entry, reason: node.selection.reason };
586
+ }
587
+
540
588
  if (node.e2e && taskId) {
541
589
  return { ...entry, reason: 'e2e node runs at feature scope only' };
542
590
  }
@@ -590,6 +638,13 @@ function judgeRun({ node, result, durationMs }) {
590
638
  // is chosen for the failures, not for the summary.
591
639
  const parsed = parseTestCount(result.output, node.runner);
592
640
  const count = parsed ? parsed.total : null;
641
+
642
+ // A narrowed run is PARTIAL by construction (#87): it has a verdict of its
643
+ // own, and the count rule below never sees it.
644
+ if (entry.selection?.mode === 'narrowed') {
645
+ return judgeNarrowedRun({ entry, node, result, durationMs, count });
646
+ }
647
+
593
648
  const verdict = compareTestCount(count, node.expectedCount);
594
649
  const warnings = verdict.warning ? [verdict.warning] : [];
595
650
 
@@ -618,6 +673,57 @@ function judgeRun({ node, result, durationMs }) {
618
673
  };
619
674
  }
620
675
 
676
+ /**
677
+ * The verdict of a NARROWED run (#87): what `judgeRun` hands over instead of
678
+ * its count rule. Reached from the host loop and from `--linux` alike, because
679
+ * both go through `judgeRun`.
680
+ *
681
+ * Two things change, and both follow from "partial by construction":
682
+ *
683
+ * - NO `compareTestCount`. `expectedCount` is the WHOLE suite's baseline: a
684
+ * recorte running 12 of 3.082 is not a drop, and a superset selection
685
+ * running more than the baseline is not a rise. Either comparison would
686
+ * report something about the suite that this run never measured.
687
+ * - a pass needs PROOF that tests ran. A filter that matches nothing exits 0
688
+ * under VSTest, and "zero tests, all green" is exactly the verdict issue #87
689
+ * must never buy. What counts as proof is the selection's `zeroGuard`
690
+ * (test-scope.js):
691
+ * `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.
694
+ *
695
+ * A FAILED run is renamed `narrowed-zero-tests (…)` only when it carries the
696
+ * runner's zero-test signature AND no count proves tests ran. A multi-project
697
+ * VSTest output may print "No test matches…" for one project while another
698
+ * 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.
702
+ *
703
+ * Every red leads its output with the verdict, like a timeout does: the
704
+ * feedback line reads `output` before `reason`, and a tail alone would bury it.
705
+ */
706
+ function judgeNarrowedRun({ entry, node, result, durationMs, count }) {
707
+ const tail = tailLines(result.output, OUTPUT_TAIL_LINES);
708
+ const base = { ...entry, durationMs, count, warnings: [] };
709
+ const red = (reason) => ({ ...base, status: 'fail', reason, output: tail ? `${reason}\n${tail}` : reason });
710
+ const green = () => ({ ...base, status: 'pass', reason: result.reason ?? null, output: '' });
711
+ const ranTests = Number.isInteger(count) && count > 0;
712
+
713
+ if (result.passed) {
714
+ if (count === 0) return red(NARROWED_ZERO_TESTS);
715
+ if (ranTests) return green();
716
+ if (entry.selection.zeroGuard === 'mtp-exit-8' && result.exitCode === 0) return green();
717
+ return red(NARROWED_COUNT_UNKNOWN);
718
+ }
719
+
720
+ if (!ranTests && isZeroTestRun(result.output, node.runner, result.exitCode)) {
721
+ return red(`narrowed-zero-tests (${node.runner}, exit ${result.exitCode ?? 'desconhecido'}): o recorte executou 0 testes`);
722
+ }
723
+
724
+ return { ...base, status: 'fail', reason: result.reason ?? null, output: tail };
725
+ }
726
+
621
727
  /**
622
728
  * Aggregate the per-node runs into the single node `verify` publishes.
623
729
  *
@@ -747,12 +853,22 @@ function refreshCacheProblem(projectPath, testPlan, filter, { skipTests }) {
747
853
  * never created — the framework repository has none on purpose (creating it
748
854
  * breaks `doctor --full`), so the honest answer is a named usage error.
749
855
  *
856
+ * A TASK id is refused first (#87): at task scope the runner narrows the nodes
857
+ * AND the commands, so what it measures is a recorte, while `expectedCount` is
858
+ * the whole fleet's number. `recordTestBaselines` refuses a measurement that
859
+ * carries a selection (the second barrier), but paying for the recorte to
860
+ * learn that is the ordering this function exists to prevent.
861
+ *
750
862
  * @param {string} projectPath
751
863
  * @param {{ nodes: Array, source: string }} testPlan
752
- * @param {{ skipTests: boolean }} opts
864
+ * @param {{ skipTests: boolean, taskId?: string|null }} opts
753
865
  * @returns {string|null}
754
866
  */
755
- function baselineTargetProblem(projectPath, testPlan, { skipTests }) {
867
+ function baselineTargetProblem(projectPath, testPlan, { skipTests, taskId = null }) {
868
+ if (taskId) {
869
+ return `--record-baseline é manutenção da frota: rode sem {task} (recebeu "${taskId}") — `
870
+ + 'no escopo de task o verify recorta nós e testes, e baseline de recorte não é baseline';
871
+ }
756
872
  if (skipTests) {
757
873
  return '--record-baseline com --skip-tests não mede nada — uma baseline vem de uma execução, não de uma omissão';
758
874
  }
@@ -796,9 +912,11 @@ function recordBaselineFrom(projectPath, testsNode) {
796
912
  );
797
913
  }
798
914
 
915
+ // The selection travels with the count so the WRITER can refuse a recorte
916
+ // (#87) — the second barrier behind baselineTargetProblem's usage error.
799
917
  return recordTestBaselines(
800
918
  projectPath,
801
- runs.map((r) => ({ id: r.id, count: r.count }))
919
+ runs.map((r) => ({ id: r.id, count: r.count, selection: r.selection ?? null }))
802
920
  );
803
921
  }
804
922
 
@@ -867,7 +985,7 @@ function appendValidatorsFeedback(feedback, validatorsNode) {
867
985
  // E2E node
868
986
  // ============================================================================
869
987
 
870
- async function runE2eNode({ projectPath, featureName, taskId, skipE2e, freshE2e, buildNode, _runE2eCheck }) {
988
+ async function runE2eNode({ projectPath, featureName, taskId, task = null, skipE2e, freshE2e, buildNode, _runE2eCheck }) {
871
989
  if (buildNode.status === 'fail') {
872
990
  return {
873
991
  status: 'skip',
@@ -879,7 +997,7 @@ async function runE2eNode({ projectPath, featureName, taskId, skipE2e, freshE2e,
879
997
  }
880
998
 
881
999
  const config = readConfig(projectPath);
882
- const taskGroup = taskId ? readTaskGroup(projectPath, featureName, taskId) : null;
1000
+ const taskGroup = task?.group ?? null;
883
1001
  const gitCommonDir = resolveGitCommonDir(projectPath);
884
1002
 
885
1003
  const start = Date.now();
@@ -931,14 +1049,11 @@ function appendE2eFeedback(feedback, e2eNode) {
931
1049
  // Rubric selection — flag > task group > config > default
932
1050
  // ============================================================================
933
1051
 
934
- function resolveRubricId({ projectPath, featureName, taskId, rubricOverride }) {
1052
+ function resolveRubricId({ projectPath, task, rubricOverride }) {
935
1053
  if (rubricOverride) return rubricOverride;
936
1054
 
937
- if (taskId) {
938
- const group = readTaskGroup(projectPath, featureName, taskId);
939
- const mapped = mapGroupToRubric(group);
940
- if (mapped) return mapped;
941
- }
1055
+ const mapped = mapGroupToRubric(task?.group);
1056
+ if (mapped) return mapped;
942
1057
 
943
1058
  const configRubric = readConfigRubric(projectPath);
944
1059
  if (configRubric) return configRubric;
@@ -959,13 +1074,30 @@ function mapGroupToRubric(group) {
959
1074
  return null;
960
1075
  }
961
1076
 
962
- function readTaskGroup(projectPath, featureName, taskId) {
1077
+ /**
1078
+ * The task as verify needs it, or null when tasks.json or the task is missing.
1079
+ *
1080
+ * The ONE reader of tasks.json for verify (#87): the group picks the rubric and
1081
+ * the e2e applicability, group + outputs pick the test scope, and the command
1082
+ * uses the null to refuse an unknown task id. Two readers is how a rubric and
1083
+ * a scope start disagreeing about the same task.
1084
+ *
1085
+ * @param {string} projectPath
1086
+ * @param {string} featureName
1087
+ * @param {string} taskId
1088
+ * @returns {{ group: string|null, outputs: string[] }|null}
1089
+ */
1090
+ export function readTask(projectPath, featureName, taskId) {
963
1091
  try {
964
1092
  const tasksPath = getAbsoluteOutputPath(projectPath, featureName, 'tasks');
965
1093
  if (!existsSync(tasksPath)) return null;
966
1094
  const tasksJson = JSON.parse(readFileSync(tasksPath, 'utf8'));
967
1095
  const task = (tasksJson.tasks || []).find((t) => t.id === taskId);
968
- return task?.group ?? null;
1096
+ if (!task) return null;
1097
+ return {
1098
+ group: task.group ?? null,
1099
+ outputs: Array.isArray(task.outputs) ? task.outputs : [],
1100
+ };
969
1101
  } catch {
970
1102
  return null;
971
1103
  }
@@ -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
  /**