sparkforensics-mcp 0.3.0 → 0.5.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 (92) hide show
  1. package/bin/sparkforensics-mcp.mjs +41 -10
  2. package/package.json +1 -1
  3. package/vendor-core/allocation.js +106 -0
  4. package/vendor-core/analyzer.js +168 -60
  5. package/vendor-core/check-coverage.js +88 -0
  6. package/vendor-core/cli/budgets.js +54 -27
  7. package/vendor-core/cli/collect-run.js +84 -32
  8. package/vendor-core/cli/regression-budgets.js +83 -0
  9. package/vendor-core/cli/threshold-config.js +28 -0
  10. package/vendor-core/comparison-verdict.js +177 -0
  11. package/vendor-core/core-source-hash.txt +1 -0
  12. package/vendor-core/core-usage-locality.js +56 -2
  13. package/vendor-core/detector-docs.js +58 -0
  14. package/vendor-core/detectors.js +1094 -500
  15. package/vendor-core/docs-config.js +0 -36
  16. package/vendor-core/docs-content/chapters/nav-index.json +31 -0
  17. package/vendor-core/docs-content/detection/cache.md +3 -2
  18. package/vendor-core/docs-content/detection/cfg.md +9 -8
  19. package/vendor-core/docs-content/detection/chrn.md +1 -2
  20. package/vendor-core/docs-content/detection/cold.md +4 -2
  21. package/vendor-core/docs-content/detection/cstor.md +9 -0
  22. package/vendor-core/docs-content/detection/fail.md +3 -2
  23. package/vendor-core/docs-content/detection/gc.md +3 -2
  24. package/vendor-core/docs-content/detection/host.md +2 -1
  25. package/vendor-core/docs-content/detection/local.md +1 -1
  26. package/vendor-core/docs-content/detection/mem.md +5 -2
  27. package/vendor-core/docs-content/detection/plan.md +2 -1
  28. package/vendor-core/docs-content/detection/sfail.md +2 -1
  29. package/vendor-core/docs-content/detection/shape.md +5 -4
  30. package/vendor-core/docs-content/detection/skew.md +3 -1
  31. package/vendor-core/docs-content/detection/slow.md +2 -2
  32. package/vendor-core/docs-content/detection/spec.md +2 -3
  33. package/vendor-core/docs-content/detection/spill.md +1 -1
  34. package/vendor-core/docs-site-config.js +3 -0
  35. package/vendor-core/effective-conf.js +107 -0
  36. package/vendor-core/efficiency-model.js +8 -6
  37. package/vendor-core/event-handlers.js +321 -44
  38. package/vendor-core/event-schemas.js +23 -0
  39. package/vendor-core/evidence-report.js +432 -115
  40. package/vendor-core/export-data.js +79 -6
  41. package/vendor-core/finding-action-label.js +9 -88
  42. package/vendor-core/finding-filter-predicate.js +9 -0
  43. package/vendor-core/finding-generic-recommendation.js +26 -105
  44. package/vendor-core/finding-names.js +28 -45
  45. package/vendor-core/finding-presentation.js +368 -0
  46. package/vendor-core/finding-tag-help.js +110 -0
  47. package/vendor-core/finding-types.js +373 -0
  48. package/vendor-core/findings-of-type.js +11 -0
  49. package/vendor-core/format-utils.js +96 -30
  50. package/vendor-core/html-export.js +51 -0
  51. package/vendor-core/impact-band.js +21 -8
  52. package/vendor-core/impact-estimator.js +25 -520
  53. package/vendor-core/impact-format.js +115 -0
  54. package/vendor-core/impact-model.js +197 -0
  55. package/vendor-core/ingest.js +6 -2
  56. package/vendor-core/intervals.js +13 -0
  57. package/vendor-core/list-runs.js +7 -5
  58. package/vendor-core/load-vendored.js +70 -5
  59. package/vendor-core/mcp-server-factory.js +14 -10
  60. package/vendor-core/mcp-tools.js +105 -45
  61. package/vendor-core/model-assembler.js +35 -1
  62. package/vendor-core/occupancy.js +1 -1
  63. package/vendor-core/parser-worker.js +2 -2
  64. package/vendor-core/plan-graph-model.js +3 -2
  65. package/vendor-core/plan-node-detail.js +1 -1
  66. package/vendor-core/proxy.js +3 -1
  67. package/vendor-core/python-stage.js +25 -0
  68. package/vendor-core/recommendation-rollup.js +70 -3
  69. package/vendor-core/redact.js +96 -37
  70. package/vendor-core/remediation.js +20 -0
  71. package/vendor-core/run-comparison.js +73 -29
  72. package/vendor-core/run-interpretation.js +291 -0
  73. package/vendor-core/run-metrics.js +198 -0
  74. package/vendor-core/run-outcome.js +74 -0
  75. package/vendor-core/run-payload.js +17 -0
  76. package/vendor-core/run-shape.js +40 -0
  77. package/vendor-core/run-totals.js +24 -0
  78. package/vendor-core/run-verdict.js +352 -0
  79. package/vendor-core/scaling-sim.js +4 -5
  80. package/vendor-core/scorecard-estimates.js +63 -0
  81. package/vendor-core/session-snapshot.js +7 -0
  82. package/vendor-core/shs-schemas.js +2 -2
  83. package/vendor-core/spark-memory.js +17 -0
  84. package/vendor-core/sql-stages.js +11 -0
  85. package/vendor-core/stage-plan-nodes.js +18 -0
  86. package/vendor-core/stage-quantiles.js +6 -0
  87. package/vendor-core/threshold-overrides.js +160 -0
  88. package/vendor-core/threshold-summary.js +11 -33
  89. package/vendor-core/types.js +54 -42
  90. package/vendor-core/wall-clock.js +1 -12
  91. package/vendor-core/wasted-core-hours.js +12 -9
  92. package/vendor-core/write-targets.js +312 -0
@@ -6,16 +6,21 @@ import { collectRun } from './cli/collect-run.js';
6
6
  import { deriveEvidenceAvailability } from './evidence-availability.js';
7
7
  import { resolveFromShs, DEFAULT_MAX_ARCHIVE_BYTES, DEFAULT_IDLE_TIMEOUT_MS } from './shs-load.js';
8
8
  import { mcpError } from './mcp-error.js';
9
- import { buildEvidenceReport, toFindingsFilter, } from './evidence-report.js';
10
- import { redactAppIdentity, redactComparison } from './redact.js';
9
+ import { buildEvidenceReport, toFindingsFilter, } from './evidence-report.js';
10
+ import { redactComparison } from './redact.js';
11
11
  import { computeWallClock } from './wall-clock.js';
12
12
  import { analyze } from './analyzer.js';
13
13
  import { buildComparison, renderComparisonMarkdown, } from './run-comparison.js';
14
+ import { comparisonVerdict, } from './comparison-verdict.js';
14
15
  import { evaluateBudgets, } from './cli/budgets.js';
15
16
  import { FINDING_NAMES, titleCase } from './finding-names.js';
16
- import { docAnchorForType, tuningDocSlugForAnchor, pageForAnchor } from './docs-config.js';
17
+ import { docAnchorForType } from './detector-docs.js';
18
+ import { DETECTORS, } from './detectors.js';
19
+ import { tunedDetectors } from './threshold-overrides.js';
20
+ import { tuningDocSlugForAnchor, pageForAnchor } from './docs-config.js';
17
21
  import { typeTag } from './format-utils.js';
18
-
22
+
23
+
19
24
 
20
25
 
21
26
  // RunRef uses a nested `source` key, matching every real call site (resolveOrCreateRun's
@@ -33,13 +38,22 @@ import { typeTag } from './format-utils.js';
33
38
 
34
39
 
35
40
 
41
+
42
+
43
+
44
+
45
+
46
+
47
+
36
48
 
37
49
  // compareRuns returns a smaller MCP-facing projection of CompareRunsResult
38
- // (runIdA/runIdB/findingsDelta/metricDeltas/confidence/reason/matchedCoverage), not the full raw
39
- // shape (no baselineLabel/stageSkew/baseStages/candStages).
50
+ // (runIdA/runIdB/verdict/findingsDelta/metricDeltas/confidence/reason/matchedCoverage), not the full
51
+ // raw shape (no baselineLabel/stageSkew/baseStages/candStages/jobOutcomes).
40
52
 
41
53
 
42
54
 
55
+
56
+
43
57
 
44
58
 
45
59
 
@@ -169,20 +183,29 @@ export async function resolveOrCreateRun(
169
183
  return resolution;
170
184
  }
171
185
 
186
+ // `thresholds` on every analyzing tool below: the server's --thresholds overrides, fixed for the
187
+ // process by createMcpServer(). A client can't set them per call; the dashboard never has them.
172
188
  export function diagnoseRun(runId , opts
173
189
 
174
-
190
+
175
191
  )
176
-
177
-
192
+
193
+
194
+
178
195
  {
179
196
  const appModel = getCachedAppModel(runId);
180
197
  const findingsFilter = toFindingsFilter(opts?.impactBand, opts?.type, opts?.stageId);
181
- const { json, markdown } = buildEvidenceReport(appModel, { redact: opts?.redact, markdown: opts?.markdown, findingsFilter });
198
+ const { json, markdown } = buildEvidenceReport(appModel, {
199
+ redact: opts?.redact, markdown: opts?.markdown, findingsFilter, thresholds: opts?.thresholds,
200
+ });
182
201
  const include = opts?.include ?? [];
202
+ const tuned = json.summary.tunedThresholds;
183
203
  return {
184
- runId, findings: json.findings, recommendations: json.recommendations, cleanChecks: json.cleanChecks,
204
+ runId, verdict: json.verdict, findings: json.findings, recommendations: json.recommendations, cleanChecks: json.cleanChecks,
205
+ notRunChecks: json.notRunChecks,
185
206
  runComplete: appModel.app?.endTime != null,
207
+ // Top level too, so a client that never asks for `summary` still sees the run was tuned.
208
+ ...(tuned ? { tunedThresholds: tuned } : {}),
186
209
  ...(include.includes('summary') ? { summary: json.summary } : {}),
187
210
  ...(include.includes('evidenceAvailability') ? { evidenceAvailability: json.evidenceAvailability } : {}),
188
211
  ...(include.includes('detectors') ? { detectors: json.detectors } : {}),
@@ -211,17 +234,26 @@ function extractDocTitle(markdown ) {
211
234
 
212
235
 
213
236
 
237
+ // A finding type documents itself; a detector-level type that never appears on a finding
238
+ // (broadcastSizing) documents the types its entry emits.
239
+ function documentedTypes(type ) {
240
+ if (FINDING_NAMES[type] !== undefined) return [type];
241
+ return DETECTORS.find((entry) => entry.type === type)?.emits ?? [];
242
+ }
243
+
214
244
  /** Detection + tuning reference documentation for one finding `type`, independent of any run
215
- * (documentation is a property of the type: a client fetches it once per type and caches it). */
245
+ * (documentation is a property of the type: a client fetches it once per type and caches it).
246
+ * A detector-level `type` resolves to the documentation of the finding types its entry emits. */
216
247
  export function getFindingDocumentation(type ) {
217
- const label = FINDING_NAMES[type];
218
- if (label === undefined) throw mcpError('invalid-type', `Unknown finding type: ${type}`);
248
+ const types = documentedTypes(type);
249
+ const [docType] = types;
250
+ if (docType === undefined) throw mcpError('invalid-type', `Unknown finding type: ${type}`);
219
251
 
220
- const tag = typeTag(type);
252
+ const tag = typeTag(docType);
221
253
  const detectionContent = readFileSync(join(DOCS_CONTENT_DIR, 'detection', `${tag.toLowerCase()}.md`), 'utf8');
222
254
  const detectionDoc = { tag, title: extractDocTitle(detectionContent), content: detectionContent };
223
255
 
224
- const anchor = docAnchorForType(type);
256
+ const anchor = docAnchorForType(docType);
225
257
  const slug = anchor ? tuningDocSlugForAnchor(anchor) : null;
226
258
  const tuningPath = slug ? join(DOCS_CONTENT_DIR, 'tuning', `${slug}.md`) : null;
227
259
  let tuningDoc = null;
@@ -235,7 +267,8 @@ export function getFindingDocumentation(type ) {
235
267
  if (entry) tuningDoc = { anchor, title: entry.title, content: readNavEntryContent(entry) };
236
268
  }
237
269
 
238
- return { type, name: titleCase(label), detectionDoc, tuningDoc };
270
+ const name = types.map((t) => titleCase(FINDING_NAMES[t] ?? t)).join(' / ');
271
+ return { type, name, detectionDoc, tuningDoc };
239
272
  }
240
273
 
241
274
  const CHAPTERS_NAV_FILE = join(DOCS_CONTENT_DIR, 'chapters', 'nav-index.json');
@@ -266,10 +299,10 @@ export function getReferenceDoc(anchor ) {
266
299
  }
267
300
 
268
301
  export function getFindingEvidence(
269
- runId , findingId , opts ,
302
+ runId , findingId , opts ,
270
303
  ) {
271
304
  const appModel = getCachedAppModel(runId);
272
- const { json } = buildEvidenceReport(appModel, { redact: opts?.redact, markdown: false });
305
+ const { json } = buildEvidenceReport(appModel, { redact: opts?.redact, markdown: false, thresholds: opts?.thresholds });
273
306
  const finding = json.findings.find((f) => f.id === findingId);
274
307
  if (!finding) throw mcpError('finding-not-found', `No finding ${findingId} on run ${runId}.`);
275
308
  return { runId, finding };
@@ -279,15 +312,19 @@ function hasCompleteInterval(app ) {
279
312
  return Number.isFinite(app?.startTime) && Number.isFinite(app?.endTime) && (app?.endTime ?? 0) > (app?.startTime ?? 0);
280
313
  }
281
314
 
282
- export function getRunSummary(runId , opts ) {
315
+ export function getRunSummary(runId , opts ) {
283
316
  const appModel = getCachedAppModel(runId);
284
317
  const { app, stages, jobs, sql, executors } = appModel;
285
318
  const durationMs = hasCompleteInterval(app) ? computeWallClock(app, stages).total : null;
286
- // No buildEvidenceReport call here to redact, so reuse redact.ts's app-id + host-token
287
- // pseudonymization directly. Passing name/sparkVersion (not just id) matters: app.name is free
288
- // text and can itself carry a host/IP token.
319
+ // The failure reason needs the stageFailed findings, so this reads the (cached) evidence report.
320
+ // With redact, the app identity comes from that same redacted report, so a host token in the app
321
+ // name and in Spark's failure reason get the same pseudonym.
322
+ const { summary } = buildEvidenceReport(appModel, { redact: opts?.redact, markdown: false, thresholds: opts?.thresholds }).json;
323
+ const { outcome } = summary;
289
324
  const rawApp = { id: app?.id ?? null, name: app?.name ?? null, sparkVersion: app?.sparkVersion ?? null };
290
- const redactedApp = opts?.redact ? redactAppIdentity(rawApp) : rawApp;
325
+ const redactedApp = opts?.redact
326
+ ? { id: summary.app.id ?? null, name: summary.app.name ?? null, sparkVersion: summary.app.sparkVersion ?? null }
327
+ : rawApp;
291
328
  return {
292
329
  runId,
293
330
  app: redactedApp,
@@ -297,27 +334,31 @@ export function getRunSummary(runId , opts )
297
334
  executorCount: { added: executors.added.length, removed: executors.removed.length },
298
335
  durationMs,
299
336
  runComplete: app?.endTime != null,
337
+ ...outcome,
338
+ runShape: summary.runShape,
300
339
  };
301
340
  }
302
341
 
303
342
  // Shared by compareRuns and evaluateBudgetsForRun: both need a resolved run's finding catalog
304
343
  // (same analyze() call shape) first.
305
- async function resolveAndAnalyze(ref ) {
344
+ async function resolveAndAnalyze(
345
+ ref , thresholds ,
346
+ ) {
306
347
  const { runId, appModel } = await resolveOrCreateRun(ref);
307
348
  const catalog = analyze(
308
349
  appModel.app, appModel.stages, appModel.executors.added, appModel.executors.removed,
309
- appModel.jobs, appModel.sql, appModel.runAggregates,
350
+ appModel.jobs, appModel.sql, appModel.runAggregates, { thresholds },
310
351
  );
311
352
  return { runId, appModel, catalog };
312
353
  }
313
354
 
314
355
  export async function compareRuns(
315
- a , b , opts ,
316
- ) {
356
+ a , b , opts ,
357
+ ) {
317
358
  const [
318
359
  { runId: runIdA, appModel: appModelA, catalog: catalogA },
319
360
  { runId: runIdB, appModel: appModelB, catalog: catalogB },
320
- ] = await Promise.all([resolveAndAnalyze(a), resolveAndAnalyze(b)]);
361
+ ] = await Promise.all([resolveAndAnalyze(a, opts?.thresholds), resolveAndAnalyze(b, opts?.thresholds)]);
321
362
 
322
363
  // buildComparison's captureSnapshot uses an empty taskDataCache: that arg only feeds the
323
364
  // interactive stage-detail drill-down, which none of compare/matchStages/metricDeltas/findingsDelta
@@ -330,44 +371,63 @@ export async function compareRuns(
330
371
  // Stage names throughout `built` carry raw Spark stage text, which can embed a host/IP token as
331
372
  // free text, the same residual redactReport() already scrubs from the evidence report.
332
373
  const result = opts?.redact ? redactComparison(built) : built;
374
+ const verdict = comparisonVerdict(result);
333
375
 
334
376
  return {
335
377
  runIdA,
336
378
  runIdB,
379
+ verdict,
337
380
  findingsDelta: result.findings,
338
381
  metricDeltas: result.metrics,
339
382
  confidence: result.confidence,
340
383
  reason: result.reason,
341
384
  matchedCoverage: result.matchedCoverage,
342
- ...(opts?.markdown ? { markdown: renderComparisonMarkdown(result) } : {}),
385
+ ...tunedField(opts?.thresholds),
386
+ ...(opts?.markdown ? { markdown: renderComparisonMarkdown(result, verdict, tunedDetectors(opts?.thresholds)) } : {}),
343
387
  };
344
388
  }
345
389
 
390
+ // Both runs of a comparison use the same overrides, so a tuned delta compares like with like.
391
+ function tunedField(thresholds ) {
392
+ const tuned = tunedDetectors(thresholds);
393
+ return tuned ? { tunedThresholds: tuned } : {};
394
+ }
395
+
346
396
  export async function evaluateBudgetsForRun(
347
397
  primary ,
348
398
  budgets ,
349
399
  secondary ,
350
- ) {
400
+ opts ,
401
+ )
402
+
403
+
404
+ {
351
405
  // Mirrors the CLI's --regression-metric/--max-regression-pct pairing guard: unlike the CLI, this
352
406
  // tool never defaults regressionMetric, so seeing it set here means the caller asked for a
353
407
  // regression check and forgot the threshold, evaluateBudgets() would otherwise skip it silently.
354
408
  if (budgets.regressionMetric !== undefined && budgets.maxRegressionPct === undefined) {
355
409
  throw mcpError('access-or-upstream-failure', 'regressionMetric requires maxRegressionPct.');
356
410
  }
357
- const [{ runId, appModel, catalog }, second] = await Promise.all([
358
- resolveAndAnalyze(primary),
359
- secondary ? resolveAndAnalyze(secondary) : Promise.resolve(undefined),
411
+ const [first, second] = await Promise.all([
412
+ resolveAndAnalyze(primary, opts?.thresholds),
413
+ secondary ? resolveAndAnalyze(secondary, opts?.thresholds) : Promise.resolve(undefined),
360
414
  ]);
361
415
 
362
- let comparison ;
363
- if (second) {
364
- comparison = buildComparison(
365
- { label: runId, appModel, catalog },
366
- { label: second.runId, appModel: second.appModel, catalog: second.catalog },
367
- );
368
- }
369
-
370
- const { results, violated, inconclusive } = evaluateBudgets({ appModel, catalog, budgets, comparison });
371
-
372
- return { runId, results, violated, inconclusive };
416
+ // Same roles as the CLI's positional run + --baseline: with a second run, `primary` is the
417
+ // baseline and `secondary` the candidate, and absolute budgets plus the run-complete check
418
+ // apply to the candidate.
419
+ const candidate = second ?? first;
420
+ const baseline = second ? first : undefined;
421
+ const comparison = baseline
422
+ ? buildComparison(
423
+ { label: baseline.runId, appModel: baseline.appModel, catalog: baseline.catalog },
424
+ { label: candidate.runId, appModel: candidate.appModel, catalog: candidate.catalog },
425
+ )
426
+ : undefined;
427
+
428
+ const { results, violated, inconclusive } = evaluateBudgets({
429
+ appModel: candidate.appModel, catalog: candidate.catalog, budgets, comparison, thresholds: opts?.thresholds,
430
+ });
431
+
432
+ return { runId: first.runId, results, violated, inconclusive, ...tunedField(opts?.thresholds) };
373
433
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
 
4
4
 
5
+
5
6
 
6
7
 
7
8
 
@@ -9,6 +10,15 @@
9
10
 
10
11
 
11
12
 
13
+ // The done message's counts of what the parse could not read. On the model, not only passed to onDone,
14
+ // so every consumer of the model (dashboard export, CLI, MCP) reports them the same way.
15
+ function recordParseGaps(appModel , data ) {
16
+ const done = data ;
17
+ appModel.skippedLines = done?.skippedLines ?? 0;
18
+ if (done?.unreadableSqlExecutions) appModel.unreadableSqlExecutions = done.unreadableSqlExecutions;
19
+ else delete appModel.unreadableSqlExecutions;
20
+ }
21
+
12
22
  // Worker-message -> appModel assembly. Shared by the file-load and SHS-URL-load paths. Pure model
13
23
  // mutation: analysis/render/persist stay in the caller via the onDone/onProgress/onError hooks.
14
24
  export function createModelCallbacks(
@@ -63,6 +73,30 @@ export function createModelCallbacks(
63
73
  if (stage) stage.executorMetrics = execMetrics;
64
74
  }
65
75
  },
66
- onDone, onError,
76
+ // Patch speculation totals that grew after StageCompleted: Spark kills a losing speculative
77
+ // copy only once its stage finishes. `data` is Map<stageId, { speculationWasteMs, speculationWastedAttempts }>.
78
+ onStageSpeculationWaste(data ) {
79
+ const totalsByStage = data ;
80
+ for (const [stageId, totals] of totalsByStage) {
81
+ const stage = appModel.stages.get(stageId);
82
+ if (stage) {
83
+ stage.speculationWasteMs = totals.speculationWasteMs;
84
+ stage.speculationWastedAttempts = totals.speculationWastedAttempts;
85
+ }
86
+ }
87
+ },
88
+ onDone(data ) {
89
+ recordParseGaps(appModel, data);
90
+ onDone?.(data);
91
+ },
92
+ // Patch the late work of failed attempts, whose TaskEnds arrive after StageCompleted.
93
+ // `data` is Map<stageId, StageAttemptTotals>.
94
+ onStageLateAttemptWork(data ) {
95
+ for (const [stageId, work] of data ) {
96
+ const stage = appModel.stages.get(stageId);
97
+ if (stage) stage.lateAttemptWork = work;
98
+ }
99
+ },
100
+ onError,
67
101
  };
68
102
  }
@@ -1,7 +1,7 @@
1
1
  // Occupancy-weighted wall-clock attribution for per-finding impact: every
2
2
  // stage's wall-clock claim is apportioned purely from its own observed window
3
3
  // and how much it overlapped with other stages. No graph, no parentIds traversal.
4
- import { mergeIntervals } from './wall-clock.js';
4
+ import { mergeIntervals } from './intervals.js';
5
5
 
6
6
 
7
7
 
@@ -12,7 +12,7 @@ export {
12
12
  buildChunkDecoder, createState, normalizeSparkProperties, parseSparkMemoryMB, extractResources,
13
13
  accumulateTask, resolvePlanTree, startApplication, updateEnvironment, startJob, endJob, submitStage,
14
14
  mergeStageRddInfo, recordStageExecutorMetrics, startSqlExecution, endSqlExecution,
15
- applyDriverAccumUpdates, addExecutor, removeExecutor, processEvent, dispatchLine,
15
+ applyDriverAccumUpdates, addExecutor, removeExecutor, recordBlockUpdate, processEvent, dispatchLine,
16
16
  collectStageExecutorMetrics,
17
17
  } from './event-handlers.js';
18
18
 
@@ -45,7 +45,7 @@ const NOT_AN_EVENT_LOG = 'Not a Spark event log: no application-start event foun
45
45
  // Minimal shape streamFile/runParse/runParseFiles read off `file` (name, size,
46
46
  // slice(start,end).arrayBuffer()): narrower than the full DOM `File`. A real
47
47
  // `File` (the browser Worker path) satisfies it structurally, but so does the
48
- // plain object src/cli/collect-run.ts's nodeFileFromPath builds for Node, which
48
+ // plain object packages/core/src/cli/collect-run.ts's nodeFileFromPath builds for Node, which
49
49
  // has no DOM `File` constructor.
50
50
 
51
51
 
@@ -15,7 +15,7 @@ import {
15
15
  formatPlanMetricValue,
16
16
  } from './plan-node-detail.js';
17
17
  import { computeSegments, mapSegmentsToStagesForDisplay } from './plan-duration-attribution.js';
18
- import { stageIdsForSqlExec } from './detectors.js';
18
+ import { stageIdsForSqlExec } from './sql-stages.js';
19
19
 
20
20
 
21
21
 
@@ -121,7 +121,8 @@ export function buildPlanGraphModel(
121
121
  const findingsByNodeId = new Map ();
122
122
  if (sqlExecutionId != null) {
123
123
  for (const finding of findings) {
124
- if (finding.executionId !== sqlExecutionId) continue;
124
+ if (!('executionId' in finding) || finding.executionId !== sqlExecutionId) continue;
125
+ // `?? []`: a hand-built or foreign finding may still lack the field the type promises.
125
126
  for (const nodeId of finding.planNodeIds ?? []) {
126
127
  const list = findingsByNodeId.get(nodeId);
127
128
  if (list) list.push(finding);
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { pathBasename, formatBytes, formatDuration } from './format-utils.js';
7
7
  import { attributeStageDurationToPlan, attributeStageDurationToPlanInclusive } from './plan-duration-attribution.js';
8
- import { stageIdsForSqlExec } from './detectors.js';
8
+ import { stageIdsForSqlExec } from './sql-stages.js';
9
9
 
10
10
 
11
11
  const BOILERPLATE_PREFIXES = ['serializefromobject', 'deserializetoobject', 'mapelements',
@@ -10,7 +10,9 @@ function sendSafeError(res, status, code) {
10
10
  res.end(body);
11
11
  }
12
12
 
13
- function isConnectionFailure(error) {
13
+ // Also read by list-runs.ts, so a History Server listing that can't be reached reports the
14
+ // same upstream-unreachable code as a run fetch.
15
+ export function isConnectionFailure(error) {
14
16
  if (error?.name === 'AbortError' || error?.name === 'TimeoutError') return true;
15
17
  const code = error?.code ?? error?.cause?.code;
16
18
  if (['ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH', 'ENOTFOUND', 'ETIMEDOUT'].includes(code)) return true;
@@ -0,0 +1,25 @@
1
+ import { planNodesOfStage } from './stage-plan-nodes.js';
2
+
3
+
4
+ // Plan operators that hand rows to a Python worker process: the row-at-a-time and Arrow Python UDF
5
+ // evaluators, the pandas/Arrow grouped and map operators, and the PythonRDD scan of an RDD
6
+ // pipeline. A suffixed name is a variant of the same operator (BatchEvalPythonUDTF,
7
+ // FlatMapGroupsInPandasWithState). Spark 4.1 renamed AggregateInPandas and WindowInPandas to
8
+ // ArrowAggregatePython and ArrowWindowPython. Spark prefixes a whole-stage-codegen child's name
9
+ // with "*(n) " in some plan strings.
10
+ const PYTHON_PLAN_NODE = /^(?:\*\(\d+\)\s*)?(?:PythonRDD|BatchEvalPython|ArrowEvalPython|ArrowAggregatePython|ArrowWindowPython|\w+InPandas|\w+InArrow)\w*\b/;
11
+
12
+ // An RDD lambda or map function has no SQL plan to match, and a stage whose plan could not be
13
+ // matched is left with nothing but its name and call site.
14
+ const PYTHON_STAGE_NAME = /PythonRDD/;
15
+ const PYTHON_STAGE_DETAILS = /org\.apache\.spark\.api\.python\./;
16
+
17
+ /** True when the stage ran Python code in a worker process: the union of a Python operator among
18
+ * the plan nodes attributed to it (catches Python UDFs inside SQL) and the stage's own name or
19
+ * call site naming PythonRDD / org.apache.spark.api.python (catches RDD lambdas, which have no
20
+ * plan, and stages that cannot be matched to one). The executor CPU time of such a stage misses
21
+ * the worker process's CPU. */
22
+ export function isPythonStage(stage , sql ) {
23
+ if (PYTHON_STAGE_NAME.test(stage.name ?? '') || PYTHON_STAGE_DETAILS.test(stage.details ?? '')) return true;
24
+ return planNodesOfStage(stage, sql).some((node) => PYTHON_PLAN_NODE.test(node.name ?? ''));
25
+ }
@@ -1,7 +1,7 @@
1
1
  // Explicit .ts extensions: plain Node's ESM resolver (the runtime CLI/MCP path
2
2
  // runs under) requires the exact specifier, unlike a bundler.
3
- import { mergeIntervals } from './wall-clock.js';
4
- import { worstImpactBand, IMPACT_BAND_ORDER } from './format-utils.js';
3
+ import { mergeIntervals } from './intervals.js';
4
+ import { formatRawWaste, formatWallClockRange, readsAsZero, worstImpactBand, IMPACT_BAND_ORDER } from './format-utils.js';
5
5
 
6
6
 
7
7
 
@@ -48,7 +48,7 @@ function groupByType(findings ) {
48
48
  }
49
49
 
50
50
  function stageIdsOf(finding ) {
51
- if (finding.stageIds) return finding.stageIds;
51
+ if ('stageIds' in finding) return finding.stageIds;
52
52
  if (finding.stageId != null) return [finding.stageId];
53
53
  return [];
54
54
  }
@@ -124,6 +124,38 @@ export function buildRecommendationRollup(
124
124
  });
125
125
  }
126
126
 
127
+ /** A resource group's summed waste as the board and CLI print it; null when it reads as zero. */
128
+ export function resourceGroupTotal(group ) {
129
+ const text = formatRawWaste({ value: group.total, unit: group.unit });
130
+ return readsAsZero(text) ? null : text;
131
+ }
132
+
133
+ /** A group's trailing figure on the Findings board, and its tooltip. Every kind leads with the
134
+ * "×N" finding count (the "worth expanding" signal). The row stays terse ("×2 · 476ms
135
+ * recoverable") to fit a dense right-aligned column; the title spells the shorthand out. */
136
+ export function rollupGroupStat(group ) {
137
+ if (group.kind === 'time') {
138
+ const recoverable = formatWallClockRange(group.recoverableMsHigh, group.recoverableMsHigh);
139
+ return {
140
+ stat: `×${group.findingCount} · ${recoverable} recoverable`,
141
+ statTitle: `${group.findingCount} findings of this type; up to ${recoverable} of run time could be recovered by fixing them`,
142
+ };
143
+ }
144
+ if (group.kind === 'resource') {
145
+ const total = resourceGroupTotal(group);
146
+ return {
147
+ stat: total ? `×${group.findingCount} · ${total}` : `×${group.findingCount}`,
148
+ statTitle: `${group.findingCount} findings of this type${total ? `; ${total} in total, a resource cost, not run time` : ''}`,
149
+ };
150
+ }
151
+ // The band heading above the row already names a one-band group's band.
152
+ const bands = Object.entries(group.byImpactBand);
153
+ return {
154
+ stat: bands.length === 1 ? `×${group.findingCount}` : `×${group.findingCount} · ${bands.map(([impactBand, count]) => `${count} ${impactBand}`).join(', ')}`,
155
+ statTitle: `${group.findingCount} findings of this type, by impact`,
156
+ };
157
+ }
158
+
127
159
  /** True when a finding is real evidence of an issue, as opposed to a mere
128
160
  * evidence-unavailable caveat. Shared by `isEligible` below and by anything
129
161
  * else that decides whether a REGISTRY type has "something to show" (an
@@ -150,6 +182,10 @@ export function isEligible(finding ) {
150
182
  // `isRealFinding`, this exclusion doesn't apply beyond the rollup: an
151
183
  // incompleteRun finding still backs its own ordinary active widget card.
152
184
  if (finding.type === 'incompleteRun') return false;
185
+ // A missing-evidence caveat (memoryUtilization's is already excluded by isRealFinding;
186
+ // cacheUtilization's storageUnobserved keeps its widget card, so a run with no block updates
187
+ // never lists Cache Storage as a passed check) is never a fix to rank.
188
+ if ('dataUnavailable' in finding && finding.dataUnavailable) return false;
153
189
  return isRealFinding(finding);
154
190
  }
155
191
 
@@ -191,3 +227,34 @@ export function rankFindings(findings ) {
191
227
  return (IMPACT_BAND_ORDER[a.impactBand] ?? 9) - (IMPACT_BAND_ORDER[b.impactBand] ?? 9);
192
228
  });
193
229
  }
230
+
231
+ /** A board group ready to render: members ranked representative first, the representative's
232
+ * band (the heading the group sits under) and the group's trailing figure. */
233
+
234
+
235
+
236
+
237
+
238
+
239
+
240
+
241
+
242
+
243
+ /** The Findings board's groups over `eligible`, in fix-first order. The run's interpretation
244
+ * carries this for every eligible finding; the board recomputes it only over a filtered subset. */
245
+ export function rankedRollup(
246
+ eligible ,
247
+ stages ,
248
+ ) {
249
+ return buildRecommendationRollup(eligible, stages).map((group) => {
250
+ const members = rankFindings(group.findings);
251
+ return {
252
+ kind: group.kind,
253
+ type: group.type,
254
+ unit: group.kind === 'resource' ? group.unit : null,
255
+ band: members[0].impactBand,
256
+ members,
257
+ ...rollupGroupStat(group),
258
+ };
259
+ });
260
+ }