sparkforensics-mcp 0.3.0 → 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.
- package/bin/sparkforensics-mcp.mjs +41 -10
- package/package.json +1 -1
- package/vendor-core/analyzer.js +156 -48
- package/vendor-core/check-coverage.js +88 -0
- package/vendor-core/cli/budgets.js +31 -18
- package/vendor-core/cli/collect-run.js +76 -31
- package/vendor-core/cli/threshold-config.js +28 -0
- package/vendor-core/comparison-verdict.js +177 -0
- package/vendor-core/core-source-hash.txt +1 -0
- package/vendor-core/core-usage-locality.js +56 -2
- package/vendor-core/detector-docs.js +58 -0
- package/vendor-core/detectors.js +918 -458
- package/vendor-core/docs-config.js +0 -36
- package/vendor-core/docs-content/chapters/nav-index.json +31 -0
- package/vendor-core/docs-content/detection/cstor.md +9 -0
- package/vendor-core/docs-site-config.js +3 -0
- package/vendor-core/event-handlers.js +170 -6
- package/vendor-core/event-schemas.js +21 -0
- package/vendor-core/evidence-report.js +421 -112
- package/vendor-core/export-data.js +79 -6
- package/vendor-core/finding-action-label.js +9 -88
- package/vendor-core/finding-filter-predicate.js +9 -0
- package/vendor-core/finding-generic-recommendation.js +6 -104
- package/vendor-core/finding-names.js +21 -45
- package/vendor-core/finding-presentation.js +333 -0
- package/vendor-core/finding-tag-help.js +110 -0
- package/vendor-core/finding-types.js +361 -0
- package/vendor-core/findings-of-type.js +11 -0
- package/vendor-core/format-utils.js +92 -27
- package/vendor-core/html-export.js +51 -0
- package/vendor-core/impact-band.js +21 -8
- package/vendor-core/impact-estimator.js +8 -521
- package/vendor-core/impact-format.js +114 -0
- package/vendor-core/impact-model.js +175 -0
- package/vendor-core/ingest.js +2 -0
- package/vendor-core/intervals.js +13 -0
- package/vendor-core/list-runs.js +2 -3
- package/vendor-core/load-vendored.js +70 -5
- package/vendor-core/mcp-server-factory.js +14 -10
- package/vendor-core/mcp-tools.js +105 -45
- package/vendor-core/model-assembler.js +12 -0
- package/vendor-core/occupancy.js +1 -1
- package/vendor-core/parser-worker.js +1 -1
- package/vendor-core/plan-graph-model.js +3 -2
- package/vendor-core/plan-node-detail.js +1 -1
- package/vendor-core/recommendation-rollup.js +63 -3
- package/vendor-core/redact.js +45 -27
- package/vendor-core/run-comparison.js +32 -7
- package/vendor-core/run-interpretation.js +290 -0
- package/vendor-core/run-outcome.js +74 -0
- package/vendor-core/run-payload.js +17 -0
- package/vendor-core/run-shape.js +40 -0
- package/vendor-core/run-verdict.js +353 -0
- package/vendor-core/scaling-sim.js +4 -5
- package/vendor-core/scorecard-estimates.js +62 -0
- package/vendor-core/sql-stages.js +11 -0
- package/vendor-core/stage-quantiles.js +2 -0
- package/vendor-core/threshold-overrides.js +160 -0
- package/vendor-core/threshold-summary.js +11 -33
- package/vendor-core/types.js +6 -42
- package/vendor-core/wall-clock.js +1 -12
- package/vendor-core/wasted-core-hours.js +2 -2
|
@@ -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.
|
|
@@ -282,14 +295,14 @@ export function metricDeltas(baseSnap , candSnap
|
|
|
282
295
|
export function findingsDelta(baseSnap , candSnap )
|
|
283
296
|
|
|
284
297
|
{
|
|
285
|
-
|
|
298
|
+
|
|
286
299
|
const tally = (snap ) => {
|
|
287
300
|
const m = new Map (); // `${rule}§${impactBand}` -> { rule, impactBand, count, stages:Set }
|
|
288
301
|
for (const f of snap.catalog) {
|
|
289
|
-
const rule = typeof f.rule === 'string' ? f.rule : f.type;
|
|
302
|
+
const rule = 'rule' in f && typeof f.rule === 'string' ? f.rule : f.type;
|
|
290
303
|
const impactBand = f.impactBand ?? 'unknown';
|
|
291
304
|
const key = `${rule}§${impactBand}`;
|
|
292
|
-
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 () };
|
|
293
306
|
e.count++;
|
|
294
307
|
// Resolve stageId → name on this snapshot only: a single-side lookup, so
|
|
295
308
|
// it needs no cross-run identity. App-level findings (stageId null) add none.
|
|
@@ -308,7 +321,7 @@ export function findingsDelta(baseSnap , candSnap
|
|
|
308
321
|
if (candCount === baseCount) continue;
|
|
309
322
|
const meta = ce ?? be ;
|
|
310
323
|
const more = candCount > baseCount ? ce : be ; // the side with more supplies the labels
|
|
311
|
-
const row = { rule: meta.rule, impactBand: meta.impactBand, baseCount, candCount,
|
|
324
|
+
const row = { rule: meta.rule, type: meta.type, impactBand: meta.impactBand, baseCount, candCount,
|
|
312
325
|
delta: candCount - baseCount, stages: [...more.stages].sort() };
|
|
313
326
|
(candCount > baseCount ? introduced : resolved).push(row);
|
|
314
327
|
}
|
|
@@ -428,6 +441,7 @@ export function compareRuns(
|
|
|
428
441
|
stageSkew: stageSkewDeltas(baseSnap, candSnap, match),
|
|
429
442
|
baseStages: stageList(baseSnap),
|
|
430
443
|
candStages: stageList(candSnap),
|
|
444
|
+
jobOutcomes: { baseline: jobOutcome(baseSnap), candidate: jobOutcome(candSnap) },
|
|
431
445
|
};
|
|
432
446
|
}
|
|
433
447
|
|
|
@@ -445,8 +459,19 @@ function renderFindingsSection(title , findings )
|
|
|
445
459
|
// evidence-report.ts's renderMarkdown house style (## section heading, ###
|
|
446
460
|
// subheadings, `- key: value` bullets). Shared by the CLI's --baseline
|
|
447
461
|
// markdown output and the MCP server's compare_runs `format: 'md'`.
|
|
448
|
-
|
|
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
|
+
) {
|
|
449
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
|
+
}
|
|
450
475
|
if (comparison.confidence === 'low') {
|
|
451
476
|
lines.push(`- confidence: low, ${comparison.reason}`);
|
|
452
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
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The run's shape as the dashboard's Scorecard, ETL Phase Attribution and Core Usage by Locality
|
|
2
|
+
// cards show it, for the CLI/MCP paths: every figure comes from the same core function those cards
|
|
3
|
+
// read, and null means the card would show "Not measured" or "Unavailable".
|
|
4
|
+
import { hasFinishedStage } from './check-coverage.js';
|
|
5
|
+
import { buildLocalityChart } from './core-usage-locality.js';
|
|
6
|
+
import { attributeEtlPhases } from './etl-phases.js';
|
|
7
|
+
import { getScorecardEstimates, hasCompleteApplicationInterval } from './scorecard-estimates.js';
|
|
8
|
+
import { computeWallClock } from './wall-clock.js';
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
export function computeRunShape(appModel ) {
|
|
29
|
+
const timed = hasCompleteApplicationInterval(appModel.app);
|
|
30
|
+
const estimates = getScorecardEstimates(appModel);
|
|
31
|
+
const phases = attributeEtlPhases(appModel.stages);
|
|
32
|
+
const chart = buildLocalityChart([...appModel.stages.values()], appModel.app);
|
|
33
|
+
return {
|
|
34
|
+
wallClockMs: timed ? computeWallClock(appModel.app, appModel.stages).total : null,
|
|
35
|
+
efficiencyPct: hasFinishedStage(appModel.stages) ? estimates.efficiency.value : null,
|
|
36
|
+
unusedCoreTimePct: estimates.wastage.value,
|
|
37
|
+
etlPhasesMs: phases.extract + phases.transform + phases.load > 0 ? phases : null,
|
|
38
|
+
peakBusyCores: chart.hasActivity ? chart.peakCores : null,
|
|
39
|
+
};
|
|
40
|
+
}
|