From a103d8129bce477bbbfd616f276d41df9bf461f8 Mon Sep 17 00:00:00 2001 From: OdooSky v3 Date: Sat, 9 May 2026 13:38:33 +0200 Subject: [PATCH] ci: vendor qualify-addon.py (Pillar 1 self-contained) --- .gitea/qualify-addon.py | 393 +++++++++++++++++++++++++++++ .gitea/workflows/addon-qualify.yml | 22 +- 2 files changed, 401 insertions(+), 14 deletions(-) create mode 100644 .gitea/qualify-addon.py diff --git a/.gitea/qualify-addon.py b/.gitea/qualify-addon.py new file mode 100644 index 0000000..267b533 --- /dev/null +++ b/.gitea/qualify-addon.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +""" +qualify-addon.py — Pillar 1 of the addon qualification gate. + +Static checks against an Odoo addon source tree: + manifest __manifest__.py parses, has 'name', 'version' starts with '.0.' + pip-deps every non-stdlib import is declared in external_dependencies['python'] + app-name every element in any view XML has a name= attribute + menu-icon top-level is set OR the addon ships + static/description/icon.png + hoot-import no JS file under static/src/ imports from '@odoo/hoot' or '@odoo/hoot-dom' + webpack-name no JS file under static/lib/ uses self.webpackChunk_ — + chunk array names must be addon-namespaced (e.g. webpackChunk_am5_) + +Usage: + python3 scripts/qualify-addon.py [ ...] + python3 scripts/qualify-addon.py --json + +Exit codes: + 0 all checks passed for every addon + 1 at least one addon failed at least one check + 2 bad usage / I/O error + +Each finding is (severity, check, message). Severity: + ERROR — the addon is broken-by-construction; refuse to admit to catalog + WARN — likely problem but could be intentional; admit-with-warning posture +""" +from __future__ import annotations + +import ast +import json +import re +import sys +from dataclasses import dataclass, asdict +from pathlib import Path +from xml.etree import ElementTree as ET + + +# Python stdlib modules. Conservative — anything imported NOT in here AND not in +# ODOO_BUILTINS is flagged as needing declaration. Better to false-positive (fixable +# by adding to external_dependencies) than miss a real missing dep. +STDLIB = frozenset({ + 'abc', 'argparse', 'ast', 'asyncio', 'base64', 'binascii', 'bisect', 'calendar', + 'collections', 'configparser', 'contextlib', 'contextvars', 'copy', 'csv', 'ctypes', + 'dataclasses', 'datetime', 'decimal', 'difflib', 'dis', 'email', 'enum', 'errno', + 'fcntl', 'fnmatch', 'functools', 'gc', 'getpass', 'gettext', 'glob', 'gzip', 'hashlib', + 'heapq', 'hmac', 'html', 'http', 'imaplib', 'importlib', 'inspect', 'io', 'ipaddress', + 'itertools', 'json', 'keyword', 'locale', 'logging', 'math', 'mimetypes', + 'multiprocessing', 'numbers', 'operator', 'os', 'pathlib', 'pickle', 'pkgutil', + 'platform', 'pprint', 'queue', 'random', 're', 'select', 'selectors', 'shlex', + 'shutil', 'signal', 'smtplib', 'socket', 'sqlite3', 'ssl', 'stat', 'string', 'struct', + 'subprocess', 'sys', 'tempfile', 'textwrap', 'threading', 'time', 'timeit', 'token', + 'tokenize', 'traceback', 'types', 'typing', 'unicodedata', 'unittest', 'urllib', + 'uuid', 'warnings', 'weakref', 'xml', 'xmlrpc', 'zipfile', 'zlib', 'zoneinfo', + '__future__', +}) + +# Modules shipped by Odoo's base image. Never need declaration. +ODOO_BUILTINS = frozenset({ + 'odoo', 'psycopg2', 'lxml', 'PIL', 'requests', 'dateutil', 'pytz', 'passlib', + 'werkzeug', 'jinja2', 'markupsafe', 'docutils', 'reportlab', 'babel', 'xlsxwriter', + 'xlrd', 'xlwt', 'qrcode', 'vobject', 'polib', 'PyPDF2', 'cryptography', 'pyOpenSSL', + 'OpenSSL', 'suds', 'num2words', 'pyldap', 'ldap', 'xmltodict', 'zeep', 'gevent', + 'greenlet', 'libsass', 'idna', 'pyusb', 'serial', 'qrcode', 'mock', 'freezegun', + 'phonenumbers', +}) + +# Bare-name webpackChunk arrays we know collide. Detector flags any +# `self.webpackChunk_` where doesn't have an addon-derived suffix. +# We allow the canonical chunk array names listed here ONLY if the addon's +# directory name matches — i.e. we suggest namespacing. +WEBPACK_CHUNK_RE = re.compile(r'self\.(webpackChunk[a-zA-Z0-9_]+)\b') + +# JS imports of the hoot test framework that should never appear in production code. +HOOT_IMPORT_RE = re.compile( + r'''(?:from\s+['"]@odoo/hoot[a-z\-]*['"]|require\s*\(\s*['"]@odoo/hoot[a-z\-]*['"]\s*\))''' +) + + +@dataclass +class Finding: + severity: str # 'ERROR' | 'WARN' + check: str # short check id + message: str # human-readable + file: str | None = None # relative path, if applicable + line: int | None = None # 1-indexed, if applicable + + +# ---------------------------------------------------------------------------- # +# Check 1 — manifest parses + has required keys +# ---------------------------------------------------------------------------- # +def check_manifest(addon_dir: Path) -> tuple[list[Finding], dict | None]: + findings: list[Finding] = [] + mf_path = addon_dir / '__manifest__.py' + if not mf_path.exists(): + findings.append(Finding('ERROR', 'manifest', 'no __manifest__.py')) + return findings, None + try: + manifest = ast.literal_eval(mf_path.read_text()) + except (SyntaxError, ValueError) as e: + findings.append(Finding('ERROR', 'manifest', + f'__manifest__.py does not parse as Python literal: {e}', + file='__manifest__.py')) + return findings, None + if not isinstance(manifest, dict): + findings.append(Finding('ERROR', 'manifest', + '__manifest__.py top-level is not a dict', + file='__manifest__.py')) + return findings, None + if not manifest.get('name'): + findings.append(Finding('ERROR', 'manifest', + "missing 'name' key (Odoo refuses install)", + file='__manifest__.py')) + version = manifest.get('version', '') + if not re.match(r'^\d+\.0\.\d+\.\d+\.\d+$', version): + findings.append(Finding('WARN', 'manifest', + f"version {version!r} is not in '.0.x.y.z' form — " + "Odoo will prepend the running Odoo major and may refuse install " + "on a different major (incident #9)", + file='__manifest__.py')) + return findings, manifest + + +# ---------------------------------------------------------------------------- # +# Check 2 — pip deps: every non-stdlib import is in external_dependencies +# ---------------------------------------------------------------------------- # +def check_pip_deps(addon_dir: Path, manifest: dict) -> list[Finding]: + findings: list[Finding] = [] + declared = set(manifest.get('external_dependencies', {}).get('python', [])) + addon_name = addon_dir.name + + # Pre-scan: collect this addon's submodule names so we don't flag intra-addon imports. + own_submodules = {p.stem for p in addon_dir.rglob('*.py') if p.stem != '__init__'} + own_submodules.add(addon_name) + + seen_imports: set[tuple[str, str, int]] = set() # (toplevel, file, line) + + for py_file in addon_dir.rglob('*.py'): + if any(part.startswith('.') for part in py_file.parts): + continue + try: + tree = ast.parse(py_file.read_text()) + except (SyntaxError, UnicodeDecodeError): + continue + rel = py_file.relative_to(addon_dir).as_posix() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + seen_imports.add((alias.name.split('.')[0], rel, node.lineno)) + elif isinstance(node, ast.ImportFrom): + if node.level: # relative import — intra-addon, skip + continue + if node.module: + seen_imports.add((node.module.split('.')[0], rel, node.lineno)) + + for top, rel, lineno in sorted(seen_imports): + if top in STDLIB or top in ODOO_BUILTINS or top in own_submodules: + continue + if top in declared: + continue + # PEP 8 names that are clearly local helpers (e.g. utils, models) — skip if + # they look like a sibling module we missed in own_submodules. + if (addon_dir / top).is_dir() or (addon_dir / f'{top}.py').exists(): + continue + findings.append(Finding( + 'ERROR', 'pip-deps', + f"imports '{top}' but it is not in external_dependencies['python'] " + "(install will fail with ModuleNotFoundError — incident #5)", + file=rel, line=lineno, + )) + return findings + + +# ---------------------------------------------------------------------------- # +# Check 3 — every element has a name= attribute +# ---------------------------------------------------------------------------- # +def check_app_name(addon_dir: Path) -> list[Finding]: + findings: list[Finding] = [] + # XML files in views/ + data/ may contain res_config_settings elements. + for xml_file in list(addon_dir.rglob('views/*.xml')) + list(addon_dir.rglob('data/*.xml')): + try: + text = xml_file.read_text() + except UnicodeDecodeError: + continue + rel = xml_file.relative_to(addon_dir).as_posix() + # Multi-line tolerant regex: with everything between. + for m in re.finditer(r']*?>', text, re.DOTALL): + tag = m.group() + if 'name=' in tag: + continue + line = text[:m.start()].count('\n') + 1 + findings.append(Finding( + 'ERROR', 'app-name', + " element missing name= attribute. Odoo 18 SettingsFormCompiler " + "calls toStringExpression(null) and crashes the entire Settings page " + "(incident #7)", + file=rel, line=line, + )) + return findings + + +# ---------------------------------------------------------------------------- # +# Check 4 — top-level menus declare web_icon OR addon ships static/description/icon.png +# ---------------------------------------------------------------------------- # +def check_menu_icon(addon_dir: Path) -> list[Finding]: + findings: list[Finding] = [] + has_default_icon = (addon_dir / 'static' / 'description' / 'icon.png').exists() + for xml_file in addon_dir.rglob('*.xml'): + try: + text = xml_file.read_text() + except UnicodeDecodeError: + continue + rel = xml_file.relative_to(addon_dir).as_posix() + # Find whose XML has no parent= attribute (top-level menu). + for m in re.finditer(r']*?/?>', text, re.DOTALL): + tag = m.group() + if 'parent=' in tag: + continue + if 'web_icon=' in tag: + continue + if has_default_icon: + # Odoo 18's auto-fallback path. Soft warning since it works for top-level + # menus that get web_icon auto-populated from the module's icon.png. + # But our incident #6 showed even with icon.png present, web_icon often + # ends up empty in DB. So WARN, not ERROR. + line = text[:m.start()].count('\n') + 1 + findings.append(Finding( + 'WARN', 'menu-icon', + "top-level has no web_icon=. Will fall back to " + "static/description/icon.png IF Odoo's auto-populate fires; " + "if not, menu shows blank (incident #6). Set web_icon explicitly.", + file=rel, line=line, + )) + else: + line = text[:m.start()].count('\n') + 1 + findings.append(Finding( + 'ERROR', 'menu-icon', + "top-level has no web_icon= AND addon ships no " + "static/description/icon.png — menu will render blank.", + file=rel, line=line, + )) + return findings + + +# ---------------------------------------------------------------------------- # +# Check 5 — no @odoo/hoot* imports in static/src/ +# ---------------------------------------------------------------------------- # +def check_hoot_import(addon_dir: Path) -> list[Finding]: + findings: list[Finding] = [] + src_dir = addon_dir / 'static' / 'src' + if not src_dir.exists(): + return findings + for js_file in src_dir.rglob('*.js'): + try: + text = js_file.read_text() + except UnicodeDecodeError: + continue + rel = js_file.relative_to(addon_dir).as_posix() + for m in HOOT_IMPORT_RE.finditer(text): + line = text[:m.start()].count('\n') + 1 + findings.append(Finding( + 'ERROR', 'hoot-import', + "imports from @odoo/hoot* in production code (static/src/). " + "@odoo/hoot is the test framework; the production bundle does not " + "register it. Page will white-screen (incident #3 class)", + file=rel, line=line, + )) + return findings + + +# ---------------------------------------------------------------------------- # +# Check 6 — webpack chunk arrays in static/lib/ must be addon-namespaced +# ---------------------------------------------------------------------------- # +def check_webpack_chunk(addon_dir: Path) -> list[Finding]: + findings: list[Finding] = [] + lib_dir = addon_dir / 'static' / 'lib' + if not lib_dir.exists(): + return findings + addon_name = addon_dir.name + seen: set[str] = set() + for js_file in lib_dir.rglob('*.js'): + try: + text = js_file.read_text() + except UnicodeDecodeError: + continue + rel = js_file.relative_to(addon_dir).as_posix() + for m in WEBPACK_CHUNK_RE.finditer(text): + chunk_name = m.group(1) + if chunk_name in seen: + continue + seen.add(chunk_name) + # Acceptable if chunk name contains: full addon name OR any 4+ char + # sub-token of the addon name (e.g. 'ksdn' for 'ks_dashboard_ninja') + # OR a known-namespaced suffix (anything past the standard library + # prefix). We just need confidence the chunk array is unique-per-addon. + addon_lower = addon_name.lower() + chunk_lower = chunk_name.lower() + tokens = [addon_lower.replace('_', '')] + [ + t for t in addon_lower.split('_') if len(t) >= 4 + ] + # Also accept any short 4+ char abbrev derived from initials of + # underscore-separated parts (ks_dashboard_ninja -> ksdn) + initials = ''.join(t[0] for t in addon_lower.split('_') if t) + if len(initials) >= 3: + tokens.append(initials) + if any(t in chunk_lower for t in tokens): + continue + line = text[:m.start()].count('\n') + 1 + findings.append(Finding( + 'ERROR', 'webpack-chunk', + f"uses bare webpack chunk array '{chunk_name}'. Two addons that ship " + f"the same library (e.g. amCharts) collide on this global → bundle " + f"execution aborts (incident #4). Rename to '{chunk_name}_{addon_name}' " + "or similar.", + file=rel, line=line, + )) + return findings + + +# ---------------------------------------------------------------------------- # +# Runner +# ---------------------------------------------------------------------------- # +def qualify_addon(addon_dir: Path) -> dict: + findings: list[Finding] = [] + + manifest_findings, manifest = check_manifest(addon_dir) + findings.extend(manifest_findings) + + if manifest is not None: + findings.extend(check_pip_deps(addon_dir, manifest)) + + findings.extend(check_app_name(addon_dir)) + findings.extend(check_menu_icon(addon_dir)) + findings.extend(check_hoot_import(addon_dir)) + findings.extend(check_webpack_chunk(addon_dir)) + + errors = sum(1 for f in findings if f.severity == 'ERROR') + warns = sum(1 for f in findings if f.severity == 'WARN') + return { + 'addon': addon_dir.name, + 'path': str(addon_dir), + 'qualified': errors == 0, + 'errors': errors, + 'warns': warns, + 'findings': [asdict(f) for f in findings], + } + + +def main(argv: list[str]) -> int: + json_out = False + args: list[str] = [] + for a in argv[1:]: + if a == '--json': + json_out = True + elif a in ('-h', '--help'): + print(__doc__) + return 0 + else: + args.append(a) + if not args: + print(__doc__, file=sys.stderr) + return 2 + + results = [] + for path_str in args: + path = Path(path_str).resolve() + if not path.is_dir() or not (path / '__manifest__.py').exists(): + print(f'ERROR: {path} is not an Odoo addon directory ' + '(missing __manifest__.py)', file=sys.stderr) + return 2 + results.append(qualify_addon(path)) + + if json_out: + print(json.dumps(results, indent=2)) + else: + for r in results: + badge = '\033[32mQUALIFIED\033[0m' if r['qualified'] else '\033[31mFAILED\033[0m' + print(f"\n{badge} {r['addon']} ({r['errors']} error(s), {r['warns']} warning(s))") + if not r['findings']: + continue + for f in r['findings']: + tag = '\033[31m' if f['severity'] == 'ERROR' else '\033[33m' + loc = '' + if f['file']: + loc = f" [{f['file']}" + (f":{f['line']}" if f['line'] else '') + ']' + print(f" {tag}{f['severity']:5}\033[0m {f['check']:<14} {f['message']}{loc}") + + any_failed = any(not r['qualified'] for r in results) + return 1 if any_failed else 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv)) diff --git a/.gitea/workflows/addon-qualify.yml b/.gitea/workflows/addon-qualify.yml index 3d9c484..444748d 100644 --- a/.gitea/workflows/addon-qualify.yml +++ b/.gitea/workflows/addon-qualify.yml @@ -1,11 +1,12 @@ # Pillar 1 of the addon-qualification proposal — runs on every push to any -# branch and on every PR. Pulls the latest qualify-addon.py from the v3 -# monorepo and runs it against every addon directory in this repo. +# branch and on every PR. Runs the vendored qualify-addon.py against every +# addon directory in this repo. # -# admit-with-warning posture: the workflow does NOT fail the build when an -# addon fails the lint. The result is published as a check annotation so -# operators can see it on the catalog card. (See docs/PROPOSAL_ADDON_QUALIFICATION.md -# in odoo-tower/odooskyv3 for the rationale.) +# admit-with-warning posture: lint findings are reported but do NOT fail +# the build (matches Pillar 3 informed-consent posture). +# +# To update the qualifier itself, edit scripts/qualify-addon.py in +# odoo-tower/odooskyv3 then sync it here. name: addon-qualify on: @@ -20,12 +21,6 @@ jobs: - name: Checkout addons repo uses: actions/checkout@v4 - - name: Fetch qualify-addon.py from v3 monorepo - run: | - curl -fsSL 'https://git.odoosky.org/odoo-tower/odooskyv3/raw/branch/main/scripts/qualify-addon.py' -o /tmp/qualify-addon.py - chmod +x /tmp/qualify-addon.py - head -1 /tmp/qualify-addon.py - - name: Run qualifier on every addon run: | set +e @@ -38,9 +33,8 @@ jobs: exit 0 fi echo "Qualifying ${#ADDONS[@]} addons..." - python3 /tmp/qualify-addon.py "${ADDONS[@]}" + python3 .gitea/qualify-addon.py "${ADDONS[@]}" QUAL_RC=$? echo echo "::notice ::qualifier exit code $QUAL_RC (admit-with-warning — not failing build)" - # Always succeed; the report is the artifact. exit 0