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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
---
created: 2026-05-05
depth: tactical
id: EVID-013
kind: evidence
links:
- target: PRD-005
relation: informs
- target: RFC-004
relation: informs
- target: EVID-012
relation: informs
status: active
title: "PRD-005 F5 audit-cleanup acceptance: PR #28 merged, 18 tests passing, radii cache regression fixed, CVE-2024-47764 closed"
updated: 2026-05-05
---

# EVID-013: PRD-005 F5 audit-cleanup — PR #28 acceptance

| Field | Value |
| ----------- | -------------------------------------------------------- |
| Status | Active |
| Created | 2026-05-05 |
| Valid Until | 2026-08-05 (3 months — re-verify if cluster lib changes) |
| Target | PRD-005, RFC-004 |

## Structured Fields

verdict: supports
congruence_level: 3
evidence_type: measurement

## Measurement

PR #28 (`feature/audit-cleanup-f5 -> develop`, merge commit `6dac7b4`)
closed all post-merge audit findings on PR #26 (4 expert agents:
TypeScript / Frontend / Security / Performance) plus a regression
caught during live verification.

Three layers of acceptance:

- **Audit closure** — every CRITICAL/HIGH/MEDIUM/LOW finding mapped
to a code change with a `Refs: PRD-005 RFC-004` commit footer.
- **Unit tests** — 18 vitest tests in `template/src/widgets/dependency-graph/lib/`:
16 in `cluster.test.ts` (chord rule, radial gap, type-rank,
anchored angles, multi-cluster placement); 2 in `regression.test.ts`
that fail against the pre-fix code.
- **Live DOM verification** — Playwright on `http://127.0.0.1:5177`
confirms `0 bbox overlaps` and `23/23 cards exact-on-orbit` for the
current 23-artifact / 5-cluster workspace.

### Layer A — audit findings closed

| Severity | Finding | Closed by |
| ---------- | ------------------------------------------------------------------------- | ---------------------------------- |
| CRITICAL-1 | ForceView each-block link key by object identity (recreates all `<line>`) | `8fd3f53` |
| CRITICAL-2 | `detectClusters` double-work + filter memo + radii cache dependency order | `8fd3f53` + `f062b38` (regression) |
| HIGH-1 | `force-cluster-repel` unsafe cast → typed `Object.assign` | `8fd3f53` |
| HIGH-2 | `forceClusterRepel` re-init only; drop redundant force re-bind | `8fd3f53` |
| HIGH-3 | `scoreById` memo + `didFit` reactive | `8fd3f53` |
| HIGH-4 | Zoom legibility floor 0.45 + repel short-circuit | `8fd3f53` |
| HIGH-5 | `noUncheckedIndexedAccess: true` + fallout | `8fd3f53` |
| MEDIUM-1 | a11y: `role="img"` + `role="button"` setup verified | `8fd3f53` (verify-only) |
| MEDIUM-2 | Drop unused `_adjacency` param; rename `_tick` → `_invalidationTick` | `8fd3f53` |
| MEDIUM-3 | Demote misleading `FIXME(reactivity-loop)` to plain comment | `8fd3f53` |
| LOW-1 | Bump transitive `cookie ≥ 0.7.0` (CVE-2024-47764) | `06574d3` |

### Layer B — vitest unit tests (18 / 18)

| Suite | Test count | What it asserts |
| -------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------ |
| `cluster.test.ts` | 16 | chord rule N=2..12; ring-gap rule; N=1 floor; ring-0 pin; monotonicity; type-rank; anchored angles; K=0/1/2/≥3 placement |
| `regression.test.ts` | 2 | radii dependency order — fails against pre-fix code where `Map.keys()` returned non-monotonic ring order |

### Layer C — live DOM verification

| Assertion | Measured | Verdict |
| ------------------------------------------------------------------------------------ | ---------------------------------- | ------- |
| Number of bbox overlaps across all rendered cards | **0** | ✅ pass |
| Cards whose centre is EXACTLY on their orbit ring (error ≤ 0.5 px) | **23 / 23** | ✅ pass |
| Centre cluster `R_centre + R_outer + INTER_CLUSTER_GAP` ≤ measured centroid distance | satisfied for all 4 outer clusters | ✅ pass |
| `npx svelte-check` | 0 errors / 0 warnings / 405 files | ✅ pass |
| `npm test` | 18 / 18 passed | ✅ pass |
| `npm run smoke` | PASS | ✅ pass |
| CI `smoke` on PR #28 | success | ✅ pass |

## Result

| ID | Target | Verdict |
| ---- | ---------------------------------------------------------------------------------------- | ------- |
| SC-1 | All CRITICAL audit findings closed in code | ✅ pass |
| SC-2 | All HIGH audit findings closed in code | ✅ pass |
| SC-3 | All MEDIUM audit findings closed in code | ✅ pass |
| SC-4 | LOW finding (CVE-2024-47764) closed via overrides | ✅ pass |
| SC-5 | Vitest suite covers chord rule + ring-gap rule + radii cache dependency-order regression | ✅ pass |
| SC-6 | Live DOM `0 overlaps`, `23/23 exact-on-orbit` (improved from `21/22` baseline) | ✅ pass |

## Interpretation

The post-merge audit on PR #26 found a real regression (radii cache
populated out of dependency order — `Map.keys()` returned rings in
insertion order, not sorted). Without it, two cluster members ended
up at exactly the same logical position on overlapping rings.

The fix (sorted iteration in `detectClusters` pass-2) is now backed
by a regression test that fails against the pre-fix code, plus the
live DOM verification on the actual 23-artifact / 5-cluster workspace
that triggered the bug originally.

The geometry guarantees from RFC-004 (chord rule, radial-gap rule,
inter-cluster gap rule) hold end-to-end: card centres sit exactly on
their orbits, no bbox overlap is possible by construction. The audit
loop also tightened the TypeScript posture (`noUncheckedIndexedAccess`)
and closed the open dependabot CVE.

## Congruence Level Justification

**CL3 (same-context, penalty 0.0)**:

- DOM measurement runs against the same SvelteKit dev server users
see. No proxy.
- Regression test reproduces the exact failure mode that affected
the live render. It runs in CI on every PR.
- `evidence_type: measurement` — every assertion is a numeric
comparison against a closed-form bound (chord, ring-gap,
centroid distance).

## Related Artifacts

| Artifact | Relation | Notes |
| -------- | -------- | -------------------------------------------------------------------- |
| PRD-005 | informs | F5 closes audit findings on the F4 work that closed PRD-005. |
| RFC-004 | informs | The geometry RFC; F5 protects its invariants with regression tests. |
| EVID-012 | informs | F4 acceptance baseline — F5 builds on it (now `23/23`, was `21/22`). |
52 changes: 52 additions & 0 deletions template/src/widgets/dependency-graph/lib/keyboard-nav.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest";
import { pickNextNode, type FocusNode } from "./keyboard-nav";

const mk = (id: string, x: number, y: number): FocusNode => ({ id, x, y });

describe("pickNextNode — direction-based focus pick", () => {
it("ArrowRight picks the rightward neighbour over the upward one", () => {
const current = mk("A", 0, 0);
const candidates = [current, mk("RIGHT", 100, 0), mk("UP", 0, -100)];
const next = pickNextNode(current, candidates, "ArrowRight");
expect(next?.id).toBe("RIGHT");
});

it("ArrowUp picks the upward node even when a rightward one is closer", () => {
const current = mk("A", 0, 0);
const candidates = [current, mk("UP", 0, -100), mk("RIGHT", 30, 0)];
const next = pickNextNode(current, candidates, "ArrowUp");
expect(next?.id).toBe("UP");
});

it("ArrowLeft falls back to nearest when no candidate inside cone", () => {
const current = mk("A", 0, 0);
// Both candidates are to the right, none to the left → fall back to nearest.
const candidates = [current, mk("R1", 50, 0), mk("R2", 100, 0)];
const next = pickNextNode(current, candidates, "ArrowLeft");
expect(next?.id).toBe("R1");
});

it("returns null when there is only the current node", () => {
const current = mk("A", 0, 0);
expect(pickNextNode(current, [current], "ArrowDown")).toBeNull();
});

it("ArrowDown prefers a slightly-off-axis closer node over a far on-axis one", () => {
const current = mk("A", 0, 0);
// CLOSE_OFF: 10° off the down axis at distance 50.
// FAR_ON: exactly down at distance 200.
const ang = (10 * Math.PI) / 180;
const closeOff = mk("CLOSE_OFF", Math.sin(ang) * 50, Math.cos(ang) * 50);
const farOn = mk("FAR_ON", 0, 200);
const next = pickNextNode(current, [current, closeOff, farOn], "ArrowDown");
expect(next?.id).toBe("CLOSE_OFF");
});

it("ignores nodes at exactly the same position (avoid divide-by-zero)", () => {
const current = mk("A", 0, 0);
const dup = mk("DUP", 0, 0);
const right = mk("R", 50, 0);
const next = pickNextNode(current, [current, dup, right], "ArrowRight");
expect(next?.id).toBe("R");
});
});
67 changes: 67 additions & 0 deletions template/src/widgets/dependency-graph/lib/keyboard-nav.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* Direction-based focus navigation for graph views.
*
* Given the currently focused node and a desired direction (one of the
* four arrow keys), pick the next node whose centre lies inside the
* angular cone of that direction (±60° around the cardinal axis) and
* minimises a cost = distance · (1 + 2·angularDeviation/π).
*
* If no candidate falls inside the cone, fall back to the nearest
* node by Euclidean distance — so the user always moves.
*/

export type Direction = "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown";

export interface FocusNode {
id: string;
/** Centre coordinates in the same coordinate space (logical or screen). */
x: number;
y: number;
}

const HALF_CONE = Math.PI / 3; // 60° each side → 120° cone total

const directionVector = (dir: Direction): { dx: number; dy: number } => {
switch (dir) {
case "ArrowLeft":
return { dx: -1, dy: 0 };
case "ArrowRight":
return { dx: 1, dy: 0 };
case "ArrowUp":
return { dx: 0, dy: -1 };
case "ArrowDown":
return { dx: 0, dy: 1 };
}
};

export function pickNextNode(
current: FocusNode,
candidates: FocusNode[],
dir: Direction,
): FocusNode | null {
if (candidates.length === 0) return null;
const others = candidates.filter((n) => n.id !== current.id);
if (others.length === 0) return null;

const target = directionVector(dir);
let best: { node: FocusNode; cost: number } | null = null;
let nearest: { node: FocusNode; dist: number } | null = null;

for (const n of others) {
const dx = n.x - current.x;
const dy = n.y - current.y;
const dist = Math.hypot(dx, dy);
if (dist === 0) continue;

if (!nearest || dist < nearest.dist) nearest = { node: n, dist };

const cosAngle = (dx * target.dx + dy * target.dy) / dist;
const angle = Math.acos(Math.max(-1, Math.min(1, cosAngle)));
if (angle > HALF_CONE) continue;

const cost = dist * (1 + (2 * angle) / Math.PI);
if (!best || cost < best.cost) best = { node: n, cost };
}

return best?.node ?? nearest?.node ?? null;
}
34 changes: 33 additions & 1 deletion template/src/widgets/dependency-graph/ui/ForceView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
type ClusterInfo
} from '../lib/cluster.svelte';
import { forceClusterRepel } from '../lib/force-cluster-repel';
import { pickNextNode, type Direction } from '../lib/keyboard-nav';

interface Node extends SimulationNodeDatum {
id: string;
Expand Down Expand Up @@ -509,6 +510,36 @@
function onNodeClick(id: string) {
onSelect?.({ id });
}

function focusNodeById(id: string) {
const target = svgEl?.querySelector<SVGGElement>(`g.node[data-id="${id}"]`);
target?.focus();
}

function onNodeKeydown(e: KeyboardEvent, currentId: string) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onNodeClick(currentId);
return;
}
if (
e.key !== 'ArrowLeft' &&
e.key !== 'ArrowRight' &&
e.key !== 'ArrowUp' &&
e.key !== 'ArrowDown'
) {
return;
}
e.preventDefault();
const current = simNodes.find((n) => n.id === currentId);
if (!current) return;
const next = pickNextNode(
{ id: current.id, x: current.x ?? 0, y: current.y ?? 0 },
simNodes.map((n) => ({ id: n.id, x: n.x ?? 0, y: n.y ?? 0 })),
e.key as Direction,
);
if (next) focusNodeById(next.id);
}
</script>

<svg
Expand Down Expand Up @@ -549,9 +580,10 @@
<g
class="node {nodeClass(node.id, focusId, hoverDistances)}"
class:selected={node.id === selectedId}
data-id={node.id}
transform="translate({nx - node.w / 2},{ny - node.h / 2})"
onclick={(e) => { e.stopPropagation(); onNodeClick(node.id); }}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && onNodeClick(node.id)}
onkeydown={(e) => onNodeKeydown(e, node.id)}
onmouseenter={() => setHovered(node.id)}
onmouseleave={clearHovered}
onfocus={() => setHovered(node.id)}
Expand Down
Loading
Loading