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