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
@@ -2,11 +2,33 @@
2
2
 
3
3
 
4
4
 
5
+
5
6
 
6
- export const EXPORT_DATA_SCHEMA_VERSION = 1;
7
+ /** Version 2 carries the interpretation layer (`interpretation`) and `provenance`. Version 3 types
8
+ * findings per detector: a text figure (stageFailed, configAudit, incompleteRun) moved from `value`
9
+ * to `valueText`, and Plan Advisor findings always carry `planNodeIds`. The export bundle renders
10
+ * only the version it was built for and refuses any other, since an older payload lacks the
11
+ * conclusions it would otherwise have to derive, or carries them in fields this bundle no longer reads. */
12
+ export const EXPORT_DATA_SCHEMA_VERSION = 3;
13
+
14
+ /** @sparkforensics/core's own version (packages/core/package.json, which a test keeps in sync).
15
+ * A constant, not a JSON import, because the vendored copies in cli/mcp/server ship without
16
+ * that package.json. */
17
+ export const CORE_VERSION = '0.1.0';
18
+
19
+ /** What produced an export, shown in the exported dashboard's footer. */
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
7
28
 
8
29
 
9
30
 
31
+
10
32
 
11
33
 
12
34
 
@@ -17,23 +39,28 @@ export const EXPORT_DATA_SCHEMA_VERSION = 1;
17
39
 
18
40
 
19
41
 
42
+
20
43
 
21
44
 
22
45
  /** Converts an in-memory AppModel (Map-based, as collectRun()/the browser
23
- * worker produce it) plus the CLI's already-computed catalog/configFindings
24
- * into a plain, JSON-serializable object for the HTML export's data.js.
46
+ * worker produce it) plus its catalog/configFindings and their interpretation
47
+ * (`interpretRun`, which indexes them in this same catalog-then-config order) into a plain, JSON-serializable object for the HTML export's data.js.
25
48
  * Maps become arrays; main-export.tsx rebuilds them client-side keyed the
26
49
  * same way the store already expects (Stage.id, Job.id,
27
- * SqlExecution.id). Deliberately excludes raw per-task TaskData, which is far
50
+ * SqlExecution.id). Maps and Sets nested deeper keep their type through
51
+ * `reviveExportCollections`. Deliberately excludes raw per-task TaskData, which is far
28
52
  * too large to inline; task-detail widgets degrade gracefully in export mode. */
29
53
  export function buildExportRunData(
30
54
  appModel ,
31
55
  catalog ,
32
56
  configFindings ,
33
57
  skippedLines ,
58
+ interpretation ,
59
+ provenance ,
34
60
  ) {
35
- return {
61
+ return encodeCollections({
36
62
  schemaVersion: EXPORT_DATA_SCHEMA_VERSION,
63
+ provenance,
37
64
  app: appModel.app,
38
65
  stages: [...appModel.stages.values()],
39
66
  jobs: [...appModel.jobs.values()],
@@ -44,5 +71,51 @@ export function buildExportRunData(
44
71
  catalog,
45
72
  configFindings,
46
73
  skippedLines,
47
- };
74
+ interpretation,
75
+ }) ;
76
+ }
77
+
78
+ // Maps and Sets nested inside the payload (`app.rddInfo`, each stage's
79
+ // `executorMetrics`) would serialize to `{}` under plain JSON, and a widget
80
+ // calling `.values()` on one then throws. They travel as tagged plain objects
81
+ // instead, which survive JSON and redaction's deep copy alike;
82
+ // `reviveExportCollections` turns them back on load.
83
+ const MAP_TAG = '__sparkforensicsMap';
84
+ const SET_TAG = '__sparkforensicsSet';
85
+
86
+ export function encodeCollections(value ) {
87
+ if (value instanceof Map) return { [MAP_TAG]: [...value].map(([k, v]) => [encodeCollections(k), encodeCollections(v)]) };
88
+ if (value instanceof Set) return { [SET_TAG]: [...value].map(encodeCollections) };
89
+ if (Array.isArray(value)) return value.map(encodeCollections);
90
+ if (value && typeof value === 'object') {
91
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, encodeCollections(v)]));
92
+ }
93
+ return value;
94
+ }
95
+
96
+ /** The in-memory counterpart of `reviveExportCollections`: rebuilds every
97
+ * tagged Map and Set in a tree `encodeCollections` produced, without a JSON
98
+ * round trip. */
99
+ export function decodeCollections(value ) {
100
+ if (Array.isArray(value)) return value.map(decodeCollections);
101
+ if (value && typeof value === 'object') {
102
+ const record = value ;
103
+ if (Array.isArray(record[MAP_TAG])) {
104
+ return new Map((record[MAP_TAG] ).map(([k, v]) => [decodeCollections(k), decodeCollections(v)]));
105
+ }
106
+ if (Array.isArray(record[SET_TAG])) return new Set((record[SET_TAG] ).map(decodeCollections));
107
+ return Object.fromEntries(Object.entries(record).map(([k, v]) => [k, decodeCollections(v)]));
108
+ }
109
+ return value;
110
+ }
111
+
112
+ /** `JSON.parse` reviver for the export payload: rebuilds every Map and Set
113
+ * `buildExportRunData` tagged. */
114
+ export function reviveExportCollections(_key , value ) {
115
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
116
+ const record = value ;
117
+ if (Array.isArray(record[MAP_TAG])) return new Map(record[MAP_TAG] );
118
+ if (Array.isArray(record[SET_TAG])) return new Set(record[SET_TAG] );
119
+ }
120
+ return value;
48
121
  }
@@ -1,91 +1,12 @@
1
+ import { findingName } from './finding-names.js';
2
+ import { presentationOf } from './finding-presentation.js';
1
3
 
2
4
 
3
- /** A short, imperative action label for a finding's row. Keyed off finding.type plus whichever
4
- * discriminant field that detector uses (rule/direction/variant/property). Core-safe (no view
5
- * import) so the dashboard's wrapper and evidence-report.ts share the same switch. Returns
6
- * undefined for any combination this switch doesn't cover; the caller decides the fallback. */
7
- export function coreFindingActionLabel(finding ) {
8
- switch (finding.type) {
9
- case 'skew':
10
- return 'Fix task skew';
11
- case 'stageShape':
12
- switch (finding.rule) {
13
- case 'lowParallelism': return 'Increase parallelism';
14
- case 'dataExplosion': return 'Check for exploding join';
15
- case 'taskStageSkew': return 'Fix straggler task';
16
- }
17
- break;
18
- case 'shuffle':
19
- return 'Reduce shuffle size';
20
- case 'partitionSizing':
21
- switch (finding.rule) {
22
- case 'shufflePartitionSkew': return 'Fix skewed partition';
23
- case 'lowShuffleParallelism': return 'Add shuffle partitions';
24
- case 'maxPartitionTooBig': return 'Repartition oversized data';
25
- }
26
- break;
27
- case 'spill':
28
- return 'Reduce spill';
29
- case 'gc':
30
- return finding.direction === 'low' ? 'Right-size executor memory' : 'Reduce GC pressure';
31
- case 'slowHost':
32
- if (finding.variant === 'durationShare') return 'Fix data locality';
33
- if (finding.variant === 'multiDim') return 'Investigate degraded executor';
34
- return 'Check slow host';
35
- case 'stageSlowness':
36
- return 'Profile slow stage';
37
- case 'stageFailed':
38
- return 'Inspect stage failure';
39
- case 'failures':
40
- return 'Investigate task failures';
41
- case 'straggler':
42
- return 'Fix stragglers';
43
- case 'speculationWaste':
44
- return 'Tune speculation settings';
45
- case 'retryWaste':
46
- return 'Investigate retry cause';
47
- case 'tinyTask':
48
- return 'Coalesce small tasks';
49
- case 'coldStart':
50
- return 'Pre-warm cluster';
51
- case 'utilization':
52
- return 'Reduce cluster size';
53
- case 'memoryUtilization':
54
- switch (finding.variant) {
55
- case 'idleCores': return 'Reduce idle cores';
56
- case 'wasteModel': return 'Right-size executor memory';
57
- case 'memoryBand':
58
- if (finding.dataUnavailable) return 'Enable memory metrics';
59
- return finding.rule === 'heapNearCapacity' ? 'Increase executor memory' : 'Reduce executor memory';
60
- }
61
- break;
62
- case 'cacheUtilization':
63
- return 'Increase cache memory';
64
- case 'coreLocality':
65
- return 'Fix data locality';
66
- case 'autoscalingChurn':
67
- return 'Reduce autoscaling churn';
68
- case 'cachingOpportunity':
69
- return finding.variant === 'composite' ? 'Cache repeated result' : 'Cache shared table';
70
- case 'jobFailureRate':
71
- return 'Investigate failed jobs';
72
- case 'configAudit':
73
- switch (finding.property) {
74
- case 'spark.shuffle.service.enabled': return 'Enable shuffle service';
75
- case 'spark.dynamicAllocation.minExecutors': return 'Fix autoscaling bounds';
76
- case 'spark.dynamicAllocation.maxExecutors': return 'Set max executors';
77
- case 'spark.serializer': return 'Switch to Kryo';
78
- case 'spark.executor.memoryOverhead': return 'Raise memory overhead';
79
- }
80
- break;
81
- case 'duplicatePlanSubtree':
82
- return 'Dedupe repeated subtree';
83
- case 'smallFiles':
84
- return finding.direction === 'write' ? 'Coalesce output files' : 'Compact small files';
85
- case 'underBroadcast':
86
- return 'Use broadcast join';
87
- case 'overBroadcast':
88
- return 'Fix oversized broadcast';
89
- }
90
- return undefined;
5
+ /** A short, imperative action label for a finding's row ("Reduce shuffle size"), from its type's
6
+ * FINDING_PRESENTATION row. A (type, discriminant) combination the row doesn't recognize falls
7
+ * back to the type's name, and an unknown type to its raw string. The dashboard, the run verdict
8
+ * and the evidence report all call this one function, so a finding shows the same label on every
9
+ * path. */
10
+ export function findingActionLabel(finding ) {
11
+ return presentationOf(finding.type)?.actionLabel(finding) ?? findingName(finding.type);
91
12
  }
@@ -18,6 +18,15 @@ function has (m , value ) {
18
18
 
19
19
 
20
20
 
21
+ /** The one stage a finding is about: its own `stageId`, or the only entry of a sql-scope
22
+ * finding's `stageIds`. Null for an app-level, config or multi-stage finding. The same rule the
23
+ * run verdict groups steps by and the stage dialog lists a stage's findings by. */
24
+ export function singleStageId(row ) {
25
+ if (typeof row.stageId === 'number') return row.stageId;
26
+ if (row.stageIds && row.stageIds.length === 1) return row.stageIds[0];
27
+ return null;
28
+ }
29
+
21
30
  export function matchesFindingFilterCriteria(
22
31
  row ,
23
32
  criteria ,
@@ -1,112 +1,14 @@
1
+ import { presentationOf } from './finding-presentation.js';
1
2
 
2
3
 
3
4
  /** A generic, type-level recommendation sentence for a finding: the shape of the fix, with no
4
- * instance data (numbers, stage ids, host names, file counts, config values). Mirrors
5
- * coreFindingActionLabel's (type, discriminant) switch (same fields: rule/direction/variant/
6
- * property, plus cacheUtilization's variant and memoryUtilization's dataUnavailable), but covers
7
- * every finding type, not just the ones with a distinct action label.
5
+ * instance data (numbers, stage ids, host names, file counts, config values), from its type's
6
+ * FINDING_PRESENTATION row.
8
7
  *
9
8
  * Used for a multi-finding group's muted description line (TypeGroupRow in FixTheseFirst.tsx),
10
9
  * where the highest-impact member's own `recommendation` (real numbers, one stage) would
11
- * misrepresent a summed-impact trailing stat covering every member. Returns undefined where a
12
- * detector's real branch key isn't exposed as a Finding field (spill's skew/volume
13
- * classification, tinyTask's shuffle-vs-no-shuffle fix, duplicatePlanSubtree's isExchangeRoot),
14
- * so those combine into one sentence covering both cases, or for any (type, discriminant)
15
- * combination this switch doesn't recognize; the caller shows no muted line rather than guess. */
10
+ * misrepresent a summed-impact trailing stat covering every member. Undefined where the row has no
11
+ * sentence for the finding; the caller shows no muted line rather than guess. */
16
12
  export function coreFindingGenericRecommendation(finding ) {
17
- switch (finding.type) {
18
- case 'skew':
19
- return 'For join-driven skew, enable AQE skew-join handling (spark.sql.adaptive.skewJoin.enabled); otherwise salt the key or repartition on a better key to reduce task skew.';
20
- case 'stageShape':
21
- switch (finding.rule) {
22
- case 'lowParallelism': return 'Too few tasks run relative to the cores available, leaving cluster capacity idle: repartition to use more of it.';
23
- case 'dataExplosion': return 'Output volume far exceeds input volume: check for an exploding join or a cross product.';
24
- case 'taskStageSkew': return 'A single straggler task gates the whole stage\'s wall-clock duration.';
25
- }
26
- break;
27
- case 'shuffle':
28
- return 'Consider increasing spark.sql.shuffle.partitions or adding a broadcast join to shrink the shuffle.';
29
- case 'partitionSizing':
30
- switch (finding.rule) {
31
- case 'shufflePartitionSkew': return 'For join skew, enable AQE skew-join handling (spark.sql.adaptive.skewJoin.enabled); otherwise salt the key or repartition on a better key.';
32
- case 'lowShuffleParallelism': return 'Raise spark.sql.shuffle.partitions so each partition is smaller.';
33
- case 'maxPartitionTooBig': return 'Repartition to break up the oversized partition before this stage.';
34
- }
35
- break;
36
- case 'spill':
37
- return 'If the spill is skew-driven, fix task skew first: adding memory will not help. Otherwise raise spark.sql.shuffle.partitions or increase executor memory.';
38
- case 'gc':
39
- return finding.direction === 'low'
40
- ? 'Memory may be over-provisioned here: consider reducing spark.executor.memory for cost savings.'
41
- : 'Reduce object creation, use primitive types, avoid UDFs, or increase executor memory to cut GC time.';
42
- case 'slowHost':
43
- if (finding.variant === 'durationShare') return 'Check for data locality or partition assignment skewing work onto one node.';
44
- if (finding.variant === 'multiDim') return 'Investigate uneven partition assignment or a degraded executor.';
45
- return 'Check what this host was running: it may just hold data locality for its tasks or carry one heavy stage, rather than a hardware fault. Enable spark.speculation to relaunch a lagging task automatically.';
46
- case 'stageSlowness':
47
- return 'Often a partition-count problem: raise parallelism via spark.sql.shuffle.partitions or spark.default.parallelism, or check for a large per-task data volume driving heavy shuffle and spill.';
48
- case 'stageFailed':
49
- return 'Inspect the driver log for the failure reason and the job that triggered it.';
50
- case 'failures':
51
- return 'Investigate driver logs for executor instability or data-driven errors.';
52
- case 'straggler':
53
- return 'Rule out a GC pause or a slow shuffle fetch before assuming a hardware issue. If a skewed key is the real cause, that is a candidate for AQE\'s skew-join handling.';
54
- case 'speculationWaste':
55
- return 'If task durations are naturally variable rather than genuine stragglers, consider tuning spark.speculation.multiplier/quantile.';
56
- case 'retryWaste':
57
- return 'Investigate executor loss or fetch failures behind the retried attempts.';
58
- case 'tinyTask':
59
- return 'Scheduler overhead may dominate: lower spark.sql.shuffle.partitions, or coalesce down to fewer, larger tasks.';
60
- case 'coldStart':
61
- return 'Keep a warm pool of idle executors, or if using dynamic allocation, raise the minimum/initial executor count so it does not scale up from zero.';
62
- case 'utilization':
63
- return 'Consider reducing cluster size or enabling dynamic allocation.';
64
- case 'memoryUtilization':
65
- switch (finding.variant) {
66
- case 'idleCores': return 'Reduce cluster size or enable dynamic allocation.';
67
- case 'wasteModel': return 'Review spark.executor.memory and executor count.';
68
- case 'memoryBand':
69
- if (finding.dataUnavailable) break;
70
- return finding.rule === 'heapNearCapacity'
71
- ? 'Memory may be too small: raise spark.executor.memory to avoid OOM/spill.'
72
- : 'Memory may be over-provisioned: consider reducing spark.executor.memory for cost savings.';
73
- }
74
- break;
75
- case 'cacheUtilization':
76
- switch (finding.variant) {
77
- case 'partialCache': return 'Increase executor memory or reduce the cached dataset size so more of it stays cached.';
78
- case 'diskSpillover': return 'Executor memory may be too small for this cached dataset: increase executor memory or reduce its size.';
79
- }
80
- break;
81
- case 'coreLocality':
82
- return 'Check spark.locality.wait settings and executor/data colocation.';
83
- case 'autoscalingChurn':
84
- return 'This looks like wasteful re-provisioning rather than normal scale-down: consider raising spark.dynamicAllocation.executorIdleTimeout or widening the minExecutors/maxExecutors bounds to reduce flapping.';
85
- case 'cachingOpportunity':
86
- return finding.variant === 'composite'
87
- ? 'Cache or persist the repeated join/union result so it is computed once instead of recomputed per query.'
88
- : 'Cache the shared DataFrame, or broadcast it if it is a small join lookup.';
89
- case 'jobFailureRate':
90
- return 'Inspect the driver log for the failed job(s) and the stage failures that triggered them.';
91
- case 'configAudit':
92
- switch (finding.property) {
93
- case 'spark.shuffle.service.enabled': return 'Set spark.shuffle.service.enabled=true so shuffle data survives executor removal.';
94
- case 'spark.dynamicAllocation.minExecutors': return 'Set the minimum executor bound at or below the maximum.';
95
- case 'spark.dynamicAllocation.maxExecutors': return 'Set spark.dynamicAllocation.maxExecutors to cap cluster growth.';
96
- case 'spark.serializer': return 'Consider spark.serializer=org.apache.spark.serializer.KryoSerializer for faster, smaller buffers.';
97
- case 'spark.executor.memoryOverhead': return 'Raise executor memoryOverhead above Spark\'s default floor to avoid off-heap OOM-kills.';
98
- }
99
- break;
100
- case 'duplicatePlanSubtree':
101
- return 'Check whether the repeated subtree could be computed once and reused, or cache/persist the shared computation.';
102
- case 'smallFiles':
103
- return finding.direction === 'write'
104
- ? 'Repartition or coalesce before writing to raise the average file size.'
105
- : 'Compact the upstream output so fewer, larger files are produced.';
106
- case 'underBroadcast':
107
- return 'This could have been a broadcast join: consider a broadcast() hint or raising spark.sql.autoBroadcastJoinThreshold.';
108
- case 'overBroadcast':
109
- return 'Check for a misapplied broadcast hint or a misconfigured spark.sql.autoBroadcastJoinThreshold.';
110
- }
111
- return undefined;
13
+ return presentationOf(finding.type)?.genericRecommendation(finding);
112
14
  }
@@ -1,51 +1,27 @@
1
- // Canonical detector `type` -> human-readable label (single source of truth). detector-registry
2
- // imports this (web uses it lowercase); evidence-report.ts Title Cases it for CLI/MCP names.
3
- //
4
- // broadcastSizing is the DETECTORS-level type but no real Finding carries it (the detector pushes
5
- // underBroadcast/overBroadcast). Kept as a dead key so the DETECTORS-type completeness check finds it.
6
- export const FINDING_NAMES = {
7
- incompleteRun: 'incomplete run',
8
-
9
- skew: 'task skew',
10
- stageShape: 'stage shape',
11
- tinyTask: 'tiny tasks',
12
-
13
- shuffle: 'shuffle I/O',
14
- partitionSizing: 'partition sizing',
15
-
16
- spill: 'spill',
17
-
18
- gc: 'GC pressure',
19
-
20
- stageFailed: 'failed stage',
21
- failures: 'failed tasks',
22
- retryWaste: 'retry waste',
23
-
24
- slowHost: 'slow executor host',
25
- stageSlowness: 'slow stage',
26
- straggler: 'straggling task',
27
- speculationWaste: 'speculation waste',
28
- coldStart: 'cold start',
29
-
30
- memoryUtilization: 'memory utilization',
31
- utilization: 'executor utilization',
32
- coreLocality: 'core locality',
33
- cachingOpportunity: 'caching opportunity',
34
- cacheUtilization: 'cache utilization',
35
- jobFailureRate: 'job failure rate',
36
- autoscalingChurn: 'autoscaling churn',
37
-
38
- configAudit: 'config audit',
39
-
40
- duplicatePlanSubtree: 'duplicate plan subtree',
41
- smallFiles: 'small files',
42
- broadcastSizing: 'broadcast sizing',
43
- underBroadcast: 'missed broadcast join',
44
- overBroadcast: 'oversized broadcast join',
45
- };
1
+ import { FINDING_PRESENTATION, presentationOf } from './finding-presentation.js';
2
+
3
+
4
+ // Finding `type` -> human-readable label, derived from FINDING_PRESENTATION. The web uses it
5
+ // lowercase; evidence-report.ts Title Cases it for CLI/MCP names. Declared string-indexed for
6
+ // lookups by a type read from report JSON or a filter.
7
+ export const FINDING_NAMES = Object.fromEntries(
8
+ (Object.keys(FINDING_PRESENTATION) ).map((type) => [type, FINDING_PRESENTATION[type].name]),
9
+ );
10
+
11
+ /** A finding type's name, or the raw type string for a type with no presentation row. */
12
+ export function findingName(type ) {
13
+ return presentationOf(type)?.name ?? type;
14
+ }
46
15
 
47
16
  // Capitalizes the first letter of each word, leaving other characters untouched so acronyms
48
17
  // ('GC', 'I/O') survive.
49
18
  export function titleCase(label ) {
50
19
  return label.replace(/\b\w/g, (c) => c.toUpperCase());
51
20
  }
21
+
22
+ /** The finding's own recommendation, or its type's name when it has none. */
23
+ export function recommendationText(finding ) {
24
+ const text = typeof finding.recommendation === 'string' ? finding.recommendation.trim() : '';
25
+ if (text) return text;
26
+ return findingName(finding.type);
27
+ }