Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
# Changelog

## 0.3.0 — 2026-11-05

### Added

- **Faults from missing lines**: 10 new built-in faults fire when a line that
good boards print is gone: eMMC/SD card, SATA, NVMe, Ethernet driver and
link, USB device, PCIe link, RTC, SPI flash, CAN. A line that only changed a
value does not count: the DUT log must have fewer matching lines than the
golden log.
- `boot-incomplete`: the DUT log stops before the end of the golden log. The
lines it never got to are reported once, not as one fault per driver.
- `sata-link`: COMRESET failures, link down, reduced link speed.
- Rule files: `match = "missing"` or `"unreached"` (default `"new"`), and
`unless = [ids]` to silence a `missing` rule that another fault explains.
- Python API: `Rule.match`, `Rule.unless`, `Fault.log`.
- JSON output: faults have `log` (`dut` or `golden`), the log their line
numbers refer to.

### Changed

- Reports name the log of a fault's lines (`golden line 11`, `dut line 62`).

## 0.2.0 — 2026-10-29

### Added
Expand Down
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ every kernel timestamp differs. boot-log-diff:
board model, U-Boot and kernel version, plus your own
- **flags error-looking lines** — new lines with *error*, *fail*, *timeout*,
*unable*, *not found* and the like are marked with `!`
- **diagnoses known faults** — 23 built-in patterns for DRAM, eMMC/SD, NAND,
- **diagnoses known faults** — 35 built-in patterns for DRAM, eMMC/SD, NAND,
SPI flash, Ethernet PHY, USB, I2C, PCIe, supplies, temperature, file system
and kernel crashes, each with what to check on the board; add your own
- **fits into CI and test scripts** — exit status 0/1/2, JSON, JUnit XML and
Expand Down Expand Up @@ -168,6 +168,15 @@ memory ECC errors, missing firmware and CPU stalls. `boot-log-diff --help`
lists their ids; the definitions are in
[`faults.toml`](src/boot_log_diff/faults.toml).

Some faults print nothing at all: a driver that no longer loads just leaves a
gap. So there are also faults for lines that are *gone* (`match = "missing"`):
eMMC/SD card, SATA, NVMe, Ethernet driver and link, USB device, PCIe link, RTC,
SPI flash and CAN. They fire only if the DUT log has fewer matching lines than
the golden log, so a link at a lower speed is not a missing link. If a more
specific fault was found (`unless`), the missing line does not get a second
diagnosis. And if the log stops early, the lines it never got to are reported
once as `boot-incomplete` (`match = "unreached"`), not as one fault per driver.

Add what you know about your own boards in a TOML file:

```toml
Expand All @@ -185,6 +194,14 @@ title = "Ethernet PHY not found"
pattern = 'unable to connect to phy'
diagnosis = "On rev C boards: R212 (PHY reset pull-up) is often cracked."
severity = "warning"

[[fault]]
id = "fpga-driver-missing"
title = "FPGA driver no longer loads"
match = "missing" # a golden line that is gone from the DUT log
pattern = 'fpga_manager fpga\d+: .* registered'
unless = ["fpga-config"] # that fault already explains it
diagnosis = "Compare the device tree and check that the FPGA module is in the image."
```

```console
Expand Down Expand Up @@ -247,8 +264,8 @@ from `load_rules(path)` or `parse_rules(text)`; pass them as
- The error flag and the known faults are pattern matches, not an
understanding of the message. A diagnosis says where to look first; it
is not a verdict.
- Known faults only look at new lines. A fault that shows as a *missing*
line (a driver that no longer probes) is in the diff, without a diagnosis.
- A missing-line fault needs a rule that knows the line. Other missing lines
are in the diff, without a diagnosis.
- One golden log per comparison. If healthy boards differ among themselves
(other eMMC vendor, optional module), mask or ignore that line.

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "boot-log-diff"
version = "0.2.0"
version = "0.3.0"
description = "Compare the serial boot log of an embedded board with a golden log, ignoring boot-to-boot noise, and diagnose known faults"
readme = "README.md"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion src/boot_log_diff/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from .faults import BUILTIN_RULES, Fault, Rule, load_rules, parse_rules
from .normalize import BUILTIN_MASKS, Mask, Normalizer

__version__ = "0.2.0"
__version__ = "0.3.0"

__all__ = [
"BUILTIN_FACTS",
Expand Down
29 changes: 26 additions & 3 deletions src/boot_log_diff/compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
from collections import Counter
from collections.abc import Iterable
from dataclasses import dataclass, field
from difflib import SequenceMatcher
from difflib import SequenceMatcher, get_close_matches

from .faults import BUILTIN_RULES, Fault, Rule, find_faults, unique_rules
from .faults import BUILTIN_RULES, Fault, Rule, find_faults, find_missing, find_unreached, unique_rules
from .normalize import Normalizer, clean

# Words that make a new line worth a second look.
Expand Down Expand Up @@ -146,6 +146,18 @@ def _take_moved(hunks: list[Hunk]) -> list[tuple[Line, Line]]:
return moved


def _unreached(tail: Hunk | None) -> list[Line]:
"""The golden lines at the end of the log that the DUT never printed.

Empty if the DUT log merely has another last line, such as a host name
that differs from the golden board's.
"""
if tail is None:
return []
added = [line.normalized for line in tail.added]
return [line for line in tail.removed if not get_close_matches(line.normalized, added, n=1, cutoff=0.6)]


def compare(
golden: str,
dut: str,
Expand All @@ -161,7 +173,11 @@ def compare(
With `strict_order` they show up as missing and new lines instead.

New DUT lines are matched against `rules` (known faults); a line counts
for the first rule that matches. Pass `rules=()` to skip that.
for the first rule that matches. Golden lines gone from the DUT log are
matched against the rules with `match = "missing"`. If the DUT log stopped
early, the lines it never got to go to the `match = "unreached"` rules
instead: they say nothing about the drivers that print them.
Pass `rules=()` to skip all of that.
"""
normalizer = normalizer or Normalizer()
facts = list(facts)
Expand All @@ -187,7 +203,14 @@ def compare(
for tag, i1, i2, j1, j2 in matcher.get_opcodes()
if tag != "equal"
]
# A last hunk that reaches the end of the golden log: the DUT may have stopped there.
tail = hunks[-1] if hunks and hunks[-1].removed and matcher.get_opcodes()[-1][0] != "equal" else None
moved = [] if strict_order else _take_moved(hunks)
rules = unique_rules(rules)
faults = find_faults((line for hunk in hunks for line in hunk.added), rules)
unreached = _unreached(tail)
skip = {id(line) for line in tail.removed} if unreached else set()
removed = (line for hunk in hunks for line in hunk.removed if id(line) not in skip)
faults += find_missing(removed, golden_lines, dut_lines, rules, (fault.rule.id for fault in faults))
faults += find_unreached(unreached, rules)
return Result(fact_diffs, hunks, moved, faults, [fact.name for fact in facts], rules)
87 changes: 82 additions & 5 deletions src/boot_log_diff/faults.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,27 @@
from .compare import Line

SEVERITIES = ("error", "warning")
_FIELDS = {"id", "title", "pattern", "diagnosis", "severity"}
MATCHES = ("new", "missing", "unreached")
_FIELDS = {"id", "title", "pattern", "diagnosis", "severity", "match", "unless"}


@dataclass(frozen=True)
class Rule:
"""A known fault: lines matching `pattern` get `title` and `diagnosis`."""
"""A known fault: lines matching `pattern` get `title` and `diagnosis`.

`match` says which lines the pattern is applied to: "new" lines of the DUT
log, golden lines "missing" from the DUT log, or golden lines "unreached"
because the DUT log stopped early. A "missing" rule stays silent if a fault
listed in `unless` was found, because that fault already explains the line.
"""

id: str
title: str
pattern: re.Pattern[str]
diagnosis: str
severity: str = "error"
match: str = "new"
unless: tuple[str, ...] = ()


@dataclass
Expand All @@ -39,6 +48,11 @@ class Fault:
rule: Rule
lines: list[Line] = field(default_factory=list)

@property
def log(self) -> str:
"""The log the line numbers refer to: "dut" or "golden"."""
return "dut" if self.rule.match == "new" else "golden"


def parse_rules(text: str, source: str = "<rules>") -> list[Rule]:
"""Read rules from TOML text with one [[fault]] table per rule."""
Expand All @@ -61,12 +75,28 @@ def parse_rules(text: str, source: str = "<rules>") -> list[Rule]:
severity = entry.get("severity", "error")
if severity not in SEVERITIES:
raise ValueError(f"{where} ({entry['id']}): severity must be error or warning")
match = entry.get("match", "new")
if match not in MATCHES:
raise ValueError(f"{where} ({entry['id']}): match must be new, missing or unreached")
unless = entry.get("unless", [])
if not isinstance(unless, list) or not all(isinstance(item, str) for item in unless):
raise ValueError(f"{where} ({entry['id']}): unless must be a list of fault ids")
if unless and match != "missing":
raise ValueError(f"{where} ({entry['id']}): unless only works with match = \"missing\"")
try:
pattern = re.compile(entry["pattern"], re.IGNORECASE)
except re.error as exc:
raise ValueError(f"{where} ({entry['id']}): pattern: {exc}") from None
rules.append(
Rule(entry["id"], entry["title"], pattern, " ".join(entry["diagnosis"].split()), severity)
Rule(
entry["id"],
entry["title"],
pattern,
" ".join(entry["diagnosis"].split()),
severity,
match,
tuple(unless),
)
)
return rules

Expand Down Expand Up @@ -95,17 +125,64 @@ def unique_rules(rules: Iterable[Rule]) -> list[Rule]:


def find_faults(lines: Iterable[Line], rules: Iterable[Rule]) -> list[Fault]:
"""Match each line against the rules; a line counts for the first rule only.
"""Match each new DUT line against the "new" rules; a line counts for the first only.

Of several rules with the same id only the first is used, so own rules
placed before the built-in ones replace them. Faults are returned in the
order they first show up in the log.
"""
rules = unique_rules(rules)
rules = [rule for rule in unique_rules(rules) if rule.match == "new"]
faults = {rule.id: Fault(rule) for rule in rules}
for line in lines:
for rule in rules:
if rule.pattern.search(line.text):
faults[rule.id].lines.append(line)
break
return sorted((fault for fault in faults.values() if fault.lines), key=lambda f: f.lines[0].number)


def _count(lines: list[Line], rule: Rule) -> int:
return sum(1 for line in lines if rule.pattern.search(line.text))


def find_missing(
removed: Iterable[Line],
golden: Iterable[Line],
dut: Iterable[Line],
rules: Iterable[Rule],
found: Iterable[str] = (),
) -> list[Fault]:
"""Match golden lines gone from the DUT log against the "missing" rules.

A rule fires only if the DUT log has fewer matching lines than the golden
log. A line that merely changed a value (a link at a lower speed) still
matches, so it does not count as a device that went away. Rules are checked
in order and see the faults of earlier rules through `unless`.
"""
removed, golden, dut = list(removed), list(golden), list(dut)
found = set(found)
taken: set[int] = set()
faults = []
for rule in unique_rules(rules):
if rule.match != "missing" or found.intersection(rule.unless):
continue
if _count(golden, rule) <= _count(dut, rule):
continue
lines = [line for line in removed if id(line) not in taken and rule.pattern.search(line.text)]
if lines:
taken.update(id(line) for line in lines)
faults.append(Fault(rule, lines))
found.add(rule.id)
return faults


def find_unreached(lines: Iterable[Line], rules: Iterable[Rule]) -> list[Fault]:
"""Match golden lines the DUT log never got to; each rule keeps the first one."""
lines = list(lines)
faults = []
for rule in unique_rules(rules):
if rule.match == "unreached":
first = next((line for line in lines if rule.pattern.search(line.text)), None)
if first is not None:
faults.append(Fault(rule, [first]))
return faults
Loading
Loading