argusqa-os 9.5.0 → 9.5.3
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 +16 -11
- package/glama.json +5 -1
- package/package.json +1 -1
- package/src/adapters/browser.js +5 -4
- package/src/adapters/figma.js +336 -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 +47 -30
- package/src/orchestration/report-processor.js +1 -1
- package/src/orchestration/slack-notifier.js +2 -1
- package/src/orchestration/watch-mode.js +1 -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/html-reporter.js +1 -1
- package/src/utils/mcp-client.js +2 -17
- package/src/utils/mcp-parsers.js +1 -1
- package/src/utils/retry.js +1 -1
- package/src/utils/session-persistence.js +16 -4
- package/src/utils/theme-analyzer.js +173 -0
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ Then ask Claude (or any MCP client):
|
|
|
52
52
|
Run argus_audit on http://localhost:3000
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
**
|
|
55
|
+
**Seven tools are exposed:**
|
|
56
56
|
|
|
57
57
|
| Tool | What it does |
|
|
58
58
|
| --- | --- |
|
|
@@ -62,6 +62,7 @@ Run argus_audit on http://localhost:3000
|
|
|
62
62
|
| `argus_last_report` | Return the last saved JSON report without re-running a scan |
|
|
63
63
|
| `argus_watch_snapshot` | Snapshot the currently open Chrome tab without navigating — raw console + network capture |
|
|
64
64
|
| `argus_get_context` | Capture everything broken on the open tab, formatted as a diagnostic context for Claude to diagnose and suggest fixes |
|
|
65
|
+
| `argus_design_audit` | Full Figma design-to-implementation fidelity audit — 13 finding types across color, typography, spacing, per-corner radius, position drift, stroke, shadow (color+spread), opacity, gap, and text. Selector fallback: `[data-testid]` → `[aria-label]` → `#id` → `.class` |
|
|
65
66
|
|
|
66
67
|
> **Requires**: Node.js ≥ 20.19, Chrome (desktop or headless), and the `chrome-devtools-mcp` server registered alongside Argus (shown above).
|
|
67
68
|
|
|
@@ -79,7 +80,7 @@ The `landing/` directory contains the product landing page (React + Vite + Tailw
|
|
|
79
80
|
|
|
80
81
|
| 🔴 Critical / 🟡 Warning / 🔵 Info | ⚙️ | 🧪 | 📋 |
|
|
81
82
|
| :---: | :---: | :---: | :---: |
|
|
82
|
-
| **114 distinct issue types detected** | **24 analysis engines** | **
|
|
83
|
+
| **114 distinct issue types detected** | **24 analysis engines** | **565 test assertions** | **128 test blocks** |
|
|
83
84
|
|
|
84
85
|
</div>
|
|
85
86
|
|
|
@@ -348,7 +349,8 @@ Argus watches your running application and automatically surfaces issues that te
|
|
|
348
349
|
| **Slack Notifications** | Rich Block Kit reports with inline screenshots routed to `#bugs-critical`, `#bugs-warnings`, `#bugs-digest` |
|
|
349
350
|
| **Slash Command** | `/argus-retest <url>` triggers an on-demand test from any Slack channel |
|
|
350
351
|
| **CI Integration** | GitHub Actions workflow runs daily at 6 AM UTC and on every push to `main` |
|
|
351
|
-
| **MCP Server (AI-callable Argus)** | Register Argus as an MCP server via `.mcp.json`; Claude (or any MCP client) can call `argus_audit`, `argus_audit_full`, `argus_compare`, `argus_last_report`, `argus_watch_snapshot`, and `
|
|
352
|
+
| **MCP Server (AI-callable Argus)** | Register Argus as an MCP server via `.mcp.json`; Claude (or any MCP client) can call `argus_audit`, `argus_audit_full`, `argus_compare`, `argus_last_report`, `argus_watch_snapshot`, `argus_get_context`, and `argus_design_audit` directly from a conversation — no CLI, no terminal required. Published to npm as **[argusqa-os](https://www.npmjs.com/package/argusqa-os)** — add via `{ "command": "npx", "args": ["-y", "argusqa-os"] }` in `.mcp.json` |
|
|
353
|
+
| **Figma Design Fidelity** | `argus_design_audit(url, figmaFrameUrl)` compares every extracted Figma property — 13 mismatch finding types: CSS token values, component presence, per-node 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, 20px), 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. |
|
|
352
354
|
|
|
353
355
|
Works with **React + SCSS**, CSS Modules, CSS-in-JS (styled-components / emotion), and plain HTML/CSS apps.
|
|
354
356
|
|
|
@@ -576,6 +578,7 @@ Ask Claude directly — no terminal needed.
|
|
|
576
578
|
| `argus_last_report` | Return the last saved JSON report without re-running a scan |
|
|
577
579
|
| `argus_watch_snapshot` | Snapshot the currently open Chrome tab without navigating — raw console + network capture |
|
|
578
580
|
| `argus_get_context` | Capture everything broken on the open tab, formatted as a diagnostic context for Claude to diagnose and suggest fixes |
|
|
581
|
+
| `argus_design_audit` | Figma design-to-implementation fidelity audit — 13 finding types across color, typography, spacing, per-corner radius, position drift, stroke, shadow (color+spread), opacity, gap, and text content |
|
|
579
582
|
|
|
580
583
|
**`argus_audit`** — fast audit of any URL:
|
|
581
584
|
|
|
@@ -632,7 +635,7 @@ Then follow with: *"Here's the context — what's causing these errors and how d
|
|
|
632
635
|
| `npm run server` | Start the Slack slash command + interaction server (port 3001) |
|
|
633
636
|
| `npm run init` | Interactive setup wizard — generates `.env` + `targets.js` |
|
|
634
637
|
| `npm run test:unit` | Run 61 unit tests (no Chrome required) |
|
|
635
|
-
| `npm run test:harness` | Run
|
|
638
|
+
| `npm run test:harness` | Run 128-block correctness harness (requires Chrome) |
|
|
636
639
|
|
|
637
640
|
**`npm run crawl`** — full audit of all configured routes:
|
|
638
641
|
|
|
@@ -867,16 +870,16 @@ argus/
|
|
|
867
870
|
│ └── argus.yml # CI pipeline
|
|
868
871
|
├── .vscode/
|
|
869
872
|
│ └── mcp.json # Chrome DevTools MCP config for VS Code
|
|
870
|
-
├── .mcp.json # Argus MCP server registration — exposes argus_audit/argus_audit_full/argus_compare/argus_last_report/argus_watch_snapshot/argus_get_context
|
|
873
|
+
├── .mcp.json # Argus MCP server registration — exposes all 7 tools to Claude: argus_audit/argus_audit_full/argus_compare/argus_last_report/argus_watch_snapshot/argus_get_context/argus_design_audit
|
|
871
874
|
├── src/
|
|
872
875
|
│ ├── argus.js # Single-page audit entry point
|
|
873
876
|
│ ├── batch-runner.js # Multi-page batch audit
|
|
874
|
-
│ ├── mcp-server.js # Argus MCP server — argus_audit / argus_audit_full / argus_compare / argus_last_report / argus_watch_snapshot / argus_get_context
|
|
877
|
+
│ ├── mcp-server.js # Argus MCP server — argus_audit / argus_audit_full / argus_compare / argus_last_report / argus_watch_snapshot / argus_get_context / argus_design_audit
|
|
875
878
|
│ ├── adapters/
|
|
876
879
|
│ │ └── browser.js # CdpBrowserAdapter — facade over all chrome-devtools-mcp calls
|
|
877
880
|
│ ├── domain/
|
|
878
881
|
│ │ └── finding.js # createFinding() factory — canonical finding shape
|
|
879
|
-
│ ├── registry.js # Analyzer plugin registry — registerExpensive/getCheap/getExpensive
|
|
882
|
+
│ ├── registry.js # Analyzer plugin registry — registerCheap/registerExpensive/getCheap/getExpensive/clearAll
|
|
880
883
|
│ ├── config/
|
|
881
884
|
│ │ ├── targets.js # Routes to test, thresholds, config
|
|
882
885
|
│ │ └── schema.js # Zod validation schema; validateConfig() called inside runCrawl()
|
|
@@ -944,12 +947,12 @@ argus/
|
|
|
944
947
|
│ └── README.md # Setup guide, Supabase SQL schema, env vars, deployment
|
|
945
948
|
├── scripts/
|
|
946
949
|
│ └── dispatch-report.js # Standalone Slack re-dispatch script (re-posts last report.json to Slack)
|
|
947
|
-
├── test-harness/ # Fixture server + test runner (
|
|
950
|
+
├── test-harness/ # Fixture server + test runner (128 blocks, 565 hard assertions, 55 fixture pages)
|
|
948
951
|
│ ├── README.md
|
|
949
952
|
│ ├── server.js # Express fixture server (ports 3100 dev / 3101 staging)
|
|
950
953
|
│ ├── harness-config.js # Route definitions + expected findings
|
|
951
|
-
│ ├── validate.js # Test runner —
|
|
952
|
-
│ ├── pages/ #
|
|
954
|
+
│ ├── validate.js # Test runner — 128 numbered blocks ([80]–[84] MCP/createFinding/withRetry/watch/init, [85]–[93] Sprint 0.5 Tier 3, [94]–[126] gap-close, [127] A7 theme, [128] D9 design fidelity)
|
|
955
|
+
│ ├── pages/ # 55 fixture HTML pages (one per detection category)
|
|
953
956
|
│ ├── nextjs-fixture/ # Next.js app structure for C3 discovery tests (10 files)
|
|
954
957
|
│ ├── source-fixture/ # Minimal app.js for C1 codebase-analyzer tests (env var audit)
|
|
955
958
|
│ └── static/
|
|
@@ -989,7 +992,7 @@ argus/
|
|
|
989
992
|
|
|
990
993
|
## Known MCP Tool Limitations
|
|
991
994
|
|
|
992
|
-
The Chrome DevTools MCP behavioral constraints below cause **3 permanent test failures** in the harness (`
|
|
995
|
+
The Chrome DevTools MCP behavioral constraints below cause **3 permanent test failures** in the harness (`562/565` pass). These are MCP-layer restrictions — they cannot be fixed in Argus code. `validate.js` now exits with code 0 when only these 3 failures remain, making the CI harness gate reliable.
|
|
993
996
|
|
|
994
997
|
> **`type_text` clarification**: `type_text` does fire DOM `input` events when the element is properly focused first with `mcp.click({ uid })`. Always use uid-based focus — passing `{ selector }` to `mcp.click` silently does nothing.
|
|
995
998
|
|
|
@@ -1000,6 +1003,8 @@ The Chrome DevTools MCP behavioral constraints below cause **3 permanent test fa
|
|
|
1000
1003
|
|
|
1001
1004
|
These constraints are documented with workarounds in [SKILL.md §10](SKILL.md).
|
|
1002
1005
|
|
|
1006
|
+
The harness passes **562/565** assertions (exits 0). The 3 failures are the permanent MCP-limited ones listed above.
|
|
1007
|
+
|
|
1003
1008
|
---
|
|
1004
1009
|
|
|
1005
1010
|
## Environment Variables Reference
|
package/glama.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://glama.ai/mcp/schemas/server.json",
|
|
3
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.
|
|
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). 128 test blocks, 565 hard assertions, 56 detection categories.",
|
|
5
5
|
"maintainers": ["ironclawdevs27"],
|
|
6
6
|
"tools": [
|
|
7
7
|
{
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
{
|
|
28
28
|
"name": "argus_get_context",
|
|
29
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."
|
|
30
34
|
}
|
|
31
35
|
]
|
|
32
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/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.
|
|
3
|
+
* Argus MCP Server (v9.5.3)
|
|
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.
|
|
339
|
+
{ name: 'argus', version: '9.5.3' },
|
|
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) {
|