Re: [LTP] [PATCH v3] metadata: add linter for JSON file

Cyril Hrubis <[email protected]> Mon, 3 Aug 2026 15:07:21 +0200
Newsgroups it.linux.lists.ltp
Message-ID <[email protected]>
Hi!
>  include $(top_srcdir)/include/mk/generic_leaf_target.mk
> diff --git a/metadata/lint.py b/metadata/lint.py
> new file mode 100755
> index 0000000000000000000000000000000000000000..d32cd27bd0af951aac873756a4cb123c3d29ed31
> --- /dev/null
> +++ b/metadata/lint.py
> @@ -0,0 +1,319 @@
> +#!/usr/bin/env python3
> +# SPDX-License-Identifier: GPL-2.0-or-later
> +# Copyright (c) 2026 Linux Test Project
> +"""
> +Lint semantic consistency of generated metadata/ltp.json.
> +
> +This is not a schema validator; metaparse tests cover JSON shape. The linter
> +checks metadata rules that depend on the final generated test catalog:
> +
> +  * Groups derived from the source path (the two nearest parent directories,
> +    skipping 'kernel' and 'cve') must be present in test 'groups'. No other
> +    groups are allowed unless they are listed in MANUAL_GROUPS.
> +
> +  * A CVE tag requires the 'cve' group and a linux-git tag requires the
> +    'regression' group.
> +
> +  * Only known tag IDs are accepted and every tag must have exactly one value.
> +
> +  * CVE tag values must use a valid bare 20YY-NNNN[...] identifier. With
> +    --check-cve-exists, every CVE is verified against the official CVE
> +    Services API (https://cveawg.mitre.org).
> +
> +The input can be a full ltp.json file or a single test entry from metaparse,
> +which is accepted on stdin with '-'.
> +"""
> +
> +import argparse
> +import json
> +import os
> +import re
> +import sys
> +from typing import (
> +    Any,
> +    Dict,
> +    List,
> +    Pattern,
> +    Tuple,
> +)
> +
> +CVE_RE: Pattern[str] = re.compile(r"^20[0-9]{2}-[0-9]{4,}$")
> +CVE_API: str = "https://cveawg.mitre.org/api/cve/CVE-"
> +SKIP_PATH_GROUPS: Tuple[str, ...] = ("kernel", "cve")
> +VALID_TAGS: Tuple[str, ...] = ("CVE", "linux-git", "glibc-git", "musl-git")
> +MANUAL_GROUPS: Tuple[str, ...] = (
> +    # insert here the groups which need to be supported
> +)
> +
> +
> +def path_groups(fname: str) -> List[str]:
> +    """
> +    Return groups derived from the two nearest parent directories.
> +    """
> +    prefix = "testcases/"
> +    if not fname.startswith(prefix):
> +        return []
> +
> +    dirs = fname[len(prefix) :].split("/")[:-1]
> +    return [grp for grp in reversed(dirs[-2:]) if grp not in SKIP_PATH_GROUPS]
> +
> +
> +def tag_values(tags: List[List[str]], name: str) -> List[str]:
> +    """
> +    Return all values for metadata tags matching name.
> +    """
> +    return [
> +        tag[1]
> +        for tag in tags
> +        if isinstance(tag, list)
> +        and len(tag) == 2
> +        and tag[0] == name
> +        and isinstance(tag[1], str)
> +    ]
> +
> +
> +def has_tag(tags: List[List[str]], name: str) -> bool:
> +    """
> +    Return whether a metadata tag exists.
> +    """
> +    return any(
> +        isinstance(tag, list) and len(tag) == 2 and tag[0] == name for tag in tags
> +    )
> +
> +
> +def expected_groups(conf: Dict[str, Any]) -> List[str]:
> +    """
> +    Return groups expected from test path and tags.
> +    """
> +    groups: List[str] = []
> +    fname: str = conf.get("fname", "")
> +    tags: List[List[str]] = conf.get("tags", [])
> +
> +    for group in path_groups(fname):
> +        if group not in groups:
> +            groups.append(group)
> +
> +    if has_tag(tags, "CVE") and "cve" not in groups:
> +        groups.append("cve")
> +
> +    if has_tag(tags, "linux-git") and "regression" not in groups:
> +        groups.append("regression")
> +
> +    return groups
> +
> +
> +def lint_groups(name: str, conf: Dict[str, Any]) -> List[str]:
> +    """
> +    Return group lint errors for a single test.
> +    """
> +    errors: List[str] = []
> +    groups: List[str] = conf.get("groups", [])
> +    expected: List[str] = expected_groups(conf)
> +    allowed: List[str] = expected + list(MANUAL_GROUPS)
> +    missing: List[str] = [group for group in expected if group not in groups]
> +    invalid: List[str] = [group for group in groups if group not in allowed]
> +
> +    if missing:
> +        errors.append(f"{name}: missing groups: {', '.join(missing)}")
> +
> +    if invalid:
> +        errors.append(f"{name}: invalid groups: {', '.join(invalid)}")
> +
> +    return errors
> +
> +
> +def lint_tags(name: str, conf: Dict[str, Any]) -> List[str]:
> +    """
> +    Return generic tag lint errors for a single test.
> +    """
> +    errors: List[str] = []
> +    tags: List[List[str]] = conf.get("tags", [])
> +
> +    for idx, tag in enumerate(tags):
> +        if not isinstance(tag, list):
> +            errors.append(f"{name}: tag #{idx} must be an array")
> +            continue
> +
> +        if len(tag) != 2:
> +            errors.append(f"{name}: tag #{idx} must have exactly 2 items")
> +
> +        if not tag:
> +            continue
> +
> +        tag_id = tag[0]
> +        if not isinstance(tag_id, str):
> +            errors.append(f"{name}: tag #{idx} ID must be a string")
> +        elif tag_id not in VALID_TAGS:
> +            errors.append(f"{name}: unknown tag ID '{tag_id}'")
> +
> +        if len(tag) >= 2 and not isinstance(tag[1], str):
> +            errors.append(f"{name}: tag #{idx} value must be a string")
> +
> +    return errors
> +
> +
> +def lint_cve_format(name: str, conf: Dict[str, Any]) -> List[str]:
> +    """
> +    Return CVE format lint errors for a single test.
> +    """
> +    errors: List[str] = []
> +    tags: List[List[str]] = conf.get("tags", [])
> +
> +    for cve in tag_values(tags, "CVE"):
> +        if cve.upper().startswith("CVE-"):
> +            errors.append(
> +                f"{name}: CVE tag '{cve}' must not start with 'CVE-' prefix, "
> +                "use the bare '20YY-NNNN' identifier"
> +            )
> +        elif not CVE_RE.match(cve):
> +            errors.append(f"{name}: malformed CVE identifier '{cve}'")
> +
> +    return errors
> +
> +
> +def cve_exists(cve: str, cache: Dict[str, bool]) -> bool:
> +    """
> +    Query the CVE Services API and cache the answer per identifier.
> +    """
> +    import urllib.error
> +    import urllib.request
> +
> +    if cve in cache:
> +        return cache[cve]
> +
> +    req = urllib.request.Request(CVE_API + cve, method="GET")
> +    try:
> +        with urllib.request.urlopen(req, timeout=30) as resp:
> +            ok = resp.status == 200
> +    except urllib.error.HTTPError as err:
> +        if err.code == 404:
> +            ok = False
> +        else:
> +            raise
> +    except urllib.error.URLError as err:
> +        raise RuntimeError(f"cannot reach CVE API: {err}") from err
> +
> +    cache[cve] = ok
> +    return ok
> +
> +
> +def lint_cve_existence(
> +    name: str,
> +    conf: Dict[str, Any],
> +    cache: Dict[str, bool],
> +) -> List[str]:
> +    """
> +    Return CVE existence lint errors for a single test.
> +    """
> +    errors: List[str] = []
> +    tags: List[List[str]] = conf.get("tags", [])
> +
> +    for cve in tag_values(tags, "CVE"):
> +        if CVE_RE.match(cve) and not cve_exists(cve, cache):
> +            errors.append(f"{name}: CVE '{cve}' does not exist")
> +
> +    return errors
> +
> +
> +def lint_tests(tests: Dict[str, Dict[str, Any]], check_cve_exists: bool) -> List[str]:
> +    """
> +    Return all lint errors for generated test metadata.
> +    """
> +    errors: List[str] = []
> +    cache: Dict[str, bool] = {}
> +
> +    for name, conf in sorted(tests.items()):
> +        errors += lint_tags(name, conf)
> +        errors += lint_groups(name, conf)
> +        errors += lint_cve_format(name, conf)
> +        if check_cve_exists:
> +            errors += lint_cve_existence(name, conf, cache)
> +
> +    return errors
> +
> +
> +def parse_stdin(data: str) -> Dict[str, Any]:
> +    """
> +    Parse full metadata or a single metaparse entry from stdin.
> +    """
> +    try:
> +        return json.loads(data)
> +    except json.JSONDecodeError as err:
> +        try:
> +            return json.loads("{\n" + data + "\n}")
> +        except json.JSONDecodeError:
> +            raise err
> +
> +
> +def extract_tests(metadata: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
> +    """
> +    Return the tests dictionary from full metadata or single-test input.
> +    """
> +    tests = metadata.get("tests")
> +
> +    if isinstance(tests, dict):
> +        return tests
> +
> +    return metadata
> +
> +
> +def main() -> int:
> +    parser = argparse.ArgumentParser(
> +        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
> +    )
> +    default = os.path.join(os.path.dirname(__file__), "ltp.json")
> +    parser.add_argument(
> +        "metadata",
> +        nargs="?",
> +        help=f"path to ltp.json, or '-' to read stdin (default: {default})",
> +    )
> +    parser.add_argument(
> +        "--check-cve-online",
> +        action="store_true",
> +        help="verify CVE existence against the online CVE database",
> +    )
> +    args = parser.parse_args()
> +
> +    source = args.metadata or default
> +
> +    try:
> +        if args.metadata == "-":
> +            metadata: Dict[str, Any] = parse_stdin(sys.stdin.read())
> +        elif args.metadata is None and not sys.stdin.isatty():
> +            stdin_data = sys.stdin.read()
> +            if stdin_data.strip():
> +                source = "stdin"
> +                metadata = parse_stdin(stdin_data)
> +            else:
> +                with open(default, encoding="utf-8") as data:
> +                    metadata = json.load(data)
> +        else:
> +            with open(source, encoding="utf-8") as data:
> +                metadata = json.load(data)
> +    except FileNotFoundError:
> +        print(
> +            f"error: metadata file '{source}' not found "
> +            "(run 'make' in metadata/ first)",
> +            file=sys.stderr,
> +        )
> +        return 1
> +    except json.JSONDecodeError as err:
> +        print(f"error: failed to parse '{source}': {err}", file=sys.stderr)
> +        return 1
> +
> +    tests: Dict[str, Dict[str, Any]] = extract_tests(metadata)
> +    errors: List[str] = lint_tests(tests, args.check_cve_online)

What I had in mind for make check is that there would be a single test
mode "-t" switch and we would skip the extract_tests and passed the JSON
right to lint_tests()

./metaparse foo.c | lint.py -t


The assertions looks good to me.

-- 
Cyril Hrubis
[email protected]

-- 
Mailing list info: https://lists.linux.it/listinfo/ltp