sparkforensics-mcp 0.2.4 → 0.4.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 (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -1
  3. package/bin/sparkforensics-mcp.mjs +41 -10
  4. package/package.json +1 -1
  5. package/vendor-core/analyzer.js +156 -48
  6. package/vendor-core/check-coverage.js +88 -0
  7. package/vendor-core/cli/budgets.js +31 -18
  8. package/vendor-core/cli/collect-run.js +76 -31
  9. package/vendor-core/cli/native-zstd.js +2 -2
  10. package/vendor-core/cli/threshold-config.js +28 -0
  11. package/vendor-core/comparison-verdict.js +177 -0
  12. package/vendor-core/core-source-hash.txt +1 -0
  13. package/vendor-core/core-usage-locality.js +56 -2
  14. package/vendor-core/detector-docs.js +58 -0
  15. package/vendor-core/detectors.js +933 -459
  16. package/vendor-core/docs-config.js +0 -36
  17. package/vendor-core/docs-content/chapters/nav-index.json +31 -0
  18. package/vendor-core/docs-content/detection/cstor.md +9 -0
  19. package/vendor-core/docs-content/detection/fail.md +6 -2
  20. package/vendor-core/docs-site-config.js +3 -0
  21. package/vendor-core/event-handlers.js +191 -6
  22. package/vendor-core/event-schemas.js +29 -0
  23. package/vendor-core/evidence-report.js +440 -112
  24. package/vendor-core/export-data.js +79 -6
  25. package/vendor-core/finding-action-label.js +9 -88
  26. package/vendor-core/finding-filter-predicate.js +9 -0
  27. package/vendor-core/finding-generic-recommendation.js +6 -104
  28. package/vendor-core/finding-names.js +21 -45
  29. package/vendor-core/finding-presentation.js +333 -0
  30. package/vendor-core/finding-tag-help.js +110 -0
  31. package/vendor-core/finding-types.js +361 -0
  32. package/vendor-core/findings-of-type.js +11 -0
  33. package/vendor-core/format-utils.js +92 -27
  34. package/vendor-core/html-export.js +51 -0
  35. package/vendor-core/impact-band.js +21 -8
  36. package/vendor-core/impact-estimator.js +8 -521
  37. package/vendor-core/impact-format.js +114 -0
  38. package/vendor-core/impact-model.js +175 -0
  39. package/vendor-core/ingest.js +2 -0
  40. package/vendor-core/intervals.js +13 -0
  41. package/vendor-core/list-runs.js +2 -3
  42. package/vendor-core/load-vendored.js +70 -5
  43. package/vendor-core/mcp-server-factory.js +14 -10
  44. package/vendor-core/mcp-tools.js +105 -45
  45. package/vendor-core/model-assembler.js +12 -0
  46. package/vendor-core/occupancy.js +1 -1
  47. package/vendor-core/parser-worker.js +22 -5
  48. package/vendor-core/plan-graph-model.js +3 -2
  49. package/vendor-core/plan-node-detail.js +1 -1
  50. package/vendor-core/recommendation-rollup.js +63 -3
  51. package/vendor-core/redact.js +68 -28
  52. package/vendor-core/run-comparison.js +40 -7
  53. package/vendor-core/run-interpretation.js +290 -0
  54. package/vendor-core/run-outcome.js +74 -0
  55. package/vendor-core/run-payload.js +17 -0
  56. package/vendor-core/run-shape.js +40 -0
  57. package/vendor-core/run-verdict.js +353 -0
  58. package/vendor-core/scaling-sim.js +4 -5
  59. package/vendor-core/scorecard-estimates.js +62 -0
  60. package/vendor-core/shs-fetch.js +175 -65
  61. package/vendor-core/shs-load.js +1 -1
  62. package/vendor-core/sql-stages.js +11 -0
  63. package/vendor-core/stage-quantiles.js +14 -0
  64. package/vendor-core/task-failure.js +151 -0
  65. package/vendor-core/threshold-overrides.js +160 -0
  66. package/vendor-core/threshold-summary.js +11 -33
  67. package/vendor-core/types.js +6 -42
  68. package/vendor-core/vendor/fflate.js +1 -1
  69. package/vendor-core/wall-clock.js +1 -12
  70. package/vendor-core/wasted-core-hours.js +2 -2
  71. package/vendor-core/zip-archive.js +167 -0
@@ -7,7 +7,9 @@
7
7
  // Deterministic (sorted assignment), idempotent (pseudonyms map to themselves),
8
8
  // and non-mutating (returns a fresh, deep-copied tree).
9
9
 
10
-
10
+ import { decodeCollections, encodeCollections, } from './export-data.js';
11
+
12
+ import { redactTaskFailureGroup, } from './task-failure.js';
11
13
 
12
14
  // Host / IP identifier patterns. Used to enumerate host names that surface only
13
15
  // inside free text: recommendation strings, `stageFailed`'s failure-reason
@@ -34,7 +36,7 @@ const APP_ID_PATTERNS = [/\bapplication_\d{10,}_\d+\b/g];
34
36
  // Walk every string in the tree once, collecting matches for each `{ patterns,
35
37
  // out }` sink. One shared traversal for every token kind (instead of one
36
38
  // traversal per kind) keeps redactComparison's dual host+app-id scan the same
37
- // cost as the single-kind scan redactReport/redactAppIdentity already do.
39
+ // cost as the single-kind scan redactReport already does.
38
40
  function scanTokens(node , sinks ) {
39
41
  if (typeof node === 'string') {
40
42
  for (const { patterns, out } of sinks) {
@@ -54,11 +56,6 @@ function scanTokens(node , sinks
54
56
  }
55
57
  }
56
58
 
57
- // Walk every string in the tree, collecting host/IP tokens into `hosts`.
58
- function scanHostTokens(node , hosts ) {
59
- scanTokens(node, [{ patterns: HOST_PATTERNS, out: hosts }]);
60
- }
61
-
62
59
  // Recursively collects every string value found under a key literally named
63
60
  // `host`, anywhere in the tree. Host names surface at several depths, a
64
61
  // finding's own `host`, `evidence.host`, and now
@@ -79,12 +76,31 @@ function collectHostFields(node , hosts ) {
79
76
  }
80
77
  }
81
78
 
79
+ // A failed-task error's message and stack text can carry file paths and data values that no
80
+ // host/app-id pattern recognizes, so they are dropped rather than pseudonymized: every array under
81
+ // a key named `failureGroups` (a `failures` finding's, or a stage's in the HTML export) gets
82
+ // redactTaskFailureGroup applied. Walking by key name, like collectHostFields, needs no path list.
83
+ // Returns a fresh tree.
84
+ function redactFailureGroups (node ) {
85
+ if (Array.isArray(node)) return node.map((n) => redactFailureGroups(n)) ;
86
+ if (node && typeof node === 'object') {
87
+ const out = {};
88
+ for (const [k, v] of Object.entries(node)) {
89
+ out[k] = k === 'failureGroups' && Array.isArray(v)
90
+ ? v.map((g) => (g && typeof g === 'object' ? redactTaskFailureGroup(g ) : g))
91
+ : redactFailureGroups(v);
92
+ }
93
+ return out ;
94
+ }
95
+ return node;
96
+ }
97
+
82
98
  // Spark config keys ending in `host`/`hostname` (e.g. spark.driver.host,
83
99
  // spark.yarn.am.hostname) carry plain FQDN host names that neither
84
100
  // HOST_PATTERNS matches (no IP/EC2 shape) nor collectHostFields's by-key-name
85
101
  // walk catches (the literal key is the dotted Spark property name, never
86
102
  // `host` itself). app.config is a flat Record<string, string> unique to
87
- // redactExportData: no other redact* export ships a raw Spark config dict.
103
+ // redactRunModel: no other redact* export ships a raw Spark config dict.
88
104
  // Known gap: a hostname value under a differently-named key isn't caught by
89
105
  // this suffix check. Confirmed against a real cluster config: spark.master,
90
106
  // spark.yarn.historyServer.address, and the plural YARN proxy/HA keys
@@ -172,29 +188,12 @@ function applyReplacements (node , ids
172
188
  return deepReplace(node, merged) ;
173
189
  }
174
190
 
175
- export function redactReport (report ) {
191
+ export function redactReport (input ) {
192
+ const report = redactFailureGroups(input);
176
193
  const { appIds, hosts } = collectIds(report);
177
194
  return applyReplacements(report, { appIds, hosts });
178
195
  }
179
196
 
180
- // Narrow counterpart to redactReport(), for getRunSummary()'s standalone app
181
- // object (no findings tree to walk). There's exactly one app id here, so no
182
- // Set/Map/sort is needed for it; name/sparkVersion still go through the
183
- // shared host/IP scan-and-replace since either can carry a host token as
184
- // free text.
185
- export function redactAppIdentity(
186
- app ,
187
- ) {
188
- const hosts = new Set ();
189
- scanHostTokens(app.name, hosts);
190
- scanHostTokens(app.sparkVersion, hosts);
191
- return {
192
- id: typeof app.id === 'string' && app.id.length > 0 ? 'app-1' : app.id,
193
- name: applyReplacements(app.name, { hosts }),
194
- sparkVersion: applyReplacements(app.sparkVersion, { hosts }),
195
- };
196
- }
197
-
198
197
  // Run-comparison counterpart: no single app-id *field* to pseudonymize
199
198
  // (baselineLabel/candidateLabel are caller-supplied labels, not Spark app
200
199
  // ids), but stage names surface throughout the tree (FindingsDeltaRow.stages,
@@ -216,7 +215,10 @@ export function redactComparison (comparison ) {
216
215
  // this also walks executors.added/removed for their literal `host` field
217
216
  // (ExecutorAddedEvent.host), since raw executor records: not just findings
218
217
  //: reach data.js.
219
- export function redactExportData(data ) {
218
+
219
+
220
+ function redactRunTree (input ) {
221
+ const data = redactFailureGroups(input);
220
222
  const appIds = new Set ();
221
223
  const hosts = new Set ();
222
224
  const appId = data.app?.id;
@@ -229,3 +231,41 @@ export function redactExportData(data ) {
229
231
  scanTokens(data, [{ patterns: HOST_PATTERNS, out: hosts }, { patterns: APP_ID_PATTERNS, out: appIds }]);
230
232
  return applyReplacements(data, { appIds, hosts });
231
233
  }
234
+
235
+ /** A run's model and findings with every identifier pseudonymized. Redact this before anything derives
236
+ * text from the run: the verdict truncates Spark's failure reason, and an
237
+ * identifier cut by that truncation is a fragment no later pass can match. */
238
+ export function redactRunModel(
239
+ appModel ,
240
+ catalog ,
241
+ configFindings ,
242
+ ) {
243
+ // Maps and Sets would lose their entries in the deep copy, so they cross as
244
+ // tagged plain objects, the way the export payload carries them.
245
+ const tree = encodeCollections({
246
+ app: appModel.app,
247
+ stages: [...appModel.stages.values()],
248
+ jobs: [...appModel.jobs.values()],
249
+ sql: [...appModel.sql.values()],
250
+ executors: appModel.executors,
251
+ runAggregates: appModel.runAggregates,
252
+ evidenceAvailability: appModel.evidenceAvailability,
253
+ catalog,
254
+ configFindings,
255
+ }) ;
256
+ const redacted = decodeCollections(redactRunTree(tree)) ;
257
+ const byId = (items ) => new Map((items ).map((item) => [item.id, item]));
258
+ return {
259
+ appModel: {
260
+ app: redacted.app,
261
+ stages: byId(redacted.stages),
262
+ jobs: byId(redacted.jobs),
263
+ sql: byId(redacted.sql),
264
+ executors: redacted.executors,
265
+ runAggregates: redacted.runAggregates ,
266
+ evidenceAvailability: redacted.evidenceAvailability ,
267
+ } ,
268
+ catalog: redacted.catalog,
269
+ configFindings: redacted.configFindings,
270
+ };
271
+ }
@@ -2,8 +2,12 @@ import { computeWallClock } from './wall-clock.js';
2
2
  import { normalizeDetail } from './detectors.js';
3
3
  import { cyrb53 } from './string-hash.js';
4
4
  import { captureSnapshot } from './session-snapshot.js';
5
-
5
+ import { tunedRunNote } from './threshold-overrides.js';
6
+
6
7
 
8
+
9
+ import { isIncompleteRun } from './check-coverage.js';
10
+ import { summarizeRunOutcome } from './run-outcome.js';
7
11
 
8
12
 
9
13
 
@@ -12,7 +16,7 @@ import { captureSnapshot } from './session-snapshot.js';
12
16
 
13
17
 
14
18
 
15
-
19
+
16
20
 
17
21
 
18
22
 
@@ -23,7 +27,16 @@ import { captureSnapshot } from './session-snapshot.js';
23
27
 
24
28
 
25
29
 
30
+
31
+
32
+
33
+
26
34
 
35
+
36
+ function jobOutcome(snapshot ) {
37
+ const { failedJobs, totalJobs } = summarizeRunOutcome(snapshot.jobs, snapshot.catalog);
38
+ return { failedJobs, totalJobs, incomplete: isIncompleteRun(snapshot.catalog) };
39
+ }
27
40
 
28
41
  // Replace run-varying tokens (digit runs, long hex ids) with a stable marker so
29
42
  // the same logical stage across two runs normalizes to one identity.
@@ -180,6 +193,14 @@ function skewRatios(stages ) {
180
193
  return out;
181
194
  }
182
195
 
196
+ // Every key metricDeltas() emits, in emission order. The CLI validates
197
+ // --regression-metric against it, so a misspelled key is a usage error rather
198
+ // than an inconclusive budget. A contract test keeps it in sync.
199
+ export const COMPARISON_METRIC_KEYS = [
200
+ 'wallClock', 'shuffleSpill', 'taskSkew', 'failedTaskRate', 'diskSpill', 'gcTime',
201
+ 'inputBytes', 'outputBytes', 'executorRunTime', 'taskCount', 'executorsAdded',
202
+ ];
203
+
183
204
  // Volume/count metrics, not cost metrics: more or less input/output data, or
184
205
  // tasks/executors, isn't inherently better or worse (it may just reflect a
185
206
  // differently-sized job), unlike wall-clock, spill, GC, etc. Exported as the
@@ -274,14 +295,14 @@ export function metricDeltas(baseSnap , candSnap
274
295
  export function findingsDelta(baseSnap , candSnap )
275
296
 
276
297
  {
277
-
298
+
278
299
  const tally = (snap ) => {
279
300
  const m = new Map (); // `${rule}§${impactBand}` -> { rule, impactBand, count, stages:Set }
280
301
  for (const f of snap.catalog) {
281
- const rule = typeof f.rule === 'string' ? f.rule : f.type;
302
+ const rule = 'rule' in f && typeof f.rule === 'string' ? f.rule : f.type;
282
303
  const impactBand = f.impactBand ?? 'unknown';
283
304
  const key = `${rule}§${impactBand}`;
284
- const e = m.get(key) ?? { rule, impactBand, count: 0, stages: new Set () };
305
+ const e = m.get(key) ?? { rule, type: f.type, impactBand, count: 0, stages: new Set () };
285
306
  e.count++;
286
307
  // Resolve stageId → name on this snapshot only: a single-side lookup, so
287
308
  // it needs no cross-run identity. App-level findings (stageId null) add none.
@@ -300,7 +321,7 @@ export function findingsDelta(baseSnap , candSnap
300
321
  if (candCount === baseCount) continue;
301
322
  const meta = ce ?? be ;
302
323
  const more = candCount > baseCount ? ce : be ; // the side with more supplies the labels
303
- const row = { rule: meta.rule, impactBand: meta.impactBand, baseCount, candCount,
324
+ const row = { rule: meta.rule, type: meta.type, impactBand: meta.impactBand, baseCount, candCount,
304
325
  delta: candCount - baseCount, stages: [...more.stages].sort() };
305
326
  (candCount > baseCount ? introduced : resolved).push(row);
306
327
  }
@@ -420,6 +441,7 @@ export function compareRuns(
420
441
  stageSkew: stageSkewDeltas(baseSnap, candSnap, match),
421
442
  baseStages: stageList(baseSnap),
422
443
  candStages: stageList(candSnap),
444
+ jobOutcomes: { baseline: jobOutcome(baseSnap), candidate: jobOutcome(candSnap) },
423
445
  };
424
446
  }
425
447
 
@@ -437,8 +459,19 @@ function renderFindingsSection(title , findings )
437
459
  // evidence-report.ts's renderMarkdown house style (## section heading, ###
438
460
  // subheadings, `- key: value` bullets). Shared by the CLI's --baseline
439
461
  // markdown output and the MCP server's compare_runs `format: 'md'`.
440
- export function renderComparisonMarkdown(comparison ) {
462
+ /** `tuned`: tunedDetectors() for the overrides both runs were analyzed with, named in the output
463
+ * as the evidence report names them. */
464
+ export function renderComparisonMarkdown(
465
+ comparison , verdict , tuned ,
466
+ ) {
441
467
  const lines = ['', '## Comparison to baseline', ''];
468
+ if (tuned) lines.push(`- Tuned thresholds (both runs): ${tunedRunNote(tuned)}`, '');
469
+ if (verdict) {
470
+ // The dashboard comparison page's headline: run A is the baseline, run B the candidate.
471
+ lines.push(`Run A: ${comparison.baselineLabel} · Run B: ${comparison.candidateLabel}`, '', verdict.title);
472
+ if (verdict.sentences.length > 0) lines.push('', verdict.sentences.join(' '));
473
+ lines.push('');
474
+ }
442
475
  if (comparison.confidence === 'low') {
443
476
  lines.push(`- confidence: low, ${comparison.reason}`);
444
477
  lines.push('');
@@ -0,0 +1,290 @@
1
+ // The interpretation layer of a run as plain data: the verdict, which checks could not run and
2
+ // why, every finding's formatted savings, and the run-shape figures. Everything here can change
3
+ // the conclusion about a run, so it is computed once, by the core that ran the detectors, and
4
+ // handed to the renderer as data. The live dashboard (useIngest) and both HTML-export producers
5
+ // (html-export.ts) call `interpretRun`; the exported bundle only renders what it carries, so an
6
+ // exported file shows the conclusions of the core that wrote it, not of the core that opens it.
7
+ import { checkCoverage, hasFinishedStage, verdictGaps } from './check-coverage.js';
8
+ import { computeCoreLocalityRatio } from './core-locality-ratio.js';
9
+ import { buildLocalityChart, } from './core-usage-locality.js';
10
+ import { detectorInfoByType, } from './detector-docs.js';
11
+ import { computeEfficiencyModel } from './efficiency-model.js';
12
+ import { attributeEtlPhases } from './etl-phases.js';
13
+ import { IMPACT_BAND_ORDER, worstImpactBand } from './format-utils.js';
14
+ import {
15
+ estimateProvenance, impactEstimateCompact, impactEstimateFigure, impactFigure, savingsMeaning,
16
+ } from './impact-format.js';
17
+ import { isEligible, rankedRollup, } from './recommendation-rollup.js';
18
+ import { quotesReasonOf } from './run-outcome.js';
19
+ import { computeRunShape, } from './run-shape.js';
20
+ import {
21
+ buildNextSteps, buildRunVerdict, FINDING_DISPLAY_ORDER, locationKey, quotedReasonText, rankBySavings, stepCopyText,
22
+ } from './run-verdict.js';
23
+ import { recommendationText } from './finding-names.js';
24
+ import { getScorecardEstimates } from './scorecard-estimates.js';
25
+ import { checkConcurrentJobGroups } from './job-groups.js';
26
+ import { computeWallClock } from './wall-clock.js';
27
+ import { computeWastedCoreHours, } from './wasted-core-hours.js';
28
+
29
+
30
+ /** A finding's savings figures, formatted with their units, so a widget shows the figure
31
+ * without formatting it. */
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+ /** One verdict step, fully worded. `leadIndex` names the finding the step routes to. */
47
+
48
+
49
+
50
+
51
+
52
+
53
+
54
+
55
+
56
+
57
+
58
+
59
+
60
+
61
+
62
+
63
+
64
+
65
+
66
+
67
+
68
+
69
+
70
+
71
+
72
+
73
+
74
+
75
+
76
+
77
+
78
+
79
+
80
+
81
+
82
+
83
+
84
+ /** The Scorecard's three headline figures (the ones the report's runShape also states) and how
85
+ * each is graded. `wallClockMs` is null without a complete application timing interval. */
86
+
87
+
88
+
89
+
90
+
91
+
92
+
93
+
94
+
95
+
96
+
97
+
98
+
99
+
100
+
101
+
102
+
103
+
104
+
105
+ /** A stage's findings as the stage dialog lists them, in the verdict's step order. */
106
+
107
+
108
+
109
+
110
+
111
+
112
+ /** One row group of the Findings board: a finding type (split by impact kind, and by unit for
113
+ * resource figures), its members representative first, the band it sits under, and its trailing
114
+ * figure. */
115
+
116
+
117
+
118
+
119
+
120
+ /** The unfiltered Findings board: which findings it lists and its groups in fix-first order. */
121
+
122
+
123
+
124
+
125
+
126
+ /** Findings are referred to by index into the run's findings in catalog-then-config order
127
+ * (`[...catalog, ...configFindings]`), the order both producers pass them in: no copies, so a
128
+ * renderer resolves them to the very objects it already holds. */
129
+
130
+
131
+
132
+
133
+
134
+
135
+
136
+
137
+
138
+
139
+
140
+
141
+
142
+
143
+
144
+
145
+
146
+
147
+
148
+
149
+
150
+
151
+
152
+
153
+
154
+
155
+ export function findingSavings(finding ) {
156
+ return {
157
+ figure: impactFigure(finding),
158
+ meaning: savingsMeaning(finding),
159
+ board: impactEstimateFigure(finding.impactEstimate)?.text ?? null,
160
+ compact: impactEstimateCompact(finding.impactEstimate),
161
+ provenance: estimateProvenance(finding),
162
+ };
163
+ }
164
+
165
+ // The Scorecard's severity rule: a tile is flagged only when the figure is bad enough to act on.
166
+ function efficiencyFlag(pct ) {
167
+ if (pct == null) return null;
168
+ return pct < 75 ? 'critical' : pct < 90 ? 'warning' : null;
169
+ }
170
+
171
+ function unusedCoreTimeFlag(pct ) {
172
+ if (pct == null) return null;
173
+ return pct >= 70 ? 'critical' : pct >= 40 ? 'warning' : null;
174
+ }
175
+
176
+ function interpretRunShape(appModel ) {
177
+ const { wallClockMs, efficiencyPct, unusedCoreTimePct } = computeRunShape(appModel);
178
+ const estimates = getScorecardEstimates(appModel);
179
+ return {
180
+ wallClockMs,
181
+ efficiencyPct,
182
+ unusedCoreTimePct,
183
+ efficiencyFlag: efficiencyFlag(efficiencyPct),
184
+ unusedCoreTimeFlag: unusedCoreTimeFlag(unusedCoreTimePct),
185
+ unusedCoreTimeUnavailableReason: estimates.wastage.unavailableReason,
186
+ };
187
+ }
188
+
189
+ function interpretEfficiency(appModel ) {
190
+ if (!appModel.runAggregates) return null;
191
+ return computeEfficiencyModel({
192
+ app: appModel.app,
193
+ stages: appModel.stages,
194
+ executorsAdded: appModel.executors.added,
195
+ runAggregates: appModel.runAggregates,
196
+ });
197
+ }
198
+
199
+ function interpretCoverage(appModel , allFindings ) {
200
+ const noFinishedStages = !hasFinishedStage(appModel.stages);
201
+ const { notRunReason } = checkCoverage(appModel.stages, allFindings);
202
+ const notRunReasons = {};
203
+ for (const type of FINDING_DISPLAY_ORDER) {
204
+ const reason = notRunReason(type);
205
+ if (reason != null) notRunReasons[type] = reason;
206
+ }
207
+ return { noFinishedStages, notRunReasons, gaps: verdictGaps(allFindings, noFinishedStages) };
208
+ }
209
+
210
+ // The stage dialog's grouping: the verdict's location rule (a sql-scope finding touching only
211
+ // this stage counts), ordered by the stage's own verdict step, then by worst band.
212
+ function interpretStages(
213
+ catalog , indexOf , failedJobStageIds ,
214
+ ) {
215
+ const byStage = new Map ();
216
+ for (const finding of catalog) {
217
+ const { stageId } = locationKey(finding);
218
+ if (stageId == null) continue;
219
+ const list = byStage.get(stageId) ?? [];
220
+ list.push(finding);
221
+ byStage.set(stageId, list);
222
+ }
223
+ const stages = {};
224
+ for (const [stageId, findings] of byStage) {
225
+ const [step] = buildNextSteps(findings, failedJobStageIds ? { failedJobStageIds } : {});
226
+ const stepTypes = step ? [step.lead.type, ...step.related.map((f) => f.type)] : [];
227
+ const worstBand = (type ) => IMPACT_BAND_ORDER[worstImpactBand(findings.filter((f) => f.type === type)) ];
228
+ const otherTypes = [...new Set(findings.map((f) => f.type))]
229
+ .filter((type) => !stepTypes.includes(type))
230
+ .sort((a, b) => worstBand(a) - worstBand(b));
231
+ stages[String(stageId)] = { findingIndexes: findings.map((f) => indexOf.get(f) ), typeOrder: [...stepTypes, ...otherTypes] };
232
+ }
233
+ return stages;
234
+ }
235
+
236
+ const DISPLAY_TYPES = new Set(FINDING_DISPLAY_ORDER);
237
+
238
+ /** Every conclusion the dashboard shows about a run whose detectors already ran. */
239
+ export function interpretRun(appModel , catalog , configFindings ) {
240
+ const allFindings = [...catalog, ...configFindings];
241
+ const indexOf = new Map(allFindings.map((finding, index) => [finding, index]));
242
+ const verdict = buildRunVerdict(appModel, allFindings);
243
+ const { outcome } = verdict;
244
+ const failed = outcome.failedJobs > 0;
245
+ // A board row needs a widget to route to, and every display type has one.
246
+ const eligible = allFindings.filter((finding) => isEligible(finding) && DISPLAY_TYPES.has(finding.type));
247
+ const steps = verdict.shown.map((step) => {
248
+ const quoted = quotesReasonOf(step.lead, outcome) && outcome.reason ? quotedReasonText(outcome.reason) : null;
249
+ return {
250
+ key: step.key,
251
+ leadIndex: indexOf.get(step.lead) ,
252
+ stageId: step.stageId,
253
+ relatedTypes: step.related.map((f) => f.type),
254
+ recommendation: quoted?.shown ?? recommendationText(step.lead),
255
+ copyText: stepCopyText(step.lead, quoted?.copied ?? recommendationText(step.lead)),
256
+ };
257
+ });
258
+ return {
259
+ savings: allFindings.map(findingSavings),
260
+ verdict: {
261
+ title: verdict.title,
262
+ summary: verdict.summary,
263
+ failed,
264
+ clean: verdict.facts.clean,
265
+ failureReason: outcome.reason,
266
+ steps,
267
+ remaining: verdict.remaining,
268
+ copyText: verdict.copyText,
269
+ },
270
+ coverage: interpretCoverage(appModel, allFindings),
271
+ runShape: interpretRunShape(appModel),
272
+ wallClock: computeWallClock(appModel.app, appModel.stages),
273
+ wastedCoreHours: computeWastedCoreHours(appModel.app, appModel.executors.added, appModel.runAggregates),
274
+ efficiency: interpretEfficiency(appModel),
275
+ wallClockReliable: checkConcurrentJobGroups(appModel.jobs).wallClockReliable,
276
+ etlPhases: attributeEtlPhases(appModel.stages),
277
+ coreLocality: {
278
+ chart: buildLocalityChart([...appModel.stages.values()], appModel.app),
279
+ ratio: computeCoreLocalityRatio([...appModel.stages.values()], { topN: Infinity }),
280
+ },
281
+ detectors: detectorInfoByType(),
282
+ savingsRank: rankBySavings(allFindings).map((finding) => indexOf.get(finding) ),
283
+ stages: interpretStages(catalog, indexOf, failed ? outcome.failedJobStageIds : null),
284
+ rollup: {
285
+ eligibleIndexes: eligible.map((finding) => indexOf.get(finding) ),
286
+ groups: rankedRollup(eligible, appModel.stages)
287
+ .map(({ members, ...group }) => ({ ...group, memberIndexes: members.map((finding) => indexOf.get(finding) ) })),
288
+ },
289
+ };
290
+ }
@@ -0,0 +1,74 @@
1
+
2
+
3
+ /** Finding types that mean work did not finish: a stage attempt that failed
4
+ * outright, and jobs that ended without succeeding. Failed tasks that a retry
5
+ * recovered (`failures`) are not in this set: the run still completed. */
6
+ export const FAILURE_TYPES = new Set(['stageFailed', 'jobFailureRate']);
7
+
8
+ /** Longest failure reason the verdict quotes; Spark reasons can carry a whole
9
+ * stack trace, and the first line already names the cause. */
10
+ export const FAILURE_REASON_MAX_CHARS = 240;
11
+
12
+ /** How the run ended, as far as its jobs say. `failedJobs` and `totalJobs`
13
+ * count only jobs with an end record, so a job still running when an
14
+ * incomplete log stops is neither. */
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+ function firstLine(text ) {
29
+ const line = text.split('\n')[0].trim();
30
+ if (line.length === 0) return null;
31
+ return line.length > FAILURE_REASON_MAX_CHARS ? `${line.slice(0, FAILURE_REASON_MAX_CHARS - 3).trimEnd()}...` : line;
32
+ }
33
+
34
+ function exceptionText(exception ) {
35
+ if (typeof exception === 'string') return exception;
36
+ return null;
37
+ }
38
+
39
+ /** Summarizes the run's job results. The reason prefers a failed stage that
40
+ * belongs to a failed job (the stage is the nearer cause), then any failed
41
+ * stage, then the first failed job's exception message. */
42
+ export function summarizeRunOutcome(jobs , findings ) {
43
+ const ended = [...jobs.values()].filter((job) => job.result != null);
44
+ const failed = ended.filter((job) => job.succeeded === false).sort((a, b) => a.id - b.id);
45
+ const failedStageIds = new Set(failed.flatMap((job) => job.stageIds));
46
+ const stageReasons = findings
47
+ .filter((finding) => finding.type === 'stageFailed')
48
+ .sort((a, b) => Number(failedStageIds.has(b.stageId )) - Number(failedStageIds.has(a.stageId )));
49
+ const stageSource = stageReasons[0];
50
+ const rawReason =
51
+ stageSource?.valueText
52
+ ?? failed.map((job) => exceptionText(job.exception)).find((text) => text != null)
53
+ ?? null;
54
+ return {
55
+ failedJobs: failed.length,
56
+ totalJobs: ended.length,
57
+ failedJobStageIds: failedStageIds,
58
+ reason: failed.length > 0 && rawReason != null ? firstLine(rawReason) : null,
59
+ reasonStageId: stageSource?.stageId ?? null,
60
+ };
61
+ }
62
+
63
+ /** Whether the quoted reason is this finding's own: the stage failure whose
64
+ * first line it is, or the job-failure finding when exactly one job failed
65
+ * and the reason belongs to that job. Any other failure has a cause the
66
+ * verdict does not show. */
67
+ export function quotesReasonOf(finding , outcome ) {
68
+ if (outcome.reason == null) return false;
69
+ if (finding.type === 'stageFailed') return firstLine(finding.valueText) === outcome.reason;
70
+ if (finding.type === 'jobFailureRate') {
71
+ return outcome.failedJobs === 1 && (outcome.reasonStageId == null || outcome.failedJobStageIds.has(outcome.reasonStageId));
72
+ }
73
+ return false;
74
+ }
@@ -0,0 +1,17 @@
1
+ // How an encoded HTML-export payload reaches the export app: a window global
2
+ // that data.js (or the inlined script) assigns and main-export.tsx reads.
3
+ // Dependency-free, so the export bundle can read the global without pulling in
4
+ // the producer side (html-export.ts and the analysis it runs).
5
+
6
+ /** The window global the payload statement assigns and the export app reads. */
7
+ export const RUN_PAYLOAD_GLOBAL = '__SPARKFORENSICS_RUN_GZ__';
8
+
9
+ /** The one JavaScript statement that hands an encoded payload to the export
10
+ * app. base64's alphabet (A-Za-z0-9+/=) can't contain "<" or a quote, so log
11
+ * free text can't inject a "</script>" break-out or end the string literal:
12
+ * the statement is safe to place inside an inline <script> with no escaping.
13
+ * That only holds while `base64` really is base64; keep any new encoding
14
+ * inside that alphabet or escape it here. */
15
+ export function runPayloadScript(base64 ) {
16
+ return `window.${RUN_PAYLOAD_GLOBAL} = "${base64}";`;
17
+ }