@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.
- package/CLAUDE.md +9 -7
- package/README.md +2 -2
- package/bin/morph-spec.js +2 -1
- 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 +184 -10
- 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 +1 -1
- 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 +14 -8
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +11 -3
- 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 +47 -18
- 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 +517 -0
- package/src/lib/tasks/test-nodes.js +190 -20
- package/src/lib/tasks/test-runner.js +122 -2
- package/src/lib/tasks/test-scope.js +567 -0
- package/src/lib/validators/traceability-check.js +9 -2
- package/src/lib/validators/verify-runner.js +150 -18
- 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 {
|
|
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({
|
|
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`,
|
|
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 =
|
|
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,
|
|
1052
|
+
function resolveRubricId({ projectPath, task, rubricOverride }) {
|
|
935
1053
|
if (rubricOverride) return rubricOverride;
|
|
936
1054
|
|
|
937
|
-
|
|
938
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|