Add finding review states, ECO export, and a report release signature.

Accepted findings become ECO rows with a required reason; false-positive and wontfix stay off the change list. Pipeline re-runs keep dispositions by finding_id like comments.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 23:21:36 +02:00
co-authored by Cursor
parent 6fc2ac583d
commit de15402309
14 changed files with 611 additions and 13 deletions
+107
View File
@@ -0,0 +1,107 @@
"""Finding review disposition, ECO export, and release signature.
Review state lives beside comments on the report JSON — it is not a
Finding field, so a pipeline re-run can keep dispositions by finding_id.
Empty reason is invalid. false_positive / wontfix / open are not ECO rows.
"""
from __future__ import annotations
import csv
import hashlib
import io
import json
from datetime import datetime, timezone
from typing import Any, Iterable, Literal
from backend.pinscopex.models import Finding
ReviewState = Literal["open", "false_positive", "accepted", "wontfix"]
VALID_STATES: frozenset[str] = frozenset({"open", "false_positive", "accepted", "wontfix"})
class ReviewError(ValueError):
"""Invalid review payload; do not store a silent default."""
def apply_review_state(
current: dict[str, dict[str, Any]],
finding_id: str,
*,
state: str,
reason: str,
user_id: str,
user_name: str = "",
updated_at: str | None = None,
) -> dict[str, dict[str, Any]]:
if not finding_id:
raise ReviewError("finding_id is required")
if state not in VALID_STATES:
raise ReviewError(f"invalid review state {state!r}")
text = (reason or "").strip()
if state != "open" and not text:
raise ReviewError("reason is required")
rec = {
"state": state,
"reason": text,
"user_id": user_id,
"user_name": user_name,
"updated_at": updated_at or datetime.now(timezone.utc).isoformat(),
}
next_states = dict(current)
if state == "open":
next_states.pop(finding_id, None)
return next_states
next_states[finding_id] = rec
return next_states
def _state_of(states: dict[str, dict[str, Any]], finding_id: str | None) -> str:
if not finding_id:
return "open"
rec = states.get(finding_id)
if not rec:
return "open"
return rec.get("state") or "open"
def build_eco(
findings: Iterable[Finding],
review_states: dict[str, dict[str, Any]],
) -> list[dict[str, str]]:
items: list[dict[str, str]] = []
for f in findings:
fid = f.finding_id
if _state_of(review_states, fid) != "accepted":
continue
rec = review_states.get(fid or "", {})
items.append({
"finding_id": fid or "",
"rule_id": f.rule_id or "",
"ref": f.designator,
"before": f.finding,
"after": f.recommendation or "",
"reason": rec.get("reason") or "",
})
return items
def eco_csv(items: list[dict[str, str]]) -> str:
buf = io.StringIO()
writer = csv.DictWriter(
buf,
fieldnames=["finding_id", "rule_id", "ref", "before", "after", "reason"],
)
writer.writeheader()
writer.writerows(items)
return buf.getvalue()
def sign_report(report: dict[str, Any], *, user_id: str, timestamp: str | None = None) -> dict[str, str]:
payload = json.dumps(report.get("findings") or [], sort_keys=True, default=str)
digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()
return {
"sha256": digest,
"user_id": user_id,
"timestamp": timestamp or datetime.now(timezone.utc).isoformat(),
}
+90 -3
View File
@@ -8,9 +8,17 @@ import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel
from backend.pinscopex.models import Finding
from backend.pinscopex.review_workflow import (
ReviewError,
apply_review_state,
build_eco,
eco_csv,
sign_report,
)
from backend.pinscopex.utils import safe_mpn
from backend.routers.deps import get_storage, get_user_id, resolve_or_404
from backend.services import projects as proj_svc
@@ -100,8 +108,87 @@ async def delete_comment(project_id: str, comment_id: str, request: Request):
comment_list.pop(i)
if not comment_list:
del comments[finding_id]
storage.write_json(key, report_data)
return JSONResponse({"ok": True})
storage.write_json(key, report_data)
return JSONResponse({"ok": True})
class ReviewBody(BaseModel):
state: str
reason: str = ""
user_name: str = ""
def _load_report(storage, owner_user_id: str, project_id: str) -> tuple[str, dict]:
prefix = proj_svc.project_prefix(owner_user_id, project_id)
key = f"{prefix}/report.json"
if not storage.exists(key):
raise HTTPException(404, "Report not found")
return key, storage.read_json(key)
def _findings_from_report(report_data: dict) -> list[Finding]:
out: list[Finding] = []
for raw in report_data.get("findings") or []:
try:
out.append(Finding.model_validate(raw))
except Exception:
continue
return out
@router.put("/report/{project_id}/findings/{finding_id}/review")
async def put_finding_review(project_id: str, finding_id: str, body: ReviewBody, request: Request):
storage = get_storage(request)
owner_user_id, _ = await resolve_or_404(request, project_id)
user_id = get_user_id(request)
key, report_data = _load_report(storage, owner_user_id, project_id)
ids = {f.finding_id for f in _findings_from_report(report_data) if f.finding_id}
if finding_id not in ids:
raise HTTPException(404, "Finding not found")
try:
states = apply_review_state(
report_data.get("review_states") or {},
finding_id,
state=body.state,
reason=body.reason,
user_id=user_id,
user_name=body.user_name,
)
except ReviewError as exc:
raise HTTPException(400, str(exc)) from exc
report_data["review_states"] = states
storage.write_json(key, report_data)
return JSONResponse(states.get(finding_id) or {"state": "open", "reason": ""})
@router.get("/report/{project_id}/eco.json")
async def get_eco_json(project_id: str, request: Request):
storage = get_storage(request)
owner_user_id, _ = await resolve_or_404(request, project_id)
_, report_data = _load_report(storage, owner_user_id, project_id)
items = build_eco(_findings_from_report(report_data), report_data.get("review_states") or {})
return JSONResponse({"items": items})
@router.get("/report/{project_id}/eco.csv")
async def get_eco_csv(project_id: str, request: Request):
storage = get_storage(request)
owner_user_id, _ = await resolve_or_404(request, project_id)
_, report_data = _load_report(storage, owner_user_id, project_id)
items = build_eco(_findings_from_report(report_data), report_data.get("review_states") or {})
return Response(eco_csv(items), media_type="text/csv")
@router.post("/report/{project_id}/sign")
async def post_sign_report(project_id: str, request: Request):
storage = get_storage(request)
owner_user_id, _ = await resolve_or_404(request, project_id)
user_id = get_user_id(request)
key, report_data = _load_report(storage, owner_user_id, project_id)
release = sign_report(report_data, user_id=user_id)
report_data["release"] = release
storage.write_json(key, report_data)
return JSONResponse(release)
raise HTTPException(404, "Comment not found")
+4
View File
@@ -740,10 +740,12 @@ async def validate_design_async(
preserved_findings: list[Finding] = []
preserved_coverage: dict[str, list[str]] = {}
preserved_comments = None
preserved_review_states = None
if existing_path.is_file():
try:
existing = json.loads(existing_path.read_text())
preserved_comments = existing.get("comments")
preserved_review_states = existing.get("review_states")
if before_ic is not None:
# Resume mode — keep findings for refs we're about to skip
for f in existing.get("findings", []):
@@ -805,6 +807,8 @@ async def validate_design_async(
report_dict = json.loads(report.model_dump_json(indent=2))
if preserved_comments is not None:
report_dict["comments"] = preserved_comments
if preserved_review_states is not None:
report_dict["review_states"] = preserved_review_states
if paused:
report_dict["partial"] = True
existing_path.write_text(json.dumps(report_dict, indent=2))
+8
View File
@@ -2,6 +2,14 @@
What's new in Pinscope.
## 2.19.0 — 2026-09-10 — Finding review and ECO
Findings can be accepted, marked false-positive, or wontfix with a required reason. Accepted rows export as ECO; OpenEMS-style layout SI is still not this.
- [New] Review state on the report (`open` / `accepted` / `false_positive` / `wontfix`). Empty reason is rejected except when returning to open.
- [New] ECO CSV/JSON of accepted findings only. False-positive and wontfix stay off the ECO.
- [New] Release signature: SHA-256 of findings + user + timestamp. Needs-review filter `?review=open`.
## 2.18.0 — 2026-09-10 — ImpedenceFinder calculator
The Impedance tab uses the closed-form engine from ImpedenceFinder (HammerstadJensen / Cohn), not a second formula set and not OpenEMS.
@@ -13,10 +13,10 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Toast, useToast } from "@/components/ui/toast";
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
import { fetchCollaborators, fetchProject, fetchMyFeedback, reprocessPipeline } from "@/lib/api";
import { fetchCollaborators, fetchProject, fetchMyFeedback, reprocessPipeline, signReport, downloadEcoCsv } from "@/lib/api";
import { exportReportToExcel } from "@/lib/report-export";
import { cn, getFindingKey } from "@/lib/utils";
import type { Finding, FindingComment, Collaborator } from "@/lib/types";
import type { Finding, FindingComment, FindingReview, Collaborator } from "@/lib/types";
interface FocusState {
key: string;
@@ -36,6 +36,7 @@ function ReportContent({ projectId }: { projectId: string }) {
const prevInFocusRef = useRef(false);
const [collaborators, setCollaborators] = useState<Collaborator[]>([]);
const [comments, setComments] = useState<Record<string, FindingComment[]>>({});
const [reviews, setReviews] = useState<Record<string, FindingReview>>({});
const [creditsSpent, setCreditsSpent] = useState<number | undefined>();
const [totalCostUsd, setTotalCostUsd] = useState<number | null>(null);
const [projectName, setProjectName] = useState<string>("");
@@ -121,6 +122,9 @@ function ReportContent({ projectId }: { projectId: string }) {
if (report?.comments) {
setComments(report.comments);
}
if (report?.review_states) {
setReviews(report.review_states);
}
}, [report]);
const handleCommentAdded = useCallback((comment: FindingComment) => {
@@ -130,6 +134,15 @@ function ReportContent({ projectId }: { projectId: string }) {
}));
}, []);
const handleReviewSaved = useCallback((findingId: string, review: FindingReview) => {
setReviews((prev) => {
const next = { ...prev };
if (review.state === "open") delete next[findingId];
else next[findingId] = review;
return next;
});
}, []);
const handleCommentDeleted = useCallback((commentId: string, findingId: string) => {
setComments((prev) => {
const list = (prev[findingId] ?? []).filter((c) => c.comment_id !== commentId);
@@ -245,6 +258,33 @@ function ReportContent({ projectId }: { projectId: string }) {
>
<Download /> Export Excel
</Button>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await downloadEcoCsv(projectId);
} catch (e) {
showToast(e instanceof Error ? e.message : "ECO export failed");
}
}}
>
<Download /> ECO CSV
</Button>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
const rel = await signReport(projectId);
showToast(`Signed ${rel.sha256.slice(0, 12)}`);
} catch (e) {
showToast(e instanceof Error ? e.message : "Sign failed");
}
}}
>
Sign release
</Button>
</div>
</div>
<ReportSummary
@@ -314,6 +354,8 @@ function ReportContent({ projectId }: { projectId: string }) {
onCommentDeleted={handleCommentDeleted}
onReportFinding={handleReportFinding}
reportedFindingIds={reportedFindingIds}
reviews={reviews}
onReviewSaved={handleReviewSaved}
/>
)}
</div>
@@ -338,6 +380,8 @@ function ReportContent({ projectId }: { projectId: string }) {
onCommentDeleted={handleCommentDeleted}
onReportFinding={handleReportFinding}
isReported={!!(focus.finding.finding_id && reportedFindingIds.has(focus.finding.finding_id))}
review={focus.finding.finding_id ? reviews[focus.finding.finding_id] : undefined}
onReviewSaved={handleReviewSaved}
/>
)}
<Toast toast={toast} />
@@ -6,7 +6,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
import { Badge } from "@/components/ui/badge";
import { FindingCard } from "./finding-card";
import { StatusBadge } from "./status-badge";
import type { Finding, FindingComment, Collaborator, Component } from "@/lib/types";
import type { Finding, FindingComment, FindingReview, Collaborator, Component } from "@/lib/types";
import { cn, sortFindings, subtypeLabel } from "@/lib/utils";
interface ComponentGroupProps {
@@ -26,9 +26,11 @@ interface ComponentGroupProps {
onCommentDeleted?: (commentId: string, findingId: string) => void;
onReportFinding?: (finding: Finding) => void;
reportedFindingIds?: Set<string>;
reviews?: Record<string, FindingReview>;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
}
export function ComponentGroup({ designator, findings, component, onViewReference, findingKeys, isReviewed, onToggleReviewed, comments, projectId, collaborators, currentUserId, currentUserName, onCommentAdded, onCommentDeleted, onReportFinding, reportedFindingIds }: 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 sorted = sortFindings(findings);
@@ -82,6 +84,8 @@ export function ComponentGroup({ designator, findings, component, onViewReferenc
onCommentDeleted={onCommentDeleted}
onReportFinding={onReportFinding}
isReported={!!(f.finding_id && reportedFindingIds?.has(f.finding_id))}
review={f.finding_id ? reviews?.[f.finding_id] : undefined}
onReviewSaved={onReviewSaved}
/>
);
})}
@@ -7,7 +7,8 @@ import { Button } from "@/components/ui/button";
import { StatusBadge } from "./status-badge";
import { FindingComments } from "./finding-comments";
import type { Finding, FindingComment, Collaborator } from "@/lib/types";
import { FindingReviewControls } from "./finding-review-controls";
import type { Finding, FindingComment, FindingReview, Collaborator } from "@/lib/types";
import { cn } from "@/lib/utils";
const BORDER_COLOR: Record<string, string> = {
@@ -31,6 +32,8 @@ interface FindingCardProps {
onReportFinding?: (finding: Finding) => void;
isReported?: boolean;
defaultOpen?: boolean;
review?: FindingReview;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
}
export function FindingCard({
@@ -48,11 +51,16 @@ export function FindingCard({
onReportFinding,
isReported,
defaultOpen,
review,
onReviewSaved,
}: FindingCardProps) {
const [open, setOpen] = useState(defaultOpen ?? false);
const commentCount = comments?.length ?? 0;
const hasCommentSupport = !!(projectId && collaborators && onCommentAdded && onCommentDeleted);
const expandable = !!finding.recommendation || (hasCommentSupport && !!finding.finding_id);
const expandable =
!!finding.recommendation ||
(hasCommentSupport && !!finding.finding_id) ||
!!(projectId && finding.finding_id && onReviewSaved);
return (
<div
@@ -189,6 +197,15 @@ export function FindingCard({
onCommentDeleted={onCommentDeleted}
/>
)}
{projectId && finding.finding_id && onReviewSaved && (
<FindingReviewControls
projectId={projectId}
findingId={finding.finding_id}
review={review}
userName={currentUserName ?? "User"}
onSaved={onReviewSaved}
/>
)}
</div>
)}
</div>
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { PdfViewerPanel } from "@/components/pdf/pdf-viewer-panel";
import { FindingCard } from "./finding-card";
import type { Finding, FindingComment, Collaborator, Component } from "@/lib/types";
import type { Finding, FindingComment, FindingReview, Collaborator, Component } from "@/lib/types";
import { subtypeLabel } from "@/lib/utils";
interface FindingFocusViewProps {
@@ -29,6 +29,8 @@ interface FindingFocusViewProps {
onCommentDeleted?: (commentId: string, findingId: string) => void;
onReportFinding?: (finding: Finding) => void;
isReported?: boolean;
review?: FindingReview;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
}
export function FindingFocusView({
@@ -51,6 +53,8 @@ export function FindingFocusView({
onCommentDeleted,
onReportFinding,
isReported,
review,
onReviewSaved,
}: FindingFocusViewProps) {
const backRef = useRef<HTMLButtonElement>(null);
@@ -110,6 +114,8 @@ export function FindingFocusView({
onReportFinding={onReportFinding}
isReported={isReported}
defaultOpen
review={review}
onReviewSaved={onReviewSaved}
/>
</div>
<div className="w-full lg:w-auto lg:shrink-0 lg:sticky lg:top-6 h-[70vh] lg:h-[calc(100vh-3rem)] rounded-lg border border-border bg-card overflow-hidden">
@@ -0,0 +1,74 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { setFindingReview } from "@/lib/api";
import type { FindingReview, FindingReviewState } from "@/lib/types";
const STATES: { value: FindingReviewState; label: string }[] = [
{ value: "open", label: "Open" },
{ value: "accepted", label: "Accepted (ECO)" },
{ value: "false_positive", label: "False positive" },
{ value: "wontfix", label: "Won't fix" },
];
export function FindingReviewControls({
projectId,
findingId,
review,
userName,
onSaved,
}: {
projectId: string;
findingId: string;
review?: FindingReview;
userName: string;
onSaved: (findingId: string, review: FindingReview) => void;
}) {
const [state, setState] = useState<FindingReviewState>(review?.state ?? "open");
const [reason, setReason] = useState(review?.reason ?? "");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function save() {
setBusy(true);
setError(null);
try {
const saved = await setFindingReview(projectId, findingId, state, reason, userName);
onSaved(findingId, saved.state === "open" ? { ...saved, state: "open", reason: "" } : saved);
} catch (e) {
setError(e instanceof Error ? e.message : "Save failed");
} finally {
setBusy(false);
}
}
return (
<div className="mt-3 space-y-2" onClick={(e) => e.stopPropagation()}>
<div className="flex flex-wrap items-center gap-2">
<select
className="h-8 rounded-lg border border-input bg-transparent px-2 text-xs"
value={state}
onChange={(e) => setState(e.target.value as FindingReviewState)}
>
{STATES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
<Input
className="h-8 min-w-[160px] flex-1 text-xs"
placeholder={state === "open" ? "Reason optional when open" : "Reason (required)"}
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
<Button size="sm" variant="outline" onClick={save} disabled={busy}>
Save
</Button>
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}
@@ -5,7 +5,7 @@ import { useCallback, useMemo } from "react";
import { ComponentGroup } from "./component-group";
import { ReportFilters } from "./report-filters";
import { ReviewedSection } from "./reviewed-section";
import type { Finding, FindingComment, FindingStatus, DesignGraph, Collaborator } from "@/lib/types";
import type { Finding, FindingComment, FindingReview, FindingStatus, DesignGraph, Collaborator } from "@/lib/types";
import { groupBy, getFindingKey } from "@/lib/utils";
interface FindingsListProps {
@@ -23,9 +23,11 @@ interface FindingsListProps {
onCommentDeleted?: (commentId: string, findingId: string) => void;
onReportFinding?: (finding: Finding) => void;
reportedFindingIds?: Set<string>;
reviews?: Record<string, FindingReview>;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
}
export function FindingsList({ findings, graph, onViewReference, projectId, isReviewed, toggleReviewed, comments, collaborators, currentUserId, currentUserName, onCommentAdded, onCommentDeleted, onReportFinding, reportedFindingIds }: FindingsListProps) {
export function FindingsList({ findings, graph, onViewReference, projectId, isReviewed, toggleReviewed, comments, collaborators, currentUserId, currentUserName, onCommentAdded, onCommentDeleted, onReportFinding, reportedFindingIds, reviews, onReviewSaved }: FindingsListProps) {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
@@ -38,6 +40,7 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
const statusParam = searchParams.get("status");
const componentParam = searchParams.get("component");
const reviewParam = searchParams.get("review");
const searchParam = searchParams.get("q") ?? "";
const statusFilters = useMemo(() => {
@@ -77,9 +80,13 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
const q = searchParam.toLowerCase();
if (q && !f.finding.toLowerCase().includes(q) && !(f.why ?? "").toLowerCase().includes(q))
return false;
if (reviewParam === "open") {
const st = f.finding_id ? reviews?.[f.finding_id]?.state : undefined;
if (st && st !== "open") return false;
}
return true;
},
[statusFilters, componentParam, searchParam]
[statusFilters, componentParam, searchParam, reviewParam, reviews]
);
const filtered = useMemo(() => {
@@ -120,6 +127,10 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
onComponentChange={(v) => updateParams({ component: v === "all" ? null : v })}
search={searchParam}
onSearchChange={(v) => updateParams({ q: v || null })}
needsReview={reviewParam === "open"}
onToggleNeedsReview={() =>
updateParams({ review: reviewParam === "open" ? null : "open" })
}
designators={designators}
/>
<div className="space-y-1">
@@ -144,6 +155,8 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
onCommentDeleted={onCommentDeleted}
onReportFinding={onReportFinding}
reportedFindingIds={reportedFindingIds}
reviews={reviews}
onReviewSaved={onReviewSaved}
/>
))}
{filtered.length === 0 && reviewedFindings.length === 0 && findings.length > 0 && (
@@ -21,6 +21,8 @@ interface ReportFiltersProps {
search: string;
onSearchChange: (value: string) => void;
designators: string[];
needsReview?: boolean;
onToggleNeedsReview?: () => void;
}
const STATUSES: { key: FindingStatus; label: string; activeClass: string }[] = [
@@ -37,6 +39,8 @@ export function ReportFilters({
search,
onSearchChange,
designators,
needsReview,
onToggleNeedsReview,
}: ReportFiltersProps) {
return (
<div className="flex flex-wrap items-center gap-3">
@@ -56,6 +60,19 @@ export function ReportFilters({
</Button>
))}
</div>
{onToggleNeedsReview && (
<Button
variant="outline"
size="sm"
className={cn(
"h-8 text-xs",
needsReview && "bg-amber-500/20 text-amber-700 dark:text-amber-400 border-amber-500/40",
)}
onClick={onToggleNeedsReview}
>
Needs review
</Button>
)}
<Select value={componentFilter} onValueChange={(v) => onComponentChange(v ?? "all")}>
<SelectTrigger className="w-[140px] h-8 text-xs">
+42
View File
@@ -15,6 +15,8 @@ import type {
ImpedanceTraceResult,
EdifSubDesign,
FindingComment,
FindingReview,
FindingReviewState,
LcscPayload,
NetlistPreviewDesignator,
PauseCheckpoint,
@@ -615,6 +617,46 @@ export async function fetchReport(
return res.json();
}
export async function setFindingReview(
projectId: string,
findingId: string,
state: FindingReviewState,
reason: string,
userName: string,
): Promise<FindingReview> {
const res = await authFetch(
`${BASE}/api/report/${projectId}/findings/${encodeURIComponent(findingId)}/review`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state, reason, user_name: userName }),
},
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(typeof body.detail === "string" ? body.detail : "Review update failed");
}
return res.json();
}
export async function signReport(projectId: string): Promise<{ sha256: string; user_id: string; timestamp: string }> {
const res = await authFetch(`${BASE}/api/report/${projectId}/sign`, { method: "POST" });
if (!res.ok) throw new Error("Failed to sign report");
return res.json();
}
export async function downloadEcoCsv(projectId: string): Promise<void> {
const res = await authFetch(`${BASE}/api/report/${projectId}/eco.csv`);
if (!res.ok) throw new Error("Failed to export ECO");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "pinscope-eco.csv";
a.click();
URL.revokeObjectURL(url);
}
export async function addComment(
projectId: string,
findingId: string,
+18
View File
@@ -41,6 +41,24 @@ export interface ValidationReport {
review_errors?: Record<string, string>;
not_reviewed?: { designator: string; reason: string }[];
comments?: Record<string, FindingComment[]>;
review_states?: Record<string, FindingReview>;
release?: ReportRelease;
}
export type FindingReviewState = "open" | "false_positive" | "accepted" | "wontfix";
export interface FindingReview {
state: FindingReviewState;
reason: string;
user_id: string;
user_name: string;
updated_at: string;
}
export interface ReportRelease {
sha256: string;
user_id: string;
timestamp: string;
}
export type NetType = "power" | "ground" | "signal" | "unknown";
+157
View File
@@ -0,0 +1,157 @@
"""Wave H — finding review state, ECO, release signature.
Favor: false_positive/accepted/wontfix with a reason persist; accepted
rows land in eco.json; signature hashes the findings payload.
Against: empty reason; unknown state; false_positive is not an ECO row;
unsigned report has no release block.
"""
from __future__ import annotations
import pytest
from backend.pinscopex.models import Finding
from backend.pinscopex.review_workflow import (
ReviewError,
apply_review_state,
build_eco,
eco_csv,
sign_report,
)
def _finding(**kwargs):
defaults = dict(
finding_id="U1-001",
designator="U1",
mpn="PART",
aspect="decoupling",
finding="missing cap",
why="no 100nF on VDD",
status="ERROR",
recommendation="add 100nF",
rule_id="PS-DEC-001",
)
defaults.update(kwargs)
return Finding(**defaults)
def test_false_positive_requires_reason():
with pytest.raises(ReviewError):
apply_review_state({}, "U1-001", state="false_positive", reason=" ", user_id="local")
def test_unknown_state_is_rejected():
with pytest.raises(ReviewError):
apply_review_state({}, "U1-001", state="fixed", reason="ok", user_id="local")
def test_accepted_with_reason_is_stored():
states = apply_review_state(
{}, "U1-001", state="accepted", reason="will spin ECO-12", user_id="local",
user_name="Michele",
)
assert states["U1-001"]["state"] == "accepted"
assert states["U1-001"]["reason"] == "will spin ECO-12"
assert states["U1-001"]["user_id"] == "local"
def test_eco_includes_accepted_not_false_positive():
findings = [
_finding(finding_id="U1-001"),
_finding(finding_id="U2-001", designator="U2", finding="noise"),
]
states = apply_review_state({}, "U1-001", state="accepted", reason="add cap", user_id="a")
states = apply_review_state(states, "U2-001", state="false_positive", reason="ok in app", user_id="a")
eco = build_eco(findings, states)
assert [row["finding_id"] for row in eco] == ["U1-001"]
assert eco[0]["rule_id"] == "PS-DEC-001"
assert eco[0]["ref"] == "U1"
assert "100nF" in eco[0]["after"]
csv = eco_csv(eco)
assert "U1-001" in csv
assert "U2-001" not in csv
def test_open_and_wontfix_are_not_eco_rows():
findings = [_finding()]
states = apply_review_state({}, "U1-001", state="wontfix", reason="wont ship", user_id="a")
assert build_eco(findings, states) == []
assert build_eco(findings, {}) == []
def test_signature_changes_when_findings_change():
a = sign_report({"findings": [{"finding_id": "U1-001"}]}, user_id="local")
b = sign_report({"findings": [{"finding_id": "U1-002"}]}, user_id="local")
assert a["user_id"] == "local"
assert a["sha256"] != b["sha256"]
assert a["timestamp"]
def _client(tmp_path):
from fastapi.testclient import TestClient
from backend.main import app
from backend.services.storage import LocalStorageBackend
app.state.storage = LocalStorageBackend(tmp_path)
return TestClient(app)
def _seed_report(client, findings):
meta = client.post("/api/projects", json={"name": "board"}).json()
pid = meta["id"]
storage = client.app.state.storage
prefix = f"users/local/projects/{pid}"
storage.write_json(f"{prefix}/report.json", {
"project": "board",
"timestamp": "2026-01-01T00:00:00+00:00",
"findings": [f.model_dump() for f in findings],
"summary": {"total": len(findings), "ERROR": 1, "WARNING": 0, "INFO": 0},
})
return pid
def test_api_review_without_reason_is_400(tmp_path):
client = _client(tmp_path)
pid = _seed_report(client, [_finding()])
res = client.put(f"/api/report/{pid}/findings/U1-001/review", json={
"state": "accepted", "reason": "",
})
assert res.status_code == 400
def test_api_review_and_eco(tmp_path):
client = _client(tmp_path)
pid = _seed_report(client, [_finding()])
res = client.put(f"/api/report/{pid}/findings/U1-001/review", json={
"state": "accepted", "reason": "add 100nF near U1.3",
})
assert res.status_code == 200
report = client.get(f"/api/report/{pid}").json()
assert report["review_states"]["U1-001"]["state"] == "accepted"
eco = client.get(f"/api/report/{pid}/eco.json").json()
assert eco["items"][0]["finding_id"] == "U1-001"
csv = client.get(f"/api/report/{pid}/eco.csv")
assert csv.status_code == 200
assert "U1-001" in csv.text
def test_api_unknown_finding_is_404(tmp_path):
client = _client(tmp_path)
pid = _seed_report(client, [_finding()])
res = client.put(f"/api/report/{pid}/findings/NOPE/review", json={
"state": "accepted", "reason": "x",
})
assert res.status_code == 404
def test_api_sign_release(tmp_path):
client = _client(tmp_path)
pid = _seed_report(client, [_finding()])
res = client.post(f"/api/report/{pid}/sign")
assert res.status_code == 200
body = res.json()
assert len(body["sha256"]) == 64
report = client.get(f"/api/report/{pid}").json()
assert report["release"]["sha256"] == body["sha256"]
assert report["release"]["user_id"] == "local"