Skip to content

Commit 91f0485

Browse files
committed
feat(ai): implement op-design-lint Rust crate (S1)
Port the pen-ai-skills diagnostics layer to a new pure Rust crate `op-design-lint`: 14 design-lint detectors, the detect_all aggregator, apply_fixes / detect_and_fix, and golden parity tests against the TS oracle. Wire it into op-mcp as the read-only debug_validation_report tool, gated by OPENPENCIL_DEBUG_TOOLS=1. Detectors: empty_paths, unexpected_rotation, excessive_frame_effects, invisible_containers, text_explicit_heights, text_effect, text_corner_radius, text_stroke, text_bg_contrast, edge_section_padding, stacked_horizontal_padding, sibling_inconsistencies (+ check_consistency), detect_all. Also includes: node_util shared helpers + pen-core color/visibility ports, node_mut field accessors, set_property issue->node mutation dispatch, golden fixture corpus + TS dump script, structural-parity test, a CI golden-drift guard, and the gitignore fix so the fixture docs/ dir is tracked. This branch's per-commit history was squashed: the original 28 commits carried fabricated timestamps and could not be honestly reconstructed, so the work is recorded as a single commit at its real completion time.
1 parent 408d7c2 commit 91f0485

50 files changed

Lines changed: 5998 additions & 2 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/rust-check.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ on:
1111
- 'deny.toml'
1212
- 'tools/check-jian-boundaries.sh'
1313
- 'tools/check-widget-boundary.sh'
14+
- 'tools/dump-diagnostics-golden.ts'
15+
- 'packages/pen-ai-skills/**'
1416
- '.github/workflows/rust-check.yml'
1517
push:
1618
branches: ['**']
@@ -24,6 +26,8 @@ on:
2426
- 'rustfmt.toml'
2527
- 'tools/check-jian-boundaries.sh'
2628
- 'tools/check-widget-boundary.sh'
29+
- 'tools/dump-diagnostics-golden.ts'
30+
- 'packages/pen-ai-skills/**'
2731
- '.github/workflows/rust-check.yml'
2832

2933
jobs:
@@ -93,3 +97,27 @@ jobs:
9397
- uses: EmbarkStudios/cargo-deny-action@v2 # auto-updates within v2.x; runner cargo at this stage carries cargo-deny 0.18+ which handles modern transitive manifests (Phase 1 Task 1.8 finding)
9498
with:
9599
command: check
100+
101+
# S1 Plan C Task 6 — golden parity drift guard (spec §8, Risk R4).
102+
# The TS `pen-ai-skills` diagnostics layer is the parity oracle for the
103+
# Rust `op-design-lint` detectors. This job re-runs the golden-dump script
104+
# against the live TS detectors and fails if the regenerated golden differs
105+
# from the committed copy — catching a TS detector change that would
106+
# silently stale the Rust parity baseline (`tests/parity.rs`).
107+
diagnostics-golden-drift:
108+
name: diagnostics golden drift
109+
runs-on: ubuntu-latest
110+
steps:
111+
- uses: actions/checkout@v4
112+
with:
113+
submodules: recursive
114+
- uses: oven-sh/setup-bun@v2
115+
- name: Install workspace dependencies
116+
run: bun install --frozen-lockfile
117+
- name: Regenerate diagnostics golden from the TS oracle
118+
run: bun run tools/dump-diagnostics-golden.ts
119+
- name: Fail on golden drift
120+
# A non-empty diff means the committed golden no longer matches the
121+
# TS detectors — regenerate locally (`bun run tools/dump-diagnostics-golden.ts`)
122+
# and re-verify the Rust parity test before committing.
123+
run: git diff --exit-code crates/op-design-lint/tests/fixtures/golden/

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ editor-*.png
1717
scripts/ab-corpus/runs/
1818

1919
docs/
20+
# The bare `docs/` pattern above also shadows this test-fixture dir, which
21+
# MUST be tracked (the CI diagnostics-golden drift guard reads it).
22+
!crates/op-design-lint/tests/fixtures/docs/
2023

2124
# Build outputs
2225
out/

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/op-design-lint/Cargo.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[package]
2+
name = "op-design-lint"
3+
version.workspace = true
4+
edition.workspace = true
5+
rust-version.workspace = true
6+
license.workspace = true
7+
description = "OpenPencil design diagnostics — pure detectors + fixes over jian PenDocument"
8+
9+
[lib]
10+
name = "op_design_lint"
11+
path = "src/lib.rs"
12+
13+
[dependencies]
14+
jian-ops-schema = { path = "../../vendor/jian/crates/jian-ops-schema" }
15+
serde = { workspace = true }
16+
serde_json = { workspace = true }

crates/op-design-lint/src/color.rs

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
//! WCAG color helpers shared across diagnostic detectors.
2+
//!
3+
//! Ported behaviour-for-behaviour from
4+
//! `packages/pen-ai-skills/src/diagnostics/color-utils.ts`. Any change here
5+
//! must keep the contrast epsilon tests green (spec §8).
6+
7+
/// A parsed sRGB color. Alpha is dropped during parsing.
8+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9+
pub struct Rgb {
10+
pub r: u8,
11+
pub g: u8,
12+
pub b: u8,
13+
}
14+
15+
/// Parse `#rgb` / `#rrggbb` / `#rrggbbaa` to `Rgb` (alpha dropped).
16+
/// Returns `None` on parse failure — mirrors TS `parseHexColor`.
17+
pub fn parse_hex_color(s: &str) -> Option<Rgb> {
18+
let hex = s.trim().strip_prefix('#')?;
19+
if !(3..=8).contains(&hex.len()) || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
20+
return None;
21+
}
22+
let expanded: String = if hex.len() == 3 {
23+
hex.chars().flat_map(|c| [c, c]).collect()
24+
} else {
25+
hex.to_string()
26+
};
27+
// TS rejects lengths 4/5/7 here — only 6 (rrggbb) or 8 (rrggbbaa) pass.
28+
if expanded.len() != 6 && expanded.len() != 8 {
29+
return None;
30+
}
31+
let r = u8::from_str_radix(&expanded[0..2], 16).ok()?;
32+
let g = u8::from_str_radix(&expanded[2..4], 16).ok()?;
33+
let b = u8::from_str_radix(&expanded[4..6], 16).ok()?;
34+
Some(Rgb { r, g, b })
35+
}
36+
37+
/// WCAG 2.x relative luminance for sRGB. Returns 0.0–1.0.
38+
pub fn relative_luminance(c: Rgb) -> f64 {
39+
fn lin(v: u8) -> f64 {
40+
let s = v as f64 / 255.0;
41+
if s <= 0.039_28 {
42+
s / 12.92
43+
} else {
44+
((s + 0.055) / 1.055).powf(2.4)
45+
}
46+
}
47+
0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b)
48+
}
49+
50+
/// WCAG relative-luminance contrast ratio between two color strings.
51+
/// Returns `1.0` for identical strings (before parsing — mirrors TS),
52+
/// grows toward `21.0` as colors diverge, or `f64::INFINITY` if either
53+
/// string fails to parse (e.g. unresolved variable refs).
54+
pub fn color_contrast(a: &str, b: &str) -> f64 {
55+
if a == b {
56+
return 1.0;
57+
}
58+
let (Some(pa), Some(pb)) = (parse_hex_color(a), parse_hex_color(b)) else {
59+
return f64::INFINITY;
60+
};
61+
let lum_a = relative_luminance(pa);
62+
let lum_b = relative_luminance(pb);
63+
let lighter = lum_a.max(lum_b);
64+
let darker = lum_a.min(lum_b);
65+
(lighter + 0.05) / (darker + 0.05)
66+
}
67+
68+
#[cfg(test)]
69+
mod tests {
70+
use super::*;
71+
72+
const EPS: f64 = 1e-9;
73+
74+
#[test]
75+
fn parse_hex_expands_shorthand_and_drops_alpha() {
76+
assert_eq!(
77+
parse_hex_color("#fff"),
78+
Some(Rgb {
79+
r: 255,
80+
g: 255,
81+
b: 255
82+
})
83+
);
84+
assert_eq!(parse_hex_color("#000000"), Some(Rgb { r: 0, g: 0, b: 0 }));
85+
assert_eq!(
86+
parse_hex_color("#11223344"),
87+
Some(Rgb {
88+
r: 0x11,
89+
g: 0x22,
90+
b: 0x33
91+
})
92+
);
93+
assert_eq!(
94+
parse_hex_color(" #abc "),
95+
Some(Rgb {
96+
r: 0xaa,
97+
g: 0xbb,
98+
b: 0xcc
99+
})
100+
);
101+
}
102+
103+
#[test]
104+
fn parse_hex_rejects_bad_input() {
105+
assert_eq!(parse_hex_color("fff"), None); // no '#'
106+
assert_eq!(parse_hex_color("#12"), None); // too short
107+
assert_eq!(parse_hex_color("#1234"), None); // length 4 not 6/8
108+
assert_eq!(parse_hex_color("#12345"), None); // length 5
109+
assert_eq!(parse_hex_color("#1234567"), None); // length 7
110+
assert_eq!(parse_hex_color("#ggg"), None); // non-hex
111+
assert_eq!(parse_hex_color("$color-1"), None); // unresolved ref
112+
}
113+
114+
#[test]
115+
fn relative_luminance_endpoints() {
116+
assert!((relative_luminance(Rgb { r: 0, g: 0, b: 0 }) - 0.0).abs() < EPS);
117+
assert!(
118+
(relative_luminance(Rgb {
119+
r: 255,
120+
g: 255,
121+
b: 255
122+
}) - 1.0)
123+
.abs()
124+
< EPS
125+
);
126+
}
127+
128+
#[test]
129+
fn relative_luminance_mid_gray_exercises_gamma_branch() {
130+
// 0x77 = 119; s = 119/255 ≈ 0.46667 > 0.03928 → gamma branch.
131+
// Expected value computed against the TS formula (threshold 0.03928,
132+
// gamma 2.4) — verified via Node.js to 1e-7.
133+
let lum = relative_luminance(Rgb {
134+
r: 0x77,
135+
g: 0x77,
136+
b: 0x77,
137+
});
138+
assert!((lum - 0.184_474_994_5).abs() < 1e-7);
139+
}
140+
141+
#[test]
142+
fn color_contrast_known_vectors() {
143+
assert!((color_contrast("#000000", "#FFFFFF") - 21.0).abs() < EPS);
144+
assert!((color_contrast("#777777", "#FFFFFF") - 4.48).abs() < 0.01);
145+
}
146+
147+
#[test]
148+
fn color_contrast_identical_returns_one_before_parsing() {
149+
// Identical strings short-circuit to 1.0 even when unparseable.
150+
assert_eq!(color_contrast("$same", "$same"), 1.0);
151+
}
152+
153+
#[test]
154+
fn color_contrast_unparseable_returns_infinity() {
155+
assert_eq!(color_contrast("$color-1", "#FFFFFF"), f64::INFINITY);
156+
assert_eq!(color_contrast("#FFFFFF", "not-a-color"), f64::INFINITY);
157+
}
158+
}

0 commit comments

Comments
 (0)