001package gudusoft.gsqlparser.pp.score; 002 003import gudusoft.gsqlparser.pp.score.json.JsonOut; 004 005import java.util.ArrayList; 006import java.util.Collections; 007import java.util.LinkedHashMap; 008import java.util.List; 009import java.util.Locale; 010import java.util.Map; 011 012/** 013 * Result of {@link PrettyScore#score}. Immutable. 014 * 015 * <p>The JSON produced by {@link #toJson()} is a published contract: 016 * {@link #VERSION} is always present, fields are only ever added, never 017 * removed or renamed, and rule ids are never renamed once released (a 018 * retired rule is marked deprecated in {@code OptionAdvisor} instead). 019 * 020 * <p>While the profile's calibration is {@code heuristic-v0} the absolute 021 * score is meaningful only as a <b>relative</b> measure: same profile, same 022 * corpus, before vs after a formatter change. Do not quote it as an absolute 023 * readability verdict. 024 */ 025public final class ScoreReport { 026 027 /** Wire-format version of the JSON contract. */ 028 public static final String VERSION = "prettyscore/0.1"; 029 030 /** Parse / engine facts recorded for the scored text. */ 031 public static final class ParseInfo { 032 private final boolean evaluated; 033 private final boolean ok; 034 private final String engineFacts; 035 private final int errorRegions; 036 private final int statements; 037 private final boolean astDepthAvailable; 038 039 public ParseInfo(boolean ok, String engineFacts, int errorRegions, int statements, boolean astDepthAvailable) { 040 this(true, ok, engineFacts, errorRegions, statements, astDepthAvailable); 041 } 042 043 /** @param evaluated false when the caller disabled the parse ({@code ok} is then meaningless) */ 044 public ParseInfo(boolean evaluated, boolean ok, String engineFacts, int errorRegions, int statements, boolean astDepthAvailable) { 045 this.evaluated = evaluated; 046 this.ok = ok; 047 this.engineFacts = engineFacts == null ? "" : engineFacts; 048 this.errorRegions = errorRegions; 049 this.statements = statements; 050 this.astDepthAvailable = astDepthAvailable; 051 } 052 053 /** False when the parse was not run (AST disabled); {@link #isOk()} is then not meaningful. */ 054 public boolean isEvaluated() { return evaluated; } 055 public boolean isOk() { return ok; } 056 public String getEngineFacts() { return engineFacts; } 057 public int getErrorRegions() { return errorRegions; } 058 public int getStatements() { return statements; } 059 public boolean isAstDepthAvailable() { return astDepthAvailable; } 060 } 061 062 /** One fidelity gate check. */ 063 public static final class GateCheck { 064 private final String id; 065 private final boolean ok; 066 private final String detail; 067 private final Map<String, Object> extra; 068 069 public GateCheck(String id, boolean ok, String detail, Map<String, Object> extra) { 070 this.id = id; 071 this.ok = ok; 072 this.detail = detail == null ? "" : detail; 073 this.extra = extra == null ? Collections.<String, Object>emptyMap() 074 : Collections.unmodifiableMap(new LinkedHashMap<String, Object>(extra)); 075 } 076 077 public String getId() { return id; } 078 public boolean isOk() { return ok; } 079 public String getDetail() { return detail; } 080 public Map<String, Object> getExtra() { return extra; } 081 } 082 083 private final String profile; 084 private final String calibration; 085 private final String vendor; 086 private final ParseInfo parse; 087 private final boolean gateEvaluated; 088 private final boolean gatePassed; 089 private final List<GateCheck> gateChecks; 090 private final double score; 091 private final double attainable; 092 private final double ratio; 093 private final double unfixableImpact; 094 private final List<Dimension> dimensions; 095 private final List<Finding> findings; 096 private final List<Improvement> improvements; 097 private final Map<String, Object> metrics; 098 private final List<String> notes; 099 100 public ScoreReport(String profile, String calibration, String vendor, ParseInfo parse, 101 boolean gateEvaluated, boolean gatePassed, List<GateCheck> gateChecks, 102 double score, double attainable, double unfixableImpact, 103 List<Dimension> dimensions, List<Finding> findings, 104 List<Improvement> improvements, Map<String, Object> metrics, 105 List<String> notes) { 106 this.profile = profile; 107 this.calibration = calibration; 108 this.vendor = vendor; 109 this.parse = parse; 110 this.gateEvaluated = gateEvaluated; 111 this.gatePassed = gatePassed; 112 this.gateChecks = Collections.unmodifiableList(new ArrayList<GateCheck>(gateChecks)); 113 this.score = score; 114 this.attainable = attainable; 115 this.ratio = attainable <= 0 ? 0.0 : Math.min(1.0, score / attainable); 116 this.unfixableImpact = unfixableImpact; 117 this.dimensions = Collections.unmodifiableList(new ArrayList<Dimension>(dimensions)); 118 this.findings = Collections.unmodifiableList(new ArrayList<Finding>(findings)); 119 this.improvements = Collections.unmodifiableList(new ArrayList<Improvement>(improvements)); 120 this.metrics = freezeMap(metrics); 121 this.notes = Collections.unmodifiableList(new ArrayList<String>(notes)); 122 } 123 124 public String getProfile() { return profile; } 125 public String getCalibration() { return calibration; } 126 public String getVendor() { return vendor; } 127 public ParseInfo getParse() { return parse; } 128 /** False when no {@code original} was supplied (gate not run). */ 129 public boolean isGateEvaluated() { return gateEvaluated; } 130 /** True when the gate passed or was not evaluated. */ 131 public boolean isGatePassed() { return gatePassed; } 132 public List<GateCheck> getGateChecks() { return gateChecks; } 133 /** 0-100. Forced to 0 when the gate failed. */ 134 public double getScore() { return score; } 135 /** The score with every unfixable finding's impact added back: the most the formatter could reach on this input. */ 136 public double getAttainable() { return attainable; } 137 /** {@code score / attainable}, the closed-loop target quantity. */ 138 public double getRatio() { return ratio; } 139 public double getUnfixableImpact() { return unfixableImpact; } 140 public List<Dimension> getDimensions() { return dimensions; } 141 /** Sorted by (line, col, rule); includes shadowed and unfixable findings. */ 142 public List<Finding> getFindings() { return findings; } 143 public List<Improvement> getImprovements() { return improvements; } 144 public Map<String, Object> getMetrics() { return metrics; } 145 public List<String> getNotes() { return notes; } 146 147 public Dimension dimension(String id) { 148 for (Dimension d : dimensions) { 149 if (d.getId().equals(id)) return d; 150 } 151 return null; 152 } 153 154 public List<Finding> findings(String rule) { 155 List<Finding> out = new ArrayList<Finding>(); 156 for (Finding f : findings) { 157 if (f.getRule().equals(rule)) out.add(f); 158 } 159 return out; 160 } 161 162 // ---- serialisation -------------------------------------------------- 163 164 /** Full JSON (pretty-printed, deterministic). */ 165 public String toJson() { 166 return toJson(false, true); 167 } 168 169 /** 170 * @param summary when true only {@code score / attainable / ratio / dimensions / improvements} 171 * (plus the header and gate) are written 172 */ 173 public String toJson(boolean summary, boolean pretty) { 174 return JsonOut.toJson(toMap(summary), pretty); 175 } 176 177 public Map<String, Object> toMap(boolean summary) { 178 Map<String, Object> m = new LinkedHashMap<String, Object>(); 179 m.put("version", VERSION); 180 m.put("calibration", calibration); 181 m.put("profile", profile); 182 m.put("vendor", vendor); 183 Map<String, Object> p = new LinkedHashMap<String, Object>(); 184 p.put("evaluated", parse.isEvaluated()); 185 p.put("ok", parse.isOk()); 186 p.put("engine_facts", parse.getEngineFacts()); 187 p.put("error_regions", parse.getErrorRegions()); 188 p.put("statements", parse.getStatements()); 189 p.put("ast_depth", parse.isAstDepthAvailable() ? "available" : "unavailable"); 190 m.put("parse", p); 191 Map<String, Object> g = new LinkedHashMap<String, Object>(); 192 g.put("evaluated", gateEvaluated); 193 g.put("passed", gatePassed); 194 List<Object> checks = new ArrayList<Object>(); 195 for (GateCheck c : gateChecks) { 196 Map<String, Object> cm = new LinkedHashMap<String, Object>(); 197 cm.put("id", c.getId()); 198 cm.put("ok", c.isOk()); 199 if (!c.getDetail().isEmpty()) cm.put("detail", c.getDetail()); 200 cm.putAll(c.getExtra()); 201 checks.add(cm); 202 } 203 g.put("checks", checks); 204 m.put("gate", g); 205 m.put("score", round1(score)); 206 m.put("attainable", round1(attainable)); 207 m.put("ratio", round3(ratio)); 208 m.put("unfixable_impact", round1(unfixableImpact)); 209 List<Object> dims = new ArrayList<Object>(); 210 for (Dimension d : dimensions) { 211 Map<String, Object> dm = new LinkedHashMap<String, Object>(); 212 dm.put("id", d.getId()); 213 dm.put("weight", round3(d.getWeight())); 214 dm.put("score", round1(d.getScore())); 215 dm.put("attainable", round1(d.getAttainableScore())); 216 dm.put("detail", d.getDetail()); 217 dims.add(dm); 218 } 219 m.put("dimensions", dims); 220 if (!summary) { 221 List<Object> fs = new ArrayList<Object>(); 222 for (Finding f : findings) { 223 fs.add(findingToMap(f)); 224 } 225 m.put("findings", fs); 226 } 227 List<Object> imps = new ArrayList<Object>(); 228 for (Improvement i : improvements) { 229 Map<String, Object> im = new LinkedHashMap<String, Object>(); 230 im.put("rank", i.getRank()); 231 im.put("rule", i.getRule()); 232 im.put("count", i.getCount()); 233 im.put("est_gain", round1(i.getEstGain())); 234 im.put("target", i.getTarget()); 235 imps.add(im); 236 } 237 m.put("improvements", imps); 238 if (!summary) { 239 m.put("metrics", metrics); 240 m.put("notes", notes); 241 } 242 return m; 243 } 244 245 public static Map<String, Object> findingToMap(Finding f) { 246 Map<String, Object> fm = new LinkedHashMap<String, Object>(); 247 fm.put("rule", f.getRule()); 248 fm.put("dimension", f.getDimension()); 249 fm.put("severity", f.getSeverity().wireName()); 250 fm.put("line", f.getLine()); 251 fm.put("col", f.getCol()); 252 fm.put("len", f.getLen()); 253 fm.put("fixable", f.isFixable()); 254 if (!f.isFixable()) fm.put("unfixable_reason", f.getUnfixableReason()); 255 fm.put("fingerprint", f.getFingerprint()); 256 fm.put("evidence", f.getEvidence()); 257 fm.put("impact", round2(-f.getImpact())); 258 fm.put("suggestion", f.getSuggestion()); 259 if (f.isShadowed()) { 260 fm.put("shadowed", true); 261 fm.put("shadowed_by", f.getShadowedBy()); 262 } 263 return fm; 264 } 265 266 /** Human-readable table (the CLI's {@code --text}). */ 267 public String toText() { 268 StringBuilder sb = new StringBuilder(); 269 sb.append(String.format(Locale.ROOT, "PrettyScore %.1f / 100 attainable %.1f ratio %.2f profile=%s vendor=%s calibration=%s%n", 270 score, attainable, ratio, profile, vendor, calibration)); 271 sb.append("parse: ").append(!parse.isEvaluated() ? "not evaluated" : parse.isOk() ? "ok" : "FAILED").append(" (").append(parse.getEngineFacts()) 272 .append(", ").append(parse.getStatements()).append(" stmt, ").append(parse.getErrorRegions()) 273 .append(" error region(s), ast depth ").append(parse.isAstDepthAvailable() ? "available" : "unavailable").append(")\n"); 274 if (gateEvaluated) { 275 sb.append("gate: ").append(gatePassed ? "passed" : "FAILED"); 276 boolean first = true; 277 for (GateCheck c : gateChecks) { 278 sb.append(first ? " (" : ", "); 279 first = false; 280 sb.append(c.getId().toLowerCase(Locale.ROOT)).append(c.isOk() ? " ok" : " FAIL"); 281 if (!c.getDetail().isEmpty()) sb.append(": ").append(c.getDetail()); 282 } 283 if (!first) sb.append(')'); 284 sb.append('\n'); 285 } else { 286 sb.append("gate: not evaluated (no --original)\n"); 287 } 288 for (Dimension d : dimensions) { 289 sb.append(String.format(Locale.ROOT, "%-12s %5.1f w=%.2f %s%n", d.getId(), d.getScore(), d.getWeight(), d.getDetail())); 290 } 291 if (!improvements.isEmpty()) { 292 sb.append("Top improvements:\n"); 293 for (Improvement i : improvements) { 294 sb.append(String.format(Locale.ROOT, " %d. %-24s +%5.1f x%-3d -> %s%n", 295 i.getRank(), i.getRule(), i.getEstGain(), i.getCount(), i.getTarget())); 296 } 297 } 298 int shown = 0; 299 for (Finding f : findings) { 300 if (f.isShadowed()) continue; 301 if (shown == 0) sb.append("Findings:\n"); 302 shown++; 303 sb.append(String.format(Locale.ROOT, " %-22s %-5s L%-4d C%-3d %6.1f %s %s%n", 304 f.getRule(), f.getSeverity().wireName(), f.getLine(), f.getCol(), -f.getImpact(), 305 f.isFixable() ? "" : "[unfixable: " + f.getUnfixableReason() + "]", f.getEvidence())); 306 } 307 for (String n : notes) { 308 sb.append("note: ").append(n).append('\n'); 309 } 310 if ("heuristic-v0".equals(calibration)) { 311 sb.append("(calibration heuristic-v0: use only for relative comparison within one profile and corpus)\n"); 312 } 313 return sb.toString(); 314 } 315 316 /** Deep, recursive freeze of a metrics map: nested lists and maps become unmodifiable copies. */ 317 private static Map<String, Object> freezeMap(Map<String, Object> m) { 318 Map<String, Object> out = new LinkedHashMap<String, Object>(); 319 for (Map.Entry<String, Object> e : m.entrySet()) out.put(e.getKey(), freeze(e.getValue())); 320 return Collections.unmodifiableMap(out); 321 } 322 323 @SuppressWarnings("unchecked") 324 private static Object freeze(Object v) { 325 if (v instanceof Map) { 326 Map<String, Object> copy = new LinkedHashMap<String, Object>(); 327 for (Map.Entry<?, ?> e : ((Map<?, ?>) v).entrySet()) copy.put(String.valueOf(e.getKey()), freeze(e.getValue())); 328 return Collections.unmodifiableMap(copy); 329 } 330 if (v instanceof List) { 331 List<Object> copy = new ArrayList<Object>(); 332 for (Object o : (List<?>) v) copy.add(freeze(o)); 333 return Collections.unmodifiableList(copy); 334 } 335 return v; 336 } 337 338 private static double round1(double d) { return Math.round(d * 10.0) / 10.0; } 339 private static double round2(double d) { return Math.round(d * 100.0) / 100.0; } 340 private static double round3(double d) { return Math.round(d * 1000.0) / 1000.0; } 341}