Replace findings list with a tree; filter ESD/PI false positives.
PE-ESD-001 only on J* connector–IC nets (skip NC, unconnected, VSYS/GND/3V3). PE-PI-001 treats any capacitor on the rail, including KiCad slash prefixes, as decoupling. Sort findings ERROR then WARNING then INFO, then RULE/RISK/REVIEW/INFO. Report sidebar is a collapsed expand-on-click tree instead of an all-open list. GET /report and complete_findings fail-soft and sort the same way.
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
"""ESD on connector nets and HS return path — REVIEW unless a MANDATORY FACT exists."""
|
"""ESD on connector↔IC paths and HS return path — REVIEW unless a MANDATORY FACT exists."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
from backend.periscopex.models import (
|
from backend.periscopex.models import (
|
||||||
ComponentConstraints,
|
ComponentConstraints,
|
||||||
ComponentType,
|
ComponentType,
|
||||||
@@ -10,13 +12,22 @@ from backend.periscopex.models import (
|
|||||||
LayoutGraph,
|
LayoutGraph,
|
||||||
NetType,
|
NetType,
|
||||||
)
|
)
|
||||||
from backend.periscopex.pcb_net_match import kicad_nets_match, normalize_kicad_hierarchy_net
|
from backend.periscopex.pcb_net_match import (
|
||||||
|
kicad_nets_match,
|
||||||
|
normalize_kicad_hierarchy_net,
|
||||||
|
refs_on_matched_net,
|
||||||
|
)
|
||||||
from backend.periscopex.pcb_power_thermal import _HS_NET_RE, _is_gnd_name
|
from backend.periscopex.pcb_power_thermal import _HS_NET_RE, _is_gnd_name
|
||||||
from backend.periscopex.validate import _match_constraints
|
|
||||||
|
|
||||||
_USB_RE = __import__("re").compile(
|
_J_REF_RE = re.compile(r"^J\d+", re.I)
|
||||||
r"(USB|DP|DM|D\+|D-|VBUS|CC1|CC2|HDMI|ESD)",
|
_UNCONNECTED_RE = re.compile(r"^unconnected", re.I)
|
||||||
__import__("re").I,
|
_NC_NET_RE = re.compile(
|
||||||
|
r"^(?:n/?c|n\.c\.|nc|unconnected|no[_-]?connect|not[_-]?connected)$",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
_ONBOARD_POWER_RE = re.compile(
|
||||||
|
r"^(?:GND|AGND|DGND|PGND|GNDA|VSYS|3V3|\+3V3|3\.3V)$",
|
||||||
|
re.I,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -29,68 +40,104 @@ def _is_esd_part(comp) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _net_leaf(net: str) -> str:
|
||||||
|
n = normalize_kicad_hierarchy_net(net)
|
||||||
|
return n.split("/")[-1] if n else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _skip_esd_net(net: str) -> bool:
|
||||||
|
leaf = _net_leaf(net)
|
||||||
|
if not leaf:
|
||||||
|
return True
|
||||||
|
if _UNCONNECTED_RE.match(leaf) or _NC_NET_RE.match(leaf):
|
||||||
|
return True
|
||||||
|
if _ONBOARD_POWER_RE.match(leaf):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_j_connector(comp) -> bool:
|
||||||
|
if comp.component_type != ComponentType.CONNECTOR:
|
||||||
|
return False
|
||||||
|
return bool(_J_REF_RE.match(comp.reference or ""))
|
||||||
|
|
||||||
|
|
||||||
def check_esd(
|
def check_esd(
|
||||||
graph: DesignGraph,
|
graph: DesignGraph,
|
||||||
constraints_map: dict[str, ComponentConstraints] | None = None,
|
constraints_map: dict[str, ComponentConstraints] | None = None,
|
||||||
) -> list[Finding]:
|
) -> list[Finding]:
|
||||||
"""Connector / USB net without an ESD part → REVIEW. On-die clamp is not a RULE."""
|
"""One REVIEW per J* connector → IC net with no ESD part. No GPIO/NC/rail spam."""
|
||||||
cmap = constraints_map or {}
|
_ = constraints_map
|
||||||
out: list[Finding] = []
|
out: list[Finding] = []
|
||||||
seen: set[str] = set()
|
seen: set[tuple[str, str, str]] = set()
|
||||||
connector_nets: set[str] = set()
|
|
||||||
for comp in graph.components.values():
|
connectors: list[tuple[str, object]] = [
|
||||||
if comp.component_type != ComponentType.CONNECTOR:
|
(ref, comp)
|
||||||
continue
|
for ref, comp in sorted(graph.components.items())
|
||||||
connector_nets.update(n for n in comp.pins.values() if n)
|
if _is_j_connector(comp)
|
||||||
for ref, comp in sorted(graph.components.items()):
|
]
|
||||||
if comp.component_type != ComponentType.IC:
|
|
||||||
continue
|
for j_ref, j_comp in connectors:
|
||||||
cons = _match_constraints(comp.mpn or comp.value, cmap)
|
for j_pin, net in sorted(j_comp.pins.items(), key=lambda x: str(x[0])):
|
||||||
clamp = list((cons.internal_features.esd_clamp_pins if cons and cons.internal_features else []) or [])
|
if not net or _skip_esd_net(net):
|
||||||
for pin_num, net in sorted(comp.pins.items(), key=lambda x: str(x[0])):
|
|
||||||
if not net or net in seen:
|
|
||||||
continue
|
continue
|
||||||
interesting = net in connector_nets or bool(_USB_RE.search(net))
|
net_key = normalize_kicad_hierarchy_net(net)
|
||||||
if clamp:
|
on_net = refs_on_matched_net(graph, net)
|
||||||
tokens = [net, str(pin_num)]
|
if any(
|
||||||
if cons:
|
r in graph.components and _is_esd_part(graph.components[r])
|
||||||
pin = cons.pin_by_number(pin_num)
|
for r in on_net
|
||||||
if pin and pin.name:
|
):
|
||||||
tokens.append(pin.name)
|
|
||||||
if any(c.upper() in t.upper() for c in clamp for t in tokens):
|
|
||||||
interesting = True
|
|
||||||
if not interesting:
|
|
||||||
continue
|
continue
|
||||||
seen.add(net)
|
ics = [
|
||||||
if any(_is_esd_part(graph.components[r]) for r in graph.components_on_net(net) if r in graph.components):
|
r for r in on_net
|
||||||
|
if r in graph.components
|
||||||
|
and graph.components[r].component_type == ComponentType.IC
|
||||||
|
]
|
||||||
|
if not ics:
|
||||||
continue
|
continue
|
||||||
rec = (
|
for ic_ref in sorted(ics):
|
||||||
f"Add a datasheet-specified ESD device on {net}, or record a "
|
path = (j_ref, ic_ref, net_key)
|
||||||
"designer decision if the connector is unused."
|
if path in seen:
|
||||||
)
|
continue
|
||||||
out.append(Finding(
|
seen.add(path)
|
||||||
designator=ref,
|
ic = graph.components[ic_ref]
|
||||||
mpn=comp.mpn or "",
|
ic_pin = next(
|
||||||
aspect="esd",
|
(str(p) for p, n in ic.pins.items() if n and kicad_nets_match(n, net)),
|
||||||
finding=f"{net} reaches {ref} with no ESD/protection part on the net.",
|
"",
|
||||||
facts=f"Net {net}; ESD parts on net: 0; connector={net in connector_nets}.",
|
)
|
||||||
requirement=(
|
rec = (
|
||||||
"External ESD is recommended unless the extraction lists a "
|
f"Add a datasheet-specified ESD device on {net} between "
|
||||||
"mandatory clamp requirement with a measured FACT."
|
f"{j_ref} and {ic_ref}."
|
||||||
),
|
)
|
||||||
inference="REVIEW — on-die esd_clamp_pins are not a board RULE.",
|
out.append(Finding(
|
||||||
why="No invented IEC 61000 level.",
|
designator=ic_ref,
|
||||||
status="WARNING",
|
mpn=ic.mpn or "",
|
||||||
recommendation=rec,
|
aspect="esd",
|
||||||
action=rec,
|
finding=(
|
||||||
source="esd_return_check",
|
f"{net} is a {j_ref}–{ic_ref} path with no ESD/"
|
||||||
rule_id="PE-ESD-001",
|
"protection part on the net."
|
||||||
finding_class="REVIEW",
|
),
|
||||||
provenance="RECOMMENDED",
|
facts=(
|
||||||
evidence_status="SUFFICIENT",
|
f"Net {net}; connector={j_ref}.{j_pin}; IC={ic_ref}; "
|
||||||
net=net,
|
"ESD parts on net: 0."
|
||||||
pins=[f"{ref}.{pin_num}"],
|
),
|
||||||
))
|
requirement=(
|
||||||
|
"External ESD is recommended on connector–IC nets unless "
|
||||||
|
"the extraction lists a mandatory clamp with a measured FACT."
|
||||||
|
),
|
||||||
|
inference="REVIEW — on-die esd_clamp_pins are not a board RULE.",
|
||||||
|
why="Only J* ∩ IC nets; NC, unconnected, VSYS/GND/3V3 excluded.",
|
||||||
|
status="WARNING",
|
||||||
|
recommendation=rec,
|
||||||
|
action=rec,
|
||||||
|
source="esd_return_check",
|
||||||
|
rule_id="PE-ESD-001",
|
||||||
|
finding_class="REVIEW",
|
||||||
|
provenance="RECOMMENDED",
|
||||||
|
evidence_status="SUFFICIENT",
|
||||||
|
net=net,
|
||||||
|
pins=[p for p in (f"{j_ref}.{j_pin}", f"{ic_ref}.{ic_pin}" if ic_pin else "") if p],
|
||||||
|
))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -111,7 +158,8 @@ def check_return_path(
|
|||||||
out: list[Finding] = []
|
out: list[Finding] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for net_name, net in sorted(graph.nets.items()):
|
for net_name, net in sorted(graph.nets.items()):
|
||||||
if net.net_type != NetType.SIGNAL:
|
ntype = getattr(net.net_type, "value", net.net_type)
|
||||||
|
if ntype != NetType.SIGNAL and ntype != "signal":
|
||||||
continue
|
continue
|
||||||
if not _HS_NET_RE.search(net_name or ""):
|
if not _HS_NET_RE.search(net_name or ""):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ never becomes ERROR. LLM output is REVIEW, never RULE.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Iterable, Literal
|
from typing import Any, Iterable, Literal
|
||||||
|
|
||||||
@@ -14,6 +15,11 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from backend.periscopex.models import Finding
|
from backend.periscopex.models import Finding
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_STATUS_ORDER = {"ERROR": 0, "WARNING": 1, "INFO": 2}
|
||||||
|
_CLASS_ORDER = {"RULE": 0, "RISK": 1, "REVIEW": 2, "INFO": 3}
|
||||||
|
|
||||||
Provenance = Literal["MANDATORY", "RECOMMENDED", "TYPICAL", "EXAMPLE"]
|
Provenance = Literal["MANDATORY", "RECOMMENDED", "TYPICAL", "EXAMPLE"]
|
||||||
FindingClass = Literal["RULE", "RISK", "REVIEW", "INFO"]
|
FindingClass = Literal["RULE", "RISK", "REVIEW", "INFO"]
|
||||||
EvidenceStatus = Literal["SUFFICIENT", "INSUFFICIENT"]
|
EvidenceStatus = Literal["SUFFICIENT", "INSUFFICIENT"]
|
||||||
@@ -256,9 +262,31 @@ def complete_finding(f: Finding) -> Finding:
|
|||||||
return f
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def sort_findings(findings: list[Finding]) -> list[Finding]:
|
||||||
|
"""ERROR then WARNING then INFO; within that RULE > RISK > REVIEW > INFO."""
|
||||||
|
findings.sort(
|
||||||
|
key=lambda f: (
|
||||||
|
_STATUS_ORDER.get(f.status or "INFO", 9),
|
||||||
|
_CLASS_ORDER.get(f.finding_class or "INFO", 9),
|
||||||
|
f.designator or "",
|
||||||
|
f.rule_id or "",
|
||||||
|
f.finding or "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
def complete_findings(findings: list[Finding]) -> None:
|
def complete_findings(findings: list[Finding]) -> None:
|
||||||
for f in findings:
|
for f in findings:
|
||||||
complete_finding(f)
|
try:
|
||||||
|
complete_finding(f)
|
||||||
|
except Exception:
|
||||||
|
log.exception(
|
||||||
|
"complete_finding failed for %s/%s",
|
||||||
|
getattr(f, "designator", "?"),
|
||||||
|
getattr(f, "rule_id", None),
|
||||||
|
)
|
||||||
|
sort_findings(findings)
|
||||||
|
|
||||||
|
|
||||||
def apply_decisions(
|
def apply_decisions(
|
||||||
|
|||||||
@@ -67,12 +67,14 @@ def build_hierarchy(
|
|||||||
ref_block[g.ref] = bid
|
ref_block[g.ref] = bid
|
||||||
else:
|
else:
|
||||||
for net in graph.nets.values():
|
for net in graph.nets.values():
|
||||||
if net.net_type.value != "power":
|
ntype = getattr(net.net_type, "value", net.net_type)
|
||||||
|
if str(ntype) != "power":
|
||||||
continue
|
continue
|
||||||
ics = [
|
ics = [
|
||||||
p.component_ref for p in net.pins
|
p.component_ref for p in net.pins
|
||||||
if (graph.components.get(p.component_ref)
|
if (graph.components.get(p.component_ref)
|
||||||
and graph.components[p.component_ref].component_type.value == "ic")
|
and str(getattr(graph.components[p.component_ref].component_type, "value",
|
||||||
|
graph.components[p.component_ref].component_type)) == "ic")
|
||||||
]
|
]
|
||||||
if not ics:
|
if not ics:
|
||||||
continue
|
continue
|
||||||
@@ -103,7 +105,7 @@ def build_hierarchy(
|
|||||||
break
|
break
|
||||||
comps.append(HierarchyComponent(
|
comps.append(HierarchyComponent(
|
||||||
ref=ref,
|
ref=ref,
|
||||||
component_type=comp.component_type.value,
|
component_type=str(getattr(comp.component_type, "value", comp.component_type)),
|
||||||
pins=pins,
|
pins=pins,
|
||||||
nets=nets,
|
nets=nets,
|
||||||
block_id=bid,
|
block_id=bid,
|
||||||
@@ -120,7 +122,7 @@ def build_hierarchy(
|
|||||||
break
|
break
|
||||||
hnets.append(HierarchyNet(
|
hnets.append(HierarchyNet(
|
||||||
name=name,
|
name=name,
|
||||||
net_type=net.net_type.value,
|
net_type=str(getattr(net.net_type, "value", net.net_type)),
|
||||||
components=refs,
|
components=refs,
|
||||||
block_id=bid,
|
block_id=bid,
|
||||||
))
|
))
|
||||||
@@ -131,7 +133,7 @@ def check_hierarchy(graph: DesignGraph, plan: FunctionalGroupsReport | None = No
|
|||||||
"""FACT: IC pin with an empty net. Skip unnamed power-flags."""
|
"""FACT: IC pin with an empty net. Skip unnamed power-flags."""
|
||||||
out: list[Finding] = []
|
out: list[Finding] = []
|
||||||
for ref, comp in sorted(graph.components.items()):
|
for ref, comp in sorted(graph.components.items()):
|
||||||
if comp.component_type.value != "ic":
|
if str(getattr(comp.component_type, "value", comp.component_type)) != "ic":
|
||||||
continue
|
continue
|
||||||
dangling = [str(n) for n, net in comp.pins.items() if not (net or "").strip()]
|
dangling = [str(n) for n, net in comp.pins.items() if not (net or "").strip()]
|
||||||
if not dangling:
|
if not dangling:
|
||||||
|
|||||||
@@ -22,6 +22,21 @@ def normalize_kicad_hierarchy_net(name: str) -> str:
|
|||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def refs_on_matched_net(graph: DesignGraph, net_name: str) -> list[str]:
|
||||||
|
"""Component refs on *net_name*, including KiCad ``/``-prefixed aliases."""
|
||||||
|
refs: set[str] = set()
|
||||||
|
if not net_name:
|
||||||
|
return []
|
||||||
|
for name, net in graph.nets.items():
|
||||||
|
if name == net_name or kicad_nets_match(name, net_name):
|
||||||
|
refs.update(pc.component_ref for pc in net.pins)
|
||||||
|
for ref, comp in graph.components.items():
|
||||||
|
for pin_net in comp.pins.values():
|
||||||
|
if pin_net and (pin_net == net_name or kicad_nets_match(pin_net, net_name)):
|
||||||
|
refs.add(ref)
|
||||||
|
return sorted(refs)
|
||||||
|
|
||||||
|
|
||||||
def kicad_nets_match(a: str, b: str) -> bool:
|
def kicad_nets_match(a: str, b: str) -> bool:
|
||||||
"""True if schematic and PCB names are the same KiCad net.
|
"""True if schematic and PCB names are the same KiCad net.
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ from backend.periscopex.models import (
|
|||||||
LayoutGraph,
|
LayoutGraph,
|
||||||
)
|
)
|
||||||
from backend.periscopex.passive_rail_check import _is_ic_supply_pin
|
from backend.periscopex.passive_rail_check import _is_ic_supply_pin
|
||||||
|
from backend.periscopex.pcb_net_match import (
|
||||||
|
normalize_kicad_hierarchy_net,
|
||||||
|
refs_on_matched_net,
|
||||||
|
)
|
||||||
from backend.periscopex.validate import _match_constraints
|
from backend.periscopex.validate import _match_constraints
|
||||||
|
|
||||||
_LOAD_KEYS = ("i_load_a", "i_load", "iout", "i_out")
|
_LOAD_KEYS = ("i_load_a", "i_load", "iout", "i_out")
|
||||||
@@ -78,15 +82,18 @@ def check_power_integrity(
|
|||||||
cons = _match_constraints(comp.mpn or comp.value, cmap)
|
cons = _match_constraints(comp.mpn or comp.value, cmap)
|
||||||
i_load = _i_load(comp)
|
i_load = _i_load(comp)
|
||||||
for pin_num, net in sorted(comp.pins.items(), key=lambda x: str(x[0])):
|
for pin_num, net in sorted(comp.pins.items(), key=lambda x: str(x[0])):
|
||||||
if not net or net in seen:
|
if not net:
|
||||||
|
continue
|
||||||
|
net_key = normalize_kicad_hierarchy_net(net)
|
||||||
|
if net_key in seen:
|
||||||
continue
|
continue
|
||||||
if not _is_ic_supply_pin(graph, cons, pin_num, net):
|
if not _is_ic_supply_pin(graph, cons, pin_num, net):
|
||||||
continue
|
continue
|
||||||
seen.add(net)
|
seen.add(net_key)
|
||||||
locals_: list[tuple[str, float]] = []
|
locals_: list[tuple[str, float]] = []
|
||||||
bulks: list[tuple[str, float]] = []
|
bulks: list[tuple[str, float]] = []
|
||||||
unvalued = 0
|
unvalued = 0
|
||||||
for cref in graph.components_on_net(net):
|
for cref in refs_on_matched_net(graph, net):
|
||||||
ccomp = graph.components.get(cref)
|
ccomp = graph.components.get(cref)
|
||||||
if not ccomp:
|
if not ccomp:
|
||||||
continue
|
continue
|
||||||
@@ -99,7 +106,8 @@ def check_power_integrity(
|
|||||||
bulks.append((cref, farads))
|
bulks.append((cref, farads))
|
||||||
else:
|
else:
|
||||||
locals_.append((cref, farads))
|
locals_.append((cref, farads))
|
||||||
if not locals_ and unvalued == 0:
|
# Any capacitor on the rail (local, bulk, or unvalued) is decoupling.
|
||||||
|
if not locals_ and not bulks and unvalued == 0:
|
||||||
rec = f"Add a local decoupling capacitor on {net} at {ref}."
|
rec = f"Add a local decoupling capacitor on {net} at {ref}."
|
||||||
facts = f"{ref} supply {net}: 0 local caps (<1 µF); bulk={len(bulks)}."
|
facts = f"{ref} supply {net}: 0 local caps (<1 µF); bulk={len(bulks)}."
|
||||||
if i_load is not None:
|
if i_load is not None:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -15,6 +16,7 @@ from backend.periscopex.finding_engine import (
|
|||||||
apply_decisions,
|
apply_decisions,
|
||||||
complete_findings,
|
complete_findings,
|
||||||
decision_from_review,
|
decision_from_review,
|
||||||
|
sort_findings,
|
||||||
upsert_decision,
|
upsert_decision,
|
||||||
)
|
)
|
||||||
from backend.periscopex.models import Finding
|
from backend.periscopex.models import Finding
|
||||||
@@ -30,6 +32,7 @@ from backend.routers.deps import get_storage, get_user_id, resolve_or_404
|
|||||||
from backend.services import projects as proj_svc
|
from backend.services import projects as proj_svc
|
||||||
|
|
||||||
router = APIRouter(tags=["reports"])
|
router = APIRouter(tags=["reports"])
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Allow alphanumeric, dash, underscore, dot, colon, forward-slash, plus, hash, space
|
# Allow alphanumeric, dash, underscore, dot, colon, forward-slash, plus, hash, space
|
||||||
_SAFE_MPN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_\-\.:/ +#,()]*$")
|
_SAFE_MPN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_\-\.:/ +#,()]*$")
|
||||||
@@ -56,7 +59,11 @@ async def get_report(project_id: str, request: Request):
|
|||||||
if merged is None:
|
if merged is None:
|
||||||
raise HTTPException(404, "Report not found — run the pipeline first")
|
raise HTTPException(404, "Report not found — run the pipeline first")
|
||||||
findings = _findings_from_report(merged)
|
findings = _findings_from_report(merged)
|
||||||
complete_findings(findings)
|
try:
|
||||||
|
complete_findings(findings)
|
||||||
|
except Exception:
|
||||||
|
log.exception("complete_findings failed while serving report %s", project_id)
|
||||||
|
sort_findings(findings)
|
||||||
dec_key = f"{prefix}/decisions.json"
|
dec_key = f"{prefix}/decisions.json"
|
||||||
if storage.exists(dec_key):
|
if storage.exists(dec_key):
|
||||||
try:
|
try:
|
||||||
@@ -158,7 +165,7 @@ def _findings_from_report(report_data: dict) -> list[Finding]:
|
|||||||
try:
|
try:
|
||||||
out.append(Finding.model_validate(raw))
|
out.append(Finding.model_validate(raw))
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
log.warning("Skipping malformed finding in report", exc_info=True)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,16 @@
|
|||||||
|
|
||||||
What's new in Periscope.
|
What's new in Periscope.
|
||||||
|
|
||||||
## 2.33.0 — 2026-09-20 — Fase B: hierarchy, derating bands, timing, PI, ESD/return
|
## 2.33.1 — 2026-09-20 — ESD/PI filters, ERROR-first sort, findings tree
|
||||||
|
|
||||||
|
PE-ESD-001 only on J* ∩ IC nets (no NC / unconnected / VSYS/GND/3V3 spam). PE-PI-001 treats any capacitor on the rail, including KiCad `/` names, as decoupling. Report API and UI sort ERROR then WARNING then INFO, then RULE > RISK > REVIEW > INFO. The report left list is an expand-on-click tree (IC → pin/net → cards), not a dump of open groups.
|
||||||
|
|
||||||
|
- [Fixed] Connector GPIO / onboard rails no longer flood PE-ESD-001.
|
||||||
|
- [Fixed] Bulk or slash-prefixed caps (C68 `/VBUS`, C71–C75 VSYS) suppress “no local cap”.
|
||||||
|
- [Changed] GET `/report` and `complete_findings` order by severity then class.
|
||||||
|
- [Changed] Findings sidebar is a collapsed tree; PCB exam cards live in the same tree.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Usable slices (not a platform): component→pin→net→block inventory, capacitor PASS/MARGIN/RISK from Vop/Vrated, RC/strap timing only with numbers, local vs bulk PI, ESD and HS return as REVIEW.
|
Usable slices (not a platform): component→pin→net→block inventory, capacitor PASS/MARGIN/RISK from Vop/Vrated, RC/strap timing only with numbers, local vs bulk PI, ESD and HS return as REVIEW.
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,12 @@
|
|||||||
|
|
||||||
import { use, useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef, Suspense } from "react";
|
import { use, useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef, Suspense } from "react";
|
||||||
import { Download, RotateCcw } from "lucide-react";
|
import { Download, RotateCcw } from "lucide-react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
||||||
import { useReport } from "@/hooks/use-report";
|
import { useReport } from "@/hooks/use-report";
|
||||||
import { useReviewedFindings } from "@/hooks/use-reviewed-findings";
|
import { useReviewedFindings } from "@/hooks/use-reviewed-findings";
|
||||||
import { ReportSummary } from "@/components/report/report-summary";
|
import { ReportSummary } from "@/components/report/report-summary";
|
||||||
import { FindingsList } from "@/components/report/findings-list";
|
import { FindingsList } from "@/components/report/findings-list";
|
||||||
import { PcbExamSection } from "@/components/report/pcb-exam-section";
|
|
||||||
import { FindingFocusView } from "@/components/report/finding-focus-view";
|
import { FindingFocusView } from "@/components/report/finding-focus-view";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
@@ -29,8 +28,6 @@ interface FocusState {
|
|||||||
|
|
||||||
function ReportContent({ projectId }: { projectId: string }) {
|
function ReportContent({ projectId }: { projectId: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const domainParam = searchParams.get("domain");
|
|
||||||
const { report, graph, loading, error } = useReport(projectId);
|
const { report, graph, loading, error } = useReport(projectId);
|
||||||
const { user } = useOptionalUser();
|
const { user } = useOptionalUser();
|
||||||
const [focus, setFocus] = useState<FocusState | null>(null);
|
const [focus, setFocus] = useState<FocusState | null>(null);
|
||||||
@@ -353,26 +350,6 @@ function ReportContent({ projectId }: { projectId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{domainParam !== "schema" && (
|
|
||||||
<PcbExamSection
|
|
||||||
findings={report.findings}
|
|
||||||
onViewReference={handleViewReference}
|
|
||||||
projectId={projectId}
|
|
||||||
isReviewed={isReviewed}
|
|
||||||
toggleReviewed={handleToggleReviewed}
|
|
||||||
findingKey={(f) => keyByFinding.get(f) ?? getFindingKey(f, 0)}
|
|
||||||
comments={comments}
|
|
||||||
collaborators={collaborators}
|
|
||||||
currentUserId={user?.id}
|
|
||||||
currentUserName={user?.name ?? user?.email ?? "User"}
|
|
||||||
onCommentAdded={handleCommentAdded}
|
|
||||||
onCommentDeleted={handleCommentDeleted}
|
|
||||||
onReportFinding={handleReportFinding}
|
|
||||||
reportedFindingIds={reportedFindingIds}
|
|
||||||
reviews={reviews}
|
|
||||||
onReviewSaved={handleReviewSaved}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<FindingsList
|
<FindingsList
|
||||||
findings={report.findings}
|
findings={report.findings}
|
||||||
graph={graph}
|
graph={graph}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ interface ComponentGroupProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ComponentGroup({ designator, findings, component, onViewReference, findingKeys, isReviewed, onToggleReviewed, comments, projectId, collaborators, currentUserId, currentUserName, onCommentAdded, onCommentDeleted, onReportFinding, reportedFindingIds, reviews, onReviewSaved }: ComponentGroupProps) {
|
export function ComponentGroup({ designator, findings, component, onViewReference, findingKeys, isReviewed, onToggleReviewed, comments, projectId, collaborators, currentUserId, currentUserName, onCommentAdded, onCommentDeleted, onReportFinding, reportedFindingIds, reviews, onReviewSaved }: ComponentGroupProps) {
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(false);
|
||||||
const sorted = sortFindings(findings);
|
const sorted = sortFindings(findings);
|
||||||
|
|
||||||
const errorCount = findings.filter((f) => f.status === "ERROR").length;
|
const errorCount = findings.filter((f) => f.status === "ERROR").length;
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
import { useSearchParams, useRouter, usePathname } from "next/navigation";
|
import { useSearchParams, useRouter, usePathname } from "next/navigation";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { ComponentGroup } from "./component-group";
|
import { FindingsTree, FindingsTreeEmpty } from "./findings-tree";
|
||||||
import { ReportFilters } from "./report-filters";
|
import { ReportFilters } from "./report-filters";
|
||||||
import { ReviewedSection } from "./reviewed-section";
|
import { ReviewedSection } from "./reviewed-section";
|
||||||
import type { Finding, FindingComment, FindingReview, FindingStatus, DesignGraph, Collaborator } from "@/lib/types";
|
import type { Finding, FindingComment, FindingReview, FindingStatus, DesignGraph, Collaborator } from "@/lib/types";
|
||||||
import { groupBy, getFindingKey } from "@/lib/utils";
|
import { groupBy, getFindingKey } from "@/lib/utils";
|
||||||
import { isLayoutFinding, isPcbExamFinding } from "@/lib/layout-finding";
|
import { isLayoutFinding } from "@/lib/layout-finding";
|
||||||
|
|
||||||
interface FindingsListProps {
|
interface FindingsListProps {
|
||||||
findings: Finding[];
|
findings: Finding[];
|
||||||
@@ -86,7 +86,6 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
|
|||||||
const st = f.finding_id ? reviews?.[f.finding_id]?.state : undefined;
|
const st = f.finding_id ? reviews?.[f.finding_id]?.state : undefined;
|
||||||
if (st && st !== "open") return false;
|
if (st && st !== "open") return false;
|
||||||
}
|
}
|
||||||
if (isPcbExamFinding(f)) return false;
|
|
||||||
if (domainParam === "layout") {
|
if (domainParam === "layout") {
|
||||||
if (!isLayoutFinding(f)) return false;
|
if (!isLayoutFinding(f)) return false;
|
||||||
} else if (domainParam === "schema") {
|
} else if (domainParam === "schema") {
|
||||||
@@ -113,7 +112,6 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
|
|||||||
});
|
});
|
||||||
}, [findings, matchesFilters, findingKeyMap, isReviewed]);
|
}, [findings, matchesFilters, findingKeyMap, isReviewed]);
|
||||||
|
|
||||||
const grouped = useMemo(() => groupBy(filtered, (f) => f.designator), [filtered]);
|
|
||||||
const designators = useMemo(() => {
|
const designators = useMemo(() => {
|
||||||
const all = [...new Set(findings.map((f) => f.designator))];
|
const all = [...new Set(findings.map((f) => f.designator))];
|
||||||
const byDesignator = groupBy(findings, (f) => f.designator);
|
const byDesignator = groupBy(findings, (f) => f.designator);
|
||||||
@@ -143,38 +141,29 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
|
|||||||
onDomainChange={(v) => updateParams({ domain: v })}
|
onDomainChange={(v) => updateParams({ domain: v })}
|
||||||
designators={designators}
|
designators={designators}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-1">
|
{filtered.length > 0 ? (
|
||||||
{designators
|
<FindingsTree
|
||||||
.filter((d) => grouped[d])
|
findings={filtered}
|
||||||
.map((d) => (
|
graph={graph}
|
||||||
<ComponentGroup
|
onViewReference={onViewReference}
|
||||||
key={d}
|
findingKeys={findingKeyMap}
|
||||||
designator={d}
|
isReviewed={isReviewed}
|
||||||
findings={grouped[d]}
|
onToggleReviewed={toggleReviewed}
|
||||||
component={graph.components[d]}
|
comments={comments}
|
||||||
onViewReference={onViewReference}
|
projectId={projectId}
|
||||||
findingKeys={findingKeyMap}
|
collaborators={collaborators}
|
||||||
isReviewed={isReviewed}
|
currentUserId={currentUserId}
|
||||||
onToggleReviewed={toggleReviewed}
|
currentUserName={currentUserName}
|
||||||
comments={comments}
|
onCommentAdded={onCommentAdded}
|
||||||
projectId={projectId}
|
onCommentDeleted={onCommentDeleted}
|
||||||
collaborators={collaborators}
|
onReportFinding={onReportFinding}
|
||||||
currentUserId={currentUserId}
|
reportedFindingIds={reportedFindingIds}
|
||||||
currentUserName={currentUserName}
|
reviews={reviews}
|
||||||
onCommentAdded={onCommentAdded}
|
onReviewSaved={onReviewSaved}
|
||||||
onCommentDeleted={onCommentDeleted}
|
/>
|
||||||
onReportFinding={onReportFinding}
|
) : reviewedFindings.length === 0 && findings.length > 0 ? (
|
||||||
reportedFindingIds={reportedFindingIds}
|
<FindingsTreeEmpty />
|
||||||
reviews={reviews}
|
) : null}
|
||||||
onReviewSaved={onReviewSaved}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{filtered.length === 0 && reviewedFindings.length === 0 && findings.some((f) => !isPcbExamFinding(f)) && (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-12">
|
|
||||||
No findings match your filters.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{reviewedFindings.length > 0 && (
|
{reviewedFindings.length > 0 && (
|
||||||
<ReviewedSection
|
<ReviewedSection
|
||||||
findings={reviewedFindings}
|
findings={reviewedFindings}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import { FindingCard } from "./finding-card";
|
||||||
|
import { StatusBadge } from "./status-badge";
|
||||||
|
import type {
|
||||||
|
Finding,
|
||||||
|
FindingComment,
|
||||||
|
FindingReview,
|
||||||
|
FindingStatus,
|
||||||
|
Collaborator,
|
||||||
|
Component,
|
||||||
|
DesignGraph,
|
||||||
|
} from "@/lib/types";
|
||||||
|
import { cn, sortFindings, subtypeLabel } from "@/lib/utils";
|
||||||
|
import { isPcbExamFinding } from "@/lib/layout-finding";
|
||||||
|
|
||||||
|
interface FindingsTreeProps {
|
||||||
|
findings: Finding[];
|
||||||
|
graph: DesignGraph;
|
||||||
|
onViewReference: (finding: Finding) => void;
|
||||||
|
findingKeys: Map<Finding, string>;
|
||||||
|
isReviewed?: (key: string) => boolean;
|
||||||
|
onToggleReviewed?: (key: string) => void;
|
||||||
|
comments?: Record<string, FindingComment[]>;
|
||||||
|
projectId?: string;
|
||||||
|
collaborators?: Collaborator[];
|
||||||
|
currentUserId?: string;
|
||||||
|
currentUserName?: string;
|
||||||
|
onCommentAdded?: (comment: FindingComment) => void;
|
||||||
|
onCommentDeleted?: (commentId: string, findingId: string) => void;
|
||||||
|
onReportFinding?: (finding: Finding) => void;
|
||||||
|
reportedFindingIds?: Set<string>;
|
||||||
|
reviews?: Record<string, FindingReview>;
|
||||||
|
onReviewSaved?: (findingId: string, review: FindingReview) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function worstStatus(findings: Finding[]): FindingStatus {
|
||||||
|
if (findings.some((f) => f.status === "ERROR")) return "ERROR";
|
||||||
|
if (findings.some((f) => f.status === "WARNING")) return "WARNING";
|
||||||
|
return "INFO";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pinNetLabel(f: Finding): string {
|
||||||
|
const pin = (f.pins && f.pins[0]) || "";
|
||||||
|
const net = f.net || "";
|
||||||
|
if (pin && net) return `${pin} · ${net}`;
|
||||||
|
if (net) return net;
|
||||||
|
if (pin) return pin;
|
||||||
|
return f.rule_id || "Finding";
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeBranch({
|
||||||
|
label,
|
||||||
|
extra,
|
||||||
|
findings,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
extra?: string;
|
||||||
|
findings: Finding[];
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const worst = worstStatus(findings);
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
|
<CollapsibleTrigger
|
||||||
|
className="flex items-center gap-2 w-full py-2 px-2 rounded-md text-left hover:bg-muted/60 min-h-11 md:min-h-8"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||||
|
open && "rotate-90",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-sm truncate">{label}</span>
|
||||||
|
{extra ? (
|
||||||
|
<span className="text-xs text-muted-foreground truncate hidden sm:inline">{extra}</span>
|
||||||
|
) : null}
|
||||||
|
<span className="ml-auto flex items-center gap-1.5 shrink-0">
|
||||||
|
<StatusBadge status={worst} />
|
||||||
|
<span className="text-xs text-muted-foreground">{findings.length}</span>
|
||||||
|
</span>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="pl-3 ml-3 border-l border-border space-y-0.5">{children}</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FindingsTree(props: FindingsTreeProps) {
|
||||||
|
const sorted = sortFindings(props.findings);
|
||||||
|
const schematic = sorted.filter((f) => !isPcbExamFinding(f));
|
||||||
|
const pcb = sorted.filter(isPcbExamFinding);
|
||||||
|
const roots: { id: string; label: string; items: Finding[] }[] = [];
|
||||||
|
if (schematic.length) roots.push({ id: "schema", label: "Schematic", items: schematic });
|
||||||
|
if (pcb.length) roots.push({ id: "pcb", label: "PCB exam", items: pcb });
|
||||||
|
|
||||||
|
const showRoots = roots.length > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="Findings tree" className="rounded-lg border border-border bg-card p-2">
|
||||||
|
{roots.map((root) => {
|
||||||
|
const body = (
|
||||||
|
<DesignatorForest
|
||||||
|
findings={root.items}
|
||||||
|
graph={props.graph}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (!showRoots) return <div key={root.id}>{body}</div>;
|
||||||
|
return (
|
||||||
|
<TreeBranch key={root.id} label={root.label} findings={root.items}>
|
||||||
|
{body}
|
||||||
|
</TreeBranch>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DesignatorForest({
|
||||||
|
findings,
|
||||||
|
graph,
|
||||||
|
...cardProps
|
||||||
|
}: FindingsTreeProps) {
|
||||||
|
const byDes = new Map<string, Finding[]>();
|
||||||
|
for (const f of sortFindings(findings)) {
|
||||||
|
const list = byDes.get(f.designator) ?? [];
|
||||||
|
list.push(f);
|
||||||
|
byDes.set(f.designator, list);
|
||||||
|
}
|
||||||
|
const designators = [...byDes.keys()].sort((a, b) => {
|
||||||
|
const wa = worstStatus(byDes.get(a)!);
|
||||||
|
const wb = worstStatus(byDes.get(b)!);
|
||||||
|
const order = { ERROR: 0, WARNING: 1, INFO: 2 } as const;
|
||||||
|
return order[wa] - order[wb] || a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{designators.map((d) => {
|
||||||
|
const group = byDes.get(d)!;
|
||||||
|
const component: Component | undefined = graph.components[d];
|
||||||
|
const extra = [
|
||||||
|
component?.mpn,
|
||||||
|
component?.component_subtype ? subtypeLabel(component.component_subtype) : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
const byLeaf = new Map<string, Finding[]>();
|
||||||
|
for (const f of group) {
|
||||||
|
const label = pinNetLabel(f);
|
||||||
|
const list = byLeaf.get(label) ?? [];
|
||||||
|
list.push(f);
|
||||||
|
byLeaf.set(label, list);
|
||||||
|
}
|
||||||
|
const leaves = [...byLeaf.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
return (
|
||||||
|
<TreeBranch key={d} label={d} extra={extra} findings={group}>
|
||||||
|
{leaves.map(([label, items]) => (
|
||||||
|
<TreeBranch key={`${d}:${label}`} label={label} findings={items}>
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
{sortFindings(items).map((f) => {
|
||||||
|
const key = cardProps.findingKeys.get(f);
|
||||||
|
return (
|
||||||
|
<FindingCard
|
||||||
|
key={key}
|
||||||
|
finding={f}
|
||||||
|
onViewReference={cardProps.onViewReference}
|
||||||
|
checked={key && cardProps.isReviewed ? cardProps.isReviewed(key) : undefined}
|
||||||
|
onCheckedChange={
|
||||||
|
key && cardProps.onToggleReviewed
|
||||||
|
? () => cardProps.onToggleReviewed!(key)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
comments={f.finding_id ? cardProps.comments?.[f.finding_id] : undefined}
|
||||||
|
projectId={cardProps.projectId}
|
||||||
|
collaborators={cardProps.collaborators}
|
||||||
|
currentUserId={cardProps.currentUserId}
|
||||||
|
currentUserName={cardProps.currentUserName}
|
||||||
|
onCommentAdded={cardProps.onCommentAdded}
|
||||||
|
onCommentDeleted={cardProps.onCommentDeleted}
|
||||||
|
onReportFinding={cardProps.onReportFinding}
|
||||||
|
isReported={!!(f.finding_id && cardProps.reportedFindingIds?.has(f.finding_id))}
|
||||||
|
review={f.finding_id ? cardProps.reviews?.[f.finding_id] : undefined}
|
||||||
|
onReviewSaved={cardProps.onReviewSaved}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TreeBranch>
|
||||||
|
))}
|
||||||
|
</TreeBranch>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FindingsTreeEmpty() {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-12">
|
||||||
|
No findings match your filters.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { clsx, type ClassValue } from "clsx"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
import type { Finding, FindingStatus } from "./types";
|
import type { Finding, FindingClass, FindingStatus } from "./types";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
@@ -16,9 +16,19 @@ export function groupBy<T>(items: T[], key: (item: T) => string): Record<string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_ORDER: Record<FindingStatus, number> = { ERROR: 0, WARNING: 1, INFO: 2 };
|
const STATUS_ORDER: Record<FindingStatus, number> = { ERROR: 0, WARNING: 1, INFO: 2 };
|
||||||
|
const CLASS_ORDER: Record<FindingClass, number> = { RULE: 0, RISK: 1, REVIEW: 2, INFO: 3 };
|
||||||
|
|
||||||
export function sortFindings(findings: Finding[]): Finding[] {
|
export function sortFindings(findings: Finding[]): Finding[] {
|
||||||
return [...findings].sort((a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status]);
|
return [...findings].sort((a, b) => {
|
||||||
|
const status = STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
|
||||||
|
if (status !== 0) return status;
|
||||||
|
const ac = CLASS_ORDER[a.finding_class ?? "INFO"] ?? 9;
|
||||||
|
const bc = CLASS_ORDER[b.finding_class ?? "INFO"] ?? 9;
|
||||||
|
if (ac !== bc) return ac - bc;
|
||||||
|
return (a.designator || "").localeCompare(b.designator || "")
|
||||||
|
|| (a.rule_id || "").localeCompare(b.rule_id || "")
|
||||||
|
|| (a.finding || "").localeCompare(b.finding || "");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFindingKey(finding: Finding, index: number): string {
|
export function getFindingKey(finding: Finding, index: number): string {
|
||||||
|
|||||||
@@ -306,3 +306,190 @@ def test_llm_error_review_clamped_in_complete_finding():
|
|||||||
complete_finding(f)
|
complete_finding(f)
|
||||||
assert f.finding_class == "REVIEW"
|
assert f.finding_class == "REVIEW"
|
||||||
assert f.status == "WARNING"
|
assert f.status == "WARNING"
|
||||||
|
|
||||||
|
|
||||||
|
def test_esd_skips_gpio_nc_unconnected_and_onboard_power():
|
||||||
|
graph = DesignGraph(
|
||||||
|
components={
|
||||||
|
"J1": Component(
|
||||||
|
reference="J1", value="HDR", footprint="",
|
||||||
|
component_type=ComponentType.CONNECTOR, mpn="",
|
||||||
|
pins={
|
||||||
|
"1": "unconnected-J1-Pad1",
|
||||||
|
"2": "NC",
|
||||||
|
"3": "VSYS",
|
||||||
|
"4": "GND",
|
||||||
|
"5": "3V3",
|
||||||
|
"6": "USB_DP",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"U1": Component(
|
||||||
|
reference="U1", value="MCU", footprint="",
|
||||||
|
component_type=ComponentType.IC, mpn="MCU",
|
||||||
|
pins={
|
||||||
|
"1": "GPIO9",
|
||||||
|
"2": "LED_SDATA",
|
||||||
|
"3": "VSYS",
|
||||||
|
"4": "GND",
|
||||||
|
"5": "3V3",
|
||||||
|
"6": "USB_DP",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
nets={},
|
||||||
|
)
|
||||||
|
findings = check_esd(graph, {})
|
||||||
|
nets = {f.net for f in findings}
|
||||||
|
assert nets == {"USB_DP"}
|
||||||
|
assert "unused" not in (findings[0].recommendation or "").lower()
|
||||||
|
assert len(findings) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_esd_one_finding_per_connector_ic_path():
|
||||||
|
graph = DesignGraph(
|
||||||
|
components={
|
||||||
|
"J1": Component(
|
||||||
|
reference="J1", value="USB", footprint="",
|
||||||
|
component_type=ComponentType.CONNECTOR, mpn="",
|
||||||
|
pins={"1": "USB_DP"},
|
||||||
|
),
|
||||||
|
"U2": Component(
|
||||||
|
reference="U2", value="A", footprint="",
|
||||||
|
component_type=ComponentType.IC, mpn="A",
|
||||||
|
pins={"1": "USB_DP"},
|
||||||
|
),
|
||||||
|
"U3": Component(
|
||||||
|
reference="U3", value="B", footprint="",
|
||||||
|
component_type=ComponentType.IC, mpn="B",
|
||||||
|
pins={"1": "USB_DP"},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
nets={
|
||||||
|
"USB_DP": Net(
|
||||||
|
name="USB_DP", net_type=NetType.SIGNAL,
|
||||||
|
pins=[
|
||||||
|
PinConnection(component_ref="J1", pin_number="1"),
|
||||||
|
PinConnection(component_ref="U2", pin_number="1"),
|
||||||
|
PinConnection(component_ref="U3", pin_number="1"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
findings = check_esd(graph, {})
|
||||||
|
assert len(findings) == 2
|
||||||
|
assert {f.designator for f in findings} == {"U2", "U3"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_esd_skips_when_protection_part_on_net():
|
||||||
|
graph = DesignGraph(
|
||||||
|
components={
|
||||||
|
"J1": Component(
|
||||||
|
reference="J1", value="USB", footprint="",
|
||||||
|
component_type=ComponentType.CONNECTOR, mpn="",
|
||||||
|
pins={"1": "USB_DP"},
|
||||||
|
),
|
||||||
|
"U2": Component(
|
||||||
|
reference="U2", value="UART", footprint="",
|
||||||
|
component_type=ComponentType.IC, mpn="CH",
|
||||||
|
pins={"1": "USB_DP"},
|
||||||
|
),
|
||||||
|
"D1": Component(
|
||||||
|
reference="D1", value="USBLC6 ESD", footprint="",
|
||||||
|
component_type=ComponentType.DISCRETE, mpn="",
|
||||||
|
pins={"1": "USB_DP"},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
nets={},
|
||||||
|
)
|
||||||
|
assert check_esd(graph, {}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_pi_any_cap_on_slash_prefixed_rail_suppresses_missing_local():
|
||||||
|
cons = ComponentConstraints(
|
||||||
|
mpn="LDO",
|
||||||
|
pintable=[Pin(number="1", name="VIN")],
|
||||||
|
absolute_maximum_ratings=[],
|
||||||
|
rules=[],
|
||||||
|
)
|
||||||
|
graph = DesignGraph(
|
||||||
|
components={
|
||||||
|
"U1": Component(
|
||||||
|
reference="U1", value="LDO", footprint="",
|
||||||
|
component_type=ComponentType.IC, mpn="LDO",
|
||||||
|
pins={"1": "VBUS"},
|
||||||
|
),
|
||||||
|
"C68": Component(
|
||||||
|
reference="C68", value="10u", footprint="",
|
||||||
|
component_type=ComponentType.CAPACITOR, mpn="",
|
||||||
|
pins={"1": "/VBUS", "2": "GND"},
|
||||||
|
specs=CapacitorSpecs(value_farads=10e-6, value_formatted="10u"),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
nets={
|
||||||
|
"VBUS": Net(
|
||||||
|
name="VBUS", net_type=NetType.POWER, voltage=5.0,
|
||||||
|
pins=[PinConnection(component_ref="U1", pin_number="1")],
|
||||||
|
),
|
||||||
|
"/VBUS": Net(
|
||||||
|
name="/VBUS", net_type=NetType.POWER, voltage=5.0,
|
||||||
|
pins=[PinConnection(component_ref="C68", pin_number="1")],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
cons.pintable = [Pin(number="1", name="VBUS")]
|
||||||
|
findings = check_power_integrity(graph, {"LDO": cons})
|
||||||
|
assert [f.rule_id for f in findings if f.rule_id == "PE-PI-001"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_pi_bulk_only_does_not_claim_no_local():
|
||||||
|
cons = ComponentConstraints(
|
||||||
|
mpn="LDO",
|
||||||
|
pintable=[Pin(number="1", name="VIN")],
|
||||||
|
absolute_maximum_ratings=[],
|
||||||
|
rules=[],
|
||||||
|
)
|
||||||
|
graph = DesignGraph(
|
||||||
|
components={
|
||||||
|
"U1": Component(
|
||||||
|
reference="U1", value="LDO", footprint="",
|
||||||
|
component_type=ComponentType.IC, mpn="LDO",
|
||||||
|
pins={"1": "VSYS"},
|
||||||
|
),
|
||||||
|
"C71": Component(
|
||||||
|
reference="C71", value="10u", footprint="",
|
||||||
|
component_type=ComponentType.CAPACITOR, mpn="",
|
||||||
|
pins={"1": "VSYS", "2": "GND"},
|
||||||
|
specs=CapacitorSpecs(value_farads=10e-6, value_formatted="10u"),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
nets={
|
||||||
|
"VSYS": Net(
|
||||||
|
name="VSYS", net_type=NetType.POWER, voltage=5.0,
|
||||||
|
pins=[
|
||||||
|
PinConnection(component_ref="U1", pin_number="1"),
|
||||||
|
PinConnection(component_ref="C71", pin_number="1"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
findings = check_power_integrity(graph, {"LDO": cons})
|
||||||
|
assert [f.rule_id for f in findings if f.rule_id == "PE-PI-001"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_sort_findings_error_then_class():
|
||||||
|
from backend.periscopex.finding_engine import sort_findings
|
||||||
|
|
||||||
|
items = [
|
||||||
|
Finding(designator="U1", finding="info review", why="x", status="INFO", finding_class="REVIEW"),
|
||||||
|
Finding(designator="U1", finding="warn risk", why="x", status="WARNING", finding_class="RISK"),
|
||||||
|
Finding(designator="U1", finding="err rule", why="x", status="ERROR", finding_class="RULE"),
|
||||||
|
Finding(designator="U1", finding="warn review", why="x", status="WARNING", finding_class="REVIEW"),
|
||||||
|
]
|
||||||
|
sort_findings(items)
|
||||||
|
assert [f.finding for f in items] == [
|
||||||
|
"err rule",
|
||||||
|
"warn risk",
|
||||||
|
"warn review",
|
||||||
|
"info review",
|
||||||
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user