sparkforensics-cli 0.1.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 (60) hide show
  1. package/bin/sparkforensics-analyze.mjs +288 -0
  2. package/package.json +29 -0
  3. package/vendor-core/analyzer.js +167 -0
  4. package/vendor-core/assert-never.js +3 -0
  5. package/vendor-core/cli/budgets.js +203 -0
  6. package/vendor-core/cli/collect-run.js +107 -0
  7. package/vendor-core/core-count.js +68 -0
  8. package/vendor-core/core-locality-ratio.js +54 -0
  9. package/vendor-core/core-time-series.js +92 -0
  10. package/vendor-core/core-usage-locality.js +70 -0
  11. package/vendor-core/detectors.js +1989 -0
  12. package/vendor-core/docs-config.js +72 -0
  13. package/vendor-core/docs-site-config.js +23 -0
  14. package/vendor-core/efficiency-model.js +62 -0
  15. package/vendor-core/etl-phases.js +28 -0
  16. package/vendor-core/event-handlers.js +906 -0
  17. package/vendor-core/event-schemas.js +405 -0
  18. package/vendor-core/evidence-availability.js +121 -0
  19. package/vendor-core/evidence-report.js +459 -0
  20. package/vendor-core/finding-action-label.js +97 -0
  21. package/vendor-core/finding-filter-predicate.js +38 -0
  22. package/vendor-core/format-utils.js +167 -0
  23. package/vendor-core/impact-band.js +50 -0
  24. package/vendor-core/impact-estimator.js +428 -0
  25. package/vendor-core/ingest.js +139 -0
  26. package/vendor-core/job-groups.js +30 -0
  27. package/vendor-core/load-vendored.js +24 -0
  28. package/vendor-core/lz4-block.js +135 -0
  29. package/vendor-core/mcp-error.js +3 -0
  30. package/vendor-core/mcp-server-factory.js +115 -0
  31. package/vendor-core/mcp-tools.js +331 -0
  32. package/vendor-core/model-assembler.js +76 -0
  33. package/vendor-core/occupancy.js +202 -0
  34. package/vendor-core/parser-worker.js +249 -0
  35. package/vendor-core/plan-dot.js +25 -0
  36. package/vendor-core/plan-duration-attribution.js +185 -0
  37. package/vendor-core/plan-graph-model.js +171 -0
  38. package/vendor-core/plan-node-detail.js +159 -0
  39. package/vendor-core/plan-summary.js +233 -0
  40. package/vendor-core/plan-tree-walk.js +29 -0
  41. package/vendor-core/proxy.js +157 -0
  42. package/vendor-core/recommendation-rollup.js +197 -0
  43. package/vendor-core/redact.js +175 -0
  44. package/vendor-core/rolling-log-reassembly.js +52 -0
  45. package/vendor-core/run-aggregates.js +44 -0
  46. package/vendor-core/run-comparison.js +458 -0
  47. package/vendor-core/scaling-sim.js +73 -0
  48. package/vendor-core/session-snapshot.js +79 -0
  49. package/vendor-core/shs-fetch.js +196 -0
  50. package/vendor-core/shs-load.js +121 -0
  51. package/vendor-core/shs-request.js +101 -0
  52. package/vendor-core/shs-schemas.js +13 -0
  53. package/vendor-core/snappy-block.js +140 -0
  54. package/vendor-core/stage-quantiles.js +199 -0
  55. package/vendor-core/threshold-summary.js +35 -0
  56. package/vendor-core/types.js +286 -0
  57. package/vendor-core/vendor/fflate.js +2695 -0
  58. package/vendor-core/vendor/fzstd.js +768 -0
  59. package/vendor-core/wall-clock.js +36 -0
  60. package/vendor-core/wasted-core-hours.js +68 -0
@@ -0,0 +1,288 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { writeFileSync, existsSync, realpathSync } from 'node:fs';
4
+ import { join, dirname } from 'node:path';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
+
7
+ const binDir = dirname(fileURLToPath(import.meta.url));
8
+ const pkgDir = dirname(binDir);
9
+
10
+ // vendor-core/ exists only in a published/standalone install (populated by
11
+ // scripts/vendor-core.mjs at pack time); in the monorepo it falls back to
12
+ // the real packages/core/src/ sibling. load-vendored.js itself is the one
13
+ // module this bootstrap has to locate by hand (see its own header comment);
14
+ // every other core module then loads through its exported loadVendored().
15
+ const vendoredHelper = join(pkgDir, 'vendor-core', 'load-vendored.js');
16
+ const helperPath = existsSync(vendoredHelper) ? vendoredHelper : join(pkgDir, '..', 'core', 'src', 'load-vendored.js');
17
+ const { loadVendored } = await import(pathToFileURL(helperPath).href);
18
+ const loadCore = (moduleName) => loadVendored(pkgDir, moduleName);
19
+
20
+ const { collectRun } = await loadCore('cli/collect-run');
21
+ const { resolveFromShs } = await loadCore('shs-load');
22
+ const { analyze } = await loadCore('analyzer');
23
+ const { deriveEvidenceAvailability } = await loadCore('evidence-availability');
24
+ const { buildEvidenceReport, toFindingsFilter } = await loadCore('evidence-report');
25
+ const { evaluateBudgets } = await loadCore('cli/budgets');
26
+ const { buildComparison, renderComparisonMarkdown } = await loadCore('run-comparison');
27
+ const { redactComparison } = await loadCore('redact');
28
+
29
+ const USAGE = `Usage: sparkforensics-analyze <event-log-file|rolling-log-dir> [options]
30
+ sparkforensics-analyze --shs-base-url <url> --app-id <id> [--attempt-id <id>] [options]
31
+
32
+ Options:
33
+ --format md|json Output format (default: json).
34
+ --out <path> Write output to a file instead of stdout.
35
+ --max-runtime <ms> Fail if app runtime exceeds this many ms.
36
+ --max-spill <gb> Fail if any stage spills more than this many GB.
37
+ --max-skew <ratio> Fail if any stage's P95/median duration ratio exceeds this.
38
+ --max-failed-task-rate <pct> Fail if the task failure rate exceeds this percent.
39
+ --min-efficiency <pct> Fail if compute efficiency falls below this percent.
40
+ --shs-base-url <url> Fetch the run from a Spark History Server instead of a
41
+ local file (mutually exclusive with the positional argument).
42
+ --app-id <id> Spark application ID to fetch. Required with --shs-base-url.
43
+ --attempt-id <id> Optional attempt ID, used with --shs-base-url.
44
+ --baseline <path> Compare the run against a baseline local event-log file or
45
+ rolling-log directory. Baseline is local-path-only (no SHS
46
+ support). Adds a comparison section to the output.
47
+ --max-regression-pct <pct> Requires --baseline. Fail if the regression metric (see
48
+ --regression-metric) regressed by more than this percent.
49
+ --regression-metric <key> Metric key to check with --max-regression-pct (default:
50
+ wallClock). Requires --baseline and --max-regression-pct.
51
+ --fail-on-introduced <band|all> Requires --baseline. Fail if any finding was introduced by
52
+ the candidate matching this impact band (or any, with "all").
53
+ --redact Pseudonymize the app id and any host/IP tokens in the output
54
+ (app-1, host-1, ...), so a report can be shared outside the
55
+ environment that produced it.
56
+ --impact <band[,band]> Filter the output's findings array to these impact bands
57
+ (critical, warning, info). recommendations/cleanChecks and
58
+ the summary counts stay on the full, unfiltered set.
59
+ --type <type[,type]> Filter the output's findings array to these finding types.
60
+ --stage <id> Filter the output's findings array to this stage id.
61
+
62
+ Exit codes: 0 pass, 1 budget violated, 2 bad arguments, the local input could not be parsed, or the --shs-base-url fetch failed, 3 a budget was inconclusive.
63
+ `;
64
+
65
+ function parseCliArgs(argv) {
66
+ const { values, positionals } = parseArgs({
67
+ args: argv,
68
+ allowPositionals: true,
69
+ options: {
70
+ out: { type: 'string' },
71
+ format: { type: 'string' },
72
+ 'max-runtime': { type: 'string' },
73
+ 'max-spill': { type: 'string' },
74
+ 'max-skew': { type: 'string' },
75
+ 'max-failed-task-rate': { type: 'string' },
76
+ 'min-efficiency': { type: 'string' },
77
+ 'shs-base-url': { type: 'string' },
78
+ 'app-id': { type: 'string' },
79
+ 'attempt-id': { type: 'string' },
80
+ baseline: { type: 'string' },
81
+ 'max-regression-pct': { type: 'string' },
82
+ 'regression-metric': { type: 'string' },
83
+ 'fail-on-introduced': { type: 'string' },
84
+ redact: { type: 'boolean' },
85
+ impact: { type: 'string' },
86
+ type: { type: 'string' },
87
+ stage: { type: 'string' },
88
+ help: { type: 'boolean' },
89
+ },
90
+ });
91
+ return { values, positionals };
92
+ }
93
+
94
+ function bail(message, code) {
95
+ process.stderr.write(message);
96
+ process.exitCode = code;
97
+ }
98
+
99
+ function splitCsv(value) {
100
+ if (value === undefined) return undefined;
101
+ return value.split(',').map((s) => s.trim()).filter(Boolean);
102
+ }
103
+
104
+ async function collectWithEvidence(path) {
105
+ const { appModel, skippedLines } = await collectRun(path);
106
+ appModel.evidenceAvailability = deriveEvidenceAvailability(appModel, { skippedLines });
107
+ return appModel;
108
+ }
109
+
110
+ export async function main(argv, { fetchImpl } = {}) {
111
+ const { values, positionals } = parseCliArgs(argv);
112
+ if (values.help) {
113
+ process.stderr.write(USAGE);
114
+ process.exitCode = 0;
115
+ return;
116
+ }
117
+
118
+ const usingShs = values['shs-base-url'] !== undefined;
119
+ if (usingShs) {
120
+ if (positionals.length > 0) {
121
+ return bail(`Pass either <event-log-file|rolling-log-dir> or --shs-base-url, not both.\n${USAGE}`, 2);
122
+ }
123
+ if (values['app-id'] === undefined) {
124
+ return bail(`--shs-base-url requires --app-id.\n${USAGE}`, 2);
125
+ }
126
+ } else {
127
+ if (positionals.length !== 1) {
128
+ return bail(USAGE, 2);
129
+ }
130
+ if (values['app-id'] !== undefined || values['attempt-id'] !== undefined) {
131
+ return bail(`--app-id/--attempt-id require --shs-base-url.\n${USAGE}`, 2);
132
+ }
133
+ }
134
+
135
+ if (values.format !== undefined && values.format !== 'json' && values.format !== 'md') {
136
+ return bail(`Invalid value for --format (expected "json" or "md").\n${USAGE}`, 2);
137
+ }
138
+
139
+ const usingBaseline = values.baseline !== undefined;
140
+ // Single source of truth for which flags need --baseline: a future flag
141
+ // just gets appended here, instead of also needing its own OR-condition
142
+ // above (easy to forget, and forgetting it means the flag is silently
143
+ // accepted without --baseline instead of erroring).
144
+ const BASELINE_DEPENDENT_FLAGS = ['max-regression-pct', 'regression-metric', 'fail-on-introduced'];
145
+ if (!usingBaseline && BASELINE_DEPENDENT_FLAGS.some((flag) => values[flag] !== undefined)) {
146
+ return bail(`--max-regression-pct/--regression-metric/--fail-on-introduced require --baseline.\n${USAGE}`, 2);
147
+ }
148
+ if (values['regression-metric'] !== undefined && values['max-regression-pct'] === undefined) {
149
+ return bail(`--regression-metric requires --max-regression-pct.\n${USAGE}`, 2);
150
+ }
151
+
152
+ const budgets = {
153
+ maxRuntimeMs: values['max-runtime'] != null ? Number(values['max-runtime']) : undefined,
154
+ maxSpillGb: values['max-spill'] != null ? Number(values['max-spill']) : undefined,
155
+ maxSkewRatio: values['max-skew'] != null ? Number(values['max-skew']) : undefined,
156
+ maxFailedTaskRatePct: values['max-failed-task-rate'] != null ? Number(values['max-failed-task-rate']) : undefined,
157
+ minEfficiencyPct: values['min-efficiency'] != null ? Number(values['min-efficiency']) : undefined,
158
+ maxRegressionPct: values['max-regression-pct'] != null ? Number(values['max-regression-pct']) : undefined,
159
+ };
160
+ // Derived from `budgets`, not hardcoded: every key on that object literal
161
+ // above is numeric (regressionMetric/failOnIntroduced are only added below,
162
+ // after this validation loop runs), so a future numeric budget field can't
163
+ // silently skip this check by only being added to one of the two places.
164
+ const NUMERIC_BUDGET_FLAGS = Object.keys(budgets);
165
+ for (const flag of NUMERIC_BUDGET_FLAGS) {
166
+ const value = budgets[flag];
167
+ if (value !== undefined && !Number.isFinite(value)) {
168
+ return bail(`Invalid numeric value for ${flag}.\n${USAGE}`, 2);
169
+ }
170
+ }
171
+ // Only set when the user actually passed --regression-metric: evaluateBudgets()
172
+ // now treats "regressionMetric is set" as "the caller asked for a regression
173
+ // check" (guarding against it being set without --max-regression-pct), so
174
+ // defaulting it here unconditionally would trip that guard on every run that
175
+ // never touched either flag. checkRegression already falls back to
176
+ // 'wallClock' itself (`budgets.regressionMetric ?? 'wallClock'`) once
177
+ // maxRegressionPct is actually set, so no default is needed here.
178
+ if (values['regression-metric'] !== undefined) budgets.regressionMetric = values['regression-metric'];
179
+ if (values['fail-on-introduced'] !== undefined) budgets.failOnIntroduced = values['fail-on-introduced'];
180
+
181
+ const impactBand = splitCsv(values.impact);
182
+ const type = splitCsv(values.type);
183
+ let stageId;
184
+ if (values.stage !== undefined) {
185
+ stageId = Number(values.stage);
186
+ if (!Number.isInteger(stageId)) {
187
+ return bail(`Invalid integer value for --stage.\n${USAGE}`, 2);
188
+ }
189
+ }
190
+ const findingsFilter = toFindingsFilter(impactBand, type, stageId);
191
+
192
+ let appModel;
193
+ let baselineAppModel;
194
+ try {
195
+ if (usingShs) {
196
+ const shsPromise = resolveFromShs(
197
+ values['shs-base-url'], values['app-id'], values['attempt-id'],
198
+ fetchImpl !== undefined ? { fetchImpl } : {},
199
+ );
200
+ // The SHS fetch (network) and a local --baseline parse (disk) are
201
+ // independent, so run them concurrently. Not applied to the
202
+ // local-candidate+local-baseline combination below: both sides there
203
+ // are synchronous-equivalent CPU work reading the same way, so
204
+ // parallelizing wouldn't help and would just add indirection.
205
+ if (usingBaseline) {
206
+ [appModel, baselineAppModel] = await Promise.all([shsPromise, collectWithEvidence(values.baseline)]);
207
+ } else {
208
+ appModel = await shsPromise;
209
+ }
210
+ } else {
211
+ appModel = await collectWithEvidence(positionals[0]);
212
+ if (usingBaseline) baselineAppModel = await collectWithEvidence(values.baseline);
213
+ }
214
+ } catch (e) {
215
+ process.stderr.write(`${e.message}\n`);
216
+ process.exitCode = 2;
217
+ return;
218
+ }
219
+
220
+ const analyzeModel = (model) => analyze(
221
+ model.app, model.stages, model.executors.added, model.executors.removed,
222
+ model.jobs, model.sql, model.runAggregates,
223
+ );
224
+ const catalog = analyzeModel(appModel);
225
+
226
+ let comparison;
227
+ if (usingBaseline) {
228
+ const baselineCatalog = analyzeModel(baselineAppModel);
229
+ comparison = buildComparison(
230
+ { label: 'baseline', appModel: baselineAppModel, catalog: baselineCatalog },
231
+ { label: 'candidate', appModel, catalog },
232
+ );
233
+ // --redact must also scrub the comparison section: stage names in
234
+ // comparison.findings/baseStages/candStages carry raw Spark stage text,
235
+ // which is exactly what --redact promises to pseudonymize.
236
+ if (values.redact) comparison = redactComparison(comparison);
237
+ }
238
+
239
+ const { markdown, json } = buildEvidenceReport(appModel, { redact: values.redact, findingsFilter, markdown: values.format === 'md' });
240
+ let output;
241
+ if (values.format === 'md') {
242
+ output = comparison ? `${markdown}${renderComparisonMarkdown(comparison)}\n` : `${markdown}\n`;
243
+ } else {
244
+ const payload = comparison
245
+ ? {
246
+ candidate: json,
247
+ comparison: {
248
+ confidence: comparison.confidence,
249
+ reason: comparison.reason,
250
+ matchedCoverage: comparison.matchedCoverage,
251
+ metrics: comparison.metrics,
252
+ findings: comparison.findings,
253
+ },
254
+ }
255
+ : json;
256
+ output = `${JSON.stringify(payload, null, 2)}\n`;
257
+ }
258
+ if (values.out) writeFileSync(values.out, output);
259
+ else process.stdout.write(output);
260
+
261
+ const { results, violated, inconclusive } = evaluateBudgets({ appModel, catalog, budgets, comparison });
262
+ for (const r of results) {
263
+ if (r.status === 'inconclusive') process.stderr.write(`[inconclusive] ${r.name}: ${r.detail}\n`);
264
+ else if (r.status === 'violation') process.stderr.write(`[violation] ${r.name}: ${r.detail}\n`);
265
+ }
266
+
267
+ // Always checked, unlike the opt-in budgets above: a run whose event log
268
+ // never recorded an ApplicationEnd is inconclusive by default, not just
269
+ // when the caller happens to pass --max-runtime.
270
+ const incompleteRunFinding = catalog.find((f) => f.type === 'incompleteRun');
271
+ if (incompleteRunFinding) process.stderr.write(`[inconclusive] run-complete: ${incompleteRunFinding.recommendation}\n`);
272
+
273
+ if (violated) process.exitCode = 1;
274
+ else if (inconclusive || incompleteRunFinding) process.exitCode = 3;
275
+ else process.exitCode = 0;
276
+ }
277
+
278
+ // Guarded so tests can import `main` for in-process runs (e.g. mocking the
279
+ // global `fetch` that resolveFromShs falls back to) without also triggering
280
+ // a second, real invocation as a side effect of the import. A published
281
+ // install's `npm install` bin creates a node_modules/.bin/ symlink (see
282
+ // server/index.js's invokedDirectly for the same fix), so argv[1] must be
283
+ // realpath-resolved before comparing against this module's real path:
284
+ // otherwise the guard never matches when invoked through that symlink and
285
+ // the CLI silently does nothing (exit 0, no output).
286
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
287
+ await main(process.argv.slice(2));
288
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "sparkforensics-cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "description": "CLI to analyze Apache Spark event logs for performance bottlenecks: skew, spill, GC pressure, stragglers, and more.",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/shuffle-works/sparkforensics.git"
10
+ },
11
+ "bin": {
12
+ "sparkforensics-analyze": "./bin/sparkforensics-analyze.mjs"
13
+ },
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "files": ["bin/", "vendor-core/"],
18
+ "scripts": {
19
+ "prepack": "node ../../scripts/vendor-core.mjs .",
20
+ "test": "vitest run"
21
+ },
22
+ "dependencies": {
23
+ "zod": "^4.4.3"
24
+ },
25
+ "devDependencies": {
26
+ "@sparkforensics/core": "*",
27
+ "vitest": "^4.1.10"
28
+ }
29
+ }
@@ -0,0 +1,167 @@
1
+ import { DETECTORS, } from './detectors.js';
2
+ import { computePeakConcurrentCores } from './core-count.js';
3
+ import { assertNever } from './assert-never.js';
4
+ import { estimateImpact } from './impact-estimator.js';
5
+ import { computeOccupancy, } from './occupancy.js';
6
+ import { deriveImpactBand } from './impact-band.js';
7
+ import { IMPACT_BAND_ORDER } from './format-utils.js';
8
+
9
+
10
+
11
+
12
+ // FNV-1a 32-bit: a small, dependency-free stable string hash. Used to derive
13
+ // a deterministic finding `id` at the single choke point below so the same
14
+ // logical finding keeps the same id across runs (no timestamps, no randomness).
15
+ function fnv1a(str ) {
16
+ let h = 0x811c9dc5;
17
+ for (let i = 0; i < str.length; i++) {
18
+ h ^= str.charCodeAt(i);
19
+ h = Math.imul(h, 0x01000193);
20
+ }
21
+ return (h >>> 0).toString(16).padStart(8, '0');
22
+ }
23
+
24
+ export function findingId(f ) {
25
+ // Location key mirrors the report's stable identity: stage, else SQL
26
+ // execution, else the audited config property.
27
+ const locKey = f.stageId ?? f.executionId ?? f.property ?? '';
28
+ // Discriminators for detectors that intentionally emit multiple findings on
29
+ // the same location+metric; without them these siblings hash to one id:
30
+ // stage-scope: slowHost (host), memoryUtilization (executorId + dimension),
31
+ // partitionSizing (rule);
32
+ // app-scope: cacheUtilization (rddId + variant, stageId is always null),
33
+ // cachingOpportunity (stageId/executionId both null too: `relation`/
34
+ // `format` distinguish leaf findings, `operator`+`relation` distinguish
35
+ // composite findings, `executionIds` as a last resort when everything
36
+ // else matches);
37
+ // SQL plan-advisor: smallFiles (direction/nodeName),
38
+ // duplicatePlanSubtree (rootName/subtreeSize/groupIndex: two unrelated
39
+ // duplicate-subtree groups can share rootName+subtreeSize, e.g. the same
40
+ // "BroadcastExchange over a Project/Filter/Scan" shape repeated once per
41
+ // dimension table; groupIndex (the group's deterministic position within
42
+ // one execution's plan) is the actual uniqueness guarantee, since the
43
+ // informational `sampleRelation` field can be null or coincide for two
44
+ // groups),
45
+ // broadcastSizing (per node/side, distinguished by value + largerSideBytes).
46
+ // The metric `value` folds in the per-node magnitude that broadcastSizing
47
+ // siblings carry no other field for; it is deterministic for a given log, so
48
+ // the id stays stable across re-parses (no timestamp/randomness).
49
+ // memoryUtilization's `rule` (heapNearCapacity/heapOverProvisioned) is excluded here: an
50
+ // executor falls in exactly one heap band, so `executorId` alone already guarantees
51
+ // uniqueness and folding `rule` in too would just add gratuitous id-rotation risk if the
52
+ // band logic ever changes. partitionSizing's `rule` (maxPartitionTooBig/
53
+ // shufflePartitionSkew/lowShuffleParallelism) IS load-bearing here: a stage can emit more
54
+ // than one of those rules at once, sharing the same stageId+metric.
55
+ const rule = f.type === 'memoryUtilization' ? undefined : f.rule;
56
+ const disc = [
57
+ f.host, f.executorId, rule, f.variant, f.dimension,
58
+ f.direction, f.nodeName, f.rootName, f.subtreeSize, f.groupIndex, f.largerSideBytes,
59
+ f.rddId, f.relation, f.format, f.operator,
60
+ f.executionIds ? f.executionIds.join(',') : '',
61
+ ].map((v) => v ?? '').join('|');
62
+ return fnv1a(`${f.type}|${locKey}|${f.metric ?? ''}|${f.value ?? ''}|${disc}`);
63
+ }
64
+
65
+ function push(out , entry , result ) {
66
+ if (!result) return;
67
+ const detectorVersion = entry.version ?? 1;
68
+ for (const f of (Array.isArray(result) ? result : [result])) {
69
+ if (!f) continue;
70
+ if (entry.suppressWhen && entry.suppressWhen(f, out)) continue;
71
+ const stamped = { ...f, docAnchor: f.docAnchor ?? entry.docAnchor, detectorVersion };
72
+ const id = findingId(stamped);
73
+ // Guardrail against any detector (this one or a future one) emitting two
74
+ // structurally-identical findings for what should be one logical
75
+ // occurrence: same id => same finding, keep only the first. Added after a
76
+ // real-log bug where duplicatePlanSubtree emitted two distinct findings
77
+ // sharing one id (see findingId's groupIndex discriminator above and
78
+ // tests/detectors-plan.test.js's duplicatePlanSubtree regression tests);
79
+ // this guard's correctness depends on findingId's discriminator list
80
+ // actually being unique per distinct finding, not on this line itself.
81
+ if (out.some((existing) => existing.id === id)) continue;
82
+ out.push({ ...stamped, id });
83
+ }
84
+ }
85
+
86
+ // `app` is widened to `SparkAppInfo | null` rather than the plan's literal
87
+ // non-nullable `SparkAppInfo`: real callers (evidence-report.ts's
88
+ // `buildJson`, useIngest.ts's `runDone`/`snapshotParsedRun`) pass
89
+ // `AppModel.app`, which is genuinely `SparkAppInfo | null` at the type
90
+ // level even though by the time `analyze()` runs in practice a full parse
91
+ // has completed and `app` is always populated (the same reasoning
92
+ // detectors.ts's `DetectorCtx.app` comment gives for treating its own,
93
+ // separate `app` field as non-nullable). Widened to match the real caller
94
+ // type rather than forcing a cast at every call site.
95
+ export function analyze(
96
+ app ,
97
+ stages ,
98
+ executorsAdded ,
99
+ executorsRemoved ,
100
+ jobs ,
101
+ sql = new Map(),
102
+ runAggregates = null,
103
+ ) {
104
+ // `app ?? {}`: every detector below tolerates a null `app` (malformed logs
105
+ // with no ApplicationStart), so this call must too; computePeakConcurrentCores
106
+ // just falls back to the executor-derived core sum when `resources` is absent.
107
+ // computePeakConcurrentCores (not computeTotalCores) here specifically: the
108
+ // ceiling this feeds (src/occupancy.ts's computeCeiling) needs a genuine
109
+ // concurrent-capacity bound, and computeTotalCores's cumulative sum over
110
+ // every addition overstates that under dynamic allocation/executor
111
+ // replacement (a churned-through executor's cores were never actually
112
+ // concurrent with its replacement's).
113
+ // `executorsAdded`/`executorsRemoved` casts: computePeakConcurrentCores only
114
+ // reads `executorId`/`timestamp`/`totalCores`, all present on
115
+ // ExecutorAddedEvent/ExecutorRemovedEvent, but `totalCores` isn't on
116
+ // ExecutorRemovedEvent, so the `ExecutorEvent` union as a whole is a
117
+ // structural mismatch against its parameter shapes.
118
+ const totalCores = computePeakConcurrentCores(
119
+ app ?? {},
120
+ executorsAdded ,
121
+ executorsRemoved ,
122
+ );
123
+ // Computed once, up front, so detectors can gate impact band on the same
124
+ // occupancy-clipped waste figure estimateImpact() below displays as that
125
+ // finding's savings (src/detectors.ts's `clippedWasteMs`), not a raw
126
+ // pre-clip delta the two passes would otherwise disagree on.
127
+ const occupancy = computeOccupancy(stages , totalCores);
128
+ const ctx = {
129
+ app, stages, executorsAdded, executorsRemoved, jobs, sql, runAggregates, occupancy,
130
+ };
131
+ const out = [];
132
+ for (const d of DETECTORS) {
133
+ if (d.inScorecard === false) continue;
134
+ switch (d.scope) {
135
+ case 'stage':
136
+ for (const s of stages.values()) push(out, d, d.detect(s, ctx));
137
+ break;
138
+ case 'sql':
139
+ for (const e of sql.values()) push(out, d, d.detect(e, ctx));
140
+ break;
141
+ case 'app':
142
+ case 'config':
143
+ push(out, d, d.detect(ctx));
144
+ break;
145
+ default:
146
+ assertNever(d.scope);
147
+ }
148
+ }
149
+ estimateImpact(out, stages, totalCores);
150
+ deriveImpactBand(out, app);
151
+ // Ascending IMPACT_BAND_ORDER (critical 0 → info 2) puts the worst band
152
+ // first, matching the old descending 3/2/1 rank this replaced; `sort` is
153
+ // stable, so findings sharing a band keep their DETECTORS declaration order.
154
+ out.sort((a, b) => IMPACT_BAND_ORDER[a.impactBand] - IMPACT_BAND_ORDER[b.impactBand]);
155
+ return out;
156
+ }
157
+
158
+ export function auditConfig(app ) {
159
+ const out = [];
160
+ for (const d of DETECTORS) if (d.scope === 'config') push(out, d, d.detect({ app }));
161
+ // configAudit's own impact-estimator case (src/impact-estimator.ts) needs neither
162
+ // `stages` nor `totalCores`: it's unconditionally `costOnly('none')`. An empty stages
163
+ // map is enough for parity with the same finding type produced via analyze().
164
+ estimateImpact(out, new Map());
165
+ deriveImpactBand(out, app);
166
+ return out.map((f) => ({ ...f, stageId: f.stageId ?? null }));
167
+ }
@@ -0,0 +1,3 @@
1
+ export function assertNever(x ) {
2
+ throw new Error(`Unreachable case: ${JSON.stringify(x)}`);
3
+ }