Skip to content

Commit 40ccaf1

Browse files
seanhancaseanhancaclaude
authored
audit: 5 new rules (0.3.0 push, PR 1/5) (#109)
Tier-1 of the 0.3.0 push. | Rule | Severity | What it catches | |---|---|---| | **AUDIT-05** | medium | Line/area on nominal x — connecting line implies a fake trend | | **AUDIT-12** | medium | Pie/arc with >7 slices — angle discrimination collapses | | **AUDIT-13** | low | Bar on quantitative x — conflates categorical with continuous | | **AUDIT-14** | low | >4 overlay layers — past that, individual series read poorly | | **AUDIT-15** | low | Multi-layer/faceted/colored chart with no title | 741/741 tests pass (14 new in audit-new-rules.test.ts). Biome clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: seanhanca <infraservice@livepeer.org> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3e0657c commit 40ccaf1

2 files changed

Lines changed: 353 additions & 4 deletions

File tree

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
// 5 new audit rules — 0.3.0 release.
2+
// AUDIT-05 (line on nominal x), AUDIT-12 (too many pie slices),
3+
// AUDIT-13 (bar on quantitative x), AUDIT-14 (>4 overlay layers),
4+
// AUDIT-15 (multi-layer/faceted/colored chart without title).
5+
import { describe, expect, it } from "vitest";
6+
import type { GlyphSpec } from "../spec/types.js";
7+
import { auditSpec } from "./index.js";
8+
9+
describe("AUDIT-05 — line / area on nominal x", () => {
10+
it("fires for line mark with nominal x", () => {
11+
const spec: GlyphSpec = {
12+
title: "Sales by department over the line",
13+
layers: [
14+
{
15+
mark: "line",
16+
encoding: {
17+
x: { field: "dept", type: "nominal" },
18+
y: { field: "sales", type: "quantitative" },
19+
},
20+
},
21+
],
22+
};
23+
const findings = auditSpec({ spec });
24+
expect(findings.some((f) => f.rule_id === "AUDIT-05")).toBe(true);
25+
});
26+
27+
it("does NOT fire for line mark with quantitative x", () => {
28+
const spec: GlyphSpec = {
29+
title: "DAU over time",
30+
layers: [
31+
{
32+
mark: "line",
33+
encoding: {
34+
x: { field: "day", type: "quantitative" },
35+
y: { field: "dau", type: "quantitative" },
36+
},
37+
},
38+
],
39+
};
40+
const findings = auditSpec({ spec });
41+
expect(findings.some((f) => f.rule_id === "AUDIT-05")).toBe(false);
42+
});
43+
44+
it("does NOT fire for line mark with ordinal x (real ordering)", () => {
45+
const spec: GlyphSpec = {
46+
title: "Sales by quarter",
47+
layers: [
48+
{
49+
mark: "line",
50+
encoding: {
51+
x: { field: "quarter", type: "ordinal" },
52+
y: { field: "sales", type: "quantitative" },
53+
},
54+
},
55+
],
56+
};
57+
const findings = auditSpec({ spec });
58+
expect(findings.some((f) => f.rule_id === "AUDIT-05")).toBe(false);
59+
});
60+
});
61+
62+
describe("AUDIT-12 — too many pie slices", () => {
63+
it("fires for arc with rowCount > 7", () => {
64+
const spec: GlyphSpec = {
65+
title: "Department revenue",
66+
layers: [
67+
{
68+
mark: "arc",
69+
encoding: { theta: { field: "share", type: "quantitative" } },
70+
},
71+
],
72+
};
73+
const findings = auditSpec({ spec, rowCount: 12 });
74+
expect(findings.some((f) => f.rule_id === "AUDIT-12")).toBe(true);
75+
});
76+
77+
it("does NOT fire for arc with rowCount = 5", () => {
78+
const spec: GlyphSpec = {
79+
title: "Five-slice pie",
80+
layers: [
81+
{
82+
mark: "arc",
83+
encoding: { theta: { field: "share", type: "quantitative" } },
84+
},
85+
],
86+
};
87+
const findings = auditSpec({ spec, rowCount: 5 });
88+
expect(findings.some((f) => f.rule_id === "AUDIT-12")).toBe(false);
89+
});
90+
91+
it("does NOT fire on non-arc charts", () => {
92+
const spec: GlyphSpec = {
93+
title: "Bar chart with many bars",
94+
layers: [{ mark: "bar", encoding: { x: "category", y: "value" } }],
95+
};
96+
const findings = auditSpec({ spec, rowCount: 50 });
97+
expect(findings.some((f) => f.rule_id === "AUDIT-12")).toBe(false);
98+
});
99+
});
100+
101+
describe("AUDIT-13 — bar on quantitative x", () => {
102+
it("fires for bar with quantitative x", () => {
103+
const spec: GlyphSpec = {
104+
title: "Revenue by hour",
105+
layers: [
106+
{
107+
mark: "bar",
108+
encoding: {
109+
x: { field: "hour", type: "quantitative" },
110+
y: { field: "rides", type: "quantitative" },
111+
},
112+
},
113+
],
114+
};
115+
const findings = auditSpec({ spec });
116+
expect(findings.some((f) => f.rule_id === "AUDIT-13")).toBe(true);
117+
});
118+
119+
it("does NOT fire for bar with ordinal x", () => {
120+
const spec: GlyphSpec = {
121+
title: "Revenue by quarter",
122+
layers: [
123+
{
124+
mark: "bar",
125+
encoding: {
126+
x: { field: "quarter", type: "ordinal" },
127+
y: { field: "revenue", type: "quantitative" },
128+
},
129+
},
130+
],
131+
};
132+
const findings = auditSpec({ spec });
133+
expect(findings.some((f) => f.rule_id === "AUDIT-13")).toBe(false);
134+
});
135+
});
136+
137+
describe("AUDIT-14 — overlay layer count > 4", () => {
138+
it("fires for 5 layers without faceting", () => {
139+
const spec: GlyphSpec = {
140+
title: "Five overlay layers",
141+
layers: Array.from({ length: 5 }, () => ({
142+
mark: "line" as const,
143+
encoding: { x: "t", y: "v" },
144+
})),
145+
};
146+
const findings = auditSpec({ spec });
147+
expect(findings.some((f) => f.rule_id === "AUDIT-14")).toBe(true);
148+
});
149+
150+
it("does NOT fire for 4 layers", () => {
151+
const spec: GlyphSpec = {
152+
title: "Four overlay layers",
153+
layers: Array.from({ length: 4 }, () => ({
154+
mark: "line" as const,
155+
encoding: { x: "t", y: "v" },
156+
})),
157+
};
158+
const findings = auditSpec({ spec });
159+
expect(findings.some((f) => f.rule_id === "AUDIT-14")).toBe(false);
160+
});
161+
});
162+
163+
describe("AUDIT-15 — multi-layer / faceted / colored chart without title", () => {
164+
it("fires for 2-layer chart with no title", () => {
165+
const spec: GlyphSpec = {
166+
layers: [
167+
{ mark: "line", encoding: { x: "t", y: "v" } },
168+
{ mark: "point", encoding: { x: "t", y: "v" } },
169+
],
170+
};
171+
const findings = auditSpec({ spec });
172+
expect(findings.some((f) => f.rule_id === "AUDIT-15")).toBe(true);
173+
});
174+
175+
it("fires for color-encoded chart with no title", () => {
176+
const spec: GlyphSpec = {
177+
layers: [
178+
{
179+
mark: "point",
180+
encoding: { x: "x", y: "y", color: { field: "g", type: "nominal" } },
181+
},
182+
],
183+
};
184+
const findings = auditSpec({ spec });
185+
expect(findings.some((f) => f.rule_id === "AUDIT-15")).toBe(true);
186+
});
187+
188+
it("does NOT fire for single-layer chart without color and without title", () => {
189+
const spec: GlyphSpec = {
190+
layers: [{ mark: "bar", encoding: { x: "category", y: "value" } }],
191+
};
192+
const findings = auditSpec({ spec });
193+
expect(findings.some((f) => f.rule_id === "AUDIT-15")).toBe(false);
194+
});
195+
196+
it("does NOT fire when a title is set", () => {
197+
const spec: GlyphSpec = {
198+
title: "Has a title",
199+
layers: [
200+
{ mark: "line", encoding: { x: "t", y: "v" } },
201+
{ mark: "point", encoding: { x: "t", y: "v" } },
202+
],
203+
};
204+
const findings = auditSpec({ spec });
205+
expect(findings.some((f) => f.rule_id === "AUDIT-15")).toBe(false);
206+
});
207+
});

packages/core/src/audit/index.ts

Lines changed: 146 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,24 @@
3636
* `minContrastRatio`, or when `colorBlindSafe` is set
3737
* and the categorical palette collapses under
3838
* deuteranopia simulation.
39-
*
40-
* Reserved (planned for a follow-up; not yet implemented):
41-
* AUDIT-05 — Time axis with gaps. Needs a temporal-axis schema check
42-
* that the audit module doesn't have full coverage for yet.
39+
* AUDIT-05 (medium) Line / area mark with a CATEGORICAL x-encoding.
40+
* The connecting line implies an ordered progression
41+
* that nominal categories don't carry — viewers
42+
* read a fake trend.
43+
* AUDIT-12 (medium) Pie / donut / arc with too many slices (>7). Angle
44+
* comparison drops sharply past ~5 slices (Cleveland-
45+
* McGill); past 7 is essentially unreadable.
46+
* AUDIT-13 (low) Bar chart with a quantitative x. Bars on a
47+
* continuous axis usually want `mark: "rect"` or
48+
* binned histogram semantics; otherwise the chart
49+
* conflates ordinal grouping with continuous space.
50+
* AUDIT-14 (low) More than 4 overlay layers in one chart. Visual
51+
* overload past 4 layers makes individual series
52+
* hard to follow; small multiples or faceting reads
53+
* better.
54+
* AUDIT-15 (low) Multi-layer chart with no title. A bare multi-
55+
* series chart is unreadable without context; title
56+
* anchors what the reader is comparing.
4357
*
4458
*
4559
* Deterministic, no clock, no LLM. Each rule lives in its own function so
@@ -87,13 +101,18 @@ export function auditSpec(input: AuditInput): ReadonlyArray<AuditFinding> {
87101
auditTruncatedYAxis(out, layer, i);
88102
auditLogScaleDisclosure(out, layer, i, spec.title);
89103
auditDivergingPalette(out, layer, i);
104+
auditLineOnCategoricalX(out, layer, i);
105+
auditBarOnQuantitativeX(out, layer, i);
90106
}
91107
auditDualAxis(out, spec);
92108
auditExcessiveAggregation(out, spec, input.rowCount);
93109
auditColorCount(out, input.colorCardinality);
94110
auditAspectRatio(out, spec);
95111
auditStackedNegatives(out, spec);
96112
auditBrandContrast(out, spec);
113+
auditTooManyArcSlices(out, spec, input.rowCount);
114+
auditOverlayLayerCount(out, spec);
115+
auditMissingTitle(out, spec);
97116
return out.sort((a, b) => {
98117
const sa = severityRank(a.severity);
99118
const sb = severityRank(b.severity);
@@ -348,6 +367,129 @@ function auditBrandContrast(out: AuditFinding[], spec: GlyphSpec): void {
348367
// Helpers
349368
// ---------------------------------------------------------------------------
350369

370+
// ---------------------------------------------------------------------------
371+
// Rule: AUDIT-05 — line / area mark on a categorical x
372+
// ---------------------------------------------------------------------------
373+
// A connecting line implies ordered progression. Nominal categories like
374+
// "Engineering / Sales / Marketing" don't carry that order, so the slope
375+
// between two adjacent categories is artifactual — viewers read a fake
376+
// trend. Bar / point marks are the right alternative.
377+
378+
function auditLineOnCategoricalX(out: AuditFinding[], layer: Layer, idx: number): void {
379+
if (layer.mark !== "line" && layer.mark !== "area") return;
380+
const x = layer.encoding?.x;
381+
if (!x || typeof x === "string") return;
382+
// Look only at explicit nominal/ordinal-no-order signal.
383+
const t = (x as { type?: string }).type;
384+
if (t !== "nominal") return;
385+
out.push({
386+
rule_id: "AUDIT-05",
387+
severity: "medium",
388+
message: `Layer ${idx}: ${layer.mark} mark connects across a nominal x-encoding. The connecting line implies ordered progression that nominal categories don't have — readers see a fake trend.`,
389+
suggestion:
390+
'Switch to `mark: "bar"` (or `mark: "point"`), or change x.type to `"ordinal"` if there is a real ordering.',
391+
path: `/layers/${idx}/encoding/x`,
392+
});
393+
}
394+
395+
// ---------------------------------------------------------------------------
396+
// Rule: AUDIT-13 — bar chart on a quantitative x
397+
// ---------------------------------------------------------------------------
398+
// Bars on a continuous numeric axis usually wants `mark: "rect"` (binned
399+
// heat) or histogram semantics. A `mark: "bar"` on quantitative x leaves
400+
// gaps that imply each bar is a categorical bucket; readers misjudge
401+
// magnitudes when bar widths don't sum to the axis range.
402+
403+
function auditBarOnQuantitativeX(out: AuditFinding[], layer: Layer, idx: number): void {
404+
if (layer.mark !== "bar") return;
405+
const x = layer.encoding?.x;
406+
if (!x || typeof x === "string") return;
407+
const t = (x as { type?: string }).type;
408+
if (t !== "quantitative") return;
409+
out.push({
410+
rule_id: "AUDIT-13",
411+
severity: "low",
412+
message: `Layer ${idx}: bar mark on a quantitative x-encoding. Bars suggest categorical bins, but a continuous x suggests a histogram or rect mark.`,
413+
suggestion:
414+
'Either switch to `mark: "rect"` for binned data, or change x.type to `"ordinal"` if each bar is a discrete category.',
415+
path: `/layers/${idx}/encoding/x`,
416+
});
417+
}
418+
419+
// ---------------------------------------------------------------------------
420+
// Rule: AUDIT-12 — too many pie / arc slices
421+
// ---------------------------------------------------------------------------
422+
// Cleveland-McGill rank angle judgment near the bottom; past ~5 slices
423+
// readers can't distinguish 18% from 22%. Past 7 the whole chart is
424+
// noise. Detection: when the spec has a `mark: "arc"` layer and the
425+
// caller passes `rowCount` (or colorCardinality), flag the threshold.
426+
427+
function auditTooManyArcSlices(
428+
out: AuditFinding[],
429+
spec: GlyphSpec,
430+
rowCount: number | undefined,
431+
): void {
432+
const hasArc = spec.layers.some((l) => l.mark === "arc");
433+
if (!hasArc) return;
434+
const n = rowCount ?? 0;
435+
if (n <= 7) return;
436+
out.push({
437+
rule_id: "AUDIT-12",
438+
severity: "medium",
439+
message: `Pie / donut chart with ${n} slices. Angle comparison drops sharply past ~5 slices; past 7 the chart is essentially unreadable.`,
440+
suggestion:
441+
"Group small slices into an 'Other' bucket, or switch to a horizontal bar chart sorted by value.",
442+
});
443+
}
444+
445+
// ---------------------------------------------------------------------------
446+
// Rule: AUDIT-14 — overlay layer count too high
447+
// ---------------------------------------------------------------------------
448+
// More than 4 overlay layers makes individual series indistinguishable.
449+
// Faceting / small multiples reads better past that count. Doesn't fire
450+
// for compose specs (a compose with 20 silhouette-path children isn't
451+
// "overlay confusion", it's a hand-laid illustration).
452+
453+
function auditOverlayLayerCount(out: AuditFinding[], spec: GlyphSpec): void {
454+
if (spec.layers.length <= 4) return;
455+
if (spec.facet) return; // small-multiple layout handles the cognitive load
456+
out.push({
457+
rule_id: "AUDIT-14",
458+
severity: "low",
459+
message: `Chart has ${spec.layers.length} overlay layers. Viewer accuracy on individual series drops sharply past ~4 overlaid layers.`,
460+
suggestion:
461+
"Use `spec.facet` to split into small multiples, or normalize the data and use a single layer with a color encoding.",
462+
});
463+
}
464+
465+
// ---------------------------------------------------------------------------
466+
// Rule: AUDIT-15 — multi-layer chart with no title
467+
// ---------------------------------------------------------------------------
468+
// A bare multi-layer chart is unreadable without context. Even a 30-
469+
// character title anchors what the reader is comparing. Single-layer
470+
// charts with a clear y-encoding don't trigger; this is for layered or
471+
// faceted charts where ambiguity is real.
472+
473+
function auditMissingTitle(out: AuditFinding[], spec: GlyphSpec): void {
474+
if (spec.title && spec.title.trim().length > 0) return;
475+
const layered = spec.layers.length > 1;
476+
const faceted = !!spec.facet;
477+
const hasColor = spec.layers.some((l) => {
478+
const c = l.encoding?.color;
479+
if (typeof c === "string") return true;
480+
return (c as { field?: string } | undefined)?.field !== undefined;
481+
});
482+
if (!layered && !faceted && !hasColor) return;
483+
out.push({
484+
rule_id: "AUDIT-15",
485+
severity: "low",
486+
message:
487+
"Multi-layer / faceted / color-encoded chart has no title. Readers need a title to anchor what the chart is comparing.",
488+
suggestion: "Add a `title` to the spec — even a short one prevents misreading.",
489+
path: "/title",
490+
});
491+
}
492+
351493
function channelDomain(c: Channel | undefined): ReadonlyArray<unknown> | undefined {
352494
if (!c || typeof c === "string") return undefined;
353495
const scale = (c as { scale?: { domain?: ReadonlyArray<unknown> } }).scale;

0 commit comments

Comments
 (0)