diff --git a/changes/feat-hwgen-json-diagnostics.md b/changes/feat-hwgen-json-diagnostics.md new file mode 100644 index 00000000..e2a59111 --- /dev/null +++ b/changes/feat-hwgen-json-diagnostics.md @@ -0,0 +1,3 @@ +### Added (the generator's output for tools, 2026-11-13) + +- `emb-hwgen validate`, `generate` and `check` take `--format json`, which prints one JSON document for an IDE or a script (`hw/schemas/diagnostics.schema.json`). It gives the status and exit code, every error and warning with its file, line, node path and message as separate fields, the diff of each outdated generated file, and the files written ([hwgen](docs/engineering/hwgen.md)). A failure of the generator itself still yields a document, with exit status 3. EmbLink Studio's first stage reads it. diff --git a/docs/engineering/hwgen.md b/docs/engineering/hwgen.md index 90bf502f..8411c856 100644 --- a/docs/engineering/hwgen.md +++ b/docs/engineering/hwgen.md @@ -15,9 +15,45 @@ The generator needs Python 3 and PyYAML, the configuration tool's one dependency - Exit status 2 means a description error. Every error is printed as `file:line: node.path: message`, and all errors found are reported, sorted. - `check` exits 1 when a committed output differs from what the descriptions give, and prints the diff. +- Exit status 3 means the generator itself failed: a bug to report, not a description error. Never edit `generated/` by hand (HW-003). Change the YAML and regenerate. +### Output for tools: `--format json` + +`validate`, `generate` and `check` take `--format json`. The command then prints a single JSON document on standard output and nothing else, for an IDE such as EmbLink Studio or a script. `hw/schemas/diagnostics.schema.json` defines the document. The exit status is the same as for text output. An application's `--system` takes it too. When `generate` leaves a merge conflict in an edited file (A126), the document's status is `stale`, exit 1, with an HW-003 finding for each such file; `check` also counts the system descriptions it checked (`systems`). + +```sh +python3 -I hw/gen/emb-hwgen.py validate --board boards/st/nucleo_f446re --format json +``` + +With `baud: fast` in the NUCLEO-F446RE's description, it prints: + +```json +{ + "tool": "emb-hwgen", "version": "0.1.0", "format": 1, "command": "validate", + "status": "errors", "exit_code": 2, "files": [], + "diagnostics": [ + {"severity": "error", "rule": "HW-002", "file": "boards/st/nucleo_f446re/board.yaml", "line": 26, + "path": "console.baud", "message": "expected integer, found string 'fast'", + "text": "boards/st/nucleo_f446re/board.yaml:26: console.baud: expected integer, found string 'fast'"} + ] +} +``` + +- **`status`** is one of four values: + - `ok` (exit 0): warnings are still possible; + - `errors` (exit 2): the descriptions are wrong; + - `stale` (exit 1): `check` found outdated outputs, or hardware facts outside a description; + - `internal-error` (exit 3): the document carries `error`, the exception's type and message, and the traceback goes to standard error. +- **Each finding** gives its file, line, node path and message as separate fields. The file is relative to the repository root, or absolute for a board kept outside it. It also keeps the line the text output prints, in `text`. +- **`rule`** says which requirement the finding concerns: + - HW-002: a description error or warning; + - HW-001: a hardware fact outside a description; + - HW-003: a generated file out of date. These findings carry `diff`, a unified diff from the committed file to the generator's output. +- **`files`** lists what `generate` wrote. **`boards`** counts what `check` checked. +- **`format`** is the document's own version. A new field keeps it; a changed or removed field bumps it. + ## Files | Path | Kind | Content | diff --git a/hw/gen/emb-hwgen.py b/hw/gen/emb-hwgen.py index e077d55a..2485374a 100644 --- a/hw/gen/emb-hwgen.py +++ b/hw/gen/emb-hwgen.py @@ -13,25 +13,32 @@ emb-hwgen.py check --board-root ~/my-project # the boards a project keeps outside the tree emb-hwgen.py validate --board boards/st/nucleo_f446re emb-hwgen.py generate --system examples/controller/system.yaml # an application (A125) + emb-hwgen.py validate --board boards/st/nucleo_f446re --format json # for tools and IDEs -Exit status 0 on success, 1 when check finds a difference, 2 on a description error; each -error is printed as 'file:line: node.path: message' (HW-002). +Exit status 0 on success, 1 when check finds a difference, 2 on a description error, 3 when the +generator itself fails; each error is printed as 'file:line: node.path: message' (HW-002). With +--format json the output is one JSON document instead (hw/schemas/diagnostics.schema.json): the +status, the exit code, each finding with its file, line, node path and message apart, and the +files written; nothing else goes to standard output. """ import argparse import difflib import os import sys +import traceback from pathlib import Path ROOT = Path(__file__).resolve().parent.parent.parent sys.path.insert(0, str(Path(__file__).resolve().parent)) -from emb_hwgen import HwError, facts, generate, writer # noqa: E402 +from emb_hwgen import HwError, diagnostics, facts, generate, writer # noqa: E402 def _boards(root): return sorted(p.parent.parent.parent for p in root.glob("boards/*/*/generated/include/hw_config.h")) +CONFLICT = "an unresolved merge conflict (<<<<<<< yours ... >>>>>>> generated now)" + _SKIP = {"build", "third_party", ".git", "node_modules"} @@ -78,12 +85,19 @@ def _target(args): def cmd_generate(args): """Each file written over what is there, edits kept (A126): created, unchanged, rewritten, - merged with its edits, or a conflict for its author to resolve.""" + merged with its edits, or a conflict for its author to resolve. With --format json, the files + written and, for each conflict, an HW-003 finding (status stale, exit 1).""" placements, warnings, _ = _target(args) outcomes = {} for path, text, base in placements: outcome = writer.write(path, text, base) outcomes.setdefault(outcome, []).append(path) + if args.format == "json": + written = sorted(os.path.relpath(path, ROOT) for kind in ("created", "rewritten", "merged", "conflict") + for path in outcomes.get(kind, [])) + conflicts = [diagnostics.stale(os.path.relpath(path, ROOT), CONFLICT) for path in outcomes.get("conflict", [])] + code = 1 if conflicts else 0 + return _emit(args, "stale" if code else "ok", code, _warnings(warnings) + conflicts, written) for w in warnings: print(f"warning: {w}", file=sys.stderr) for outcome in ("merged", "conflict"): @@ -102,11 +116,14 @@ def cmd_check(args): """Every baseline is what the descriptions give now (HW-003), no conflict is left, and nothing in a generated directory is an output of no description. With --strict, a board's files are exactly their baseline: a board's facts belong in its description (A126).""" - bad = 0 roots = [Path(r) for r in args.board_root] or [ROOT] + found = [] for r in roots: if not (r / "boards").is_dir(): - print(f"emb-hwgen: {r} has no boards/ directory (a board root holds boards///)", file=sys.stderr) + message = f"{r} has no boards/ directory (a board root holds boards///)" + if args.format == "json": + return _emit(args, "errors", 2, [diagnostics.finding(message, "error", None)]) + print(f"emb-hwgen: {message}", file=sys.stderr) return 2 boards = [b for r in roots for b in _boards(r)] systems = [s for r in roots for s in _systems(r)] @@ -124,59 +141,83 @@ def cmd_check(args): have = path.read_text(encoding="utf-8") if path.is_file() else None baseline = base.read_text(encoding="utf-8") if base.is_file() else None if have is None: - bad += 1 - print(f"{rel}: missing; run emb-hwgen.py generate {how}") + found.append(diagnostics.stale(rel, f"missing; run emb-hwgen.py generate {how}")) elif baseline != text: - bad += 1 - print(f"{rel}: its description changed since it was generated; run emb-hwgen.py generate {how}") - sys.stdout.writelines(difflib.unified_diff((baseline or "").splitlines(True), text.splitlines(True), - f"{rel} (generated then)", f"{rel} (generated now)")) + diff = "".join(difflib.unified_diff((baseline or "").splitlines(True), text.splitlines(True), + f"{rel} (generated then)", f"{rel} (generated now)")) + found.append(diagnostics.stale( + rel, f"its description changed since it was generated; run emb-hwgen.py generate {how}", diff)) elif writer.has_conflict(have): - bad += 1 - print(f"{rel}: an unresolved merge conflict (<<<<<<< yours ... >>>>>>> generated now)") + found.append(diagnostics.stale(rel, CONFLICT)) elif strict and have != text: - bad += 1 - print(f"{rel}: edited; a board's facts belong in its description (check --strict)") - sys.stdout.writelines(difflib.unified_diff(text.splitlines(True), have.splitlines(True), - f"{rel} (generated)", f"{rel} (committed)")) + diff = "".join(difflib.unified_diff(text.splitlines(True), have.splitlines(True), + f"{rel} (generated)", f"{rel} (committed)")) + found.append(diagnostics.stale(rel, "edited; a board's facts belong in its description (check --strict)", + diff)) if gen.is_dir(): for path in sorted(p for p in gen.rglob("*") if p.is_file() and ".base" not in p.relative_to(gen).parts): if path not in expected: - bad += 1 - print(f"{os.path.relpath(path, ROOT)}: not an output of emb-hwgen; remove it") + found.append(diagnostics.stale(os.path.relpath(path, ROOT), "not an output of emb-hwgen; remove it")) + bad = len(found) # HW-001 for the tree, or for each board root: its boards hold no hand-written files, and its # own SoC families' C no address-like constant (A84) problems = [p for r in roots for p in facts.check(r)] + code = 1 if bad or problems else 0 + if args.format == "json": + found += [diagnostics.finding(p, "error", "HW-001", with_path=False) for p in problems] + return _emit(args, "stale" if code else "ok", code, found, boards=len(boards), systems=len(systems)) + for f in found: # the file and what to do, then the diff where there is one + print(f["text"]) + if f.get("diff"): + sys.stdout.write(f["diff"]) for p in problems: print(p) print(f"emb-hwgen check: {len(boards)} board(s), {len(systems)} system(s), {bad} stale file(s), " f"{len(problems)} hardware fact(s) outside a description") - return 1 if bad or problems else 0 + return code def cmd_validate(args): _, warnings, what = _target(args) + if args.format == "json": + return _emit(args, "ok", 0, _warnings(warnings)) for w in warnings: print(f"warning: {w}", file=sys.stderr) print(f"emb-hwgen: {what} is valid") return 0 +def _warnings(warnings): + return [diagnostics.finding(w, "warning", "HW-002") for w in warnings] + + +def _emit(args, status, code, findings=(), files=(), boards=None, error=None, systems=None): + doc = diagnostics.document(args.cmd, status, code, findings, files, boards, error) + if systems is not None: + doc["systems"] = systems + sys.stdout.write(diagnostics.dumps(doc)) + return code + + def main(argv=None): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + fmt = argparse.ArgumentParser(add_help=False) + fmt.add_argument("--format", choices=("text", "json"), default="text", + help="text (default), or one JSON document for tools (hw/schemas/diagnostics.schema.json)") sub = ap.add_subparsers(dest="cmd", required=True) - g = sub.add_parser("generate", help="write a board's or an application's generated outputs") + g = sub.add_parser("generate", parents=[fmt], help="write a board's or an application's generated outputs") which = g.add_mutually_exclusive_group(required=True) which.add_argument("--board", help="the board directory, holding board.yaml") which.add_argument("--system", help="an application's system.yaml (SPEC-014 ยง5, A125)") g.add_argument("--out", help="output directory (default: generated/ beside the board.yaml or system.yaml)") - c = sub.add_parser("check", help="regenerate every board and compare with the committed outputs (HW-003), " + c = sub.add_parser("check", parents=[fmt], help="regenerate every board and compare with the committed outputs (HW-003), " "and look for hardware facts outside the descriptions (HW-001)") c.add_argument("--strict", action="store_true", help="a board's files must be exactly what was generated: no edits (the tree's boards, in CI)") c.add_argument("--board-root", action="append", default=[], help="check the boards under DIR/boards/ instead of the tree's (repeatable)") - v = sub.add_parser("validate", help="validate a board and its SoC, or a system, without writing anything") + v = sub.add_parser("validate", parents=[fmt], + help="validate a board and its SoC, or a system, without writing anything") which = v.add_mutually_exclusive_group(required=True) which.add_argument("--board") which.add_argument("--system") @@ -184,10 +225,17 @@ def main(argv=None): try: return {"generate": cmd_generate, "check": cmd_check, "validate": cmd_validate}[args.cmd](args) except HwError as e: + if args.format == "json": + return _emit(args, "errors", 2, [diagnostics.finding(line, "error", "HW-002") for line in e.errors]) for line in e.errors: print(line, file=sys.stderr) print(f"emb-hwgen: {len(e.errors)} error(s)", file=sys.stderr) return 2 + except Exception as e: # the generator's own fault: a tool still gets a document, the trace goes to stderr + if args.format != "json": + raise + traceback.print_exc() + return _emit(args, "internal-error", 3, error={"type": type(e).__name__, "message": str(e)}) if __name__ == "__main__": diff --git a/hw/gen/emb_hwgen/diagnostics.py b/hw/gen/emb_hwgen/diagnostics.py new file mode 100644 index 00000000..e943bdec --- /dev/null +++ b/hw/gen/emb_hwgen/diagnostics.py @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Junior Deogracias +# +# This file is part of EmbLinkRTOS, a deterministic real-time kernel for microcontrollers. +# Licensed under the Apache License, Version 2.0; see the LICENSE file at the repository root. + +"""The generator's findings as data, for tools that read them (`--format json`, HW-002). + +Every error and warning the generator raises is a line 'file:line: node.path: message' (HW-002); +the HW-001 check's problems are 'file[:line]: message'. Here each becomes a record with those +parts apart, and a command's outcome one JSON document (hw/schemas/diagnostics.schema.json): what +ran, its status and exit code, its findings, and the files it wrote. A line that does not have the +shape keeps its whole text as the message, so nothing is lost. +""" +import json +import re + +from . import VERSION + +FORMAT = 1 # the document's own version: a field added keeps it, one changed or removed bumps it + +# a Windows drive is part of the file name; a node path has no spaces, and its segments may be +# compatibles (bindings.st,stm32f4-iwdg.class) +_LINE = re.compile(r"^(?P(?:[A-Za-z]:)?[^:\n]+?)(?::(?P\d+))?: " + r"(?:(?P\(root\)|\(file\)|[A-Za-z_][\w,+-]*(?:\.[\w,+-]+|\[\d+\])*): )?" + r"(?P.+)$", re.S) +_FACT = re.compile(r"^(?P(?:[A-Za-z]:)?[^:\n]+?)(?::(?P\d+))?: (?P.+)$", re.S) + + +def finding(text, severity, rule, with_path=True): + """One finding from its line of text; with_path false for the HW-001 check's lines, which name + no node.""" + m = (_LINE if with_path else _FACT).match(text) + if not m: + return _record(severity, rule, None, None, None, text, text) + return _record(severity, rule, m["file"], m["line"], m.groupdict().get("path"), m["message"], text) + + +def _record(severity, rule, file, line, path, message, text): + return {"severity": severity, "rule": rule, "file": file, "line": int(line) if line else None, + "path": path, "message": message, "text": text} + + +def stale(file, message, diff=None): + """A generated file that differs from what the generator writes now (HW-003).""" + rec = _record("error", "HW-003", file, None, None, message, f"{file}: {message}") + rec["diff"] = diff + return rec + + +def document(command, status, exit_code, findings=(), files=(), boards=None, error=None): + doc = {"tool": "emb-hwgen", "version": VERSION, "format": FORMAT, "command": command, + "status": status, "exit_code": exit_code, "diagnostics": list(findings), "files": list(files)} + if boards is not None: + doc["boards"] = boards + if error is not None: + doc["error"] = error + return doc + + +def dumps(doc): + return json.dumps(doc, indent=2, sort_keys=True) + "\n" diff --git a/hw/gen/test_hwgen.py b/hw/gen/test_hwgen.py index 80972ede..6f38fb33 100644 --- a/hw/gen/test_hwgen.py +++ b/hw/gen/test_hwgen.py @@ -19,7 +19,7 @@ HERE = Path(__file__).resolve().parent ROOT = HERE.parent.parent sys.path.insert(0, str(HERE)) -from emb_hwgen import HwError, facts, generate, load, model, schema # noqa: E402 +from emb_hwgen import HwError, diagnostics, facts, generate, load, model, schema # noqa: E402 BOARD = "boards/st/nucleo_f446re" UNO = "boards/arduino/arduino_uno" @@ -29,6 +29,14 @@ " - { name: storage, base: 0x08060000, size: 128K } # sector 7") +def assert_every_error_parses(test, errors): + """--format json takes every error apart into file, line, node path and message (Diagnostics): + each must come back as its own text.""" + for e in errors: + f = diagnostics.finding(e, "error", "HW-002") + test.assertEqual(f"{f['file']}:{f['line']}: {f['path']}: {f['message']}", e) + + class Root: """A scratch repository root holding copies of the descriptions a test edits.""" @@ -78,6 +86,7 @@ def expect(self, file, line, path, message): want = f"{file}:{line}: {path}: " hits = [e for e in cm.exception.errors if e.startswith(want) and re.search(message, e)] self.assertTrue(hits, f"expected '{want}...{message}', got:\n" + "\n".join(cm.exception.errors)) + assert_every_error_parses(self, cm.exception.errors) def board_line(self, needle): return self.root.line_of(f"{BOARD}/board.yaml", needle) @@ -439,6 +448,7 @@ def expect(self, board, file, needle, path, message): want = f"{file}:{self.root.line_of(file, needle)}: {path}: " hits = [e for e in cm.exception.errors if e.startswith(want) and re.search(message, e)] self.assertTrue(hits, f"expected '{want}...{message}', got:\n" + "\n".join(cm.exception.errors)) + assert_every_error_parses(self, cm.exception.errors) def test_pin_table(self): files, _ = generate.generate(ROOT / PICO, ROOT) @@ -643,6 +653,7 @@ def expect(self, message, file=f"{BOARD}/board.yaml", needle=None, path=None): want = f"{file}:{self.root.line_of(file, needle)}: {path}: " hits = [e for e in cm.exception.errors if e.startswith(want) and re.search(message, e)] self.assertTrue(hits, f"expected '{want}...{message}', got:\n" + "\n".join(cm.exception.errors)) + assert_every_error_parses(self, cm.exception.errors) def files(self): return generate.generate(self.root.dir / BOARD, self.root.dir)[0] @@ -879,6 +890,130 @@ def test_check_board_root(self): self.assertIn("has no boards/ directory", r.stderr) +class Diagnostics(unittest.TestCase): + """--format json: one document, valid against hw/schemas/diagnostics.schema.json, for each + outcome, and nothing else on standard output.""" + + def setUp(self): + import importlib.util + spec = importlib.util.spec_from_file_location("emb_hwgen_cli", HERE / "emb-hwgen.py") + self.cli = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.cli) + self.schema = schema.load_schema("diagnostics") + + def run_json(self, *argv): + import contextlib + import io + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = self.cli.main(list(argv) + ["--format", "json"]) + doc = json.loads(out.getvalue()) + self.assertEqual(schema.validate(doc, self.schema), [], doc) + self.assertEqual(doc["exit_code"], code) + return doc + + def board(self, edit=None): + d = Path(tempfile.mkdtemp(prefix="hwgen-diag-")) + self.addCleanup(shutil.rmtree, d) + board = d / "boards" / "acme" / "acme_m4" + board.mkdir(parents=True) + text = (ROOT / "boards/arm/mps2_an386/board.yaml").read_text(encoding="utf-8") + text = text.replace("name: mps2_an386", "name: acme_m4") + if edit: + self.assertIn(edit[0], text) + text = text.replace(*edit) + (board / "board.yaml").write_text(text, encoding="utf-8") + return d, board + + def test_validate_ok_with_its_warnings(self): + doc = self.run_json("validate", "--board", str(ROOT / BOARD)) + self.assertEqual((doc["status"], doc["command"], doc["tool"]), ("ok", "validate", "emb-hwgen")) + warn = [f for f in doc["diagnostics"] if f["path"] == "cpu.mpu"] + self.assertTrue(warn and warn[0]["severity"] == "warning" and warn[0]["line"] >= 1, doc) + + def test_description_error_located(self): + d, board = self.board(("baud: 115200", "baud: fast")) + doc = self.run_json("validate", "--board", str(board)) + self.assertEqual((doc["status"], doc["exit_code"]), ("errors", 2)) + f = next(f for f in doc["diagnostics"] if f["path"] == "console.baud") + self.assertEqual((f["severity"], f["rule"]), ("error", "HW-002")) + self.assertTrue(f["file"].endswith("board.yaml")) + lines = (board / "board.yaml").read_text(encoding="utf-8").splitlines() + self.assertIn("baud: fast", lines[f["line"] - 1]) + self.assertRegex(f["message"], "expected integer") + + def test_generate_lists_its_files(self): + d, board = self.board() + doc = self.run_json("generate", "--board", str(board)) + self.assertEqual(doc["status"], "ok") + self.assertIn("hw_config.h", {Path(f).name for f in doc["files"]}) + self.assertTrue(all((ROOT / f).is_file() for f in doc["files"])) + + def test_check_stale_with_its_diff(self): + d, board = self.board() + self.run_json("generate", "--board", str(board)) + self.assertEqual(self.run_json("check", "--board-root", str(d))["status"], "ok") + text = (board / "board.yaml").read_text(encoding="utf-8") + (board / "board.yaml").write_text(text.replace("baud: 115200", "baud: 57600"), encoding="utf-8") + doc = self.run_json("check", "--board-root", str(d)) + self.assertEqual((doc["status"], doc["exit_code"], doc["boards"], doc["systems"]), ("stale", 1, 1, 0)) + stale = [f for f in doc["diagnostics"] if f["rule"] == "HW-003"] + self.assertTrue(any(f["diff"] and "57600" in f["diff"] for f in stale), doc) + + def test_a_system_validated_and_generated(self): + """--system takes --format json too (A125): an application's description, its warnings, the + files written beside it and under generated//.""" + doc = self.run_json("validate", "--system", str(ROOT / "examples/tasks/system.yaml")) + self.assertEqual((doc["status"], doc["command"]), ("ok", "validate")) + d = Path(tempfile.mkdtemp(prefix="hwgen-diag-sys-")) + self.addCleanup(shutil.rmtree, d) + system = d / "system.yaml" + system.write_text((ROOT / "examples/tasks/system.yaml").read_text(encoding="utf-8"), encoding="utf-8") + doc = self.run_json("generate", "--system", str(system)) + self.assertEqual(doc["status"], "ok") + names = {Path(f).name for f in doc["files"]} + self.assertTrue({"system.h", "main.c"} <= names, names) + self.assertEqual(self.run_json("generate", "--system", str(system))["files"], []) # unchanged + + def test_generate_reports_a_merge_conflict(self): + """An edit where the generator changed the same lines: the file keeps both, and the document + says so (A126): status stale, exit 1, an HW-003 finding for the file.""" + d, board = self.board() + self.run_json("generate", "--board", str(board)) + conf = board / "generated" / "include" / "hw_config.h" + lines = conf.read_text(encoding="utf-8").splitlines(True) + edit = next(i for i, line in enumerate(lines) if "EMB_HW_CONSOLE_BAUD" in line) + lines[edit] = "#define EMB_HW_CONSOLE_BAUD 9600u /* mine */\n" + conf.write_text("".join(lines), encoding="utf-8") + text = (board / "board.yaml").read_text(encoding="utf-8") + (board / "board.yaml").write_text(text.replace("baud: 115200", "baud: 57600"), encoding="utf-8") + doc = self.run_json("generate", "--board", str(board)) + self.assertEqual((doc["status"], doc["exit_code"]), ("stale", 1)) + self.assertTrue(any(f["rule"] == "HW-003" and "merge conflict" in f["message"] for f in doc["diagnostics"]), doc) + + def test_check_without_boards(self): + d, board = self.board() + doc = self.run_json("check", "--board-root", str(board)) + self.assertEqual((doc["status"], doc["exit_code"]), ("errors", 2)) + self.assertIn("has no boards/ directory", doc["diagnostics"][0]["message"]) + + def test_internal_error_is_still_a_document(self): + from unittest import mock + with mock.patch.object(self.cli.generate, "generate", side_effect=RuntimeError("boom")): + doc = self.run_json("validate", "--board", str(ROOT / BOARD)) + self.assertEqual((doc["status"], doc["exit_code"]), ("internal-error", 3)) + self.assertEqual(doc["error"], {"type": "RuntimeError", "message": "boom"}) + + def test_findings_of_each_shape(self): + f = diagnostics.finding(r"C:\p\b.yaml:4: devices[2].cs: no such pin", "error", "HW-002") + self.assertEqual((f["file"], f["line"], f["path"]), (r"C:\p\b.yaml", 4, "devices[2].cs")) + f = diagnostics.finding("soc/x/board.c:7: address-like constant 0x40004000u; describe it", "error", + "HW-001", with_path=False) + self.assertEqual((f["line"], f["path"], f["message"][:12]), (7, None, "address-like")) + f = diagnostics.finding("no shape at all", "error", None) + self.assertEqual((f["file"], f["message"]), (None, "no shape at all")) + + class Avr(unittest.TestCase): """The ATmega328P and the Arduino Uno: separate address spaces, vectors, fixed-function pins, page-sized erase units, and HW-006's second linker output.""" @@ -946,6 +1081,7 @@ def expect(self, file, line, path, message): want = f"{file}:{line}: {path}: " hits = [e for e in cm.exception.errors if e.startswith(want) and re.search(message, e)] self.assertTrue(hits, f"expected '{want}...{message}', got:\n" + "\n".join(cm.exception.errors)) + assert_every_error_parses(self, cm.exception.errors) def test_fixed_pin_with_another_signal(self): b = f"{UNO}/board.yaml" @@ -1161,7 +1297,7 @@ def test_every_part_validates(self): class Schemas(unittest.TestCase): def test_subset_only(self): - for kind in ("soc", "board", "hw", "bindings", "system"): + for kind in ("soc", "board", "hw", "bindings", "system", "diagnostics"): schema.load_schema(kind) # raises on a keyword outside the supported subset def test_bool_is_not_integer(self): diff --git a/hw/schemas/diagnostics.schema.json b/hw/schemas/diagnostics.schema.json new file mode 100644 index 00000000..769ed342 --- /dev/null +++ b/hw/schemas/diagnostics.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://emblinkos.org/schemas/hw/0/diagnostics.schema.json", + "title": "emb-hwgen's outcome, format 1 (--format json, HW-002)", + "description": "What one emb-hwgen command did, for tools and IDEs: its status and exit code, every finding with its file, line, node path and message apart, and the files it wrote. Format 1: a field added keeps the format, a field changed or removed bumps it.", + "type": "object", + "required": ["tool", "version", "format", "command", "status", "exit_code", "diagnostics", "files"], + "additionalProperties": false, + "properties": { + "tool": { "const": "emb-hwgen" }, + "version": { "type": "string", "description": "emb-hwgen's own version" }, + "format": { "const": 1 }, + "command": { "enum": ["generate", "check", "validate"] }, + "status": { + "enum": ["ok", "errors", "stale", "internal-error"], + "description": "ok (exit 0, warnings possible); errors (exit 2: the descriptions are wrong, or check found no boards/); stale (exit 1: check found generated files that differ, or hardware facts outside a description; generate left a merge conflict in an edited file, A126); internal-error (exit 3: the generator itself failed, see error)" + }, + "exit_code": { "enum": [0, 1, 2, 3] }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/finding" } }, + "files": { + "type": "array", + "items": { "type": "string" }, + "description": "generate: the files written, relative to the repository root" + }, + "boards": { "type": "integer", "minimum": 0, "description": "check: the boards checked" }, + "systems": { "type": "integer", "minimum": 0, "description": "check: the system descriptions checked (A125)" }, + "error": { + "type": "object", + "required": ["type", "message"], + "additionalProperties": false, + "properties": { + "type": { "type": "string", "description": "the Python exception's class" }, + "message": { "type": "string" } + } + } + }, + "$defs": { + "finding": { + "type": "object", + "required": ["severity", "rule", "file", "line", "path", "message", "text"], + "additionalProperties": false, + "properties": { + "severity": { "enum": ["error", "warning"] }, + "rule": { + "type": ["string", "null"], + "description": "HW-002 (a description error or warning), HW-001 (a hardware fact outside a description), HW-003 (a generated file out of date), or null" + }, + "file": { "type": ["string", "null"], "description": "relative to the repository root, or absolute for a board outside it" }, + "line": { "type": ["integer", "null"], "minimum": 1 }, + "path": { + "type": ["string", "null"], + "description": "the node in the description, dotted (console.baud, devices[2].cs), (root) or (file)" + }, + "message": { "type": "string" }, + "text": { "type": "string", "description": "the finding as the text output prints it" }, + "diff": { + "type": ["string", "null"], + "description": "HW-003 only: a unified diff from the committed file to what emb-hwgen generates now" + } + } + } + } +}