fix(findings): path-shaped file refs + per-round collapsible findings (#687)

* fix(findings): enforce path-shaped file refs; group panel findings by round

- The findings chokepoint rejects a file that is not a repo-relative
  path shape (prose like a PR reference validated before, and the panel
  then rendered a doomed file-content fetch for it) — narrative belongs
  in evidence, the remediate says so.
- The task-detail Findings tab groups findings into per-round
  collapsible sections (newest expanded) and only attempts a code
  snippet for a path-shaped file ref, so historical prose refs render
  as plain metadata instead of a broken loader.

* fix(findings): admit client-repo path conventions; teach the file-less option

- The shape gate reviews arbitrary client projects, not just this repo:
  plus and at-sign join the character class so SvelteKit route files,
  @types dirs, and @2x assets stay citable. Spaces stay excluded — they
  are the prose signal.
- The file-rejection remediate names the file-less option for
  cross-cutting findings.
- The client mirror notes its deliberate non-ASCII divergence from the
  server gate (unicode server-pass renders snippetless, fail-open).

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-24 17:09:49 +02:00
committed by GitHub
co-authored by Renn F
parent 21910d75ea
commit 4b2546ae19
9 changed files with 302 additions and 52 deletions
@@ -44,7 +44,7 @@ describe("TabFindings", () => {
).toBeInTheDocument();
});
it("groups findings by round and renders severity/status", () => {
it("groups findings by round, expands the newest, collapses older rounds", async () => {
const response: TaskFindingsResponse = {
findings: [
{
@@ -99,8 +99,20 @@ describe("TabFindings", () => {
expect(screen.getByText("Round 2")).toBeInTheDocument();
expect(screen.getByText("Round 1")).toBeInTheDocument();
// The newest round (2) is expanded by default — its finding is visible.
const round2Button = screen.getByText("Round 2").closest("button");
const round1Button = screen.getByText("Round 1").closest("button");
expect(round2Button).toHaveAttribute("aria-expanded", "true");
expect(round1Button).toHaveAttribute("aria-expanded", "false");
expect(screen.getByText("blocker")).toBeInTheDocument();
expect(screen.getByText("minor")).toBeInTheDocument();
// Round 1's finding is collapsed, so its content isn't rendered yet.
expect(screen.queryByText("minor")).toBeNull();
expect(screen.queryByText("abc1234")).toBeNull();
// Expanding round 1 reveals its finding.
const user = userEvent.setup();
await user.click(round1Button!);
expect(await screen.findByText("minor")).toBeInTheDocument();
expect(screen.getByText("abc1234")).toBeInTheDocument();
expect(screen.queryByText(/more not shown/)).toBeNull();
});
@@ -179,4 +191,75 @@ describe("TabFindings", () => {
"Must be fixed before this task can pass review.",
);
});
it("skips the CodeSnippet fetch for a non-path file but still renders it as text", () => {
const response: TaskFindingsResponse = {
findings: [
{
id: "eeeeeeee-0000-0000-0000-000000000000",
task_id: "t1",
origin: "pm",
round: 1,
author_slug: "be-pm",
file: "PR #676 description",
line: null,
severity: "major",
criterion: null,
expected: "x",
actual: "y",
fix: null,
evidence: null,
status: "open",
addressed_by_commit: null,
resolution_note: null,
created_at: "2026-07-11T00:00:00Z",
updated_at: null,
},
],
summary: [],
total: 1,
truncated: false,
};
useTaskFindings.mockReturnValue({ data: response, isLoading: false });
render(<TabFindings task={buildTask()} />);
// The prose file still renders as plain metadata text...
expect(screen.getByText("PR #676 description")).toBeInTheDocument();
// ...but never drives the doomed CodeSnippet fetch.
expect(screen.queryByTestId("code-snippet")).toBeNull();
});
it("still renders CodeSnippet for a real path-shaped file", () => {
const response: TaskFindingsResponse = {
findings: [
{
id: "ffffffff-0000-0000-0000-000000000000",
task_id: "t1",
origin: "qa",
round: 1,
author_slug: "be-qa",
file: "roboco/services/task.py",
line: 12,
severity: "major",
criterion: null,
expected: "x",
actual: "y",
fix: null,
evidence: null,
status: "open",
addressed_by_commit: null,
resolution_note: null,
created_at: "2026-07-11T00:00:00Z",
updated_at: null,
},
],
summary: [],
total: 1,
truncated: false,
};
useTaskFindings.mockReturnValue({ data: response, isLoading: false });
render(<TabFindings task={buildTask()} />);
expect(screen.getByTestId("code-snippet")).toBeInTheDocument();
});
});
@@ -9,6 +9,19 @@ import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import { ListChecks } from "lucide-react";
import { CodeSnippet } from "@/components/git/code-snippet";
import { CollapsibleSection } from "./collapsible-section";
// Mirrors the server-side shape gate (Finding._file_repo_relative in
// roboco/foundation/policy/content/models.py) — keep the character classes
// in sync, knowing they deliberately diverge on non-ASCII: Python's \w is
// unicode, JS's is ASCII, so a unicode path the server accepted renders
// snippetless here (fail-open — the plain-text metadata still shows). A
// prose `file` (e.g. a PR reference) predates that gate on older ledger
// rows and must not drive a doomed CodeSnippet fetch.
const FILE_SHAPE_RE = /^[\w.\-/()[\]+@]+$/;
function looksLikePath(file: string): boolean {
return FILE_SHAPE_RE.test(file);
}
interface TabFindingsProps {
task: Task;
@@ -43,14 +56,16 @@ const ORIGIN_LABEL: Record<string, string> = {
// findings-ledger domain (roboco/foundation/policy/conventions/findings.py).
const SEVERITY_DESCRIPTIONS: Record<string, string> = {
blocker: "Must be fixed before this task can pass review.",
major: "A significant defect; should be fixed but isn't review-blocking alone.",
major:
"A significant defect; should be fixed but isn't review-blocking alone.",
minor: "A smaller defect worth fixing.",
nit: "A nitpick — cosmetic or stylistic; fix if convenient.",
};
const STATUS_DESCRIPTIONS: Record<string, string> = {
open: "Not yet addressed by the assignee.",
addressed: "The assignee says this is fixed — awaiting reviewer verification.",
addressed:
"The assignee says this is fixed — awaiting reviewer verification.",
verified: "A reviewer confirmed the fix.",
waived: "Explicitly waived — no fix required.",
};
@@ -67,7 +82,9 @@ function FindingCard({
<CardContent className="pt-4 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<HelpTip label={SEVERITY_DESCRIPTIONS[finding.severity]}>
<Badge className={SEVERITY_CLASS[finding.severity] ?? SEVERITY_CLASS.nit}>
<Badge
className={SEVERITY_CLASS[finding.severity] ?? SEVERITY_CLASS.nit}
>
{finding.severity}
</Badge>
</HelpTip>
@@ -95,7 +112,7 @@ function FindingCard({
</HelpTip>
)}
</div>
{finding.file && (
{finding.file && looksLikePath(finding.file) && (
<CodeSnippet
branch={branch}
file={finding.file}
@@ -158,8 +175,7 @@ export function TabFindings({ task }: TabFindingsProps) {
<ListChecks className="mx-auto mb-4 h-12 w-12 opacity-50" />
<p>No revision findings recorded yet.</p>
<p className="mt-2 text-sm">
Findings appear here after the first QA / PR-review / PM / CEO
bounce.
Findings appear here after the first QA / PR-review / PM / CEO bounce.
</p>
</div>
);
@@ -190,27 +206,43 @@ export function TabFindings({ task }: TabFindingsProps) {
))}
</div>
)}
{rounds.map((group) => (
<div key={group.round} className="space-y-3">
<div className="flex items-center gap-2">
<HelpTip label="Each bounce back to the assignee starts a new round">
<h3 className="text-sm font-semibold w-fit">
Round {group.round}
</h3>
</HelpTip>
<HelpTip label="Which reviewer stage raised this round's findings">
<Badge variant="outline">
{ORIGIN_LABEL[group.origin] ?? group.origin}
</Badge>
</HelpTip>
</div>
<div className="space-y-3">
{group.items.map((f) => (
<FindingCard key={f.id} finding={f} branch={task.branch_name} />
))}
</div>
</div>
))}
{rounds.map((group, idx) => {
const openCount = group.items.filter((f) => f.status === "open").length;
return (
<CollapsibleSection
key={group.round}
// Rounds arrive newest-first (see the grouping loop above) — only
// the newest is worth seeing without a click; older rounds are
// usually already resolved history.
defaultOpen={idx === 0}
title={
<div className="flex flex-wrap items-center gap-2">
<HelpTip label="Each bounce back to the assignee starts a new round">
<span>Round {group.round}</span>
</HelpTip>
<HelpTip label="Which reviewer stage raised this round's findings">
<Badge variant="outline">
{ORIGIN_LABEL[group.origin] ?? group.origin}
</Badge>
</HelpTip>
<HelpTip label="Total findings this round, and how many are still unaddressed">
<Badge variant="secondary">
{group.items.length}{" "}
{group.items.length === 1 ? "finding" : "findings"}
{openCount > 0 ? ` · ${openCount} open` : ""}
</Badge>
</HelpTip>
</div>
}
>
<div className="space-y-3">
{group.items.map((f) => (
<FindingCard key={f.id} finding={f} branch={task.branch_name} />
))}
</div>
</CollapsibleSection>
);
})}
{data?.truncated && (
<p className="text-xs text-muted-foreground">
{data.total - findings.length} more not shown ({data.total} total)