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}