@polymorphism-tech/morph-spec 10.1.1 → 10.5.2

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 (86) hide show
  1. package/README.md +3 -7
  2. package/bin/morph-spec.js +10 -41
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -4
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/MORPH.md +1 -1
  7. package/framework/agents.json +1 -2
  8. package/framework/commands/morph-archive.md +1 -1
  9. package/framework/commands/morph-preflight.md +3 -4
  10. package/framework/docs/CLI.md +45 -22
  11. package/framework/evals/review-checklist.md +5 -5
  12. package/framework/hooks/README.md +2 -2
  13. package/framework/hooks/claude-code/core/heavy-gate.js +21 -2
  14. package/framework/hooks/claude-code/core/validate-completion.js +0 -35
  15. package/framework/hooks/shared/e2e-check.js +44 -7
  16. package/framework/hooks/shared/heavy-command.js +12 -1
  17. package/framework/hooks/shared/heavy-config.js +12 -0
  18. package/framework/hooks/shared/host-services.js +9 -6
  19. package/framework/hooks/shared/next-step.js +12 -8
  20. package/framework/phases.json +4 -4
  21. package/framework/rules/plan-grammar.md +4 -1
  22. package/framework/schemas/config.schema.json +59 -1
  23. package/framework/schemas/feature.schema.json +15 -0
  24. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  25. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  26. package/framework/skills/core/morph-apply/SKILL.md +8 -5
  27. package/framework/skills/core/morph-apply/references/hotfix.md +1 -1
  28. package/framework/skills/core/morph-apply/references/machine.md +3 -2
  29. package/framework/skills/core/morph-apply/references/review.md +9 -7
  30. package/framework/skills/core/morph-plan/SKILL.md +7 -6
  31. package/framework/skills/core/morph-plan/references/critique.md +1 -1
  32. package/framework/skills/core/morph-uiux/SKILL.md +2 -2
  33. package/framework/templates/IDE-SNIPPETS.md +262 -262
  34. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  35. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  36. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  37. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  38. package/framework/templates/docs/plan.md +2 -1
  39. package/framework/templates/examples/design-system-examples.md +0 -3
  40. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  41. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  42. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  43. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  44. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  45. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  46. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  47. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  48. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  49. package/framework/templates/state.template.json +17 -17
  50. package/framework/templates/ui/design-system.css +226 -226
  51. package/package.json +1 -1
  52. package/src/commands/project/doctor.js +0 -1
  53. package/src/commands/project/heavy.js +13 -0
  54. package/src/commands/state/approve.js +9 -109
  55. package/src/commands/state/report-check.js +44 -2
  56. package/src/commands/validation/verify.js +57 -7
  57. package/src/core/state/state-manager.js +0 -24
  58. package/src/dashboard/app/main.jsx +13 -13
  59. package/src/dashboard/vite.config.js +24 -24
  60. package/src/lib/heavy/cleanup.js +8 -2
  61. package/src/lib/heavy/gate.js +24 -1
  62. package/src/lib/heavy/reclaim.js +107 -0
  63. package/src/lib/heavy/watchdog.js +46 -0
  64. package/src/lib/next-step.js +1 -0
  65. package/src/lib/plan/checks.js +1 -1
  66. package/src/lib/plan/critique.js +23 -3
  67. package/src/lib/procs/prune.js +23 -1
  68. package/src/lib/review-confirmation.js +67 -0
  69. package/src/lib/review-reports.js +1 -0
  70. package/src/lib/risk-detector.js +2 -2
  71. package/src/lib/tasks/test-nodes.js +10 -0
  72. package/src/lib/tasks/test-runner.js +37 -43
  73. package/src/lib/telemetry/telemetry-reader.js +1 -2
  74. package/src/lib/telemetry/verify-event.js +7 -0
  75. package/src/lib/validators/verify-cache.js +63 -0
  76. package/src/lib/validators/verify-runner.js +131 -26
  77. package/src/lib/verify/affected.js +141 -0
  78. package/src/lib/verify/detached.js +154 -0
  79. package/src/utils/agents-installer.js +2 -2
  80. package/src/utils/hooks-installer.js +0 -20
  81. package/bin/validate.js +0 -212
  82. package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
  83. package/framework/templates/.idea/morph-templates.xml +0 -92
  84. package/framework/templates/.vscode/morph-templates.code-snippets +0 -186
  85. package/src/commands/observability/telemetry.js +0 -60
  86. package/src/commands/validation/validate-feature.js +0 -337
@@ -0,0 +1,67 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { basename } from 'path';
3
+ import { treeMovedSince } from './git/evidence-staleness.js';
4
+ import { isTestOnly, securityReviewFor } from './risk-detector.js';
5
+
6
+ function resolveCommit(cwd, ref) {
7
+ try {
8
+ return execFileSync('git', ['rev-parse', '--verify', `${ref}^{commit}`], { cwd, stdio: 'pipe' }).toString().trim();
9
+ } catch {
10
+ return null;
11
+ }
12
+ }
13
+
14
+ function changedBetween(cwd, from, to) {
15
+ try {
16
+ return execFileSync('git', ['diff', '--name-only', '--no-renames', from, to, '--', '.', ':(exclude).morph'], { cwd, stdio: 'pipe' })
17
+ .toString().split('\n').map((line) => line.trim()).filter(Boolean);
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function citedByBlockers(reports) {
24
+ return Object.values(reports)
25
+ .flatMap((report) => report.rounds ?? [])
26
+ .flatMap((round) => round.blockers ?? [])
27
+ .map((blocker) => `${blocker.where} ${blocker.evidence}`)
28
+ .join('\n');
29
+ }
30
+
31
+ const openedBlockers = (report) => (report.rounds ?? []).some((round) => (round.blockers ?? []).length > 0);
32
+ const isCleanShip = (report) => report.verdict === 'SHIP' && (report.blockers ?? []).length === 0;
33
+
34
+ function deltaIsNarrow(role, delta, cited) {
35
+ if (delta === null) return false;
36
+ if (role === 'security') return !securityReviewFor(delta).required;
37
+ return delta.every((file) => isTestOnly(file) || cited.includes(file) || cited.includes(basename(file)));
38
+ }
39
+
40
+ /**
41
+ * After a fix, a role that shipped clean need not return when the delta stays inside what the
42
+ * blockers named (security: when it touches no sensitive path). `moved` lists the roles behind
43
+ * `head`; `skippable` says it holds; `confirmed` says a role that opened blockers re-judged `head`,
44
+ * without which nothing is skippable at approval.
45
+ */
46
+ export function reviewConfirmation(cwd, reports, head) {
47
+ const cited = citedByBlockers(reports);
48
+ const moved = [];
49
+ let confirmed = false;
50
+
51
+ for (const [role, report] of Object.entries(reports)) {
52
+ if (report.status === 'missing' || !report.reviewedSha) continue;
53
+ const reviewed = resolveCommit(cwd, report.reviewedSha);
54
+ if (!reviewed) continue;
55
+ if (!treeMovedSince(cwd, reviewed, head)) {
56
+ if (openedBlockers(report)) confirmed = true;
57
+ continue;
58
+ }
59
+ moved.push({
60
+ role,
61
+ reviewed,
62
+ skippable: isCleanShip(report) && deltaIsNarrow(role, changedBetween(cwd, reviewed, head), cited),
63
+ });
64
+ }
65
+
66
+ return { moved, confirmed };
67
+ }
@@ -32,6 +32,7 @@ const SCREEN_CHECKLIST_SECTION_RE = sectionRe('Checklist\\s+de\\s+tela');
32
32
 
33
33
  /** Hotfix: só o verificador, escrito pelo orquestrador sem dispatch. Chore: só o revisor (review leve). */
34
34
  export function requiredReviewRoles({ workType, securityRequired = false } = {}) {
35
+ // Sem `security` mesmo em caminho sensível: o humano assina todo hotfix.
35
36
  if (workType === 'hotfix') return ['verifier'];
36
37
  const base = workType === 'chore' ? ['reviewer'] : ['verifier', 'reviewer'];
37
38
  return securityRequired ? [...base, 'security'] : base;
@@ -83,7 +83,7 @@ export function reviewPause(signal) {
83
83
  + ' auto-avaliação de quem implementou não decide o ship';
84
84
  }
85
85
  if (r.status === 'partial') {
86
- return `\`${file}\` está PARCIAL — retome o mesmo agente por SendMessage; parcial não é veredito`;
86
+ return `\`${file}\` está PARCIAL — despache o mesmo papel de novo dizendo o que já está no relatório; parcial não é veredito`;
87
87
  }
88
88
  if (r.status !== 'ok') {
89
89
  return `\`${file}\` sem \`**Verdict:** SHIP|FIX\` — o relatório não seguiu o contrato`;
@@ -144,7 +144,7 @@ export function verifierModelFor({ promptModelTouched = false, generatesMedia =
144
144
 
145
145
  // Teste e artefato de teste não são superfície de ataque: `e2e/login.spec.ts` não pede a persona security.
146
146
  const E2E_DIR_RE = /(^|\/)e2e\//;
147
- const isTestOnly = (f) => isTestPath(f) || E2E_DIR_RE.test(f) || isTestArtifact(f);
147
+ export const isTestOnly = (f) => isTestPath(f) || E2E_DIR_RE.test(f) || isTestArtifact(f);
148
148
 
149
149
  export function securityReviewFor(changedFiles = []) {
150
150
  const risky = changedFiles
@@ -83,12 +83,14 @@ function makeNode(partial) {
83
83
  runner,
84
84
  expectedCount: keepsBaseline(runner) ? partial.expectedCount ?? null : null,
85
85
  timeoutMs: partial.timeoutMs ?? null,
86
+ parallel: partial.parallel ?? false,
86
87
  build: partial.build ?? [],
87
88
  e2e: partial.e2e ?? false,
88
89
  linuxImage: partial.linuxImage ?? null,
89
90
  projectCount: partial.projectCount ?? 1,
90
91
  // null is "not an eval node", distinct from an empty block (already refused).
91
92
  evals: partial.evals ?? null,
93
+ affected: partial.affected ?? null,
92
94
  status: partial.status ?? null,
93
95
  reason: partial.reason ?? null,
94
96
  };
@@ -127,12 +129,18 @@ function firstProblem(raw, projectPath) {
127
129
  if (raw.runner !== undefined && !TEST_RUNNERS.includes(raw.runner)) {
128
130
  return `runner must be one of ${TEST_RUNNERS.join(' | ')}`;
129
131
  }
132
+ if (raw.affected !== undefined && (!isPlainObject(raw.affected) || typeof raw.affected.flag !== 'string' || !raw.affected.flag.trim())) {
133
+ return 'affected must be an object with a non-empty flag';
134
+ }
130
135
  if (raw.expectedCount !== undefined && !isNonNegativeInteger(raw.expectedCount)) {
131
136
  return 'expectedCount must be an integer >= 0';
132
137
  }
133
138
  if (raw.timeoutMs !== undefined && !(Number.isInteger(raw.timeoutMs) && raw.timeoutMs > 0)) {
134
139
  return 'timeoutMs must be an integer > 0';
135
140
  }
141
+ if (raw.parallel !== undefined && typeof raw.parallel !== 'boolean') {
142
+ return 'parallel must be a boolean';
143
+ }
136
144
  if (raw.build !== undefined && (!Array.isArray(raw.build) || raw.build.some((b) => typeof b !== 'string'))) {
137
145
  return 'build must be an array of strings';
138
146
  }
@@ -208,10 +216,12 @@ export function validateTestNodes(config, projectPath = null) {
208
216
  runner: entry.runner ?? inferRunner(entry.command),
209
217
  expectedCount: entry.expectedCount ?? null,
210
218
  timeoutMs: entry.timeoutMs ?? null,
219
+ parallel: entry.parallel ?? false,
211
220
  build: entry.build ?? [],
212
221
  e2e: entry.e2e ?? false,
213
222
  linuxImage: entry.linuxImage ?? null,
214
223
  evals: entry.evals ?? null,
224
+ affected: entry.affected ?? null,
215
225
  projectCount: 1,
216
226
  });
217
227
  });
@@ -1,5 +1,5 @@
1
- import { execSync, execFileSync } from 'child_process';
2
- import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'fs';
1
+ import { exec as execCallback, execSync, execFileSync } from 'child_process';
2
+ import { existsSync, readFileSync, readdirSync, realpathSync } from 'fs';
3
3
  import { join, resolve, relative, isAbsolute } from 'path';
4
4
  import { globSync } from 'glob';
5
5
 
@@ -157,6 +157,7 @@ function countCompletedRuns(output) {
157
157
  }
158
158
 
159
159
  const NO_BUILD_FLAG = /(^|\s)--no-build(\s|$)/;
160
+ const DOTNET_COMMAND = /^\s*dotnet\s/;
160
161
 
161
162
  // `[]` is the safe answer: nothing compiled here, so a `--no-build` in the same command is refused.
162
163
  export function extractCsprojTargets(command) {
@@ -177,28 +178,6 @@ export function resolveTestBuildTargets(node) {
177
178
  return extractCsprojTargets(node.command);
178
179
  }
179
180
 
180
- const DOTNET_BUILDABLE = /"([^"]+\.(?:slnx?|csproj))"|'([^']+\.(?:slnx?|csproj))'|(\S+\.(?:slnx?|csproj))/gi;
181
-
182
- /**
183
- * What `dotnet test` compiles for this node, relative to its `cwd`: declared `build`,
184
- * else what the command names, else the directory's single solution/project; `[]` = undiscoverable.
185
- */
186
- export function discoverDotnetBuildTargets(node, cwd) {
187
- if (node?.runner !== 'dotnet') return [];
188
- if (Array.isArray(node.build) && node.build.length > 0) return node.build;
189
- const command = typeof node.command === 'string' ? node.command : '';
190
- const named = [...command.matchAll(DOTNET_BUILDABLE)].map((m) => m[1] ?? m[2] ?? m[3]);
191
- if (named.length > 0) return [...new Set(named)];
192
-
193
- const dirArg = command.split(/\s+/).find((token) => token && !token.startsWith('-') && token !== 'dotnet' && token !== 'test'
194
- && isDirectory(join(cwd, token)));
195
- const dir = dirArg ?? '.';
196
- let entries;
197
- try { entries = readdirSync(join(cwd, dir)); } catch { return []; }
198
- const only = singleDotnetBuildable(entries);
199
- return only ? [dir === '.' ? only : join(dir, only).replace(/\\/g, '/')] : [];
200
- }
201
-
202
181
  function singleDotnetBuildable(files) {
203
182
  const solutions = files.filter((f) => /\.slnx?$/i.test(f));
204
183
  const projects = files.filter((f) => /\.csproj$/i.test(f));
@@ -237,10 +216,6 @@ export function discoverDotnetSolution(root) {
237
216
  return { target: single, candidates: pool };
238
217
  }
239
218
 
240
- function isDirectory(path) {
241
- try { return statSync(path).isDirectory(); } catch { return false; }
242
- }
243
-
244
219
  // Derived from build-check.js, the single spelling of "dotnet build". `--no-restore` is
245
220
  // dropped: a test project outside the solution may never have been restored.
246
221
  export function dotnetBuildCommandFor(target, projectPath) {
@@ -253,7 +228,7 @@ export function dotnetBuildCommandFor(target, projectPath) {
253
228
 
254
229
  // Runs only with declared `build[]` or `--no-build`: a plain `dotnet test` builds
255
230
  // itself, and pre-compiling it would double the clock of the hot path.
256
- function runTestPrebuild({ node, cwd, exec, timeout, projectPath }) {
231
+ function runTestPrebuild({ node, cwd, exec, timeout, projectPath, builtTargets = new Set() }) {
257
232
  if (node.runner !== 'dotnet') return { built: false, failure: null };
258
233
 
259
234
  const declared = Array.isArray(node.build) ? node.build : [];
@@ -265,10 +240,13 @@ function runTestPrebuild({ node, cwd, exec, timeout, projectPath }) {
265
240
  const outputs = [];
266
241
 
267
242
  for (const target of targets) {
243
+ const key = `${cwd}\0${target}`;
244
+ if (builtTargets.has(key)) continue;
268
245
  const command = dotnetBuildCommandFor(target, projectPath);
269
246
  if (!command) return { built: false, failure: null };
270
247
  try {
271
248
  exec(command, { cwd, timeout, encoding: 'utf8', stdio: 'pipe' });
249
+ builtTargets.add(key);
272
250
  } catch (err) {
273
251
  outputs.push(`[${command}]\n${(err.stdout || '') + (err.stderr || '')}`);
274
252
  return {
@@ -511,12 +489,12 @@ export function runTestSuite(projectPath = '.', opts = {}) {
511
489
  const cwd = resolve(projectPath, node.cwd || '.');
512
490
 
513
491
  // Prebuild BEFORE judging `--no-build`: the build is what makes the flag legal.
514
- const prebuild = runTestPrebuild({ node, cwd, exec, timeout, projectPath });
492
+ const prebuild = runTestPrebuild({ node, cwd, exec, timeout, projectPath, builtTargets: opts.builtTargets });
515
493
  if (prebuild.failure) return prebuild.failure;
516
494
 
517
495
  // `--no-build` without a build here would run a stale assembly. The command is never
518
496
  // rewritten: a silent fix would make the reported `command` a lie about what ran.
519
- if (node.runner === 'dotnet' && NO_BUILD_FLAG.test(cmd) && !prebuild.built) {
497
+ if (DOTNET_COMMAND.test(cmd) && NO_BUILD_FLAG.test(cmd) && !prebuild.built && !opts.solutionBuilt) {
520
498
  return {
521
499
  passed: false,
522
500
  buildFailed: false,
@@ -530,18 +508,17 @@ export function runTestSuite(projectPath = '.', opts = {}) {
530
508
  // Layered OVER process.env: a child that lost PATH fails for reasons unrelated to the suite.
531
509
  const env = opts.env ? { ...process.env, ...opts.env } : undefined;
532
510
 
533
- try {
534
- const output = exec(cmd, {
535
- cwd,
536
- timeout,
537
- encoding: 'utf8',
538
- stdio: 'pipe',
539
- // Node's 1 MiB default kills a large suite mid-run (ENOBUFS), which then reads as a red suite.
540
- maxBuffer: TEST_OUTPUT_MAX_BUFFER,
541
- ...(env ? { env } : {}),
542
- });
543
- return { passed: true, output: output ?? '', exitCode: 0, timeoutMs: timeout };
544
- } catch (err) {
511
+ const execOptions = {
512
+ cwd,
513
+ timeout,
514
+ encoding: 'utf8',
515
+ stdio: 'pipe',
516
+ // Node's 1 MiB default kills a large suite mid-run (ENOBUFS), which then reads as a red suite.
517
+ maxBuffer: TEST_OUTPUT_MAX_BUFFER,
518
+ ...(env ? { env } : {}),
519
+ };
520
+ const passed = (output) => ({ passed: true, output: output ?? '', exitCode: 0, timeoutMs: timeout });
521
+ const failed = (err) => {
545
522
  // A spawn failure or a kill leaves both streams empty; its own error is then the only lead.
546
523
  const output = (err.stdout || '') + (err.stderr || '')
547
524
  || [err.code, err.signal, err.message].filter(Boolean).join(' ');
@@ -571,5 +548,22 @@ export function runTestSuite(projectPath = '.', opts = {}) {
571
548
  exitCode: err.status ?? 1,
572
549
  timeoutMs: timeout,
573
550
  };
551
+ };
552
+
553
+ if (opts.background) return runInBackground(cmd, execOptions).then(passed, failed);
554
+ try {
555
+ return passed(exec(cmd, execOptions));
556
+ } catch (err) {
557
+ return failed(err);
574
558
  }
575
559
  }
560
+
561
+ // `exec` reports the exit code in `err.code`; the shared handler reads `err.status`, as from execSync.
562
+ function runInBackground(cmd, options) {
563
+ return new Promise((resolvePromise, reject) => {
564
+ execCallback(cmd, options, (err, stdout, stderr) => {
565
+ if (!err) return resolvePromise(stdout);
566
+ return reject(Object.assign(err, { stdout, stderr, status: Number.isInteger(err.code) ? err.code : null }));
567
+ });
568
+ });
569
+ }
@@ -164,8 +164,7 @@ export function aggregateTelemetry(events, opts = {}) {
164
164
  };
165
165
  }
166
166
 
167
- // Kept apart from aggregateTelemetry: its return shape is spread into `telemetry --json`, so new fields
168
- // there would leak into that contract. Bytes re-ingested is a proxy for cost, never a token count.
167
+ // Bytes re-ingested is a proxy for cost, never a token count.
169
168
 
170
169
  function mapToObj(map) {
171
170
  const out = {};
@@ -11,6 +11,11 @@ export function buildVerifyEvent(result, { sha, tree, persisted, reason, fingerp
11
11
  if (!node) continue;
12
12
  nodes[name] = { status: node.status, durationMs: Number.isFinite(node.durationMs) ? node.durationMs : null };
13
13
  }
14
+ if (nodes.tests && Array.isArray(result.nodes.tests.runs)) {
15
+ nodes.tests.runs = result.nodes.tests.runs.map(({ id, status, durationMs }) => ({
16
+ id, status: status ?? null, durationMs: Number.isFinite(durationMs) ? durationMs : null,
17
+ }));
18
+ }
14
19
  if (nodes.validators) {
15
20
  nodes.validators.ran = [...(result.nodes.validators.ran || [])];
16
21
  nodes.validators.skipped = (result.nodes.validators.skipped || [])
@@ -26,6 +31,8 @@ export function buildVerifyEvent(result, { sha, tree, persisted, reason, fingerp
26
31
  persisted,
27
32
  reason: persisted ? null : (reason ?? null),
28
33
  source,
34
+ reused: result.reused ?? [],
35
+ flaky: result.flaky ?? [],
29
36
  nodes,
30
37
  };
31
38
  }
@@ -0,0 +1,63 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+
4
+ // Ignored files (`.env`, a database) are outside the fingerprint: `verify --fresh` is the way out.
5
+ function cachePath(gitCommonDir, featureName) {
6
+ return join(gitCommonDir, 'morph-spec', `verify-nodes-${featureName}.json`);
7
+ }
8
+
9
+ export function readVerifyCache(gitCommonDir, featureName) {
10
+ try {
11
+ return JSON.parse(readFileSync(cachePath(gitCommonDir, featureName), 'utf8'));
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ /** Fail-open: a cache that cannot be written costs a rerun, never a verdict. */
18
+ export function writeVerifyCache(gitCommonDir, featureName, entry) {
19
+ try {
20
+ const path = cachePath(gitCommonDir, featureName);
21
+ mkdirSync(dirname(path), { recursive: true });
22
+ writeFileSync(path, JSON.stringify(entry));
23
+ } catch { /* see above */ }
24
+ }
25
+
26
+ const withoutOutput = ({ output: _output, ...rest }) => rest;
27
+
28
+ export function cacheEntryFrom(fingerprint, nodes) {
29
+ return {
30
+ fingerprint,
31
+ build: withoutOutput(nodes.build),
32
+ tests: (nodes.tests.runs || []).map(withoutOutput),
33
+ e2e: withoutOutput(nodes.e2e),
34
+ };
35
+ }
36
+
37
+ const NOTHING = Object.freeze({
38
+ build: null, tests: new Map(), e2e: null, redFirst: new Set(), retried: new Set(),
39
+ });
40
+
41
+ /**
42
+ * Same tree: green nodes stand, red ones run again (`retried`, to name a flake when they turn green).
43
+ * Tree moved: nothing stands, and last round's red suites run first (`redFirst`).
44
+ */
45
+ export function planReuse(previous, fingerprint) {
46
+ if (!previous || !fingerprint) return NOTHING;
47
+ const runs = previous.tests || [];
48
+ const red = new Set(runs.filter((run) => run.status === 'fail').map((run) => run.id));
49
+
50
+ if (previous.fingerprint !== fingerprint) return { ...NOTHING, redFirst: red };
51
+
52
+ return {
53
+ build: previous.build?.status === 'pass' ? previous.build : null,
54
+ tests: new Map(runs.filter((run) => run.status === 'pass').map((run) => [run.id, run])),
55
+ e2e: previous.e2e?.status === 'pass' ? previous.e2e : null,
56
+ redFirst: red,
57
+ retried: previous.e2e?.status === 'fail' ? new Set([...red, 'e2e']) : red,
58
+ };
59
+ }
60
+
61
+ export function reusedEntry(entry) {
62
+ return { ...entry, output: '', durationMs: 0, reused: true };
63
+ }