sela-core 1.0.7 → 1.0.9

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 (146) hide show
  1. package/README.md +27 -17
  2. package/dist/cli/ErrorHandler.d.ts +1 -1
  3. package/dist/cli/ErrorHandler.js +14 -14
  4. package/dist/cli/commands/bulk.d.ts +1 -1
  5. package/dist/cli/commands/bulk.d.ts.map +1 -1
  6. package/dist/cli/commands/bulk.js +51 -33
  7. package/dist/cli/commands/init.d.ts +1 -1
  8. package/dist/cli/commands/init.d.ts.map +1 -1
  9. package/dist/cli/commands/init.js +26 -18
  10. package/dist/cli/commands/merge.d.ts +13 -0
  11. package/dist/cli/commands/merge.d.ts.map +1 -0
  12. package/dist/cli/commands/merge.js +119 -0
  13. package/dist/cli/commands/showReport.d.ts +1 -1
  14. package/dist/cli/commands/showReport.d.ts.map +1 -1
  15. package/dist/cli/commands/showReport.js +12 -10
  16. package/dist/cli/commands/status.d.ts +1 -1
  17. package/dist/cli/commands/status.d.ts.map +1 -1
  18. package/dist/cli/commands/status.js +8 -8
  19. package/dist/cli/commands/sync.d.ts +1 -1
  20. package/dist/cli/commands/sync.d.ts.map +1 -1
  21. package/dist/cli/commands/sync.js +38 -30
  22. package/dist/cli/index.js +12 -9
  23. package/dist/cli/ui/DnaTable.d.ts +1 -1
  24. package/dist/cli/ui/DnaTable.d.ts.map +1 -1
  25. package/dist/cli/ui/DnaTable.js +23 -16
  26. package/dist/cli/ui/RefactorWizard.d.ts +3 -3
  27. package/dist/cli/ui/RefactorWizard.d.ts.map +1 -1
  28. package/dist/cli/ui/RefactorWizard.js +108 -82
  29. package/dist/config/ConfigLoader.d.ts +2 -2
  30. package/dist/config/ConfigLoader.d.ts.map +1 -1
  31. package/dist/config/ConfigLoader.js +15 -15
  32. package/dist/config/DryRunGuard.d.ts +1 -1
  33. package/dist/config/DryRunGuard.js +7 -7
  34. package/dist/config/SelaConfig.d.ts +13 -17
  35. package/dist/config/SelaConfig.d.ts.map +1 -1
  36. package/dist/config/SelaConfig.js +32 -26
  37. package/dist/engine/HealingRegistry.d.ts +1 -1
  38. package/dist/engine/HealingRegistry.js +3 -3
  39. package/dist/engine/SelaEngine.d.ts +1 -1
  40. package/dist/engine/SelaEngine.d.ts.map +1 -1
  41. package/dist/engine/SelaEngine.js +117 -51
  42. package/dist/errors/SelaError.d.ts +8 -1
  43. package/dist/errors/SelaError.d.ts.map +1 -1
  44. package/dist/errors/SelaError.js +14 -7
  45. package/dist/fixtures/expectProxy.d.ts +2 -2
  46. package/dist/fixtures/expectProxy.js +16 -16
  47. package/dist/fixtures/index.d.ts +18 -2
  48. package/dist/fixtures/index.d.ts.map +1 -1
  49. package/dist/fixtures/index.js +47 -27
  50. package/dist/fixtures/moduleExpect.js +7 -7
  51. package/dist/fixtures/proxyTag.d.ts +1 -1
  52. package/dist/fixtures/proxyTag.js +4 -4
  53. package/dist/index.d.ts +8 -8
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +4 -4
  56. package/dist/reporter/SelaReporter.d.ts +45 -12
  57. package/dist/reporter/SelaReporter.d.ts.map +1 -1
  58. package/dist/reporter/SelaReporter.js +139 -39
  59. package/dist/services/ASTSourceUpdater.d.ts +48 -3
  60. package/dist/services/ASTSourceUpdater.d.ts.map +1 -1
  61. package/dist/services/ASTSourceUpdater.js +283 -105
  62. package/dist/services/AnchorResolver.d.ts +157 -0
  63. package/dist/services/AnchorResolver.d.ts.map +1 -0
  64. package/dist/services/AnchorResolver.js +289 -0
  65. package/dist/services/ArgumentTypeAnalyzer.js +7 -7
  66. package/dist/services/BlastRadiusAnalyzer.d.ts.map +1 -1
  67. package/dist/services/BlastRadiusAnalyzer.js +5 -2
  68. package/dist/services/ChainValidator.js +22 -22
  69. package/dist/services/CrossFileHealer.js +7 -7
  70. package/dist/services/DecisionEngine.d.ts +51 -0
  71. package/dist/services/DecisionEngine.d.ts.map +1 -0
  72. package/dist/services/DecisionEngine.js +260 -0
  73. package/dist/services/DefinitionTracer.d.ts +1 -1
  74. package/dist/services/DefinitionTracer.d.ts.map +1 -1
  75. package/dist/services/DefinitionTracer.js +28 -13
  76. package/dist/services/DnaEditorService.d.ts +2 -2
  77. package/dist/services/DnaEditorService.d.ts.map +1 -1
  78. package/dist/services/DnaEditorService.js +81 -62
  79. package/dist/services/DnaIndexService.d.ts +1 -1
  80. package/dist/services/DnaIndexService.d.ts.map +1 -1
  81. package/dist/services/DnaIndexService.js +14 -14
  82. package/dist/services/HealReportService.d.ts +31 -55
  83. package/dist/services/HealReportService.d.ts.map +1 -1
  84. package/dist/services/HealReportService.js +864 -1240
  85. package/dist/services/HealingAdvisory.d.ts +9 -1
  86. package/dist/services/HealingAdvisory.d.ts.map +1 -1
  87. package/dist/services/HealingAdvisory.js +9 -1
  88. package/dist/services/HealingCacheService.d.ts +8 -8
  89. package/dist/services/HealingCacheService.d.ts.map +1 -1
  90. package/dist/services/HealingCacheService.js +24 -24
  91. package/dist/services/HealthReportService.d.ts +1 -1
  92. package/dist/services/HealthReportService.d.ts.map +1 -1
  93. package/dist/services/HealthReportService.js +25 -25
  94. package/dist/services/InitializerUpdater.d.ts.map +1 -1
  95. package/dist/services/InitializerUpdater.js +12 -2
  96. package/dist/services/IntentAuditor.d.ts.map +1 -1
  97. package/dist/services/IntentAuditor.js +32 -21
  98. package/dist/services/InteractiveReview.d.ts +8 -17
  99. package/dist/services/InteractiveReview.d.ts.map +1 -1
  100. package/dist/services/InteractiveReview.js +34 -124
  101. package/dist/services/LLMService.d.ts +1 -1
  102. package/dist/services/LLMService.js +16 -16
  103. package/dist/services/MutationApplier.d.ts +55 -0
  104. package/dist/services/MutationApplier.d.ts.map +1 -0
  105. package/dist/services/MutationApplier.js +322 -0
  106. package/dist/services/PRAutomationService.d.ts +2 -2
  107. package/dist/services/PRAutomationService.d.ts.map +1 -1
  108. package/dist/services/PRAutomationService.js +43 -39
  109. package/dist/services/PendingPromptLedger.d.ts +9 -2
  110. package/dist/services/PendingPromptLedger.d.ts.map +1 -1
  111. package/dist/services/PendingPromptLedger.js +31 -6
  112. package/dist/services/ReportGenerator.d.ts +116 -30
  113. package/dist/services/ReportGenerator.d.ts.map +1 -1
  114. package/dist/services/ReportGenerator.js +149 -62
  115. package/dist/services/ReportMergeService.d.ts +95 -0
  116. package/dist/services/ReportMergeService.d.ts.map +1 -0
  117. package/dist/services/ReportMergeService.js +0 -0
  118. package/dist/services/SafetyGuard.d.ts +4 -7
  119. package/dist/services/SafetyGuard.d.ts.map +1 -1
  120. package/dist/services/SafetyGuard.js +29 -106
  121. package/dist/services/SelectorSanitizer.d.ts +52 -0
  122. package/dist/services/SelectorSanitizer.d.ts.map +1 -0
  123. package/dist/services/SelectorSanitizer.js +318 -0
  124. package/dist/services/SnapshotService.js +6 -6
  125. package/dist/services/SourceLinkService.d.ts +2 -2
  126. package/dist/services/SourceLinkService.d.ts.map +1 -1
  127. package/dist/services/SourceLinkService.js +31 -22
  128. package/dist/services/SourceUpdater.d.ts +1 -1
  129. package/dist/services/SourceUpdater.d.ts.map +1 -1
  130. package/dist/services/SourceUpdater.js +44 -26
  131. package/dist/services/TemplateDiffService.d.ts.map +1 -1
  132. package/dist/services/TemplateDiffService.js +18 -15
  133. package/dist/services/TraceBackEngine.d.ts +67 -0
  134. package/dist/services/TraceBackEngine.d.ts.map +1 -0
  135. package/dist/services/TraceBackEngine.js +672 -0
  136. package/dist/services/WorkspaceSnapshotService.d.ts +6 -6
  137. package/dist/services/WorkspaceSnapshotService.d.ts.map +1 -1
  138. package/dist/services/WorkspaceSnapshotService.js +10 -12
  139. package/dist/types/index.d.ts +1 -1
  140. package/dist/utils/DOMUtils.d.ts +18 -2
  141. package/dist/utils/DOMUtils.d.ts.map +1 -1
  142. package/dist/utils/DOMUtils.js +335 -49
  143. package/dist/utils/IsolatedDiff.d.ts +2 -2
  144. package/dist/utils/IsolatedDiff.d.ts.map +1 -1
  145. package/dist/utils/IsolatedDiff.js +13 -10
  146. package/package.json +2 -2
@@ -8,6 +8,13 @@ export interface PendingPromptEntry {
8
8
  createdBySela: boolean;
9
9
  /** 1-based line number for the vscode:// deeplink, or null when unknown. */
10
10
  lineNumber: number | null;
11
+ /**
12
+ * Number of healed selectors that landed in THIS file. A single file can
13
+ * absorb several heals in one run (e.g. two locators in the same spec), so
14
+ * the DX banner counts fixes from this field rather than from the entry
15
+ * (= file) count. Older ledgers without the field are treated as 1.
16
+ */
17
+ healCount: number;
11
18
  }
12
19
  /**
13
20
  * Resolve the ledger directory for a given cwd. The directory is created
@@ -17,7 +24,7 @@ export interface PendingPromptEntry {
17
24
  export declare function ledgerDir(cwd: string): string;
18
25
  /**
19
26
  * Persist this worker's mutated snapshots to a fresh JSON file under
20
- * `<cwd>/.sela/pending-prompts/`. Idempotent within a single worker
27
+ * `<cwd>/.sela/pending-prompts/`. Idempotent within a single worker -
21
28
  * subsequent calls in the same process append a NEW file (rare in
22
29
  * practice but keeps the contract simple).
23
30
  *
@@ -33,7 +40,7 @@ export declare function writePendingPromptsLedger(opts: {
33
40
  /**
34
41
  * Drain every per-worker JSON file under the ledger directory and merge
35
42
  * their entries into a single de-duplicated list (keyed by absolutePath).
36
- * The most-recent contentAfter wins on collision that's the disk truth.
43
+ * The most-recent contentAfter wins on collision - that's the disk truth.
37
44
  *
38
45
  * The directory is NOT deleted here so the reporter can inspect entries
39
46
  * before calling `clearPendingPromptsLedger()`.
@@ -1 +1 @@
1
- {"version":3,"file":"PendingPromptLedger.d.ts","sourceRoot":"","sources":["../../src/services/PendingPromptLedger.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAEV,wBAAwB,EACzB,MAAM,4BAA4B,CAAC;AACpC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAEvD,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,OAAO,CAAC;IACvB,4EAA4E;IAC5E,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAID;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE;IAC9C,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,wBAAwB,CAAC;IACpC,YAAY,EAAE,WAAW,EAAE,CAAC;CAC7B,GAAG,MAAM,GAAG,IAAI,CAehB;AAED;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,kBAAkB,EAAE,CAyB1E;AAED,yEAAyE;AACzE,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAI3D"}
1
+ {"version":3,"file":"PendingPromptLedger.d.ts","sourceRoot":"","sources":["../../src/services/PendingPromptLedger.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAEV,wBAAwB,EACzB,MAAM,4BAA4B,CAAC;AACpC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAEvD,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,OAAO,CAAC;IACvB,4EAA4E;IAC5E,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B;;;;;OAKG;IACH,SAAS,EAAE,MAAM,CAAC;CACnB;AAID;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE;IAC9C,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,wBAAwB,CAAC;IACpC,YAAY,EAAE,WAAW,EAAE,CAAC;CAC7B,GAAG,MAAM,GAAG,IAAI,CAehB;AAED;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,kBAAkB,EAAE,CAyB1E;AAED,yEAAyE;AACzE,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAI3D"}
@@ -11,10 +11,10 @@
11
11
  // Solution: each worker SERIALISES its mutated FileSnapshots to a JSON
12
12
  // file under `<cwd>/.sela/pending-prompts/`, one file per worker process
13
13
  // (keyed by pid + monotonic timestamp). The reporter's onEnd hook reads
14
- // every file in that directory, drives `runInteractiveReview()` against
15
- // the merged list, then deletes the directory.
14
+ // every file in that directory, prints a non-blocking summary via
15
+ // `printHealReview()`, then deletes the directory.
16
16
  //
17
- // CRITICAL: workers MUST NOT call `readline` / block on stdin they have
17
+ // CRITICAL: workers MUST NOT call `readline` / block on stdin - they have
18
18
  // no TTY attached. Blocking the event loop here causes the Playwright
19
19
  // test timeout to fire. This ledger is the boundary between silent
20
20
  // worker-side execution and TTY-bound reporter-side prompting.
@@ -69,7 +69,7 @@ function ledgerDir(cwd) {
69
69
  }
70
70
  /**
71
71
  * Persist this worker's mutated snapshots to a fresh JSON file under
72
- * `<cwd>/.sela/pending-prompts/`. Idempotent within a single worker
72
+ * `<cwd>/.sela/pending-prompts/`. Idempotent within a single worker -
73
73
  * subsequent calls in the same process append a NEW file (rare in
74
74
  * practice but keeps the contract simple).
75
75
  *
@@ -92,7 +92,7 @@ function writePendingPromptsLedger(opts) {
92
92
  /**
93
93
  * Drain every per-worker JSON file under the ledger directory and merge
94
94
  * their entries into a single de-duplicated list (keyed by absolutePath).
95
- * The most-recent contentAfter wins on collision that's the disk truth.
95
+ * The most-recent contentAfter wins on collision - that's the disk truth.
96
96
  *
97
97
  * The directory is NOT deleted here so the reporter can inspect entries
98
98
  * before calling `clearPendingPromptsLedger()`.
@@ -120,7 +120,7 @@ function readPendingPromptsLedger(cwd) {
120
120
  }
121
121
  }
122
122
  catch {
123
- // Corrupt ledger file skip rather than crashing the whole review.
123
+ // Corrupt ledger file - skip rather than crashing the whole review.
124
124
  }
125
125
  }
126
126
  return Array.from(merged.values());
@@ -143,8 +143,33 @@ function snapshotToEntry(snap, heals, cwd) {
143
143
  contentAfter: snap.contentAfter ?? "",
144
144
  createdBySela: snap.createdBySela,
145
145
  lineNumber: findLineNumberForSnapshot(snap, heals, cwd),
146
+ healCount: countHealsForSnapshot(snap, heals, cwd),
146
147
  };
147
148
  }
149
+ /**
150
+ * Count how many heals mutated `snap`. A heal's mutation target is its
151
+ * `definitionSite.file` when the write crossed into a const/variable
152
+ * declaration, otherwise its `sourceFile`. Resolved to absolute form so a
153
+ * relative event path matches the snapshot's absolute path.
154
+ *
155
+ * Falls back to 1 when no heal resolves to the file - the snapshot only
156
+ * exists because Sela wrote to it, so the file represents at least one fix.
157
+ */
158
+ function countHealsForSnapshot(snap, heals, cwd) {
159
+ const normSnap = path.normalize(snap.absolutePath);
160
+ let n = 0;
161
+ for (const heal of heals) {
162
+ const target = heal.definitionSite?.file ?? heal.sourceFile;
163
+ if (!target)
164
+ continue;
165
+ const abs = path.isAbsolute(target)
166
+ ? path.normalize(target)
167
+ : path.normalize(path.resolve(cwd, target));
168
+ if (abs === normSnap)
169
+ n++;
170
+ }
171
+ return n > 0 ? n : 1;
172
+ }
148
173
  /**
149
174
  * Resolve a 1-based line number for the vscode:// deeplink. Priority:
150
175
  * 1. `definitionSite.line` for a heal whose `definitionSite.file`
@@ -1,4 +1,77 @@
1
1
  import type { HealEvent, HealEventKind } from "./HealReportService";
2
+ export declare const TIME_SAVED_CONSTANTS: {
3
+ /** A healed selector saved an engineer from manually hunting + patching. */
4
+ readonly PER_HEAL: 12;
5
+ /**
6
+ * Catching a masked regression is worth more — it would have silently
7
+ * changed test semantics and required a full debugging cycle to uncover.
8
+ */
9
+ readonly PER_PROTECT: 18;
10
+ /**
11
+ * Partial value: Sela narrowed the debug scope even when it couldn't fix.
12
+ */
13
+ readonly PER_FAILED: 4;
14
+ };
15
+ /**
16
+ * Persisted after every run. Kept in a rolling window of up to 14 entries
17
+ * by HealReportService. Used to render the Management trend chart.
18
+ *
19
+ * **Backend addition required:** HealReportService.flushToDisk() reads
20
+ * `.sela-history.json`, appends the result of
21
+ * `ReportGenerator.buildCurrentHistoricalRun()`, trims to 14 entries, writes
22
+ * it back, and passes the array into `ReportGenerator.generate()`.
23
+ */
24
+ export interface HistoricalRun {
25
+ /** Stable identifier — defaults to ISO timestamp but can be CI run ID. */
26
+ id: string;
27
+ /** Human-readable x-axis label shown in the trend chart (e.g. "Jan 15"). */
28
+ label: string;
29
+ healed: number;
30
+ protected: number;
31
+ failed: number;
32
+ /**
33
+ * Mean `aiConfidence` across all events in the run (0–100).
34
+ * Computed by `buildCurrentHistoricalRun()`.
35
+ * 0 when no events carried a confidence value.
36
+ */
37
+ avg_confidence: number;
38
+ /** Estimated developer minutes saved this run. */
39
+ time_saved_min: number;
40
+ /** ISO 8601 — used for deduplication and display sorting. */
41
+ timestamp: string;
42
+ }
43
+ export interface RoiMetrics {
44
+ /**
45
+ * Estimated minutes of developer time saved in THIS run.
46
+ * Mirrors stats.timeSavedMin — kept here so callers don't need to import
47
+ * both interfaces.
48
+ */
49
+ timeSavedThisRunMin: number;
50
+ /**
51
+ * Cumulative minutes saved across all historical runs (including current).
52
+ * The Management Dashboard renders this as "Total Time Saved This Month"
53
+ * when the history window covers ~30 days.
54
+ */
55
+ timeSavedCumulativeMin: number;
56
+ /**
57
+ * Number of PROTECTED events in this run.
58
+ * Each represents a regression that would have silently mutated test
59
+ * semantics had Sela auto-applied the AI fix.
60
+ */
61
+ bugsPrevented: number;
62
+ /**
63
+ * Percentage of all healing attempts (HEALED + PROTECTED) where the
64
+ * Intent Auditor's final confidence was ≥ 80. Rendered as the
65
+ * "High Confidence Rate" KPI in the Management view.
66
+ * `null` when there are no audited events.
67
+ */
68
+ highConfidenceRatePct: number | null;
69
+ /**
70
+ * Mean aiConfidence across HEALED events only (0–100).
71
+ * `null` when there are no healed events with a confidence value.
72
+ */
73
+ avgHealConfidence: number | null;
74
+ }
2
75
  export interface ReportStats {
3
76
  total: number;
4
77
  healed: number;
@@ -6,64 +79,77 @@ export interface ReportStats {
6
79
  protectedCnt: number;
7
80
  iframeCnt: number;
8
81
  shadowCnt: number;
9
- /** Estimated developer time saved, in minutes. */
82
+ /** Estimated developer minutes saved this run. */
10
83
  timeSavedMin: number;
11
84
  }
12
- /**
13
- * The canonical JSON payload embedded into sela-report.html and
14
- * mirrored verbatim into sela-report.json. Treat this as a public
15
- * contract — additive changes only.
16
- */
17
85
  export interface ReportData {
18
86
  generatedAt: string;
19
87
  startedAt: string;
20
88
  cwd: string;
21
89
  events: HealEvent[];
22
90
  stats: ReportStats;
91
+ roi: RoiMetrics;
92
+ /**
93
+ * Rolling window of up to 14 runs injected from `.sela-history.json`.
94
+ * Empty array when no history file exists yet (first run).
95
+ */
96
+ historical_runs: HistoricalRun[];
23
97
  }
24
- /**
25
- * Time-saved heuristics (minutes per event). Exposed as a constant
26
- * so tests pin the math against the same source as production.
27
- */
28
- export declare const TIME_SAVED_CONSTANTS: {
29
- readonly PER_HEAL: 12;
30
- readonly PER_PROTECT: 18;
31
- readonly PER_FAILED: 4;
32
- };
33
98
  export declare const MANDATORY_FIELDS_BY_KIND: Readonly<Record<HealEventKind, ReadonlyArray<string>>>;
34
99
  export declare class ReportGenerator {
35
100
  /**
36
- * Validates a single event against MANDATORY_FIELDS_BY_KIND. Throws
37
- * a SelaError(subsystem="Report") on the first violation found. No
38
- * partial diagnosis: the report is all-or-nothing.
101
+ * Validates a single event against MANDATORY_FIELDS_BY_KIND.
102
+ * Throws a SelaError(subsystem="Report") on the first violation.
39
103
  */
40
104
  static validateEvent(event: unknown): asserts event is HealEvent;
41
105
  /**
42
- * Validates every event in the buffer. Returns the array unchanged
43
- * when all events pass. Throws on the first violation.
106
+ * Validates every event in the buffer. Returns the typed array when all
107
+ * events pass. Throws on the first violation.
44
108
  */
45
109
  static validateAll(events: ReadonlyArray<unknown>): HealEvent[];
46
110
  /**
47
- * Pure stats computation. No clock, no IO — easy to pin in tests.
111
+ * Pure aggregate counters. No clock, no IO — trivially pinnable in tests.
48
112
  */
49
113
  static computeStats(events: ReadonlyArray<HealEvent>): ReportStats;
50
114
  /**
51
- * Build the ReportData payload from a raw event buffer. Validates
52
- * every event first. `now` / `startedAt` are injectable so tests
53
- * pin them deterministically.
115
+ * Derives Management Dashboard KPIs from the event buffer + historical runs.
116
+ *
117
+ * `historicalRuns` should already include the current run if you want
118
+ * cumulative totals to include this run — pass the array AFTER appending
119
+ * via `buildCurrentHistoricalRun()`.
120
+ */
121
+ static computeRoiMetrics(events: ReadonlyArray<HealEvent>, historicalRuns: ReadonlyArray<HistoricalRun>, stats: ReportStats): RoiMetrics;
122
+ /**
123
+ * Builds the `HistoricalRun` entry for the current run so the caller can
124
+ * append it to the persisted sidecar file.
125
+ *
126
+ * `runId` — optional override; defaults to ISO timestamp. Pass a CI run ID
127
+ * (e.g. `process.env.GITHUB_RUN_ID`) when available so deduplication is
128
+ * reliable across retries.
129
+ */
130
+ static buildCurrentHistoricalRun(events: ReadonlyArray<HealEvent>, stats: ReportStats, opts?: {
131
+ runId?: string;
132
+ now?: () => Date;
133
+ }): HistoricalRun;
134
+ /**
135
+ * Validates every event, computes all metrics, and assembles the
136
+ * `ReportData` payload ready for serialisation.
137
+ *
138
+ * `historicalRuns` — pass the array from `.sela-history.json` BEFORE
139
+ * appending the current run; this method appends it internally so the
140
+ * trend chart always includes the present run.
54
141
  */
55
142
  static generate(events: ReadonlyArray<unknown>, opts?: {
56
143
  cwd?: string;
57
144
  startedAt?: string;
145
+ historicalRuns?: HistoricalRun[];
146
+ runId?: string;
58
147
  now?: () => Date;
59
148
  }): ReportData;
60
149
  /**
61
- * Format a confidence value for UI consumption. Returns a
62
- * percentage string ("87%") for finite numbers, "n/a" for anything
63
- * else. NEVER emits literal "undefined" / "null" / "NaN".
64
- *
65
- * Public so the HTML template's `fmtPct()` can be replaced by this
66
- * single source of truth in a future refactor.
150
+ * Format a confidence value for UI consumption.
151
+ * Returns a percentage string ("87%") for finite numbers, "n/a" for
152
+ * anything else. NEVER emits literal "undefined" / "null" / "NaN".
67
153
  */
68
154
  static formatConfidence(value: unknown): string;
69
155
  }
@@ -1 +1 @@
1
- {"version":3,"file":"ReportGenerator.d.ts","sourceRoot":"","sources":["../../src/services/ReportGenerator.ts"],"names":[],"mappings":"AAgCA,OAAO,KAAK,EACV,SAAS,EACT,aAAa,EAId,MAAM,qBAAqB,CAAC;AAM7B,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,kDAAkD;IAClD,YAAY,EAAE,MAAM,CAAC;CACtB;AAED;;;;GAIG;AACH,MAAM,WAAW,UAAU;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB,KAAK,EAAE,WAAW,CAAC;CACpB;AAED;;;GAGG;AACH,eAAO,MAAM,oBAAoB;;;;CAIvB,CAAC;AAmCX,eAAO,MAAM,wBAAwB,EAAE,QAAQ,CAAC,MAAM,CACpD,aAAa,EACb,aAAa,CAAC,MAAM,CAAC,CACtB,CAIA,CAAC;AAsBF,qBAAa,eAAe;IAC1B;;;;OAIG;IACH,MAAM,CAAC,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,KAAK,IAAI,SAAS;IAuChE;;;OAGG;IACH,MAAM,CAAC,WAAW,CAAC,MAAM,EAAE,aAAa,CAAC,OAAO,CAAC,GAAG,SAAS,EAAE;IAK/D;;OAEG;IACH,MAAM,CAAC,YAAY,CAAC,MAAM,EAAE,aAAa,CAAC,SAAS,CAAC,GAAG,WAAW;IA4BlE;;;;OAIG;IACH,MAAM,CAAC,QAAQ,CACb,MAAM,EAAE,aAAa,CAAC,OAAO,CAAC,EAC9B,IAAI,GAAE;QACJ,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;KACb,GACL,UAAU;IAYb;;;;;;;OAOG;IACH,MAAM,CAAC,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM;CAOhD"}
1
+ {"version":3,"file":"ReportGenerator.d.ts","sourceRoot":"","sources":["../../src/services/ReportGenerator.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EACV,SAAS,EACT,aAAa,EAId,MAAM,qBAAqB,CAAC;AAQ7B,eAAO,MAAM,oBAAoB;IAC/B,4EAA4E;;IAE5E;;;OAGG;;IAEH;;OAEG;;CAEK,CAAC;AAMX;;;;;;;;GAQG;AACH,MAAM,WAAW,aAAa;IAC5B,0EAA0E;IAC1E,EAAE,EAAE,MAAM,CAAC;IACX,4EAA4E;IAC5E,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,cAAc,EAAE,MAAM,CAAC;IACvB,kDAAkD;IAClD,cAAc,EAAE,MAAM,CAAC;IACvB,6DAA6D;IAC7D,SAAS,EAAE,MAAM,CAAC;CACnB;AAMD,MAAM,WAAW,UAAU;IACzB;;;;OAIG;IACH,mBAAmB,EAAE,MAAM,CAAC;IAC5B;;;;OAIG;IACH,sBAAsB,EAAE,MAAM,CAAC;IAC/B;;;;OAIG;IACH,aAAa,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,qBAAqB,EAAE,MAAM,GAAG,IAAI,CAAC;IACrC;;;OAGG;IACH,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAMD,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,kDAAkD;IAClD,YAAY,EAAE,MAAM,CAAC;CACtB;AAQD,MAAM,WAAW,UAAU;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB,KAAK,EAAE,WAAW,CAAC;IACnB,GAAG,EAAE,UAAU,CAAC;IAChB;;;OAGG;IACH,eAAe,EAAE,aAAa,EAAE,CAAC;CAClC;AAmCD,eAAO,MAAM,wBAAwB,EAAE,QAAQ,CAC7C,MAAM,CAAC,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAK7C,CAAC;AAwCF,qBAAa,eAAe;IAG1B;;;OAGG;IACH,MAAM,CAAC,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,KAAK,IAAI,SAAS;IAsChE;;;OAGG;IACH,MAAM,CAAC,WAAW,CAAC,MAAM,EAAE,aAAa,CAAC,OAAO,CAAC,GAAG,SAAS,EAAE;IAO/D;;OAEG;IACH,MAAM,CAAC,YAAY,CAAC,MAAM,EAAE,aAAa,CAAC,SAAS,CAAC,GAAG,WAAW;IA8BlE;;;;;;OAMG;IACH,MAAM,CAAC,iBAAiB,CACtB,MAAM,EAAE,aAAa,CAAC,SAAS,CAAC,EAChC,cAAc,EAAE,aAAa,CAAC,aAAa,CAAC,EAC5C,KAAK,EAAE,WAAW,GACjB,UAAU;IAkDb;;;;;;;OAOG;IACH,MAAM,CAAC,yBAAyB,CAC9B,MAAM,EAAE,aAAa,CAAC,SAAS,CAAC,EAChC,KAAK,EAAE,WAAW,EAClB,IAAI,GAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,IAAI,CAAA;KAAO,GAC9C,aAAa;IAkChB;;;;;;;OAOG;IACH,MAAM,CAAC,QAAQ,CACb,MAAM,EAAE,aAAa,CAAC,OAAO,CAAC,EAC9B,IAAI,GAAE;QACJ,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,cAAc,CAAC,EAAE,aAAa,EAAE,CAAC;QACjC,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;KACb,GACL,UAAU;IAqCb;;;;OAIG;IACH,MAAM,CAAC,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM;CAMhD"}
@@ -1,49 +1,46 @@
1
1
  "use strict";
2
2
  // src/services/ReportGenerator.ts
3
3
  //
4
- // Pure (no-IO) report layer. Takes the HealReportService event buffer
5
- // and produces a stable, validated JSON shape that the HTML template
6
- // and any external consumer (CI dashboards, telemetry forwarders)
7
- // can rely on.
4
+ // Sela Insights — Pure (no-IO) report layer.
8
5
  //
9
- // Design goals:
10
- // 1. SCHEMA ENFORCED. Every event passed through ReportGenerator
11
- // is validated against MANDATORY_FIELDS_BY_KIND a SelaError
12
- // with subsystem="Report" is thrown for any malformed input.
13
- // The HTML template never sees a partial event.
14
- //
15
- // 2. CONFIDENCE NEVER LEAKS. formatConfidence() and the rendered
16
- // `confidenceLabel` field always produce either a "<n>%" string
17
- // or "n/a"never `undefined`, `null`, or a stringified
18
- // "undefined". The JS embedded in HealReportService already
19
- // uses fmtPct() for the same reason; this module is the
20
- // authoritative implementation.
21
- //
22
- // 3. STATS DETERMINISTIC. computeStats() exposes the time-saved
23
- // constants via TIME_SAVED_CONSTANTS so tests can pin the math
24
- // without duplicating the multipliers.
25
- //
26
- // 4. NO IO. Disk writes still live in HealReportService.flushToDisk
27
- // so this module stays trivially unit-testable.
6
+ // Responsibilities:
7
+ // 1. SCHEMA VALIDATION every event is validated against
8
+ // MANDATORY_FIELDS_BY_KIND before the HTML template ever sees it.
9
+ // 2. STATS + METRICS — computeStats(), computeRoiMetrics(), and
10
+ // buildCurrentHistoricalRun() are the single source of truth for
11
+ // every number rendered in the Management Dashboard.
12
+ // 3. CONFIDENCE SAFETY — formatConfidence() always emits "<n>%" or
13
+ // "n/a"; never "undefined", "null", or "NaN".
14
+ // 4. NO IO disk operations stay in HealReportService so
15
+ // this module is trivially unit-testable without mocking fs.
28
16
  Object.defineProperty(exports, "__esModule", { value: true });
29
17
  exports.ReportGenerator = exports.MANDATORY_FIELDS_BY_KIND = exports.TIME_SAVED_CONSTANTS = void 0;
30
18
  const SelaError_1 = require("../errors/SelaError");
31
- /**
32
- * Time-saved heuristics (minutes per event). Exposed as a constant
33
- * so tests pin the math against the same source as production.
34
- */
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+ // TIME-SAVED HEURISTICS (minutes per event kind)
21
+ // Exposed as a constant so tests can pin the math against the same multipliers
22
+ // as production without duplicating them.
23
+ // ─────────────────────────────────────────────────────────────────────────────
35
24
  exports.TIME_SAVED_CONSTANTS = {
25
+ /** A healed selector saved an engineer from manually hunting + patching. */
36
26
  PER_HEAL: 12,
27
+ /**
28
+ * Catching a masked regression is worth more — it would have silently
29
+ * changed test semantics and required a full debugging cycle to uncover.
30
+ */
37
31
  PER_PROTECT: 18,
32
+ /**
33
+ * Partial value: Sela narrowed the debug scope even when it couldn't fix.
34
+ */
38
35
  PER_FAILED: 4,
39
36
  };
40
- // ───────────────────────────────────────────────────────────────────
41
- // Field manifests — mandatory for each event kind
37
+ // ─────────────────────────────────────────────────────────────────────────────
38
+ // FIELD MANIFESTS — mandatory per event kind
42
39
  //
43
- // "Mandatory" = the HTML template will render an unintelligible card
44
- // without this field. Anything optional belongs in the interface
45
- // declarations in HealReportService.ts but NOT here.
46
- // ───────────────────────────────────────────────────────────────────
40
+ // "Mandatory" means the HTML card renderer produces a broken / misleading UI
41
+ // without this field. Optional fields belong in the interface declarations in
42
+ // HealReportService.ts but NOT here.
43
+ // ─────────────────────────────────────────────────────────────────────────────
47
44
  const COMMON_MANDATORY = [
48
45
  "kind",
49
46
  "stableId",
@@ -71,9 +68,9 @@ exports.MANDATORY_FIELDS_BY_KIND = {
71
68
  PROTECTED: [...COMMON_MANDATORY, ...PROTECTED_MANDATORY],
72
69
  };
73
70
  const VALID_KINDS = new Set(["HEALED", "FAILED", "PROTECTED"]);
74
- // ───────────────────────────────────────────────────────────────────
75
- // Helpers
76
- // ───────────────────────────────────────────────────────────────────
71
+ // ─────────────────────────────────────────────────────────────────────────────
72
+ // HELPERS
73
+ // ─────────────────────────────────────────────────────────────────────────────
77
74
  function throwReport(code, reason, ctx = {}) {
78
75
  throw new SelaError_1.SelaError({ subsystem: "Report", code, reason, context: ctx });
79
76
  }
@@ -84,14 +81,28 @@ function isPresent(value) {
84
81
  return false;
85
82
  return true;
86
83
  }
87
- // ───────────────────────────────────────────────────────────────────
84
+ function shortDateLabel(iso) {
85
+ try {
86
+ const d = new Date(iso);
87
+ return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
88
+ }
89
+ catch {
90
+ return iso.slice(0, 10);
91
+ }
92
+ }
93
+ function mean(values) {
94
+ if (values.length === 0)
95
+ return null;
96
+ return Math.round(values.reduce((a, b) => a + b, 0) / values.length);
97
+ }
98
+ // ─────────────────────────────────────────────────────────────────────────────
88
99
  // ReportGenerator
89
- // ───────────────────────────────────────────────────────────────────
100
+ // ─────────────────────────────────────────────────────────────────────────────
90
101
  class ReportGenerator {
102
+ // ── Validation ────────────────────────────────────────────────────────────
91
103
  /**
92
- * Validates a single event against MANDATORY_FIELDS_BY_KIND. Throws
93
- * a SelaError(subsystem="Report") on the first violation found. No
94
- * partial diagnosis: the report is all-or-nothing.
104
+ * Validates a single event against MANDATORY_FIELDS_BY_KIND.
105
+ * Throws a SelaError(subsystem="Report") on the first violation.
95
106
  */
96
107
  static validateEvent(event) {
97
108
  if (!event || typeof event !== "object") {
@@ -108,29 +119,25 @@ class ReportGenerator {
108
119
  throwReport(SelaError_1.REPORT_CODES.MISSING_FIELD, `${ev.kind} event missing mandatory field "${field}"`, { stableId: ev.stableId, kind: ev.kind, field });
109
120
  }
110
121
  }
111
- // Field-type sanity for the fields we publicly mandate.
112
122
  if (typeof ev.sourceLine !== "number") {
113
123
  throwReport(SelaError_1.REPORT_CODES.INVALID_FIELD_TYPE, `Field "sourceLine" must be a number, received ${typeof ev.sourceLine}`, { stableId: ev.stableId, kind: ev.kind, field: "sourceLine" });
114
124
  }
115
125
  }
116
126
  /**
117
- * Validates every event in the buffer. Returns the array unchanged
118
- * when all events pass. Throws on the first violation.
127
+ * Validates every event in the buffer. Returns the typed array when all
128
+ * events pass. Throws on the first violation.
119
129
  */
120
130
  static validateAll(events) {
121
131
  for (const e of events)
122
132
  ReportGenerator.validateEvent(e);
123
133
  return events;
124
134
  }
135
+ // ── Stats ─────────────────────────────────────────────────────────────────
125
136
  /**
126
- * Pure stats computation. No clock, no IO — easy to pin in tests.
137
+ * Pure aggregate counters. No clock, no IO — trivially pinnable in tests.
127
138
  */
128
139
  static computeStats(events) {
129
- let healed = 0;
130
- let failed = 0;
131
- let protectedCnt = 0;
132
- let iframeCnt = 0;
133
- let shadowCnt = 0;
140
+ let healed = 0, failed = 0, protectedCnt = 0, iframeCnt = 0, shadowCnt = 0;
134
141
  for (const e of events) {
135
142
  if (e.kind === "HEALED")
136
143
  healed++;
@@ -156,34 +163,114 @@ class ReportGenerator {
156
163
  timeSavedMin,
157
164
  };
158
165
  }
166
+ // ── ROI Metrics ───────────────────────────────────────────────────────────
159
167
  /**
160
- * Build the ReportData payload from a raw event buffer. Validates
161
- * every event first. `now` / `startedAt` are injectable so tests
162
- * pin them deterministically.
168
+ * Derives Management Dashboard KPIs from the event buffer + historical runs.
169
+ *
170
+ * `historicalRuns` should already include the current run if you want
171
+ * cumulative totals to include this run — pass the array AFTER appending
172
+ * via `buildCurrentHistoricalRun()`.
173
+ */
174
+ static computeRoiMetrics(events, historicalRuns, stats) {
175
+ // bugs prevented = all PROTECTED events this run
176
+ const bugsPrevented = stats.protectedCnt;
177
+ // cumulative time saved across all historical runs (includes current if
178
+ // caller appended it before passing here)
179
+ const timeSavedCumulativeMin = historicalRuns.reduce((sum, r) => sum + r.time_saved_min, 0);
180
+ // high-confidence rate: among events that have an auditor confidence,
181
+ // what fraction scored ≥ 80?
182
+ const audited = events.filter((e) => (e.kind === "HEALED" || e.kind === "PROTECTED") &&
183
+ e.auditor != null &&
184
+ typeof e.auditor.confidence === "number" &&
185
+ isFinite(e.auditor.confidence));
186
+ const highConfidenceRatePct = audited.length === 0
187
+ ? null
188
+ : Math.round((audited.filter((e) => (e.auditor.confidence ?? 0) >= 80).length /
189
+ audited.length) *
190
+ 100);
191
+ // avg heal confidence (HEALED events only)
192
+ const healedWithConf = events.filter((e) => e.kind === "HEALED")
193
+ .map((e) => e.aiConfidence)
194
+ .filter((v) => typeof v === "number" && isFinite(v));
195
+ const avgHealConfidence = mean(healedWithConf);
196
+ return {
197
+ timeSavedThisRunMin: stats.timeSavedMin,
198
+ timeSavedCumulativeMin,
199
+ bugsPrevented,
200
+ highConfidenceRatePct,
201
+ avgHealConfidence,
202
+ };
203
+ }
204
+ // ── Historical run builder ────────────────────────────────────────────────
205
+ /**
206
+ * Builds the `HistoricalRun` entry for the current run so the caller can
207
+ * append it to the persisted sidecar file.
208
+ *
209
+ * `runId` — optional override; defaults to ISO timestamp. Pass a CI run ID
210
+ * (e.g. `process.env.GITHUB_RUN_ID`) when available so deduplication is
211
+ * reliable across retries.
212
+ */
213
+ static buildCurrentHistoricalRun(events, stats, opts = {}) {
214
+ const now = opts.now ? opts.now() : new Date();
215
+ const ts = now.toISOString();
216
+ const confidenceValues = events
217
+ .filter((e) => typeof e.aiConfidence === "number")
218
+ .map((e) => e.aiConfidence)
219
+ .filter((v) => isFinite(v));
220
+ const avg_confidence = confidenceValues.length > 0
221
+ ? Math.round(confidenceValues.reduce((a, b) => a + b, 0) /
222
+ confidenceValues.length)
223
+ : 0;
224
+ return {
225
+ id: opts.runId ?? ts,
226
+ label: shortDateLabel(ts),
227
+ healed: stats.healed,
228
+ protected: stats.protectedCnt,
229
+ failed: stats.failed,
230
+ avg_confidence,
231
+ time_saved_min: stats.timeSavedMin,
232
+ timestamp: ts,
233
+ };
234
+ }
235
+ // ── Full payload builder ──────────────────────────────────────────────────
236
+ /**
237
+ * Validates every event, computes all metrics, and assembles the
238
+ * `ReportData` payload ready for serialisation.
239
+ *
240
+ * `historicalRuns` — pass the array from `.sela-history.json` BEFORE
241
+ * appending the current run; this method appends it internally so the
242
+ * trend chart always includes the present run.
163
243
  */
164
244
  static generate(events, opts = {}) {
165
245
  const validated = ReportGenerator.validateAll(events);
166
246
  const now = opts.now ?? (() => new Date());
247
+ const stats = ReportGenerator.computeStats(validated);
248
+ const currentRun = ReportGenerator.buildCurrentHistoricalRun(validated, stats, { runId: opts.runId, now });
249
+ // Merge history: drop any prior entry with the same id, append current,
250
+ // then keep only the most recent HISTORY_WINDOW entries.
251
+ const HISTORY_WINDOW = 14;
252
+ const prior = (opts.historicalRuns ?? []).filter((r) => r.id !== currentRun.id);
253
+ const historical_runs = [...prior, currentRun].slice(-HISTORY_WINDOW);
254
+ const roi = ReportGenerator.computeRoiMetrics(validated, historical_runs, stats);
167
255
  return {
168
256
  generatedAt: now().toISOString(),
169
257
  startedAt: opts.startedAt ?? now().toISOString(),
170
258
  cwd: opts.cwd ?? process.cwd(),
171
259
  events: validated,
172
- stats: ReportGenerator.computeStats(validated),
260
+ stats,
261
+ roi,
262
+ historical_runs,
173
263
  };
174
264
  }
265
+ // ── Confidence formatter ──────────────────────────────────────────────────
175
266
  /**
176
- * Format a confidence value for UI consumption. Returns a
177
- * percentage string ("87%") for finite numbers, "n/a" for anything
178
- * else. NEVER emits literal "undefined" / "null" / "NaN".
179
- *
180
- * Public so the HTML template's `fmtPct()` can be replaced by this
181
- * single source of truth in a future refactor.
267
+ * Format a confidence value for UI consumption.
268
+ * Returns a percentage string ("87%") for finite numbers, "n/a" for
269
+ * anything else. NEVER emits literal "undefined" / "null" / "NaN".
182
270
  */
183
271
  static formatConfidence(value) {
184
272
  if (typeof value === "number" && Number.isFinite(value)) {
185
- const clamped = Math.max(0, Math.min(100, Math.round(value)));
186
- return `${clamped}%`;
273
+ return `${Math.max(0, Math.min(100, Math.round(value)))}%`;
187
274
  }
188
275
  return "n/a";
189
276
  }