@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
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test Scope — which test nodes a TASK-scoped `verify {feature} {task}` runs,
|
|
3
|
+
* and which tests inside each node (issue #87).
|
|
4
|
+
*
|
|
5
|
+
* GHLBrain ran its whole suite 121 times in 8 days from the per-task loop — about
|
|
6
|
+
* five hours a week — because a task-scoped verify had no way to run less. The
|
|
7
|
+
* feature-scoped verify (Gate 3) keeps running everything; this module only
|
|
8
|
+
* DECIDES the task-scoped cut, verify-runner executes it (task B4).
|
|
9
|
+
*
|
|
10
|
+
* Two independent decisions:
|
|
11
|
+
* 1. selection — the task's `group` picks the nodes (`effectiveGroups`);
|
|
12
|
+
* 2. narrowing — the task's `outputs` narrow the command INSIDE each node.
|
|
13
|
+
*
|
|
14
|
+
* The declared command is literal and nobody's parser owns it, so narrowing is
|
|
15
|
+
* string surgery on a short list of shapes that were read and, where it
|
|
16
|
+
* mattered, measured (test/fixtures/test-output/README.md). Anything else runs
|
|
17
|
+
* WHOLE with a reason: a narrowing that guesses runs the wrong tests or none,
|
|
18
|
+
* and a run of none is the green that issue #87 must never buy.
|
|
19
|
+
*
|
|
20
|
+
* Pure: reads files (and one environment variable, TESTINGPLATFORM_EXITCODE_IGNORE),
|
|
21
|
+
* never spawns a process.
|
|
22
|
+
*
|
|
23
|
+
* @module test-scope
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { readFileSync, statSync } from 'fs';
|
|
27
|
+
import { dirname, relative, resolve } from 'path';
|
|
28
|
+
|
|
29
|
+
import { effectiveGroups } from './test-nodes.js';
|
|
30
|
+
import { canonicalPath, extractCsprojTargets, isInside } from './test-runner.js';
|
|
31
|
+
import { normalizePath } from '../validators/traceability-check.js';
|
|
32
|
+
import { samePathCase } from '../worktree/docker-audit.js';
|
|
33
|
+
import {
|
|
34
|
+
csharpClasses, declaresCaseFilter, dotnetTestMode, dotnetTestProjectOf, floorOverride, ignoresExitCode,
|
|
35
|
+
imposesMinimumExpected, isDotnetSource, isDotnetTestProject, nearestCsprojOf, nearestProjectOf, readsLikeTestSource,
|
|
36
|
+
} from './dotnet-test-introspection.js';
|
|
37
|
+
|
|
38
|
+
// Part of this module's API (task B3); implemented beside the rest of the .NET introspection.
|
|
39
|
+
export { dotnetTestMode, classNamesOf } from './dotnet-test-introspection.js';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The per-node decision B4 consumes (`preDecideRun` skips `out-of-scope`;
|
|
43
|
+
* `judgeRun` reads `mode`/`zeroGuard`). `null` — in fact absent — at feature scope.
|
|
44
|
+
*
|
|
45
|
+
* @typedef {Object} TestSelection
|
|
46
|
+
* @property {'narrowed'|'whole'|'out-of-scope'|'full'} mode
|
|
47
|
+
* - `narrowed`: `node.command` was rewritten to run only the task's tests. Its
|
|
48
|
+
* count is PARTIAL: never compare it with `expectedCount` (the whole-suite
|
|
49
|
+
* baseline, left untouched on the node), and a pass without proof that tests
|
|
50
|
+
* ran is not a pass — see `zeroGuard`.
|
|
51
|
+
* - `whole`: selected, runs its declared command unchanged; `reason` says why.
|
|
52
|
+
* - `out-of-scope`: must not run at this task scope; `reason` says why.
|
|
53
|
+
* - `full`: `--full` — declared command unchanged, no recorte by request.
|
|
54
|
+
* @property {string|null} reason - Why whole/out-of-scope; null when narrowed.
|
|
55
|
+
* @property {string|null} declaredCommand - The command as declared, before narrowing.
|
|
56
|
+
* @property {string[]} files - Task test files this node owns (project-relative,
|
|
57
|
+
* `/`, spelled as on disk): inside its cwd, or — dotnet — belonging to a
|
|
58
|
+
* `.csproj` a link of its command names, wherever they sit (ownership is the
|
|
59
|
+
* link's project, never the cwd).
|
|
60
|
+
* @property {string[]} selectors - What the narrowed command selects: BARE type
|
|
61
|
+
* names (dotnet — every class/record/struct/interface of the task's test files,
|
|
62
|
+
* no namespace, each matched namespace-free as `--filter-class "*T*"` (MTP +
|
|
63
|
+
* xunit.v3, measured) or the substring `FullyQualifiedName~T`, so any namespace,
|
|
64
|
+
* nested `Outer+Inner` and prefix siblings are covered: a SUPERSET of the file's
|
|
65
|
+
* tests, never a subset) or paths relative to the node cwd (vitest/jest). `[]`
|
|
66
|
+
* unless narrowed.
|
|
67
|
+
* @property {'count'|'mtp-exit-8'|null} zeroGuard - How a narrowed run proves tests
|
|
68
|
+
* ran: `count` = only a parsed count > 0 proves it (VSTest exits 0 on an empty
|
|
69
|
+
* filter; vitest/jest print a string); `mtp-exit-8` = a parsed count > 0 proves it
|
|
70
|
+
* (the MTP summary, pt-BR/en-US, #115) and, with no readable summary, exit 0 does —
|
|
71
|
+
* MTP exits 8 on zero tests. A parsed 0 is red under both. null unless narrowed.
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
const NO_TEST_FILE = 'no test file from task outputs in this node — running it whole';
|
|
75
|
+
const SHAPE = 'command shape not recognised for narrowing — running it whole';
|
|
76
|
+
/**
|
|
77
|
+
* Measured (test/fixtures/test-output/mtp-minimum-expected-cli-override-ignored.txt):
|
|
78
|
+
* `--minimum-expected-tests 1` on the CLI does NOT override the project's floor —
|
|
79
|
+
* a green narrowed run still exits 9. The global `-p:` property does, and since
|
|
80
|
+
* #114 a floor floorOverride can re-pass is lowered to 1 for the recorte; every
|
|
81
|
+
* other floor (VSTest, set elsewhere or conditionally) runs whole with this reason
|
|
82
|
+
* rather than produce a red that no test caused. Wording pinned by the plan.
|
|
83
|
+
*/
|
|
84
|
+
const MIN_EXPECTED = 'projeto impõe --minimum-expected-tests; recorte desligado';
|
|
85
|
+
/** #114 review F6: `-p:` is global to ONE `dotnet test`, so it would replace the arguments of EVERY project the link runs. */
|
|
86
|
+
const FLOOR_SHARED = 'a link runs several test projects and one has a floor — its -p: would replace the others\' arguments too; running it whole';
|
|
87
|
+
/** #114 review F2: the recorte's `-p:` comes last, and the last one wins — the command's own value would be gone. */
|
|
88
|
+
const COMMAND_SETS_ARGS = 'command sets TestingPlatformCommandLineArguments itself — the recorte\'s -p: would replace that value; running it whole';
|
|
89
|
+
const FILTER_TAKEN = 'command already carries a test filter — a second one would replace it; running it whole';
|
|
90
|
+
const CASE_FILTER = 'project declares <VSTestTestCaseFilter> — an appended --filter would replace it; running it whole';
|
|
91
|
+
/**
|
|
92
|
+
* Final review F3: under MTP, when the summary is NOT readable (a locale other than
|
|
93
|
+
* pt-BR/en-US, #115), a narrowed run's proof that tests ran is exit 0 (zero tests
|
|
94
|
+
* exit 8). Whatever makes MTP ignore an exit code takes that proof away — zero tests
|
|
95
|
+
* then exit 0 — so the node runs whole, readable summary or not: this is decided
|
|
96
|
+
* BEFORE the run, which is when nobody knows yet whether its summary will read.
|
|
97
|
+
* The COMMAND is read by FLOOR_OR_IGNORE; these two are the project
|
|
98
|
+
* (ignoresExitCode) and the environment.
|
|
99
|
+
*/
|
|
100
|
+
const IGNORE_EXIT_PROJECT = 'project makes MTP ignore an exit code (--ignore-exit-code / TESTINGPLATFORM_EXITCODE_IGNORE in its MSBuild files or testconfig) — exit 0 would no longer prove that tests ran; running it whole';
|
|
101
|
+
const IGNORE_EXIT_ENV = 'TESTINGPLATFORM_EXITCODE_IGNORE is set in the environment — MTP may exit 0 on zero tests, so exit 0 would no longer prove that tests ran; running it whole';
|
|
102
|
+
|
|
103
|
+
const CLASS_NAME = /^[A-Za-z0-9_.]+$/;
|
|
104
|
+
/** Characters that change meaning inside double quotes in `sh` (the `--linux` container) or `cmd.exe`. */
|
|
105
|
+
const UNSAFE_IN_QUOTES = /["$`%!]/;
|
|
106
|
+
const JS_TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)__tests__\/.+\.[cm]?[jt]sx?$/;
|
|
107
|
+
const JS_SOURCE = /\.[cm]?[jt]sx?$/i;
|
|
108
|
+
/** Read in the RAW text of a JS/TS output the recogniser did not classify as a test file (the .NET markers: readsLikeTestSource). */
|
|
109
|
+
const JS_TEST_CALL = /^[ \t]*(?:describe|it|test)(?:\.\w+)*\s*\(/m;
|
|
110
|
+
const EXISTING_FILTER = /(^|\s)--(?:treenode-)?filter(?=$|[\s=-])/;
|
|
111
|
+
/** As a flag, or inside `-p:TestingPlatformCommandLineArguments=…` (preceded by `=` or a quote). */
|
|
112
|
+
const FLOOR_OR_IGNORE = /(^|[\s="'])--(?:ignore-exit-code|minimum-expected-tests)(?=$|[\s="'])/;
|
|
113
|
+
const MTP_FRAMEWORKS = new Set(['xunit.v3', 'mstest', 'nunit']);
|
|
114
|
+
|
|
115
|
+
const toPosix = (p) => String(p).replace(/\\/g, '/');
|
|
116
|
+
const samePath = (a, b) => isInside(a, b) && isInside(b, a);
|
|
117
|
+
const whole = (reason) => ({ command: null, selectors: [], zeroGuard: null, reason });
|
|
118
|
+
|
|
119
|
+
const isFile = (path) => { try { return statSync(path).isFile(); } catch { return false; } };
|
|
120
|
+
const isDirectory = (path) => { try { return statSync(path).isDirectory(); } catch { return false; } };
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The on-disk spelling of a path inside the root (#87 F4). A mis-cased output
|
|
124
|
+
* resolves on a case-insensitive disk and was forwarded verbatim; the `--linux`
|
|
125
|
+
* container is case-sensitive, and jest `--runTestsByPath` skips a path it cannot
|
|
126
|
+
* find while a sibling keeps the count above zero. `canonicalPath` (test-runner.js)
|
|
127
|
+
* is the one spelling, and only its CASE is taken: a path it moves ELSEWHERE — a
|
|
128
|
+
* symlink or junction, inside the root or out of it — keeps the plain spelling, so a
|
|
129
|
+
* file never changes node (re-review N3: `web/src/shared` → `libs/shared` left the
|
|
130
|
+
* `web` node narrowed past it). Case is compared by `samePathCase` (docker-audit.js),
|
|
131
|
+
* the one copy of that rule.
|
|
132
|
+
*/
|
|
133
|
+
function spelled(abs, rootAbs) {
|
|
134
|
+
const real = canonicalPath(abs);
|
|
135
|
+
return isInside(real, rootAbs) && samePathCase(real) === samePathCase(abs) ? real : abs;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The project root as on disk: every path compared with it is spelled the same way (F4). */
|
|
139
|
+
const rootOf = (projectPath) => canonicalPath(resolve(projectPath));
|
|
140
|
+
const cwdOf = (node, rootAbs) => spelled(resolve(rootAbs, node?.cwd || '.'), rootAbs);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Task outputs → the existing FILES and DIRECTORIES among them, absolute and
|
|
144
|
+
* spelled as on disk; ghosts dropped. A directory is kept apart and never
|
|
145
|
+
* narrows: which of its files the task touched cannot be told (forcedWhole).
|
|
146
|
+
*/
|
|
147
|
+
function existingEntries(outputs, rootAbs) {
|
|
148
|
+
const files = new Set();
|
|
149
|
+
const dirs = new Set();
|
|
150
|
+
for (const o of outputs || []) {
|
|
151
|
+
if (typeof o !== 'string' || !o.trim()) continue;
|
|
152
|
+
const abs = resolve(rootAbs, normalizePath(o.trim()));
|
|
153
|
+
if (isFile(abs)) files.add(spelled(abs, rootAbs));
|
|
154
|
+
else if (isDirectory(abs)) dirs.add(spelled(abs, rootAbs));
|
|
155
|
+
}
|
|
156
|
+
return { files: [...files], dirs: [...dirs] };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Is this file a test file for THIS runner? (A `.cs` file is nothing to a vitest node.) */
|
|
160
|
+
function isTestFileFor(runner, fileAbs, rootAbs) {
|
|
161
|
+
const js = JS_TEST_FILE.test(toPosix(relative(rootAbs, fileAbs)));
|
|
162
|
+
if (runner === 'dotnet' || runner === 'evals') return dotnetTestProjectOf(fileAbs, rootAbs) !== null;
|
|
163
|
+
if (['vitest', 'jest', 'node-test', 'playwright'].includes(runner)) return js;
|
|
164
|
+
return js || dotnetTestProjectOf(fileAbs, rootAbs) !== null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const readRaw = (path) => { try { return readFileSync(path, 'utf8'); } catch { return null; } };
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* An output the recogniser did NOT classify as a test file for this runner but
|
|
171
|
+
* that READS like one in its raw text: a `.cs` carrying a test marker
|
|
172
|
+
* (readsLikeTestSource — loose on purpose), or a JS/TS file with a top-level
|
|
173
|
+
* `describe`/`it`/`test` call. Recognition is a heuristic (csproj name or packages
|
|
174
|
+
* listed in the csproj itself; file-name pattern) — a touched test file it misses
|
|
175
|
+
* would be dropped from `owned`, and the recognised files would narrow the node
|
|
176
|
+
* PAST it: a green run that skipped a touched class. Such a file is named and the
|
|
177
|
+
* node runs whole (and is never scoped out). Non-C# .NET sources: forcedWhole.
|
|
178
|
+
*/
|
|
179
|
+
function unrecognisedTestFile(runner, fileAbs, rootAbs) {
|
|
180
|
+
if (isTestFileFor(runner, fileAbs, rootAbs)) return false;
|
|
181
|
+
const dotnet = runner === 'dotnet' || runner === 'evals';
|
|
182
|
+
if (/\.cs$/i.test(fileAbs)) return dotnet && readsLikeTestSource(fileAbs);
|
|
183
|
+
if (JS_SOURCE.test(fileAbs)) return !dotnet && JS_TEST_CALL.test(readRaw(fileAbs) ?? '');
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* An output the recorte cannot narrow on, for THIS node: the reason to run it
|
|
189
|
+
* whole — and never scope it out — or null. The final review of branch B found
|
|
190
|
+
* both shapes narrowing PAST touched tests:
|
|
191
|
+
* - F1: an existing DIRECTORY overlapping the node's cwd or the folder of a
|
|
192
|
+
* project a link names (either one containing the other). Dropped as "not a
|
|
193
|
+
* file", it let the task's other files narrow the node while the tests inside
|
|
194
|
+
* it never ran — and the count guard passed on the ones that did.
|
|
195
|
+
* - F2 (dotnet): ANY non-`.cs` file this node may run — inside its cwd, or its
|
|
196
|
+
* nearest project is one a link names — whose nearest project is a TEST project
|
|
197
|
+
* (a bUnit `.razor`, a Reqnroll/SpecFlow `.feature`, a Verify `.verified.txt`, test
|
|
198
|
+
* data; re-review N2: a list of source kinds let the `.feature` through), or that
|
|
199
|
+
* reads like a test (readsLikeTestSource: `.razor`/`.cshtml`/`.fs`/`.fsi`/`.fsx`/`.vb`
|
|
200
|
+
* outside a recognised test project). It yields no selector, and the tests it feeds
|
|
201
|
+
* may sit in files the task did not touch, so a recorte skips them.
|
|
202
|
+
*/
|
|
203
|
+
function forcedWhole(node, entries, cwdAbs, rootAbs) {
|
|
204
|
+
const rel = (abs) => toPosix(relative(rootAbs, abs)) || '.';
|
|
205
|
+
const targets = node?.runner === 'dotnet' ? extractCsprojTargets(node.command).map((t) => resolve(cwdAbs, t)) : [];
|
|
206
|
+
const areas = [cwdAbs, ...targets.map((t) => dirname(t))];
|
|
207
|
+
const dir = entries.dirs.find((d) => areas.some((a) => isInside(d, a) || isInside(a, d)));
|
|
208
|
+
if (dir) {
|
|
209
|
+
return `output "${rel(dir)}" is a directory — which of its files the task touched cannot be told, and a recorte would skip them; running it whole`;
|
|
210
|
+
}
|
|
211
|
+
if (node?.runner !== 'dotnet') return null;
|
|
212
|
+
const source = entries.files.find((abs) => {
|
|
213
|
+
if (/\.cs$/i.test(abs)) return false;
|
|
214
|
+
const project = nearestProjectOf(abs, rootAbs);
|
|
215
|
+
const mayRun = isInside(abs, cwdAbs) || (project !== null && targets.some((t) => samePath(t, project)));
|
|
216
|
+
return mayRun && (inTestProject(abs, rootAbs) || readsLikeTestSource(abs));
|
|
217
|
+
});
|
|
218
|
+
if (source) {
|
|
219
|
+
return `output file "${rel(source)}" is a test source the recorte cannot read — not C#, and it belongs to a test project or reads like a test; running it whole`;
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The file belongs to a .NET test project: its nearest `.csproj`/`.fsproj`/`.vbproj` is one (N2). */
|
|
225
|
+
function inTestProject(abs, rootAbs) {
|
|
226
|
+
const project = nearestProjectOf(abs, rootAbs);
|
|
227
|
+
return project !== null && isDotnetTestProject(project);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The `.cs` outputs a LINK of the command owns through its `.csproj` — wherever
|
|
232
|
+
* the file sits, recognised as a test file or not (the caller decides which of
|
|
233
|
+
* the two questions it is asking). A link's ownership is decided by the link's
|
|
234
|
+
* project, never by the node cwd: `cd tests/A.Tests && dotnet test --project
|
|
235
|
+
* ../B.Tests/B.Tests.csproj` runs B's tests, so a touched `tests/B.Tests/XTests.cs`
|
|
236
|
+
* belongs to that link and must select on it (round 5: it was dropped as
|
|
237
|
+
* "outside the cwd", and the link with it — XTests never ran). A bare link keeps
|
|
238
|
+
* the cwd rule: it runs what its cwd resolves, and a file outside is not its business.
|
|
239
|
+
*/
|
|
240
|
+
function linkedOutputs(command, filesAbs, cwdAbs, rootAbs) {
|
|
241
|
+
const targets = extractCsprojTargets(command).map((t) => resolve(cwdAbs, t));
|
|
242
|
+
if (targets.length === 0) return [];
|
|
243
|
+
return filesAbs.filter((abs) => {
|
|
244
|
+
const csproj = nearestCsprojOf(abs, rootAbs);
|
|
245
|
+
return csproj !== null && targets.some((t) => samePath(t, csproj));
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The first project target a command names that is NOT an existing `.csproj`
|
|
251
|
+
* inside the project root — as `extractCsprojTargets` spells it — or null.
|
|
252
|
+
*
|
|
253
|
+
* Link ownership is decided by the link's csproj (linkedOutputs), so a target
|
|
254
|
+
* the recorte cannot resolve turns every ownership answer into a guess, and the
|
|
255
|
+
* guess was "owns nothing": the link was DROPPED, and with it the touched class
|
|
256
|
+
* (B3 final review — compiled, exit 0, the class never ran). Two shapes reach
|
|
257
|
+
* here: `--project=PATH` / `--project:PATH`, which `extractCsprojTargets` returns
|
|
258
|
+
* as ONE token, flag included (resolved, it names nothing), and a csproj OUTSIDE
|
|
259
|
+
* the root, which `nearestCsprojOf` never walks to. `extractCsprojTargets` keeps
|
|
260
|
+
* its semantics for its other callers; this only refuses what it cannot match.
|
|
261
|
+
*/
|
|
262
|
+
function unresolvedProjectTarget(command, cwdAbs, rootAbs) {
|
|
263
|
+
return extractCsprojTargets(command).find((target) => {
|
|
264
|
+
const abs = resolve(cwdAbs, target);
|
|
265
|
+
return !/\.csproj$/i.test(abs) || !isFile(abs) || !isInside(abs, rootAbs);
|
|
266
|
+
}) ?? null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const strayProject = (target) => whole(`command names project "${target}", which is not an existing .csproj inside the project root — the task's files cannot be matched to its link; running it whole`);
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The flags of a link that names no csproj, ready to follow a re-pointed
|
|
273
|
+
* project — or null when it names a target that cannot be re-pointed.
|
|
274
|
+
* Re-pointing keeps the class filter off projects that do not own the class:
|
|
275
|
+
* under MTP each of them exits 8, under VSTest each prints "No test matches the
|
|
276
|
+
* given testcase filter" — a zero-test signature on a green run (false red).
|
|
277
|
+
* MTP: a solution/folder target → null. VSTest: a `.sln` token is dropped (the
|
|
278
|
+
* csproj replaces it); a folder → null. A target FLAG — `--solution`,
|
|
279
|
+
* `--directory`, `--test-modules`, `--project` — → null in either spelling: as
|
|
280
|
+
* its own token, or with its value after `=`/`:` in ONE token (final review C2:
|
|
281
|
+
* that spelling was kept, the link re-pointed and filtered, and the `dotnet` CLI
|
|
282
|
+
* refused the narrowed command — a red with no culprit test). A `--project` left
|
|
283
|
+
* for this function never names a csproj: extractCsprojTargets took those, and
|
|
284
|
+
* `--project=X.csproj` runs whole by unresolvedProjectTarget before reaching here.
|
|
285
|
+
*/
|
|
286
|
+
function retargetFlags(link, mode, cwdAbs) {
|
|
287
|
+
let refused = false;
|
|
288
|
+
const flags = link.replace(/^dotnet\s+test/, '').replace(/(\s*)("[^"]*"|'[^']*'|\S+)/g, (all, _ws, raw) => {
|
|
289
|
+
const value = raw.replace(/^(["'])(.*)\1$/, '$2');
|
|
290
|
+
if (value.startsWith('-')) {
|
|
291
|
+
if (/^--(?:solution|directory|test-modules|project)(?:[=:]|$)/.test(value)) refused = true;
|
|
292
|
+
return all;
|
|
293
|
+
}
|
|
294
|
+
if (/\.slnx?f?$/i.test(value)) {
|
|
295
|
+
if (mode === 'mtp') refused = true;
|
|
296
|
+
return '';
|
|
297
|
+
}
|
|
298
|
+
if (isDirectory(resolve(cwdAbs, value))) refused = true;
|
|
299
|
+
return all;
|
|
300
|
+
});
|
|
301
|
+
return refused ? null : flags;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function selectorArgs(mode, framework, classes) {
|
|
305
|
+
// Namespace-free by construction (R1). Measured (test/fixtures/test-output/
|
|
306
|
+
// mtp-filter-class-any-namespace*.txt): `--filter-class "*Outer*"` runs Outer in
|
|
307
|
+
// EVERY namespace, its nested Outer+Inner classes and prefix siblings; the exact
|
|
308
|
+
// name runs Outer alone (mtp-filter-class-exact-skips-nested.txt) and a prefix
|
|
309
|
+
// without the namespace runs NOTHING (mtp-filter-class-prefix-without-namespace-
|
|
310
|
+
// zero.txt, exit 8). `FullyQualifiedName~T` is a substring of the FQN: same superset.
|
|
311
|
+
if (mode === 'mtp' && framework === 'xunit.v3') return classes.map((c) => `--filter-class "*${c}*"`).join(' ');
|
|
312
|
+
return `--filter "${classes.map((c) => `FullyQualifiedName~${c}`).join('|')}"`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const classesOf = (files) => [...new Set(files.flatMap((f) => f.types))];
|
|
316
|
+
|
|
317
|
+
function narrowDotnet(command, owned, cwdAbs, rootAbs) {
|
|
318
|
+
if (/[;|]/.test(command) || /(^|[^&])&(?!&)/.test(command)) return whole(SHAPE);
|
|
319
|
+
const links = command.split('&&').map((l) => l.trim());
|
|
320
|
+
if (links.some((l) => !/^dotnet\s+test(\s|$)/.test(l))) return whole(SHAPE);
|
|
321
|
+
if (links.some((l) => EXISTING_FILTER.test(l))) return whole(FILTER_TAKEN);
|
|
322
|
+
if (links.some((l) => /(^|\s)--(\s|$)/.test(l))) return whole('command passes arguments after "--" — running it whole');
|
|
323
|
+
if (links.some((l) => FLOOR_OR_IGNORE.test(l))) {
|
|
324
|
+
return whole('command carries --ignore-exit-code/--minimum-expected-tests — a recorte would be judged by the wrong exit; running it whole');
|
|
325
|
+
}
|
|
326
|
+
if (process.env.TESTINGPLATFORM_EXITCODE_IGNORE?.trim()) return whole(IGNORE_EXIT_ENV);
|
|
327
|
+
|
|
328
|
+
const mode = dotnetTestMode(cwdAbs);
|
|
329
|
+
if (mode === null) return whole('global.json unreadable — cannot tell VSTest from MTP; running it whole');
|
|
330
|
+
|
|
331
|
+
const files = owned.map((abs) => ({ abs, inside: isInside(abs, cwdAbs), ...dotnetTestProjectOf(abs, rootAbs), ...csharpClasses(abs) }));
|
|
332
|
+
// The PROJECT binds the run, in either mode. A floor exits 9 on a green
|
|
333
|
+
// recorte: under MTP it is lowered to 1 by a `-p:` that re-passes the rest of
|
|
334
|
+
// the value when floorOverride can read it from ONE place (#114); otherwise —
|
|
335
|
+
// VSTest (the MTP bridge, not measured), a floor set elsewhere or conditionally
|
|
336
|
+
// — the node runs whole. A declared case filter is REPLACED by an appended
|
|
337
|
+
// --filter (`Category!=Eval` silently gone).
|
|
338
|
+
const projects = [...new Set(files.map((f) => f.csproj))];
|
|
339
|
+
const floors = new Map();
|
|
340
|
+
for (const project of projects.filter((p) => imposesMinimumExpected(p))) {
|
|
341
|
+
const value = mode === 'mtp' ? floorOverride(project) : null;
|
|
342
|
+
if (value === null) return whole(MIN_EXPECTED);
|
|
343
|
+
if (links.some((l) => /TestingPlatformCommandLineArguments/i.test(l))) return whole(COMMAND_SETS_ARGS);
|
|
344
|
+
floors.set(project, value);
|
|
345
|
+
}
|
|
346
|
+
// `-p:` is global to ONE `dotnet test` invocation: it lowers the floor of the project
|
|
347
|
+
// it was read from and REPLACES the arguments of every other project the link runs.
|
|
348
|
+
const floorArg = (owners, projectCount) => {
|
|
349
|
+
const value = owners.map((c) => floors.get(c)).find((v) => v !== undefined);
|
|
350
|
+
if (value === undefined) return '';
|
|
351
|
+
return projectCount === 1 ? ` "-p:TestingPlatformCommandLineArguments=${value}"` : null;
|
|
352
|
+
};
|
|
353
|
+
if (projects.some((p) => ignoresExitCode(p))) return whole(IGNORE_EXIT_PROJECT);
|
|
354
|
+
if (projects.some((p) => declaresCaseFilter(p))) return whole(CASE_FILTER);
|
|
355
|
+
if (mode === 'mtp') {
|
|
356
|
+
const other = files.find((f) => !MTP_FRAMEWORKS.has(f.framework));
|
|
357
|
+
if (other) return whole(`MTP test project uses a framework with no known class filter (${other.framework ?? 'unknown'}) — running it whole`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Selectors are the files' BARE type names at any depth, matched namespace-free
|
|
361
|
+
// (`--filter-class "*T*"` / `FullyQualifiedName~T`): no namespace, scope or depth
|
|
362
|
+
// reading can misname a type, and a misread only ADDS a selector. A MISSED type
|
|
363
|
+
// is what must never happen — and `scannable` means exactly that every type
|
|
364
|
+
// keyword of the RAW text is a named declaration (csharpClasses states the
|
|
365
|
+
// guarantee and its limits); else the file is untrusted and never narrows.
|
|
366
|
+
const unscannable = files.find((f) => !f.scannable);
|
|
367
|
+
if (unscannable) {
|
|
368
|
+
const rel = toPosix(relative(rootAbs, unscannable.abs));
|
|
369
|
+
return whole(`test file "${rel}" could not be scanned safely (${unscannable.why}) — running it whole`);
|
|
370
|
+
}
|
|
371
|
+
// R3: an abstract class's tests run under the names of its DERIVED classes —
|
|
372
|
+
// declared in files the task may not have touched, so no selector here names them.
|
|
373
|
+
const abstractFile = files.find((f) => f.abstract.length > 0);
|
|
374
|
+
if (abstractFile) {
|
|
375
|
+
const rel = toPosix(relative(rootAbs, abstractFile.abs));
|
|
376
|
+
return whole(`test file "${rel}" declares an abstract class (${abstractFile.abstract.join(', ')}) — its tests run under derived classes' names, possibly in files the task did not touch; running it whole`);
|
|
377
|
+
}
|
|
378
|
+
if (!files.some((f) => f.hasTestClass)) return whole('no test class found in the task\'s test files — running it whole');
|
|
379
|
+
// Round 5: a test-project file WITHOUT a test attribute is a helper, fake or
|
|
380
|
+
// fixture. Its types select nothing (`*FakeClock*` → exit 8, a red no test
|
|
381
|
+
// earned) while the tests that USE it live in files the task did not touch.
|
|
382
|
+
const helper = files.find((f) => !f.hasTestAttribute);
|
|
383
|
+
if (helper) {
|
|
384
|
+
const rel = toPosix(relative(rootAbs, helper.abs));
|
|
385
|
+
return whole(`test file "${rel}" carries no test attribute ([Fact]/[Theory]/[Test…]) — a helper, fake or fixture whose tests live in files the task did not touch; running it whole`);
|
|
386
|
+
}
|
|
387
|
+
const unsafe = classesOf(files).find((c) => !CLASS_NAME.test(c));
|
|
388
|
+
if (unsafe) return whole(`type name "${unsafe}" is not safe to pass as a filter — running it whole`);
|
|
389
|
+
|
|
390
|
+
const out = [];
|
|
391
|
+
for (const link of links) {
|
|
392
|
+
const targets = extractCsprojTargets(link).map((t) => resolve(cwdAbs, t));
|
|
393
|
+
if (targets.length > 0) {
|
|
394
|
+
const mine = files.filter((f) => targets.some((t) => samePath(t, f.csproj)));
|
|
395
|
+
const classes = classesOf(mine);
|
|
396
|
+
if (classes.length > 0) {
|
|
397
|
+
const floor = floorArg([...new Set(mine.map((f) => f.csproj))], targets.length);
|
|
398
|
+
if (floor === null) return whole(FLOOR_SHARED);
|
|
399
|
+
out.push(`${link} ${selectorArgs(mode, mine[0].framework, classes)}${floor}`);
|
|
400
|
+
}
|
|
401
|
+
continue; // a link whose project owns none of the files is dropped
|
|
402
|
+
}
|
|
403
|
+
const flags = retargetFlags(link, mode, cwdAbs);
|
|
404
|
+
if (flags === null) return whole('command targets a solution or folder the recorte cannot re-point — running it whole');
|
|
405
|
+
// A bare link runs what its cwd resolves: re-point it to the projects of the
|
|
406
|
+
// files INSIDE the cwd only (a linked file outside belongs to its own link).
|
|
407
|
+
for (const csproj of [...new Set(files.filter((f) => f.inside).map((f) => f.csproj))]) {
|
|
408
|
+
const rel = toPosix(relative(cwdAbs, csproj));
|
|
409
|
+
if (UNSAFE_IN_QUOTES.test(rel)) return whole(`path "${rel}" has a character unsafe inside quotes — running it whole`);
|
|
410
|
+
const mine = files.filter((f) => f.csproj === csproj && f.inside);
|
|
411
|
+
const classes = classesOf(mine);
|
|
412
|
+
const target = mode === 'mtp' ? `--project "${rel}"` : `"${rel}"`;
|
|
413
|
+
if (classes.length > 0) out.push(`dotnet test ${target}${flags} ${selectorArgs(mode, mine[0].framework, classes)}${floorArg([csproj], 1)}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (out.length === 0) {
|
|
417
|
+
return whole('no link of the command runs the test project owning the task\'s test files — running it whole');
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
command: out.join(' && '),
|
|
421
|
+
selectors: classesOf(files),
|
|
422
|
+
zeroGuard: mode === 'mtp' ? 'mtp-exit-8' : 'count',
|
|
423
|
+
reason: null,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function narrowJs(runner, command, owned, cwdAbs) {
|
|
428
|
+
const single = !/[;&|]/.test(command) && !/^\s*(?:npm|pnpm|yarn|bun)\s+run(?:-script)?\b/.test(command);
|
|
429
|
+
const shaped = runner === 'vitest'
|
|
430
|
+
? /\bvitest\b/.test(command) && /(^|\s)(?:--)?run(\s|$)/.test(command)
|
|
431
|
+
: /\bjest\b/.test(command);
|
|
432
|
+
if (!single || !shaped) return whole(SHAPE);
|
|
433
|
+
const rels = owned.map((abs) => toPosix(relative(cwdAbs, abs)));
|
|
434
|
+
const unsafe = rels.find((r) => UNSAFE_IN_QUOTES.test(r));
|
|
435
|
+
if (unsafe) return whole(`path "${unsafe}" has a character unsafe inside quotes — running it whole`);
|
|
436
|
+
const quoted = rels.map((r) => `"${r}"`).join(' ');
|
|
437
|
+
return {
|
|
438
|
+
command: `${command.trim()} ${runner === 'jest' ? `--runTestsByPath ${quoted}` : quoted}`,
|
|
439
|
+
selectors: rels,
|
|
440
|
+
zeroGuard: 'count',
|
|
441
|
+
reason: null,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Narrow ONE node's command to the task's test files it owns.
|
|
447
|
+
*
|
|
448
|
+
* @param {import('./test-nodes.js').TestNode} node
|
|
449
|
+
* @param {string[]} testFiles - Task output paths (project-relative or absolute),
|
|
450
|
+
* spelled as on disk before anything reads them (F4). Only EXISTING files that
|
|
451
|
+
* are test files for its runner are narrowed on: those inside the node cwd, plus
|
|
452
|
+
* (dotnet) those a link's `.csproj` owns wherever they sit (linkedOutputs). The
|
|
453
|
+
* node runs whole rather than skip anything when an output is a directory it
|
|
454
|
+
* may hold, or (dotnet) a non-C# test source it may compile (forcedWhole); when
|
|
455
|
+
* a candidate — inside the cwd OR compiled by a link's `.csproj` — merely READS
|
|
456
|
+
* like a test file (see unrecognisedTestFile); and when a dotnet command names a
|
|
457
|
+
* project that is not an existing `.csproj` inside the root
|
|
458
|
+
* (unresolvedProjectTarget): no file could be matched to that link at all.
|
|
459
|
+
* @param {string} projectPath
|
|
460
|
+
* @returns {{ command: string|null, selectors: string[], zeroGuard: 'count'|'mtp-exit-8'|null, reason: string|null }}
|
|
461
|
+
* `command` null ⇒ run the declared command whole, and `reason` says why.
|
|
462
|
+
*/
|
|
463
|
+
export function narrowNode(node, testFiles, projectPath = '.') {
|
|
464
|
+
const rootAbs = rootOf(projectPath);
|
|
465
|
+
const cwdAbs = cwdOf(node, rootAbs);
|
|
466
|
+
if (!['dotnet', 'vitest', 'jest'].includes(node?.runner)) {
|
|
467
|
+
return whole(`runner "${node?.runner}" has no in-node narrowing — running it whole`);
|
|
468
|
+
}
|
|
469
|
+
if (typeof node.command !== 'string' || !node.command.trim()) return whole('node has no command to narrow');
|
|
470
|
+
if (node.runner === 'dotnet') {
|
|
471
|
+
const stray = unresolvedProjectTarget(node.command, cwdAbs, rootAbs);
|
|
472
|
+
if (stray) return strayProject(stray);
|
|
473
|
+
}
|
|
474
|
+
const entries = existingEntries(testFiles, rootAbs);
|
|
475
|
+
const forced = forcedWhole(node, entries, cwdAbs, rootAbs);
|
|
476
|
+
if (forced) return whole(forced);
|
|
477
|
+
const existing = entries.files;
|
|
478
|
+
// This node's candidates: inside its cwd, plus (dotnet) whatever a link's csproj compiles.
|
|
479
|
+
const candidates = existing.filter((abs) => isInside(abs, cwdAbs));
|
|
480
|
+
if (node.runner === 'dotnet') {
|
|
481
|
+
for (const abs of linkedOutputs(node.command, existing, cwdAbs, rootAbs)) if (!candidates.includes(abs)) candidates.push(abs);
|
|
482
|
+
}
|
|
483
|
+
const missed = candidates.find((abs) => unrecognisedTestFile(node.runner, abs, rootAbs));
|
|
484
|
+
if (missed) {
|
|
485
|
+
return whole(`output file "${toPosix(relative(rootAbs, missed))}" reads like a test file but is not recognised as one for runner "${node.runner}" — a recorte would skip it; running it whole`);
|
|
486
|
+
}
|
|
487
|
+
const owned = candidates.filter((abs) => isTestFileFor(node.runner, abs, rootAbs));
|
|
488
|
+
if (owned.length === 0) return whole(NO_TEST_FILE);
|
|
489
|
+
return node.runner === 'dotnet'
|
|
490
|
+
? narrowDotnet(node.command.trim(), owned, cwdAbs, rootAbs)
|
|
491
|
+
: narrowJs(node.runner, node.command, owned, cwdAbs);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* How the node set was chosen, for the `--json` summary.
|
|
496
|
+
* `feature` scope has none; `full`/`filter` switch group selection off.
|
|
497
|
+
*/
|
|
498
|
+
function derivationOf({ full, filter, group, nodes }) {
|
|
499
|
+
if (full) return 'full';
|
|
500
|
+
if (filter) return 'filter';
|
|
501
|
+
if (group === 'docs') return 'docs';
|
|
502
|
+
if (!group) return 'no-group';
|
|
503
|
+
return nodes.some((n) => effectiveGroups(n)?.includes(group)) ? 'group' : 'unclaimed-group';
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Scope a test plan to one task.
|
|
508
|
+
*
|
|
509
|
+
* - Feature scope (`taskId` empty): nodes returned as copies, deep-equal to the
|
|
510
|
+
* input — no `selection` key at all (readers take absent as null).
|
|
511
|
+
* - `full`: every node `mode: 'full'`, commands untouched.
|
|
512
|
+
* - `filter`: the caller's `--filter` picks the nodes; here it only switches
|
|
513
|
+
* group selection OFF — narrowing inside the nodes still applies.
|
|
514
|
+
* - Otherwise: group `docs` runs only nodes owning an output file; a group some
|
|
515
|
+
* node claims (`effectiveGroups`) runs the nodes serving it, the unmapped ones
|
|
516
|
+
* (null) and any node owning one of the task's TEST files (safety net for a
|
|
517
|
+
* mis-grouped task); an unclaimed or missing group runs every node.
|
|
518
|
+
*
|
|
519
|
+
* Input nodes are never mutated.
|
|
520
|
+
*
|
|
521
|
+
* @param {Object} args
|
|
522
|
+
* @param {string} args.projectPath
|
|
523
|
+
* @param {import('./test-nodes.js').TestNode[]} args.nodes
|
|
524
|
+
* @param {{ group?: string, outputs?: string[] }|null} args.task
|
|
525
|
+
* @param {string|null} args.taskId
|
|
526
|
+
* @param {{ raw: string, tokens: string[] }|null} [args.filter]
|
|
527
|
+
* @param {boolean} [args.full]
|
|
528
|
+
* @returns {{ nodes: Array<import('./test-nodes.js').TestNode & { selection?: TestSelection }>,
|
|
529
|
+
* summary: { scope: 'feature'|'task', derivation: string|null, group: string|null, testFiles: string[] } }}
|
|
530
|
+
*/
|
|
531
|
+
export function applyTaskScope({ projectPath = '.', nodes = [], task = null, taskId = null, filter = null, full = false } = {}) {
|
|
532
|
+
if (!taskId) {
|
|
533
|
+
return { nodes: nodes.map((n) => ({ ...n })), summary: { scope: 'feature', derivation: null, group: null, testFiles: [] } };
|
|
534
|
+
}
|
|
535
|
+
const rootAbs = rootOf(projectPath);
|
|
536
|
+
const rel = (abs) => toPosix(relative(rootAbs, abs));
|
|
537
|
+
const group = typeof task?.group === 'string' ? task.group : null;
|
|
538
|
+
const taskOutputs = Array.isArray(task?.outputs) ? task.outputs : [];
|
|
539
|
+
const entries = existingEntries(taskOutputs, rootAbs);
|
|
540
|
+
const outputs = entries.files;
|
|
541
|
+
const testFiles = outputs.filter((abs) => isTestFileFor(null, abs, rootAbs));
|
|
542
|
+
const derivation = derivationOf({ full, filter, group, nodes });
|
|
543
|
+
|
|
544
|
+
const scoped = nodes.map((node) => {
|
|
545
|
+
const cwdAbs = cwdOf(node, rootAbs);
|
|
546
|
+
const ownOutputs = outputs.filter((abs) => isInside(abs, cwdAbs));
|
|
547
|
+
// Dotnet: a file a LINK's csproj compiles is this node's too, wherever it sits.
|
|
548
|
+
if (node.runner === 'dotnet') {
|
|
549
|
+
for (const abs of linkedOutputs(node.command, outputs, cwdAbs, rootAbs)) if (!ownOutputs.includes(abs)) ownOutputs.push(abs);
|
|
550
|
+
}
|
|
551
|
+
// A link naming a project the recorte cannot resolve may compile ANY touched
|
|
552
|
+
// .NET source outside the cwd, run any test project's file there (N2), or
|
|
553
|
+
// anything inside a touched folder there: its ownership is unknowable, so the
|
|
554
|
+
// node is never scoped out past one (narrowNode then runs it whole, naming it).
|
|
555
|
+
const mayOwnOutside = node.runner === 'dotnet'
|
|
556
|
+
&& unresolvedProjectTarget(node.command, cwdAbs, rootAbs) !== null
|
|
557
|
+
&& (outputs.some((abs) => !isInside(abs, cwdAbs) && (isDotnetSource(abs) || inTestProject(abs, rootAbs)))
|
|
558
|
+
|| entries.dirs.some((d) => !isInside(d, cwdAbs)));
|
|
559
|
+
// F1/F2: a folder it may hold, or a non-C# test source it may compile.
|
|
560
|
+
const forced = forcedWhole(node, entries, cwdAbs, rootAbs) !== null;
|
|
561
|
+
const ownTests = ownOutputs.filter((abs) => isTestFileFor(node.runner, abs, rootAbs));
|
|
562
|
+
// A test-looking output the recogniser missed counts as owned for the pull-in
|
|
563
|
+
// too: scoping the node out would skip it just as surely as narrowing past it.
|
|
564
|
+
const ownsTests = ownTests.length > 0 || mayOwnOutside || forced
|
|
565
|
+
|| ownOutputs.some((abs) => unrecognisedTestFile(node.runner, abs, rootAbs));
|
|
566
|
+
const select = (selection, command = node.command) => ({
|
|
567
|
+
...node,
|
|
568
|
+
command,
|
|
569
|
+
selection: {
|
|
570
|
+
declaredCommand: node.command ?? null, files: ownTests.map(rel), selectors: [], zeroGuard: null, ...selection,
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
if (derivation === 'full') return select({ mode: 'full', reason: '--full: task-scope narrowing switched off — running it whole' });
|
|
575
|
+
// The resolver already decided (invalid config, "no suites"): preDecideRun
|
|
576
|
+
// reports that verdict first, and a recorte would only disguise it.
|
|
577
|
+
if (node.status) return select({ mode: 'whole', reason: node.reason ?? `pre-decided: ${node.status}` });
|
|
578
|
+
|
|
579
|
+
// Ownership for docs is the SAME rule as for links (B3 carry-over): the cwd
|
|
580
|
+
// contains the file OR a link's csproj compiles it — ownOutputs holds both;
|
|
581
|
+
// a folder or non-C# test source it may run (forced) keeps it too.
|
|
582
|
+
if (derivation === 'docs' && ownOutputs.length === 0 && !mayOwnOutside && !forced) {
|
|
583
|
+
return select({ mode: 'out-of-scope', files: [], reason: 'out of task scope: task group "docs" runs only the nodes owning a file from the task\'s outputs' });
|
|
584
|
+
}
|
|
585
|
+
const groups = effectiveGroups(node);
|
|
586
|
+
if (derivation === 'group' && groups !== null && !groups.includes(group) && !ownsTests) {
|
|
587
|
+
return select({ mode: 'out-of-scope', files: [], reason: `out of task scope: node serves [${groups.join(', ')}], task group is "${group}"` });
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Every output: narrowNode keeps the cwd rule itself and adds the link-owned files.
|
|
591
|
+
const narrowed = narrowNode(node, taskOutputs, rootAbs);
|
|
592
|
+
if (narrowed.command === null) return select({ mode: 'whole', reason: narrowed.reason });
|
|
593
|
+
return select({ mode: 'narrowed', reason: null, selectors: narrowed.selectors, zeroGuard: narrowed.zeroGuard }, narrowed.command);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
return { nodes: scoped, summary: { scope: 'task', derivation, group, testFiles: testFiles.map(rel) } };
|
|
597
|
+
}
|
|
@@ -92,8 +92,15 @@ export function extractCitedFiles(recapText) {
|
|
|
92
92
|
return [...cited];
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
/**
|
|
96
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Backslashes to forward slashes, strip a leading `./`.
|
|
97
|
+
*
|
|
98
|
+
* Exported because `src/lib/tasks/test-scope.js` normalises the SAME thing —
|
|
99
|
+
* a task's `outputs` entry — before deciding which tests a task-scoped verify
|
|
100
|
+
* runs. One normaliser for `outputs`, so the traceability check and the test
|
|
101
|
+
* recorte never disagree about which file a task delivered.
|
|
102
|
+
*/
|
|
103
|
+
export function normalizePath(p) {
|
|
97
104
|
return String(p).replace(/\\/g, '/').replace(/^\.\//, '');
|
|
98
105
|
}
|
|
99
106
|
|