From bee6369c1d879d47c939c0e88db6c8e9409ca893 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Sun, 20 Sep 2026 10:03:50 +0200 Subject: [PATCH] Replace findings list with a tree; filter ESD/PI false positives. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/periscopex/esd_return_check.py | 170 +++++++++----- backend/periscopex/finding_engine.py | 30 ++- backend/periscopex/hierarchy.py | 12 +- backend/periscopex/pcb_net_match.py | 15 ++ backend/periscopex/pi_check.py | 16 +- backend/routers/reports.py | 11 +- frontend/content/changelog.md | 11 +- .../app/(app)/project/[id]/report/page.tsx | 25 +-- .../src/components/report/component-group.tsx | 2 +- .../src/components/report/findings-list.tsx | 61 +++-- .../src/components/report/findings-tree.tsx | 211 ++++++++++++++++++ frontend/src/lib/utils.ts | 14 +- tests/test_fase_b_checks.py | 187 ++++++++++++++++ 13 files changed, 628 insertions(+), 137 deletions(-) create mode 100644 frontend/src/components/report/findings-tree.tsx diff --git a/backend/periscopex/esd_return_check.py b/backend/periscopex/esd_return_check.py index 11472b5..8c0d78a 100644 --- a/backend/periscopex/esd_return_check.py +++ b/backend/periscopex/esd_return_check.py @@ -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 +import re + from backend.periscopex.models import ( ComponentConstraints, ComponentType, @@ -10,13 +12,22 @@ from backend.periscopex.models import ( LayoutGraph, 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.validate import _match_constraints -_USB_RE = __import__("re").compile( - r"(USB|DP|DM|D\+|D-|VBUS|CC1|CC2|HDMI|ESD)", - __import__("re").I, +_J_REF_RE = re.compile(r"^J\d+", re.I) +_UNCONNECTED_RE = re.compile(r"^unconnected", 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 +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( graph: DesignGraph, constraints_map: dict[str, ComponentConstraints] | None = None, ) -> list[Finding]: - """Connector / USB net without an ESD part → REVIEW. On-die clamp is not a RULE.""" - cmap = constraints_map or {} + """One REVIEW per J* connector → IC net with no ESD part. No GPIO/NC/rail spam.""" + _ = constraints_map out: list[Finding] = [] - seen: set[str] = set() - connector_nets: set[str] = set() - for comp in graph.components.values(): - if comp.component_type != ComponentType.CONNECTOR: - continue - connector_nets.update(n for n in comp.pins.values() if n) - for ref, comp in sorted(graph.components.items()): - if comp.component_type != ComponentType.IC: - continue - cons = _match_constraints(comp.mpn or comp.value, cmap) - clamp = list((cons.internal_features.esd_clamp_pins if cons and cons.internal_features else []) or []) - for pin_num, net in sorted(comp.pins.items(), key=lambda x: str(x[0])): - if not net or net in seen: + seen: set[tuple[str, str, str]] = set() + + connectors: list[tuple[str, object]] = [ + (ref, comp) + for ref, comp in sorted(graph.components.items()) + if _is_j_connector(comp) + ] + + for j_ref, j_comp in connectors: + for j_pin, net in sorted(j_comp.pins.items(), key=lambda x: str(x[0])): + if not net or _skip_esd_net(net): continue - interesting = net in connector_nets or bool(_USB_RE.search(net)) - if clamp: - tokens = [net, str(pin_num)] - if cons: - pin = cons.pin_by_number(pin_num) - 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: + net_key = normalize_kicad_hierarchy_net(net) + on_net = refs_on_matched_net(graph, net) + if any( + r in graph.components and _is_esd_part(graph.components[r]) + for r in on_net + ): continue - seen.add(net) - if any(_is_esd_part(graph.components[r]) for r in graph.components_on_net(net) if r in graph.components): + ics = [ + r for r in on_net + if r in graph.components + and graph.components[r].component_type == ComponentType.IC + ] + if not ics: continue - rec = ( - f"Add a datasheet-specified ESD device on {net}, or record a " - "designer decision if the connector is unused." - ) - out.append(Finding( - designator=ref, - mpn=comp.mpn or "", - aspect="esd", - 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=( - "External ESD is recommended unless the extraction lists a " - "mandatory clamp requirement with a measured FACT." - ), - inference="REVIEW — on-die esd_clamp_pins are not a board RULE.", - why="No invented IEC 61000 level.", - 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=[f"{ref}.{pin_num}"], - )) + for ic_ref in sorted(ics): + path = (j_ref, ic_ref, net_key) + if path in seen: + continue + seen.add(path) + ic = graph.components[ic_ref] + ic_pin = next( + (str(p) for p, n in ic.pins.items() if n and kicad_nets_match(n, net)), + "", + ) + rec = ( + f"Add a datasheet-specified ESD device on {net} between " + f"{j_ref} and {ic_ref}." + ) + out.append(Finding( + designator=ic_ref, + mpn=ic.mpn or "", + aspect="esd", + finding=( + f"{net} is a {j_ref}–{ic_ref} path with no ESD/" + "protection part on the net." + ), + facts=( + f"Net {net}; connector={j_ref}.{j_pin}; IC={ic_ref}; " + "ESD parts on net: 0." + ), + 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 @@ -111,7 +158,8 @@ def check_return_path( out: list[Finding] = [] seen: set[str] = set() 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 if not _HS_NET_RE.search(net_name or ""): continue diff --git a/backend/periscopex/finding_engine.py b/backend/periscopex/finding_engine.py index df52bdc..67d71ea 100644 --- a/backend/periscopex/finding_engine.py +++ b/backend/periscopex/finding_engine.py @@ -7,6 +7,7 @@ never becomes ERROR. LLM output is REVIEW, never RULE. from __future__ import annotations +import logging from datetime import datetime, timezone from typing import Any, Iterable, Literal @@ -14,6 +15,11 @@ from pydantic import BaseModel 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"] FindingClass = Literal["RULE", "RISK", "REVIEW", "INFO"] EvidenceStatus = Literal["SUFFICIENT", "INSUFFICIENT"] @@ -256,9 +262,31 @@ def complete_finding(f: Finding) -> Finding: 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: 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( diff --git a/backend/periscopex/hierarchy.py b/backend/periscopex/hierarchy.py index c36d6b5..fbc0654 100644 --- a/backend/periscopex/hierarchy.py +++ b/backend/periscopex/hierarchy.py @@ -67,12 +67,14 @@ def build_hierarchy( ref_block[g.ref] = bid else: 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 ics = [ p.component_ref for p in net.pins 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: continue @@ -103,7 +105,7 @@ def build_hierarchy( break comps.append(HierarchyComponent( ref=ref, - component_type=comp.component_type.value, + component_type=str(getattr(comp.component_type, "value", comp.component_type)), pins=pins, nets=nets, block_id=bid, @@ -120,7 +122,7 @@ def build_hierarchy( break hnets.append(HierarchyNet( name=name, - net_type=net.net_type.value, + net_type=str(getattr(net.net_type, "value", net.net_type)), components=refs, 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.""" out: list[Finding] = [] 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 dangling = [str(n) for n, net in comp.pins.items() if not (net or "").strip()] if not dangling: diff --git a/backend/periscopex/pcb_net_match.py b/backend/periscopex/pcb_net_match.py index a7170c4..295c043 100644 --- a/backend/periscopex/pcb_net_match.py +++ b/backend/periscopex/pcb_net_match.py @@ -22,6 +22,21 @@ def normalize_kicad_hierarchy_net(name: str) -> str: 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: """True if schematic and PCB names are the same KiCad net. diff --git a/backend/periscopex/pi_check.py b/backend/periscopex/pi_check.py index bfeafb8..7dbb084 100644 --- a/backend/periscopex/pi_check.py +++ b/backend/periscopex/pi_check.py @@ -14,6 +14,10 @@ from backend.periscopex.models import ( LayoutGraph, ) 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 _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) i_load = _i_load(comp) 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 if not _is_ic_supply_pin(graph, cons, pin_num, net): continue - seen.add(net) + seen.add(net_key) locals_: list[tuple[str, float]] = [] bulks: list[tuple[str, float]] = [] unvalued = 0 - for cref in graph.components_on_net(net): + for cref in refs_on_matched_net(graph, net): ccomp = graph.components.get(cref) if not ccomp: continue @@ -99,7 +106,8 @@ def check_power_integrity( bulks.append((cref, farads)) else: 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}." facts = f"{ref} supply {net}: 0 local caps (<1 µF); bulk={len(bulks)}." if i_load is not None: diff --git a/backend/routers/reports.py b/backend/routers/reports.py index 53c8621..93d065a 100644 --- a/backend/routers/reports.py +++ b/backend/routers/reports.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging import re import uuid from datetime import datetime, timezone @@ -15,6 +16,7 @@ from backend.periscopex.finding_engine import ( apply_decisions, complete_findings, decision_from_review, + sort_findings, upsert_decision, ) 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 router = APIRouter(tags=["reports"]) +log = logging.getLogger(__name__) # Allow alphanumeric, dash, underscore, dot, colon, forward-slash, plus, hash, space _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: raise HTTPException(404, "Report not found — run the pipeline first") 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" if storage.exists(dec_key): try: @@ -158,7 +165,7 @@ def _findings_from_report(report_data: dict) -> list[Finding]: try: out.append(Finding.model_validate(raw)) except Exception: - continue + log.warning("Skipping malformed finding in report", exc_info=True) return out diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index 1f13516..aa9f4c9 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,7 +2,16 @@ 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. diff --git a/frontend/src/app/(app)/project/[id]/report/page.tsx b/frontend/src/app/(app)/project/[id]/report/page.tsx index 7488c8e..a70d3d6 100644 --- a/frontend/src/app/(app)/project/[id]/report/page.tsx +++ b/frontend/src/app/(app)/project/[id]/report/page.tsx @@ -2,13 +2,12 @@ import { use, useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef, Suspense } from "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 { useReport } from "@/hooks/use-report"; import { useReviewedFindings } from "@/hooks/use-reviewed-findings"; import { ReportSummary } from "@/components/report/report-summary"; import { FindingsList } from "@/components/report/findings-list"; -import { PcbExamSection } from "@/components/report/pcb-exam-section"; import { FindingFocusView } from "@/components/report/finding-focus-view"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; @@ -29,8 +28,6 @@ interface FocusState { function ReportContent({ projectId }: { projectId: string }) { const router = useRouter(); - const searchParams = useSearchParams(); - const domainParam = searchParams.get("domain"); const { report, graph, loading, error } = useReport(projectId); const { user } = useOptionalUser(); const [focus, setFocus] = useState(null); @@ -353,26 +350,6 @@ function ReportContent({ projectId }: { projectId: string }) { ) : ( <> - {domainParam !== "schema" && ( - 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} - /> - )} f.status === "ERROR").length; diff --git a/frontend/src/components/report/findings-list.tsx b/frontend/src/components/report/findings-list.tsx index 7cc621f..1f133ee 100644 --- a/frontend/src/components/report/findings-list.tsx +++ b/frontend/src/components/report/findings-list.tsx @@ -2,12 +2,12 @@ import { useSearchParams, useRouter, usePathname } from "next/navigation"; import { useCallback, useMemo } from "react"; -import { ComponentGroup } from "./component-group"; +import { FindingsTree, FindingsTreeEmpty } from "./findings-tree"; import { ReportFilters } from "./report-filters"; import { ReviewedSection } from "./reviewed-section"; import type { Finding, FindingComment, FindingReview, FindingStatus, DesignGraph, Collaborator } from "@/lib/types"; import { groupBy, getFindingKey } from "@/lib/utils"; -import { isLayoutFinding, isPcbExamFinding } from "@/lib/layout-finding"; +import { isLayoutFinding } from "@/lib/layout-finding"; interface FindingsListProps { 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; if (st && st !== "open") return false; } - if (isPcbExamFinding(f)) return false; if (domainParam === "layout") { if (!isLayoutFinding(f)) return false; } else if (domainParam === "schema") { @@ -113,7 +112,6 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe }); }, [findings, matchesFilters, findingKeyMap, isReviewed]); - const grouped = useMemo(() => groupBy(filtered, (f) => f.designator), [filtered]); const designators = useMemo(() => { const all = [...new Set(findings.map((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 })} designators={designators} /> -
- {designators - .filter((d) => grouped[d]) - .map((d) => ( - - ))} - {filtered.length === 0 && reviewedFindings.length === 0 && findings.some((f) => !isPcbExamFinding(f)) && ( -

- No findings match your filters. -

- )} -
+ {filtered.length > 0 ? ( + + ) : reviewedFindings.length === 0 && findings.length > 0 ? ( + + ) : null} {reviewedFindings.length > 0 && ( void; + findingKeys: Map; + isReviewed?: (key: string) => boolean; + onToggleReviewed?: (key: string) => void; + comments?: Record; + projectId?: string; + collaborators?: Collaborator[]; + currentUserId?: string; + currentUserName?: string; + onCommentAdded?: (comment: FindingComment) => void; + onCommentDeleted?: (commentId: string, findingId: string) => void; + onReportFinding?: (finding: Finding) => void; + reportedFindingIds?: Set; + reviews?: Record; + 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 ( + + + + {label} + {extra ? ( + {extra} + ) : null} + + + {findings.length} + + + +
{children}
+
+
+ ); +} + +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 ( + + ); +} + +function DesignatorForest({ + findings, + graph, + ...cardProps +}: FindingsTreeProps) { + const byDes = new Map(); + 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 ( +
+ {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(); + 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 ( + + {leaves.map(([label, items]) => ( + +
+ {sortFindings(items).map((f) => { + const key = cardProps.findingKeys.get(f); + return ( + 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} + /> + ); + })} +
+
+ ))} +
+ ); + })} +
+ ); +} + +export function FindingsTreeEmpty() { + return ( +

+ No findings match your filters. +

+ ); +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 2f6f054..bb4782b 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,6 +1,6 @@ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" -import type { Finding, FindingStatus } from "./types"; +import type { Finding, FindingClass, FindingStatus } from "./types"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -16,9 +16,19 @@ export function groupBy(items: T[], key: (item: T) => string): Record = { ERROR: 0, WARNING: 1, INFO: 2 }; +const CLASS_ORDER: Record = { RULE: 0, RISK: 1, REVIEW: 2, INFO: 3 }; 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 { diff --git a/tests/test_fase_b_checks.py b/tests/test_fase_b_checks.py index e5fe17a..25fb2c2 100644 --- a/tests/test_fase_b_checks.py +++ b/tests/test_fase_b_checks.py @@ -306,3 +306,190 @@ def test_llm_error_review_clamped_in_complete_finding(): complete_finding(f) assert f.finding_class == "REVIEW" 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", + ] +