#!/usr/bin/env python3 """Somebody else's analyser, normalised into findings this repository can measure. WHY THIS EXISTS --------------- `crosswalk` compares this corpus against another ruleset by reading the other ruleset's PATTERNS. It says so in its own docstring, and says why: "Semgrep is not installed here and adding it would make an offline tool need a package index." That reasoning was about a dependency, and it conflated two things. Running another analyser costs `stdlib only`. It does not cost OFFLINE. pylint runs on this machine; OpenSPP's lint scripts are Python in their own tree. Nothing leaves the laptop either way, which is the promise that matters. So this module shells out rather than importing. subprocess and json are stdlib, the analyser is optional, and an absent one is a GAP rather than an error -- the same shape as the OpenTelemetry bridge in telemetry.py, for the same reason. WHAT IT IS FOR, AND IT IS NOT COVERAGE -------------------------------------- Measured on OpenSPP, the two tool sets barely overlap. Their seven checkers found 748 things: N+1 queries, naming conventions, UI patterns, ACL files, PII in logs. Not one of them checks @api.depends completeness, @api.constrains field lists, ondelete or check_company. A production Odoo team wrote their own tooling from scratch and did not build what this corpus is. So wrapping them is not how this gets better at what it already does. It is two other things: ONE TOOL an Odoo shop runs one command instead of three, and the findings arrive ranked together with a single suppression convention. AIM `recall --aim` can be pointed at THEIR rules. performance.n_plus_one is 445 of their 748 findings -- 60% from a single check -- and nobody has ever published its false-positive rate or how often it lands on a line somebody actually fixed. This repository has the harness to say. That is the part worth having. An external finding arrives at likelihood "unmeasured" by definition. That is not a placeholder, it is the accurate value: nobody has measured it yet, and measuring it is the point. Stdlib only. """ import io import json import os import subprocess import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from layers import SourceHit, SUPPRESSION_MARKERS # noqa: E402 # Their words for how bad it is, in ours. Deliberately conservative: an external # rule nobody here has classified does not get to claim `critical`, because the # severity axis in this corpus means "silently ships bad data to production" and # that is a judgement about the rule, not a label it arrives wearing. SEVERITY = { "error": "high", "fatal": "high", "critical": "high", "warning": "medium", "warn": "medium", "convention": "medium", "refactor": "medium", "info": "medium", "information": "medium", "note": "medium", } def _relative(path, root): try: return os.path.relpath(path, root).replace(os.sep, "/") except ValueError: # different drive on Windows return path.replace(os.sep, "/") def _hit(namespace, rule_id, severity, line, message, body_lines): """One external finding as a SourceHit. The rule id is namespaced so an external id can never be mistaken for one of ours, in a report or in an artifact somebody diffs six months from now. """ marked = False if body_lines and 0 < line <= len(body_lines): marked = any(m in body_lines[line - 1] for m in SUPPRESSION_MARKERS) return SourceHit( "{0}:{1}".format(namespace, rule_id), SEVERITY.get(str(severity).lower(), "medium"), line, message, marked, False, "unmeasured") def from_json(path, root, namespace="external"): """Findings from an analyser that wrote JSON, as {relative path: [SourceHit]}. Reads the shape OpenSPP's lint emits and pylint's --output-format=json, which agree on everything that matters: a file, a line, an id, a message, and a word for severity. Keys are tried in order rather than required, so an analyser that spells one of them differently still lands. """ try: with io.open(path, encoding="utf-8-sig") as handle: payload = json.load(handle) except (OSError, ValueError): return {} rows = payload.get("violations") or payload.get("results") or payload \ if isinstance(payload, dict) else payload if not isinstance(rows, list): return {} out = {} bodies = {} for row in rows: if not isinstance(row, dict): continue where = row.get("file") or row.get("path") or row.get("filename") rule = (row.get("rule_id") or row.get("message-id") or row.get("symbol") or row.get("check") or "?") if not where: continue rel = _relative(where, root) if os.path.isabs(where) else \ where.replace(os.sep, "/") if rel not in bodies: bodies[rel] = _read_lines(os.path.join(root, rel.replace("/", os.sep))) out.setdefault(rel, []).append(_hit( namespace, rule, row.get("severity") or row.get("type") or "warning", int(row.get("line") or 0) or 1, (row.get("message") or "").strip(), bodies[rel])) return out def _read_lines(path): try: with io.open(path, encoding="utf-8", errors="ignore") as handle: return handle.read().splitlines() except OSError: return [] def run_pylint(root, plugin="pylint_odoo", timeout=900): """pylint with a plugin, if this machine has it. {} if it does not. Shelled out rather than imported, so nexa stays stdlib-only and pylint stays something you either have or do not. Absent is reported by the caller as a gap, never as a failure: a review that could not run somebody else's checker is still a review, and saying which one was missing is the whole contract the layers already hold to. """ command = [sys.executable, "-m", "pylint", "--output-format=json", "--load-plugins", plugin, "--disable=all", "--enable=odoolint", root] try: proc = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout) except (OSError, subprocess.SubprocessError): return {} if not (proc.stdout or "").strip(): return {} try: rows = json.loads(proc.stdout) except ValueError: return {} out, bodies = {}, {} for row in rows: where = row.get("path") or "" if not where: continue rel = _relative(where, root) if os.path.isabs(where) else \ where.replace(os.sep, "/") if rel not in bodies: bodies[rel] = _read_lines(os.path.join(root, rel.replace("/", os.sep))) out.setdefault(rel, []).append(_hit( "pylint-odoo", row.get("symbol") or row.get("message-id") or "?", row.get("type") or "warning", int(row.get("line") or 1), (row.get("message") or "").strip(), bodies[rel])) return out def available(plugin="pylint_odoo"): """Is the pylint plugin here? Cheap enough to ask before promising anything.""" try: proc = subprocess.run( [sys.executable, "-m", "pylint", "--version"], capture_output=True, text=True, timeout=30) return proc.returncode == 0 except (OSError, subprocess.SubprocessError): return False def describe(findings): """One line per external analyser's contribution, for a report to print.""" rules = {hit.rule.split(":", 1)[0] for hits in findings.values() for hit in hits} total = sum(len(hits) for hits in findings.values()) return "{0} finding(s) from {1}, all at likelihood 'unmeasured'".format( total, ", ".join(sorted(rules)) or "no external analyser") if __name__ == "__main__": if len(sys.argv) < 3: print(__doc__) sys.exit(2) found = from_json(sys.argv[1], sys.argv[2], sys.argv[3] if len(sys.argv) > 3 else "external") print(describe(found)) for rel in sorted(found)[:10]: for hit in found[rel][:2]: print(" {0}:{1} {2}".format(rel, hit.line, hit.rule))