argusqa-os 9.5.1 → 9.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1100 -1089
- package/glama.json +36 -32
- package/package.json +1 -1
- package/src/adapters/browser.js +5 -4
- package/src/adapters/figma.js +336 -0
- package/src/config/targets.js +4 -0
- package/src/domain/finding.js +16 -1
- package/src/mcp-server.js +54 -3
- package/src/orchestration/dispatcher.js +1 -1
- package/src/orchestration/orchestrator.js +36 -24
- package/src/orchestration/report-processor.js +1 -1
- package/src/orchestration/slack-notifier.js +2 -1
- package/src/registry.js +1 -1
- package/src/utils/css-analyzer.js +7 -0
- package/src/utils/design-fidelity-analyzer.js +685 -0
- package/src/utils/flow-runner.js +2 -0
- package/src/utils/mcp-client.js +2 -17
- package/src/utils/retry.js +1 -1
- package/src/utils/session-persistence.js +16 -4
- package/src/utils/theme-analyzer.js +173 -0
- package/src/utils/visual-diff-analyzer.js +207 -0
- package/src/utils/web-vitals-analyzer.js +284 -0
package/glama.json
CHANGED
|
@@ -1,32 +1,36 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://glama.ai/mcp/schemas/server.json",
|
|
3
|
-
"name": "argus",
|
|
4
|
-
"description": "AI-powered QA harness that audits web apps via Chrome DevTools Protocol. Catches JS errors, network failures, a11y violations, SEO issues, security headers, CSS regressions, and more — directly from Claude conversations.
|
|
5
|
-
"maintainers": ["ironclawdevs27"],
|
|
6
|
-
"tools": [
|
|
7
|
-
{
|
|
8
|
-
"name": "argus_audit",
|
|
9
|
-
"description": "Fast QA audit — JS errors, network failures (4xx/5xx), API frequency loops, CSS cascade issues, SEO violations, security headers, accessibility, and content. Returns { findings, summary }. Supports cache: true to skip re-crawl on repeat calls."
|
|
10
|
-
},
|
|
11
|
-
{
|
|
12
|
-
"name": "argus_audit_full",
|
|
13
|
-
"description": "Deep QA audit — extends argus_audit with Lighthouse performance/accessibility scoring, responsive layout checks at 4 viewports, memory leak detection via heap snapshot, and accessibility tree analysis."
|
|
14
|
-
},
|
|
15
|
-
{
|
|
16
|
-
"name": "argus_compare",
|
|
17
|
-
"description": "Diffs dev vs staging environments side-by-side. Captures screenshots, runs all analyzers on each, and surfaces regressions — findings present in staging but not dev, or with changed severity."
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
"name": "argus_last_report",
|
|
21
|
-
"description": "Returns the most recent Argus JSON report from the reports/ directory without re-running a scan."
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
"name": "argus_watch_snapshot",
|
|
25
|
-
"description": "Snapshots the currently open Chrome tab without navigating — captures console errors, network failures, CORS blocks, and auth failures in one poll. Accepts optional tabId to inspect a specific tab."
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
"name": "argus_get_context",
|
|
29
|
-
"description": "LLM-optimized diagnostic context for the open Chrome tab. Returns snapshot_id for fix-loop diffing: pass it back on the next call to get resolved/new_issues/persisting arrays. Accepts optional tabId for multi-tab workflows."
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://glama.ai/mcp/schemas/server.json",
|
|
3
|
+
"name": "argus",
|
|
4
|
+
"description": "AI-powered QA harness that audits web apps via Chrome DevTools Protocol. Catches JS errors, network failures, a11y violations, SEO issues, security headers, CSS regressions, and more — directly from Claude conversations. 7 MCP tools: argus_audit (fast 8-analyzer pass), argus_audit_full (Lighthouse + memory + responsive), argus_compare (dev vs staging diff), argus_last_report (retrieve last JSON report), argus_watch_snapshot (live tab snapshot without navigating), argus_get_context (LLM-optimized context + fix loop with snapshot_id diff), argus_design_audit (Figma design fidelity — 13 finding types). 130 test blocks, 581 hard assertions, 58 detection categories.",
|
|
5
|
+
"maintainers": ["ironclawdevs27"],
|
|
6
|
+
"tools": [
|
|
7
|
+
{
|
|
8
|
+
"name": "argus_audit",
|
|
9
|
+
"description": "Fast QA audit — JS errors, network failures (4xx/5xx), API frequency loops, CSS cascade issues, SEO violations, security headers, accessibility, and content. Returns { findings, summary }. Supports cache: true to skip re-crawl on repeat calls."
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"name": "argus_audit_full",
|
|
13
|
+
"description": "Deep QA audit — extends argus_audit with Lighthouse performance/accessibility scoring, responsive layout checks at 4 viewports, memory leak detection via heap snapshot, and accessibility tree analysis."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"name": "argus_compare",
|
|
17
|
+
"description": "Diffs dev vs staging environments side-by-side. Captures screenshots, runs all analyzers on each, and surfaces regressions — findings present in staging but not dev, or with changed severity."
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"name": "argus_last_report",
|
|
21
|
+
"description": "Returns the most recent Argus JSON report from the reports/ directory without re-running a scan."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"name": "argus_watch_snapshot",
|
|
25
|
+
"description": "Snapshots the currently open Chrome tab without navigating — captures console errors, network failures, CORS blocks, and auth failures in one poll. Accepts optional tabId to inspect a specific tab."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "argus_get_context",
|
|
29
|
+
"description": "LLM-optimized diagnostic context for the open Chrome tab. Returns snapshot_id for fix-loop diffing: pass it back on the next call to get resolved/new_issues/persisting arrays. Accepts optional tabId for multi-tab workflows."
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "argus_design_audit",
|
|
33
|
+
"description": "Full Figma design-to-implementation fidelity audit. Fetches design spec from a Figma frame URL (requires FIGMA_API_TOKEN) and compares every extracted property against live DOM computed styles. Detects 13 mismatch finding types: CSS token values, component presence, fill/text color (RGB distance), typography (fontSize/fontWeight/lineHeight/fontFamily/letterSpacing), Auto Layout padding and gap, border-radius (per-corner), bounding-box overflow, absolute position drift (scroll-corrected x/y vs Figma bounds), border stroke (color+weight), box-shadow (offset+blur+spread+color), opacity, and text content. Selector fallback: tries [data-testid], [aria-label], #id, .class per node."
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
package/package.json
CHANGED
package/src/adapters/browser.js
CHANGED
|
@@ -25,7 +25,7 @@ export class CdpBrowserAdapter {
|
|
|
25
25
|
|
|
26
26
|
// ── Evaluation & snapshots ──────────────────────────────────────────────────
|
|
27
27
|
evaluate(fn) { return this._mcp.evaluate_script({ function: fn }); }
|
|
28
|
-
snapshot()
|
|
28
|
+
snapshot(opts = {}) { return this._mcp.take_snapshot(opts); }
|
|
29
29
|
screenshot(opts = {}) { return this._mcp.take_screenshot(opts); }
|
|
30
30
|
heapSnapshot(opts = {}) { return this._mcp.take_heapsnapshot(opts); }
|
|
31
31
|
|
|
@@ -45,9 +45,10 @@ export class CdpBrowserAdapter {
|
|
|
45
45
|
waitFor(opts) { return this._mcp.wait_for(opts); }
|
|
46
46
|
|
|
47
47
|
// ── Viewport ────────────────────────────────────────────────────────────────
|
|
48
|
-
emulate(viewport)
|
|
49
|
-
emulateCpu(rate)
|
|
50
|
-
|
|
48
|
+
emulate(viewport) { return this._mcp.emulate({ viewport }); }
|
|
49
|
+
emulateCpu(rate) { return this._mcp.emulate({ cpuThrottlingRate: rate }); }
|
|
50
|
+
emulateColorScheme(scheme) { return this._mcp.emulate({ colorScheme: scheme }); }
|
|
51
|
+
resize(w, h) { return this._mcp.resize_page({ width: w, height: h }); }
|
|
51
52
|
|
|
52
53
|
// ── Network & performance ───────────────────────────────────────────────────
|
|
53
54
|
getNetworkRequest(reqId) { return this._mcp.get_network_request({ requestId: reqId }); }
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figma REST adapter — extracts the full design spec from a Figma frame.
|
|
3
|
+
*
|
|
4
|
+
* For each node in the frame tree, extracts:
|
|
5
|
+
* bounds — x, y, width, height relative to the frame origin
|
|
6
|
+
* fill — primary solid fill color (r,g,b,a — 0-255)
|
|
7
|
+
* stroke — border color + weight
|
|
8
|
+
* typography — fontSize, fontWeight, lineHeightPx, letterSpacing (TEXT nodes)
|
|
9
|
+
* spacing — Auto Layout padding + gap
|
|
10
|
+
* cornerRadius
|
|
11
|
+
* shadow — primary DROP_SHADOW effect
|
|
12
|
+
* opacity
|
|
13
|
+
*
|
|
14
|
+
* Returns null gracefully when FIGMA_API_TOKEN is absent, the URL is invalid,
|
|
15
|
+
* or the API call fails — callers skip analysis without crashing.
|
|
16
|
+
*
|
|
17
|
+
* Supported Figma URL formats:
|
|
18
|
+
* https://www.figma.com/file/<fileKey>/Name?node-id=42%3A0
|
|
19
|
+
* https://www.figma.com/design/<fileKey>/Name?node-id=42-0
|
|
20
|
+
*
|
|
21
|
+
* Requires env: FIGMA_API_TOKEN (Personal Access Token from figma.com/settings)
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { childLogger } from '../utils/logger.js';
|
|
25
|
+
|
|
26
|
+
const logger = childLogger('figma-adapter');
|
|
27
|
+
|
|
28
|
+
const FIGMA_API = 'https://api.figma.com/v1';
|
|
29
|
+
|
|
30
|
+
// Node types that carry no useful layout/style data — skip during tree walk
|
|
31
|
+
const SKIP_TYPES = new Set(['VECTOR', 'STAR', 'LINE', 'BOOLEAN_OPERATION', 'REGULAR_POLYGON']);
|
|
32
|
+
|
|
33
|
+
// ── URL parsing ───────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse fileKey and nodeId from a Figma frame URL.
|
|
37
|
+
* Returns null if the URL is not a recognisable Figma frame URL.
|
|
38
|
+
*/
|
|
39
|
+
export function parseFigmaUrl(url) {
|
|
40
|
+
if (!url || typeof url !== 'string') return null;
|
|
41
|
+
const fileMatch = url.match(/figma\.com\/(?:file|design)\/([A-Za-z0-9]+)/);
|
|
42
|
+
const nodeMatch = url.match(/node-id=([^&]+)/);
|
|
43
|
+
if (!fileMatch) return null;
|
|
44
|
+
const fileKey = fileMatch[1];
|
|
45
|
+
const rawNode = nodeMatch?.[1];
|
|
46
|
+
if (!rawNode) return null;
|
|
47
|
+
// node-id can be URL-encoded "42%3A0" or dash-separated "42-0" — normalise to "42:0"
|
|
48
|
+
const nodeId = decodeURIComponent(rawNode).replace('-', ':');
|
|
49
|
+
return { fileKey, nodeId };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Selector inference ────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Infer a prioritised list of CSS selector candidates from a Figma layer name.
|
|
56
|
+
* The analyzer tries each in order and uses the first that matches a DOM element.
|
|
57
|
+
*
|
|
58
|
+
* Priority:
|
|
59
|
+
* 1. Explicit selector — if the name starts with #, ., or [ use it verbatim
|
|
60
|
+
* 2. data-testid attribute (slug form)
|
|
61
|
+
* 3. aria-label attribute (raw name)
|
|
62
|
+
* 4. ID selector (slug form)
|
|
63
|
+
* 5. Class selector (BEM slug: spaces→-, /→--)
|
|
64
|
+
*
|
|
65
|
+
* Examples:
|
|
66
|
+
* "Button / Primary" → [data-testid="button--primary"], [aria-label="Button / Primary"], #button--primary, .button--primary
|
|
67
|
+
* "#hero" → ["#hero"] (explicit — used verbatim)
|
|
68
|
+
* ".card" → [".card"] (explicit)
|
|
69
|
+
*/
|
|
70
|
+
function inferSelectors(node) {
|
|
71
|
+
const name = node.name;
|
|
72
|
+
|
|
73
|
+
// Designer typed an explicit selector — honour it and skip inference
|
|
74
|
+
if (name.startsWith('#') || name.startsWith('.') || name.startsWith('[')) {
|
|
75
|
+
return [name];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const slug = name
|
|
79
|
+
.toLowerCase()
|
|
80
|
+
.replace(/\s*\/\s*/g, '--')
|
|
81
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
82
|
+
.replace(/^-+|-+$/g, '');
|
|
83
|
+
|
|
84
|
+
if (!slug) return [];
|
|
85
|
+
|
|
86
|
+
return [
|
|
87
|
+
`[data-testid="${slug}"]`,
|
|
88
|
+
`[aria-label="${name}"]`,
|
|
89
|
+
`#${slug}`,
|
|
90
|
+
`.${slug}`,
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Node extraction ───────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Extract all design properties from a single Figma node into a flat object.
|
|
98
|
+
* All color channels are 0-255. Bounds are relative to the frame origin.
|
|
99
|
+
*/
|
|
100
|
+
function extractNode(node, frameX, frameY) {
|
|
101
|
+
if (!node) return null;
|
|
102
|
+
|
|
103
|
+
const selectors = inferSelectors(node);
|
|
104
|
+
const result = {
|
|
105
|
+
id: node.id,
|
|
106
|
+
name: node.name,
|
|
107
|
+
type: node.type,
|
|
108
|
+
selectors: selectors, // ordered candidates — analyzer tries each until one matches
|
|
109
|
+
selector: selectors[0] ?? `.${node.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
|
|
110
|
+
opacity: node.opacity ?? 1,
|
|
111
|
+
|
|
112
|
+
// Populated below
|
|
113
|
+
bounds: null,
|
|
114
|
+
fill: null,
|
|
115
|
+
stroke: null,
|
|
116
|
+
typography: null,
|
|
117
|
+
spacing: null,
|
|
118
|
+
cornerRadius: null,
|
|
119
|
+
shadow: null,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Bounds — relative to frame origin so they map to viewport coords when the
|
|
123
|
+
// browser viewport matches the frame dimensions.
|
|
124
|
+
if (node.absoluteBoundingBox) {
|
|
125
|
+
result.bounds = {
|
|
126
|
+
x: node.absoluteBoundingBox.x - frameX,
|
|
127
|
+
y: node.absoluteBoundingBox.y - frameY,
|
|
128
|
+
width: node.absoluteBoundingBox.width,
|
|
129
|
+
height: node.absoluteBoundingBox.height,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Primary solid fill (first visible solid fill)
|
|
134
|
+
for (const fill of (node.fills ?? [])) {
|
|
135
|
+
if (fill.type === 'SOLID' && fill.color && fill.visible !== false) {
|
|
136
|
+
result.fill = {
|
|
137
|
+
r: Math.round(fill.color.r * 255),
|
|
138
|
+
g: Math.round(fill.color.g * 255),
|
|
139
|
+
b: Math.round(fill.color.b * 255),
|
|
140
|
+
a: Math.round((fill.opacity ?? 1) * 255),
|
|
141
|
+
};
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Primary stroke
|
|
147
|
+
for (const stroke of (node.strokes ?? [])) {
|
|
148
|
+
if (stroke.type === 'SOLID' && stroke.color && stroke.visible !== false) {
|
|
149
|
+
result.stroke = {
|
|
150
|
+
r: Math.round(stroke.color.r * 255),
|
|
151
|
+
g: Math.round(stroke.color.g * 255),
|
|
152
|
+
b: Math.round(stroke.color.b * 255),
|
|
153
|
+
a: Math.round((stroke.opacity ?? 1) * 255),
|
|
154
|
+
weight: node.strokeWeight ?? 1,
|
|
155
|
+
};
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Typography (TEXT nodes only)
|
|
161
|
+
if (node.type === 'TEXT' && node.style) {
|
|
162
|
+
const s = node.style;
|
|
163
|
+
result.typography = {
|
|
164
|
+
fontFamily: s.fontFamily ?? null,
|
|
165
|
+
fontSize: s.fontSize ?? null,
|
|
166
|
+
fontWeight: s.fontWeight ?? null,
|
|
167
|
+
lineHeightPx: s.lineHeightPx ?? null,
|
|
168
|
+
letterSpacing: s.letterSpacing ?? 0,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Text content — actual Figma copy; compared against DOM textContent
|
|
173
|
+
if (node.type === 'TEXT' && typeof node.characters === 'string') {
|
|
174
|
+
result.characters = node.characters.trim() || null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Auto Layout spacing — maps to CSS padding + gap.
|
|
178
|
+
// layoutMode ('HORIZONTAL'|'VERTICAL') lets the analyzer pick columnGap vs rowGap.
|
|
179
|
+
if (node.layoutMode && node.layoutMode !== 'NONE') {
|
|
180
|
+
result.spacing = {
|
|
181
|
+
paddingTop: node.paddingTop ?? 0,
|
|
182
|
+
paddingRight: node.paddingRight ?? 0,
|
|
183
|
+
paddingBottom: node.paddingBottom ?? 0,
|
|
184
|
+
paddingLeft: node.paddingLeft ?? 0,
|
|
185
|
+
gap: node.itemSpacing ?? 0,
|
|
186
|
+
layoutMode: node.layoutMode,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Corner radius — uniform number or per-corner object.
|
|
191
|
+
// Figma rectangleCornerRadii = [topLeft, topRight, bottomRight, bottomLeft].
|
|
192
|
+
if (node.cornerRadius != null) {
|
|
193
|
+
result.cornerRadius = node.cornerRadius; // uniform
|
|
194
|
+
} else if (node.rectangleCornerRadii) {
|
|
195
|
+
const [tl, tr, br, bl] = node.rectangleCornerRadii;
|
|
196
|
+
// Collapse to a single number if all corners are equal (avoids noise in findings)
|
|
197
|
+
result.cornerRadius = (tl === tr && tr === br && br === bl)
|
|
198
|
+
? tl
|
|
199
|
+
: { topLeft: tl, topRight: tr, bottomRight: br, bottomLeft: bl };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Primary DROP_SHADOW effect
|
|
203
|
+
for (const eff of (node.effects ?? [])) {
|
|
204
|
+
if (eff.type === 'DROP_SHADOW' && eff.visible !== false) {
|
|
205
|
+
result.shadow = {
|
|
206
|
+
offsetX: eff.offset?.x ?? 0,
|
|
207
|
+
offsetY: eff.offset?.y ?? 0,
|
|
208
|
+
blur: eff.radius ?? 0,
|
|
209
|
+
spread: eff.spread ?? 0,
|
|
210
|
+
r: Math.round((eff.color?.r ?? 0) * 255),
|
|
211
|
+
g: Math.round((eff.color?.g ?? 0) * 255),
|
|
212
|
+
b: Math.round((eff.color?.b ?? 0) * 255),
|
|
213
|
+
a: Math.round((eff.color?.a ?? 0.25) * 255),
|
|
214
|
+
};
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Tree walker ───────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
function parseFigmaNodes(data, nodeId) {
|
|
225
|
+
const nodeKey = nodeId.replace(':', '-');
|
|
226
|
+
const nodeData = data?.nodes?.[nodeKey] ?? data?.nodes?.[nodeId];
|
|
227
|
+
if (!nodeData?.document) return null;
|
|
228
|
+
|
|
229
|
+
const doc = nodeData.document;
|
|
230
|
+
const frameX = doc.absoluteBoundingBox?.x ?? 0;
|
|
231
|
+
const frameY = doc.absoluteBoundingBox?.y ?? 0;
|
|
232
|
+
|
|
233
|
+
const nodes = [];
|
|
234
|
+
const tokens = {}; // legacy CSS-var format
|
|
235
|
+
const components = []; // legacy component presence format
|
|
236
|
+
|
|
237
|
+
function walk(node) {
|
|
238
|
+
if (!node || SKIP_TYPES.has(node.type)) return;
|
|
239
|
+
|
|
240
|
+
const extracted = extractNode(node, frameX, frameY);
|
|
241
|
+
if (extracted) {
|
|
242
|
+
nodes.push(extracted);
|
|
243
|
+
|
|
244
|
+
// Build legacy tokens map for backward compat
|
|
245
|
+
if (extracted.fill) {
|
|
246
|
+
const hex = '#' +
|
|
247
|
+
extracted.fill.r.toString(16).padStart(2, '0') +
|
|
248
|
+
extracted.fill.g.toString(16).padStart(2, '0') +
|
|
249
|
+
extracted.fill.b.toString(16).padStart(2, '0');
|
|
250
|
+
tokens[`--figma-${extracted.selector.slice(1)}-fill`] = hex;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Legacy component presence list
|
|
254
|
+
if (node.type === 'COMPONENT' || node.type === 'INSTANCE') {
|
|
255
|
+
components.push({ name: node.name, selector: extracted.selector });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const child of (node.children ?? [])) walk(child);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
walk(doc);
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
nodes,
|
|
266
|
+
frame: {
|
|
267
|
+
name: doc.name ?? '',
|
|
268
|
+
x: frameX,
|
|
269
|
+
y: frameY,
|
|
270
|
+
width: doc.absoluteBoundingBox?.width ?? 0,
|
|
271
|
+
height: doc.absoluteBoundingBox?.height ?? 0,
|
|
272
|
+
},
|
|
273
|
+
// Legacy fields — still consumed by backward-compat token comparison path
|
|
274
|
+
tokens,
|
|
275
|
+
components,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Fetch the full design spec for a Figma frame URL.
|
|
283
|
+
*
|
|
284
|
+
* Returns null when FIGMA_API_TOKEN is unset, the URL is unparseable,
|
|
285
|
+
* or the Figma API returns an error. All errors are logged at warn level.
|
|
286
|
+
*
|
|
287
|
+
* @param {string} figmaFrameUrl
|
|
288
|
+
* @returns {Promise<{nodes, frame, tokens, components}|null>}
|
|
289
|
+
*/
|
|
290
|
+
export async function getFigmaFrame(figmaFrameUrl) {
|
|
291
|
+
const token = process.env.FIGMA_API_TOKEN;
|
|
292
|
+
if (!token) {
|
|
293
|
+
logger.debug('[ARGUS] figma-adapter: FIGMA_API_TOKEN not set — skipping design fidelity fetch');
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const parsed = parseFigmaUrl(figmaFrameUrl);
|
|
298
|
+
if (!parsed) {
|
|
299
|
+
logger.warn(`[ARGUS] figma-adapter: cannot parse Figma URL: ${figmaFrameUrl}`);
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const { fileKey, nodeId } = parsed;
|
|
304
|
+
const encodedId = nodeId.replace(':', '-');
|
|
305
|
+
const apiUrl = `${FIGMA_API}/files/${fileKey}/nodes?ids=${encodedId}&geometry=paths`;
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
const res = await fetch(apiUrl, {
|
|
309
|
+
headers: { 'X-Figma-Token': token },
|
|
310
|
+
signal: AbortSignal.timeout(15000),
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
if (!res.ok) {
|
|
314
|
+
logger.warn(`[ARGUS] figma-adapter: Figma API ${res.status} for ${figmaFrameUrl}`);
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const data = await res.json();
|
|
319
|
+
const result = parseFigmaNodes(data, nodeId);
|
|
320
|
+
|
|
321
|
+
if (!result) {
|
|
322
|
+
logger.warn(`[ARGUS] figma-adapter: no node data for nodeId "${nodeId}" in ${figmaFrameUrl}`);
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
logger.info(
|
|
327
|
+
`[ARGUS] figma-adapter: extracted ${result.nodes.length} node(s) from "${result.frame.name}" ` +
|
|
328
|
+
`(${result.frame.width}×${result.frame.height})`
|
|
329
|
+
);
|
|
330
|
+
return result;
|
|
331
|
+
|
|
332
|
+
} catch (err) {
|
|
333
|
+
logger.warn(`[ARGUS] figma-adapter: fetch failed for ${figmaFrameUrl}: ${err.message}`);
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
}
|
package/src/config/targets.js
CHANGED
|
@@ -64,6 +64,10 @@ export const thresholds = {
|
|
|
64
64
|
seo: { critical: 50, warning: 90 },
|
|
65
65
|
'best-practices': { critical: 50, warning: 90 },
|
|
66
66
|
},
|
|
67
|
+
visual: {
|
|
68
|
+
warnPercent: parseFloat(process.env.VISUAL_WARN_PERCENT ?? '0.1'), // % pixels changed → warning
|
|
69
|
+
critPercent: parseFloat(process.env.VISUAL_CRIT_PERCENT ?? '5.0'), // % pixels changed → critical
|
|
70
|
+
},
|
|
67
71
|
};
|
|
68
72
|
|
|
69
73
|
/**
|
package/src/domain/finding.js
CHANGED
|
@@ -14,7 +14,22 @@ const VALID_SEVERITIES = new Set(['critical', 'warning', 'info']);
|
|
|
14
14
|
/**
|
|
15
15
|
* Create an immutable finding object.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
17
|
+
* Required fields: type, severity ('critical'|'warning'|'info'), message.
|
|
18
|
+
* Common optional fields passed via ...rest (use these names for consistency):
|
|
19
|
+
* url — affected URL (defaults to '')
|
|
20
|
+
* selector — CSS selector of the offending element
|
|
21
|
+
* requestUrl — URL of the offending network request
|
|
22
|
+
* status — HTTP status code (number)
|
|
23
|
+
* method — HTTP method string
|
|
24
|
+
* element — human-readable element description (e.g. 'button#submit')
|
|
25
|
+
* property — CSS property name
|
|
26
|
+
* metric — performance metric name (e.g. 'LCP')
|
|
27
|
+
* value — measured value
|
|
28
|
+
* budget — expected threshold value
|
|
29
|
+
* count — numeric count of occurrences
|
|
30
|
+
* source — source file or stylesheet path
|
|
31
|
+
*
|
|
32
|
+
* @param {{ type: string, severity: string, message: string, url?: string, [key: string]: any }} opts
|
|
18
33
|
* @returns {Readonly<object>}
|
|
19
34
|
*/
|
|
20
35
|
export function createFinding({ type, severity, message, url = '', ...rest }) {
|
package/src/mcp-server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Argus MCP Server (v9.5.
|
|
3
|
+
* Argus MCP Server (v9.5.5)
|
|
4
4
|
*
|
|
5
5
|
* Exposes Argus as an MCP server so Claude (or any MCP client) can call
|
|
6
6
|
* argus_audit, argus_audit_full, argus_compare, argus_last_report, and
|
|
@@ -30,6 +30,8 @@ import { crawlRouteCheap, runCrawl } from './orchestration/crawl-and-re
|
|
|
30
30
|
import { runComparison } from './orchestration/env-comparison.js';
|
|
31
31
|
import { WatchSession } from './orchestration/watch-mode.js';
|
|
32
32
|
import { CdpBrowserAdapter } from './adapters/browser.js';
|
|
33
|
+
import { getFigmaFrame } from './adapters/figma.js';
|
|
34
|
+
import { analyzeDesignFidelity } from './utils/design-fidelity-analyzer.js';
|
|
33
35
|
|
|
34
36
|
const REPORTS_DIR = path.resolve(process.cwd(), 'reports');
|
|
35
37
|
|
|
@@ -117,6 +119,18 @@ const TOOLS = [
|
|
|
117
119
|
},
|
|
118
120
|
},
|
|
119
121
|
},
|
|
122
|
+
{
|
|
123
|
+
name: 'argus_design_audit',
|
|
124
|
+
description: 'Full design-to-implementation fidelity audit against a Figma frame. 13 mismatch finding types: CSS token values, component presence, fill/text color (RGB delta), typography (fontSize/fontWeight/lineHeight/fontFamily/letterSpacing), Auto Layout padding and gap, border-radius (per-corner), bounding-box overflow, absolute position drift (scroll-corrected x/y, 20px threshold), border stroke (color+weight), box-shadow (offset+blur+spread+color), opacity, and text content. Selector fallback: tries [data-testid], [aria-label], #id, .class per node. Requires FIGMA_API_TOKEN env var and Chrome on --remote-debugging-port=9222. Returns { findings, summary } where summary includes 13 mismatch-type counts.',
|
|
125
|
+
inputSchema: {
|
|
126
|
+
type: 'object',
|
|
127
|
+
properties: {
|
|
128
|
+
url: { type: 'string', description: 'Full URL of the page to audit (e.g. http://localhost:3000/dashboard). Must be reachable by the running Chrome instance.' },
|
|
129
|
+
figmaFrameUrl: { type: 'string', description: 'Figma frame URL to fetch design tokens from (e.g. https://www.figma.com/file/ABC123/Name?node-id=42%3A0). Must include the node-id query parameter pointing to the specific frame.' },
|
|
130
|
+
},
|
|
131
|
+
required: ['url', 'figmaFrameUrl'],
|
|
132
|
+
},
|
|
133
|
+
},
|
|
120
134
|
];
|
|
121
135
|
|
|
122
136
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
@@ -129,7 +143,7 @@ async function withMcp(fn) {
|
|
|
129
143
|
logger.error('[ARGUS] MCP tool handler error:', err.message);
|
|
130
144
|
throw err;
|
|
131
145
|
} finally {
|
|
132
|
-
try { mcp.close(); } catch {
|
|
146
|
+
try { mcp.close(); } catch (e) { logger.debug({ err: e }, 'mcp close (ignored)'); }
|
|
133
147
|
}
|
|
134
148
|
}
|
|
135
149
|
|
|
@@ -268,6 +282,42 @@ async function handleGetContext({ url, snapshot_id: prevId, tabId } = {}) {
|
|
|
268
282
|
});
|
|
269
283
|
}
|
|
270
284
|
|
|
285
|
+
async function handleDesignAudit({ url, figmaFrameUrl }) {
|
|
286
|
+
if (!url) throw new Error('argus_design_audit: url is required');
|
|
287
|
+
if (!figmaFrameUrl) throw new Error('argus_design_audit: figmaFrameUrl is required');
|
|
288
|
+
|
|
289
|
+
const figmaData = await getFigmaFrame(figmaFrameUrl);
|
|
290
|
+
if (!figmaData) {
|
|
291
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
292
|
+
error: 'Could not fetch Figma data. Ensure FIGMA_API_TOKEN is set and the figmaFrameUrl is valid.',
|
|
293
|
+
findings: [],
|
|
294
|
+
summary: { tokenMismatches: 0, missingComponents: 0, colorMismatches: 0, typographyMismatches: 0, spacingMismatches: 0, radiusMismatches: 0, boundsOverflows: 0, positionDrifts: 0, strokeMismatches: 0, shadowMismatches: 0, opacityMismatches: 0, gapMismatches: 0, textMismatches: 0 },
|
|
295
|
+
}) }] };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return withMcp(async (mcp) => {
|
|
299
|
+
const browser = new CdpBrowserAdapter(mcp);
|
|
300
|
+
const findings = await analyzeDesignFidelity(browser, url, figmaData);
|
|
301
|
+
const count = (type) => findings.filter(f => f.type === type).length;
|
|
302
|
+
const summary = {
|
|
303
|
+
tokenMismatches: count('design_token_mismatch'),
|
|
304
|
+
missingComponents: count('design_component_missing'),
|
|
305
|
+
colorMismatches: count('design_color_mismatch'),
|
|
306
|
+
typographyMismatches: count('design_typography_mismatch'),
|
|
307
|
+
spacingMismatches: count('design_spacing_mismatch'),
|
|
308
|
+
radiusMismatches: count('design_radius_mismatch'),
|
|
309
|
+
boundsOverflows: count('design_bounds_overflow'),
|
|
310
|
+
positionDrifts: count('design_position_drift'),
|
|
311
|
+
strokeMismatches: count('design_stroke_mismatch'),
|
|
312
|
+
shadowMismatches: count('design_shadow_mismatch'),
|
|
313
|
+
opacityMismatches: count('design_opacity_mismatch'),
|
|
314
|
+
gapMismatches: count('design_gap_mismatch'),
|
|
315
|
+
textMismatches: count('design_text_mismatch'),
|
|
316
|
+
};
|
|
317
|
+
return { content: [{ type: 'text', text: JSON.stringify({ findings, summary }, null, 2) }] };
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
271
321
|
async function handleLastReport() {
|
|
272
322
|
if (!fs.existsSync(REPORTS_DIR)) {
|
|
273
323
|
return { content: [{ type: 'text', text: '{"error":"No reports found in reports/"}' }] };
|
|
@@ -286,7 +336,7 @@ async function handleLastReport() {
|
|
|
286
336
|
// ── Server bootstrap ──────────────────────────────────────────────────────────
|
|
287
337
|
|
|
288
338
|
const server = new Server(
|
|
289
|
-
{ name: 'argus', version: '9.5.
|
|
339
|
+
{ name: 'argus', version: '9.5.5' },
|
|
290
340
|
{ capabilities: { tools: {} } },
|
|
291
341
|
);
|
|
292
342
|
|
|
@@ -301,6 +351,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
301
351
|
case 'argus_last_report': return await handleLastReport();
|
|
302
352
|
case 'argus_watch_snapshot': return await handleWatchSnapshot(req.params.arguments ?? {});
|
|
303
353
|
case 'argus_get_context': return await handleGetContext(req.params.arguments ?? {});
|
|
354
|
+
case 'argus_design_audit': return await handleDesignAudit(req.params.arguments ?? {});
|
|
304
355
|
default: throw new Error(`Unknown tool: ${req.params.name}`);
|
|
305
356
|
}
|
|
306
357
|
} catch (err) {
|