@mmerterden/multi-agent-toolkit-mcp 3.13.1 → 3.15.0
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/CHANGELOG.md +192 -5
- package/README.md +80 -8
- package/README.tr.md +104 -8
- package/index.js +526 -162
- package/package.json +4 -4
- package/tools/context/index.js +34 -18
- package/tools/design-check/component-walk.js +359 -0
- package/tools/design-check/content-cardinality.js +3 -3
- package/tools/design-check/index.js +78 -11
- package/tools/design-check/report.js +152 -10
- package/tools/design-check/scan.js +1 -1
- package/tools/design-check/scenario-inventory.js +404 -50
- package/tools/design-check/visual-compare.js +17 -2
- package/tools/ios-app-store-audit/context.js +3 -3
- package/tools/ios-app-store-audit/exec.js +17 -0
- package/tools/ios-app-store-audit/index.js +0 -15
- package/tools/ios-app-store-audit/rules/code-signing.js +2 -2
- package/tools/ios-app-store-audit/rules/dead-reference.js +2 -2
- package/tools/ios-app-store-audit/rules/debug-tool-leak.js +2 -2
- package/tools/ios-app-store-audit/rules/embedded-sdk.js +3 -3
- package/tools/ios-app-store-audit/rules/extension-signing.js +2 -2
- package/tools/ios-app-store-audit/rules/ipv6-compliance.js +2 -2
- package/tools/ios-app-store-audit/rules/production-hygiene.js +2 -2
- package/tools/ios-app-store-audit/rules/provisioning-profile.js +3 -3
- package/tools/ios-app-store-audit/rules/required-reason-api.js +2 -2
- package/tools/offload/index.js +7 -3
- package/tools/policy/egress-proxy.js +268 -0
- package/tools/policy/index.js +283 -0
- package/tools/security/cvss.js +108 -0
- package/tools/security/deps.js +0 -0
- package/tools/security/index.js +115 -0
- package/tools/spawn-collect/index.js +141 -0
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* design_scenario_inventory - every state driver the mock build exposes (audit target set)
|
|
10
10
|
* design_mock_launch - launch the app in mock mode (UserDefaults launch arg / intent extra)
|
|
11
11
|
* design_ui_geometry - flat element bounding boxes of the current screen
|
|
12
|
+
* design_component_variants - design node -> component set -> variants, from caller-supplied Figma data
|
|
12
13
|
* design_visual_compare - normalize + pixel/geometry/color/type diff vs a Figma render
|
|
13
14
|
* design_report - render HTML (+ optional PDF) from a report object
|
|
14
15
|
*/
|
|
@@ -17,10 +18,11 @@ import { join } from "path";
|
|
|
17
18
|
import { existsSync, mkdirSync } from "fs";
|
|
18
19
|
|
|
19
20
|
import { detectMock } from "./mock-detect.js";
|
|
20
|
-
import { inventoryScenarios } from "./scenario-inventory.js";
|
|
21
|
+
import { inventoryScenarios, TARGET_SCHEMA } from "./scenario-inventory.js";
|
|
21
22
|
import { flattenIosAxTree, flattenAndroidUiXml, flattenIdbDescribeAll } from "./geometry.js";
|
|
22
23
|
import { compareVisual } from "./visual-compare.js";
|
|
23
|
-
import {
|
|
24
|
+
import { walkComponents } from "./component-walk.js";
|
|
25
|
+
import { writeReport, writeRollup } from "./report.js";
|
|
24
26
|
|
|
25
27
|
// Single-quote for POSIX sh; the swift dumper path is interpolated into a shell
|
|
26
28
|
// command via ctx.run. Kept local so this module stays self-contained.
|
|
@@ -40,14 +42,16 @@ export const DESIGN_TOOLS = [
|
|
|
40
42
|
},
|
|
41
43
|
{
|
|
42
44
|
name: "design_scenario_inventory",
|
|
43
|
-
description: "Enumerate every state driver a mock-mode build exposes, so a design audit has a countable target set instead of walking the UI until it feels done. Returns { targets:[{id,kind,label,screen,driver,cost,evidence}], plan[], relaunchCount, groups[], byKind, byCost, targetCount, ignored[], truncated }. Kinds: launch-arg (relaunch flags / boolean intent extras), scenario-case (cases of *Scenario / *Outcome / *MockCase enums), code-scenario (short uppercase codes typed at an entry field, read only from debug/mock code), fixture (MockData JSON), deep-link (custom-scheme URLs). Each target carries a cost ('relaunch' vs 'in-app') and `plan` batches them so ONE relaunch serves every in-app target on that screen - relaunchCount, not targetCount, is the real cost of full coverage.
|
|
44
|
-
inputSchema: { type: "object", properties: {
|
|
45
|
+
description: "Enumerate every state driver a mock-mode build exposes, so a design audit has a countable target set instead of walking the UI until it feels done. Returns { targets:[{id,kind,label,screen,driver,cost,evidence}], plan[], relaunchCount, groups[], byKind, byCost, targetCount, ignored[], acceptedSelectors[], rejectedSelectors[], truncated }. Kinds: launch-arg (relaunch flags / boolean intent extras), scenario-case (cases of *Scenario / *Outcome / *MockCase enums - counted only when the type is declared in debug-only code (#if DEBUG, an Android debug/mock source set), referenced only from debug/mock code, or read from a runtime key; a production type that merely shares the suffix is listed in rejectedSelectors with the production reference that disqualified it), code-scenario (short uppercase codes typed at an entry field, read only from debug/mock code), prefix-code, fixture (MockData JSON), deep-link (custom-scheme URLs). Each target carries a cost ('relaunch' vs 'in-app') and `plan` batches them so ONE relaunch serves every in-app target on that screen - relaunchCount, not targetCount, is the real cost of full coverage. Pass targets_file and/or targets[] to supply the target set from an external catalog instead: source scanning is skipped and plan, groups, relaunchCount and the coverage gate work unchanged. Run right after design_mock_detect; feed targets + truncated into the design_report coverage gate.",
|
|
46
|
+
inputSchema: { type: "object", additionalProperties: false, properties: {
|
|
45
47
|
repo_path: { type: "string", description: "Absolute path to the repo or module to scan" },
|
|
46
48
|
platform: { type: "string", enum: ["ios", "android"], description: "Optional; auto-detected if omitted" },
|
|
47
49
|
extra_launch_args: { type: "array", items: { type: "string" }, description: "Project-specific launch args no signal reveals" },
|
|
48
50
|
extra_targets: { type: "array", items: { type: "object" }, description: "Config-declared targets [{id,kind,label,screen,driver}]" },
|
|
49
51
|
ignore_targets: { type: "array", items: { type: "string" }, description: "Target ids to drop entirely (dead drivers, states retired in code). Returned in `ignored` - unlike a skip these never reach the coverage gate, so prefer a skip with a reason." },
|
|
50
|
-
|
|
52
|
+
targets_file: { type: "string", description: "Path to a JSON target catalog: a bare array of targets, or { version: 1, platform?, description?, targets: [...] }. Each target has the shape this tool returns ({ id, driver:{type,...}, kind?, label?, screen?, cost?, evidence? }); unknown fields are refused. When given, source scanning is skipped." },
|
|
53
|
+
targets: { type: "array", items: TARGET_SCHEMA, description: "Inline target catalog, same shape as targets_file entries. When given, source scanning is skipped. Combined with targets_file when both are passed; ids must be unique across the two." },
|
|
54
|
+
summary: { type: "boolean", description: "Return counts, groups and plan without the per-target list, for modules whose full payload exceeds a host's tool-result cap. plan[].targetIds and groups[].ids still carry every id, so a run can be driven from the summary and fetch labels + file/line evidence later with summary:false." },
|
|
51
55
|
}, required: ["repo_path"] },
|
|
52
56
|
},
|
|
53
57
|
{
|
|
@@ -72,6 +76,29 @@ export const DESIGN_TOOLS = [
|
|
|
72
76
|
max_depth: { type: "number", description: "iOS AX tree depth (default 12)" },
|
|
73
77
|
}, required: ["platform"] },
|
|
74
78
|
},
|
|
79
|
+
{
|
|
80
|
+
name: "design_component_variants",
|
|
81
|
+
description: "Resolve design nodes to the component set they belong to and list that set's variants, so a live component is compared against its own variant instead of a screen frame whose mock content (5 rows in the design, 3 in the app) shifts every container. Works only on Figma data the caller already fetched - this server never contacts Figma: per file, a REST GET /v1/files/:key/nodes response (`nodes`) or MCP get_metadata XML (`metadata`, parent-relative x/y), plus optionally the library's GET /v1/files/:key/component_sets listing, which locates a set that lives in another file. Walks instance -> main component -> component set -> variant children (names like 'State=Error, Size=L'). Returns { components:[{ requested, resolvedVia, component, componentSet:{nodeId,name,fileKey}, codeConnect, properties, variants:[{ name, nodeId, fileKey, properties, frame:{w,h}, spec[], render:{fileKey,nodeId,format,scale}, image }], matches[], unmatchedStates[] }], needs[], rendersNeeded[], unresolved[] }. needs[] names nodes to fetch before the walk can finish; rendersNeeded[] names the variant renders to download. Per variant, pass spec as figma_spec, frame as figma_frame and the render as figma_png to design_visual_compare, with the live component's bounds as live_region; pass componentFindingFields-shaped `component` so design_report shows the main component and variant thumbnails. States (e.g. inventory targets) are matched to variants by name; an ambiguous or distant match is listed in unmatchedStates, never assigned.",
|
|
82
|
+
inputSchema: { type: "object", additionalProperties: false, properties: {
|
|
83
|
+
sources: { type: "array", minItems: 1, description: "Figma data per file: [{ file_key, nodes? (REST /v1/files/:key/nodes response object), metadata? (MCP get_metadata XML string) }]. List the audited screen's file first.", items: {
|
|
84
|
+
type: "object", additionalProperties: false, required: ["file_key"], properties: {
|
|
85
|
+
file_key: { type: "string", minLength: 1 },
|
|
86
|
+
nodes: { type: "object" },
|
|
87
|
+
metadata: { type: "string" },
|
|
88
|
+
} } },
|
|
89
|
+
component_sets: { type: "array", description: "The meta.component_sets array of a library's GET /v1/files/:key/component_sets response: [{ key, file_key, node_id, name, ... }]", items: {
|
|
90
|
+
type: "object", required: ["file_key", "node_id"], properties: { key: { type: "string" }, file_key: { type: "string" }, node_id: { type: "string" }, name: { type: "string" } } } },
|
|
91
|
+
node_ids: { type: "array", items: { type: "string" }, description: "Nodes to resolve (instances, components or sets), as a node id or as fileKey/nodeId to pick one of several sources that use the same id. Default: the top-level nodes of the first source." },
|
|
92
|
+
code_connect: { type: "array", description: "Code Connect mapping: [{ url | file_key + node_id, component, source? }]. The component a node maps to names the result.", items: {
|
|
93
|
+
type: "object", additionalProperties: false, required: ["component"], properties: {
|
|
94
|
+
url: { type: "string" }, file_key: { type: "string" }, node_id: { type: "string" },
|
|
95
|
+
component: { type: "string", minLength: 1 }, source: { type: "string" },
|
|
96
|
+
} } },
|
|
97
|
+
states: { type: "array", description: "Audited states to match to variants by name: [{ id, label?, screen? }] (inventory targets reduced to these fields).", items: {
|
|
98
|
+
type: "object", additionalProperties: false, required: ["id"], properties: { id: { type: "string" }, label: { type: "string" }, screen: { type: "string" } } } },
|
|
99
|
+
renders: { type: "object", description: "Variant renders already downloaded: { '<nodeId>' or '<fileKey>/<nodeId>': '/abs/path.png' }. Each becomes the variant's image." },
|
|
100
|
+
}, required: ["sources"] },
|
|
101
|
+
},
|
|
75
102
|
{
|
|
76
103
|
name: "design_visual_compare",
|
|
77
104
|
description: "Compare a Figma render PNG against a live device screenshot WITHOUT failing on size mismatch. RESPONSIVE BY DEFAULT: because the device and the Figma frame are usually different widths (e.g. 402pt vs 375pt), raw width/height deltas are meaningless - so it measures what a designer actually specs, in POINTS: per-element edge insets (left/right), the GAPS between consecutive elements (text <-> divider spacing), vertical placement, font size/family and text colour (sampled from the text ink, not the box average). Height is still reported but flagged advisory, since a content-hugging container changes height whenever the fixture differs; advisories never decide pass/fail. Pass responsive:false for the legacy absolute-delta behaviour. Writes figma/live/diff/overlay/side-by-side PNGs. Returns findings + image paths. For a BOTTOM SHEET / modal / partial overlay pass live_region (the sheet's bounds from design_ui_geometry): the Figma frame covers only the sheet while the capture is the whole screen, and stretching one onto the other misplaces every element inside it. Add expected_region to have the sheet's own edge insets reported as findings - a design showing a sheet flush to the edges is not satisfied by one floating inset from them.",
|
|
@@ -96,6 +123,11 @@ export const DESIGN_TOOLS = [
|
|
|
96
123
|
relations: { type: "boolean", description: "Default true. Emit content-independent relational checks - centred-in-frame, left/right margin symmetry, shared centre lines (is the close button aligned with the title), and icon/control sizing. These are what actually answer 'is it built 1:1', because absolute Y moves with the mock content above it." },
|
|
97
124
|
responsive: { type: "boolean", description: "Default true. Measure edge insets + inter-element gaps in points instead of raw width/height in the stretched compare space. Requires figma_frame + live_screen. Turn off only to get legacy absolute deltas." },
|
|
98
125
|
verify_font_family: { type: "boolean", description: "Emit an advisory per text element naming the design's font family (not readable from the device, so it is a code-review item, never a measured delta). Default false." },
|
|
126
|
+
component: { type: "object", additionalProperties: false, description: "The component this capture region belongs to, from design_component_variants: { component, componentNode, componentFileKey, componentVariant?, componentVariants:[{name,node,fileKey,image}] }. Stamped onto every finding that names no component of its own, so design_report renders the main component link and the variant thumbnails.", properties: {
|
|
127
|
+
component: { type: "string" }, componentNode: { type: "string" }, componentFileKey: { type: "string" }, componentVariant: { type: "string" },
|
|
128
|
+
componentVariants: { type: "array", items: { type: "object", additionalProperties: false, required: ["name"], properties: {
|
|
129
|
+
name: { type: "string" }, node: { type: "string" }, fileKey: { type: "string" }, image: { type: "string" } } } },
|
|
130
|
+
} },
|
|
99
131
|
content_cardinality: { type: "boolean", description: "Demote the geometry findings a fixture-count difference explains (5 design rows vs 3 live rows shifts every container height and everything below it). Default true. Demoted findings are NOT removed - they become advisory with a shared root cause and are reported in `contentCardinality.demoted`. Copy, colour, typography and tap-target findings, and anything above the first differing group, are never demoted. Pass false to see the raw findings." },
|
|
100
132
|
color_tolerance: { type: "number", description: "Ignore color ΔE ≤ this (default 3)" },
|
|
101
133
|
perceptual_precision: { type: "number", description: "pixelmatch threshold 0..1 (default 0.1)" },
|
|
@@ -104,11 +136,18 @@ export const DESIGN_TOOLS = [
|
|
|
104
136
|
},
|
|
105
137
|
{
|
|
106
138
|
name: "design_report",
|
|
107
|
-
description: "Render a design-audit report object into a self-contained HTML file (base64 images, CSS-positioned annotations over the live capture) and optionally PDF. Enforces a COVERAGE GATE: pass report.coverage = { targets, covered, skipped:[{id,group,reason}], floor? } and every target must be audited or skipped WITH a reason - otherwise the returned object carries coverage.gate='fail' + coverageError and the report renders a red gate banner. Human-facing wording comes from report.lang ('en' default, 'tr' built in) and per-key report.labels overrides. Returns written file paths + the coverage verdict. Confluence upload is handled by the caller.",
|
|
139
|
+
description: "Render a design-audit report object into a self-contained HTML file (base64 images, CSS-positioned annotations over the live capture) and optionally PDF. Enforces a COVERAGE GATE: pass report.coverage = { targets, covered, skipped:[{id,group,reason}], floor? } and every target must be audited or skipped WITH a reason - otherwise the returned object carries coverage.gate='fail' + coverageError and the report renders a red gate banner. Human-facing wording comes from report.lang ('en' default, 'tr' built in) and per-key report.labels overrides. Returns written file paths + the coverage verdict. ROLL-UP MODE: pass modules:[{ module, report | report_file, href? }] and a header-only report ({ project, platform, timestamp, lang, labels }) to render index.html with one row per module (coverage, gate, deviations, link to the module's own report, rendered into <out_dir>/<module>/ unless href points at an existing one). Each module runs through the same coverage gate, and any module that fails it - or declares no coverage - makes the roll-up gate fail; the result carries rollup:{ gate, modules[], failedModules[] }. Confluence upload is handled by the caller.",
|
|
108
140
|
inputSchema: { type: "object", properties: {
|
|
109
141
|
report: { type: "object", description: "{ project, module, platform, figmaUrl, timestamp, lang, labels, coverage:{targets,covered,skipped:[{id,group,reason}],floor}, variants:[{name, figmaNodeId, perceptualPct, passed, compareSize, images:{figma,live,diff,overlay,sideBySide}, findings:[...], fixPrompt, componentRefs:[{name,image}] }] }" },
|
|
110
142
|
out_dir: { type: "string", description: "Directory to write report into" },
|
|
111
143
|
formats: { type: "array", items: { type: "string", enum: ["html", "pdf", "confluence"] }, description: "Default ['html']" },
|
|
144
|
+
modules: { type: "array", minItems: 1, description: "Roll-up mode: the module reports to summarise. `report` then carries only the roll-up header.", items: {
|
|
145
|
+
type: "object", additionalProperties: false, required: ["module"], properties: {
|
|
146
|
+
module: { type: "string", minLength: 1 },
|
|
147
|
+
report: { type: "object", description: "The module's report object, same shape as a single design_report call" },
|
|
148
|
+
report_file: { type: "string", description: "Path to a JSON file holding the module's report object" },
|
|
149
|
+
href: { type: "string", description: "Link to an already-rendered module report; when omitted one is rendered and linked" },
|
|
150
|
+
} } },
|
|
112
151
|
}, required: ["report", "out_dir"] },
|
|
113
152
|
},
|
|
114
153
|
];
|
|
@@ -135,11 +174,16 @@ export async function handleDesign(name, args, ctx) {
|
|
|
135
174
|
return JSON.stringify(detectMock({ repoPath: args.repo_path, platform: args.platform, extraKeys: args.extra_keys || [] }), null, 2);
|
|
136
175
|
|
|
137
176
|
case "design_scenario_inventory":
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
177
|
+
try {
|
|
178
|
+
return JSON.stringify(inventoryScenarios({
|
|
179
|
+
repoPath: args.repo_path, platform: args.platform,
|
|
180
|
+
extraLaunchArgs: args.extra_launch_args || [], extraTargets: args.extra_targets || [],
|
|
181
|
+
ignoreTargets: args.ignore_targets || [], summary: args.summary === true,
|
|
182
|
+
targets: args.targets ?? null, targetsFile: args.targets_file ?? null,
|
|
183
|
+
}), null, 2);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
return `ERROR: ${err.message}`;
|
|
186
|
+
}
|
|
143
187
|
|
|
144
188
|
case "design_mock_launch": {
|
|
145
189
|
if (args.platform === "ios") {
|
|
@@ -188,6 +232,20 @@ export async function handleDesign(name, args, ctx) {
|
|
|
188
232
|
return JSON.stringify({ platform: "android", unit: "pixels", screen: sm ? { w: +sm[1], h: +sm[2] } : null, count: elements.length, elements }, null, 2);
|
|
189
233
|
}
|
|
190
234
|
|
|
235
|
+
case "design_component_variants":
|
|
236
|
+
try {
|
|
237
|
+
return JSON.stringify(walkComponents({
|
|
238
|
+
sources: args.sources.map((src) => ({ fileKey: src.file_key, nodes: src.nodes, metadata: src.metadata })),
|
|
239
|
+
componentSets: args.component_sets || null,
|
|
240
|
+
nodeIds: args.node_ids || null,
|
|
241
|
+
codeConnect: (args.code_connect || []).map((e) => ({ url: e.url, fileKey: e.file_key, nodeId: e.node_id, component: e.component, source: e.source })),
|
|
242
|
+
states: args.states || [],
|
|
243
|
+
renders: args.renders || {},
|
|
244
|
+
}), null, 2);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
return `ERROR: ${err.message}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
191
249
|
case "design_visual_compare": {
|
|
192
250
|
ensureDir(args.out_dir);
|
|
193
251
|
const res = await compareVisual({
|
|
@@ -204,6 +262,7 @@ export async function handleDesign(name, args, ctx) {
|
|
|
204
262
|
edges: args.edges !== false,
|
|
205
263
|
designCopy: args.design_copy || null,
|
|
206
264
|
verifyFontFamily: args.verify_font_family === true,
|
|
265
|
+
component: args.component || null,
|
|
207
266
|
perceptualPrecision: args.perceptual_precision, maxDiffPct: args.max_diff_pct,
|
|
208
267
|
});
|
|
209
268
|
return JSON.stringify(res, null, 2);
|
|
@@ -211,6 +270,14 @@ export async function handleDesign(name, args, ctx) {
|
|
|
211
270
|
|
|
212
271
|
case "design_report": {
|
|
213
272
|
ensureDir(args.out_dir);
|
|
273
|
+
if (args.modules) {
|
|
274
|
+
try {
|
|
275
|
+
const out = await writeRollup({ rollup: args.report, modules: args.modules, outDir: args.out_dir, formats: args.formats || ["html"] });
|
|
276
|
+
return JSON.stringify(out, null, 2);
|
|
277
|
+
} catch (err) {
|
|
278
|
+
return `ERROR: ${err.message}`;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
214
281
|
const out = await writeReport({ report: args.report, outDir: args.out_dir, formats: args.formats || ["html"] });
|
|
215
282
|
return JSON.stringify(out, null, 2);
|
|
216
283
|
}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* single natural language.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
23
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
24
24
|
import { join } from "path";
|
|
25
25
|
|
|
26
26
|
// Single-quote a value for POSIX sh: pdfPath/htmlPath derive from the caller's
|
|
@@ -47,6 +47,7 @@ const LABEL_PACKS = {
|
|
|
47
47
|
variantsWord: "variants",
|
|
48
48
|
toggleHint: "(show/hide · click → Figma)",
|
|
49
49
|
noPreview: "No preview captured.",
|
|
50
|
+
variantNotRendered: "not rendered",
|
|
50
51
|
fixPromptTitle: "Developer prompt (paste into an agent)",
|
|
51
52
|
figmaCol: "FIGMA · expected", liveCol: "LIVE · actual",
|
|
52
53
|
coverageTitle: "Coverage",
|
|
@@ -59,6 +60,12 @@ const LABEL_PACKS = {
|
|
|
59
60
|
coverageUndeclared: "target undeclared by the run",
|
|
60
61
|
coverageTruncated: "The scenario inventory hit its cap, so the target set is incomplete and this percentage is measured against a partial denominator.",
|
|
61
62
|
coverageBelowFloor: "below the required coverage floor",
|
|
63
|
+
rollupTitle: "Design Check Roll-up",
|
|
64
|
+
thModule: "Module", thCoverage: "Coverage", thGate: "Gate", thReport: "Report",
|
|
65
|
+
rollupGatePass: "ROLL-UP GATE PASSED", rollupGateFail: "ROLL-UP GATE FAILED",
|
|
66
|
+
rollupGateHint: "every module must pass its own coverage gate",
|
|
67
|
+
rollupNoCoverage: "no coverage declared",
|
|
68
|
+
rollupOpen: "open",
|
|
62
69
|
footerNote: "multi-agent-toolkit-mcp design-check · copy is compared against the design's source-of-truth UX writing, not a stale frame render; localization differences are not counted as defects.",
|
|
63
70
|
},
|
|
64
71
|
tr: {
|
|
@@ -78,6 +85,7 @@ const LABEL_PACKS = {
|
|
|
78
85
|
variantsWord: "varyant",
|
|
79
86
|
toggleHint: "(göster/gizle · tıkla → Figma)",
|
|
80
87
|
noPreview: "Önizleme çekilmedi.",
|
|
88
|
+
variantNotRendered: "render alınmadı",
|
|
81
89
|
fixPromptTitle: "Geliştirme promptu (ajana yapıştır)",
|
|
82
90
|
figmaCol: "FIGMA · olması gereken", liveCol: "CANLI · mevcut",
|
|
83
91
|
coverageTitle: "Kapsam",
|
|
@@ -90,6 +98,12 @@ const LABEL_PACKS = {
|
|
|
90
98
|
coverageUndeclared: "hedef koşuda hiç bildirilmedi",
|
|
91
99
|
coverageTruncated: "Senaryo envanteri üst sınıra takıldı; hedef kümesi eksik ve bu yüzde kısmi bir payda üzerinden hesaplandı.",
|
|
92
100
|
coverageBelowFloor: "istenen kapsam tabanının altında",
|
|
101
|
+
rollupTitle: "Design Check Özeti",
|
|
102
|
+
thModule: "Modül", thCoverage: "Kapsam", thGate: "Geçit", thReport: "Rapor",
|
|
103
|
+
rollupGatePass: "ÖZET GEÇİDİ GEÇTİ", rollupGateFail: "ÖZET GEÇİDİ BAŞARISIZ",
|
|
104
|
+
rollupGateHint: "her modül kendi kapsam geçidini geçmeli",
|
|
105
|
+
rollupNoCoverage: "kapsam bildirilmedi",
|
|
106
|
+
rollupOpen: "aç",
|
|
93
107
|
footerNote: "multi-agent-toolkit-mcp design-check · metin karşılaştırması tasarımın kaynak-doğru UX-writing'ine göre yapılır (bayat frame render'ına değil); localization farkları hata sayılmaz.",
|
|
94
108
|
},
|
|
95
109
|
};
|
|
@@ -117,16 +131,16 @@ function figmaNodeUrl(fileKey, node) {
|
|
|
117
131
|
/**
|
|
118
132
|
* The file a finding's component actually lives in.
|
|
119
133
|
*
|
|
120
|
-
* Components are NOT all in the audited screen's file
|
|
134
|
+
* Components are NOT all in the audited screen's file; a design system spreads
|
|
135
|
+
* them over library files:
|
|
121
136
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
137
|
+
* ProductCardHeader -> AAAAfileKeyA
|
|
138
|
+
* ProductDetailLayout -> BBBBfileKeyB
|
|
124
139
|
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* fall back to the report-level one, which is correct for the same-file case.
|
|
140
|
+
* A link built from the report-level `figmaFileKey` for a component in another
|
|
141
|
+
* file still opens but resolves to nothing, which reads as "the node was
|
|
142
|
+
* deleted" rather than "the link is wrong". So the per-finding key wins, and the
|
|
143
|
+
* report-level key is the fallback for the same-file case.
|
|
130
144
|
*/
|
|
131
145
|
function componentFileKey(finding, reportFileKey) {
|
|
132
146
|
return (finding && finding.componentFileKey) || reportFileKey;
|
|
@@ -323,7 +337,7 @@ function componentsBlock(v, fileKey, L) {
|
|
|
323
337
|
const compKey = componentFileKey(f, fileKey);
|
|
324
338
|
const url = figmaNodeUrl(compKey, f.componentNode);
|
|
325
339
|
const thumbs = variants.length
|
|
326
|
-
? `<div class="cvgrid">${variants.map((cv) => `<figure>${cv.node && url ? `<a href="${esc(figmaNodeUrl(cv.fileKey || compKey, cv.node))}" target="_blank">` : ""}
|
|
340
|
+
? `<div class="cvgrid">${variants.map((cv) => { const src = dataUri(cv.image); return `<figure>${cv.node && url ? `<a href="${esc(figmaNodeUrl(cv.fileKey || compKey, cv.node))}" target="_blank">` : ""}${src ? `<img src="${src}" alt="">` : `<div class="cvmissing">${esc(L.variantNotRendered)}</div>`}${cv.node && url ? "</a>" : ""}<figcaption>${esc(cv.name)}</figcaption></figure>`; }).join("")}</div>`
|
|
327
341
|
: `<p class="cvnote">${esc(L.noPreview)}</p>`;
|
|
328
342
|
const name = url ? `<a href="${esc(url)}" target="_blank">${esc(f.component)} ↗</a>` : `<b>${esc(f.component)}</b>`;
|
|
329
343
|
return `<details class="cvcomp"><summary>◈ ${esc(L.mainComponent)}: ${name}${variants.length ? ` · ${variants.length} ${esc(L.variantsWord)}` : ""} <span class="cvhint">${esc(L.toggleHint)}</span></summary>${thumbs}</details>`;
|
|
@@ -514,6 +528,7 @@ code{background:#eee;padding:1px 5px;border-radius:4px;font-size:12px}
|
|
|
514
528
|
.cvgrid figure{margin:0;text-align:center}.cvgrid img{height:150px;border:1px solid var(--line);border-radius:6px;display:block}
|
|
515
529
|
.cvgrid figcaption{font-size:11px;color:var(--muted);margin-top:4px;max-width:160px}
|
|
516
530
|
.cvnote{color:var(--muted);font-size:12px;margin:10px 0 2px}
|
|
531
|
+
.cvmissing{height:150px;width:110px;border:1px dashed var(--line);border-radius:6px;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:11px}
|
|
517
532
|
a{color:#0A66C2;text-decoration:none}a:hover{text-decoration:underline}
|
|
518
533
|
.coverage{border-radius:12px;padding:20px;margin-bottom:28px}
|
|
519
534
|
.covfail{border:2px solid #cf222e;background:#fff5f5}.covpass{border:1px solid #0A7D33;background:#f6fff8}
|
|
@@ -604,3 +619,130 @@ export async function writeReport({ report, outDir, formats = ["html"] }) {
|
|
|
604
619
|
}
|
|
605
620
|
return out;
|
|
606
621
|
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Roll-up over several module reports: one row per module with its coverage,
|
|
625
|
+
* deviations and a link to its own report.
|
|
626
|
+
*
|
|
627
|
+
* Each module runs through the same coverageVerdict as a single report, and the
|
|
628
|
+
* roll-up gate is the conjunction: any module that fails - or declares no
|
|
629
|
+
* coverage, which leaves nothing to verify - makes the roll-up fail. A green
|
|
630
|
+
* roll-up therefore never averages a failing module away behind passing ones.
|
|
631
|
+
*
|
|
632
|
+
* modules: [{ module, report? (report object), report_file? (JSON of one),
|
|
633
|
+
* href? (an existing sub-report; otherwise one is rendered into
|
|
634
|
+
* <outDir>/<slug>/report.html and linked relatively) }]
|
|
635
|
+
*/
|
|
636
|
+
const deviationsOf = (report) => (report.variants || []).reduce((n, v) =>
|
|
637
|
+
n + (v.findings || []).filter((f) => f.status !== "verified" && !f.advisory).length, 0);
|
|
638
|
+
const advisoriesOf = (report) => (report.variants || []).reduce((n, v) =>
|
|
639
|
+
n + (v.findings || []).filter((f) => f.status !== "verified" && f.advisory).length, 0);
|
|
640
|
+
const slugOf = (s) => String(s || "").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase() || "module";
|
|
641
|
+
|
|
642
|
+
function loadModuleReport(entry, i) {
|
|
643
|
+
if (!entry || typeof entry.module !== "string" || !entry.module) throw new Error(`modules[${i}].module is required`);
|
|
644
|
+
if (entry.report && typeof entry.report === "object") return entry.report;
|
|
645
|
+
if (entry.report_file) {
|
|
646
|
+
let raw;
|
|
647
|
+
try { raw = readFileSync(entry.report_file, "utf-8"); } catch (err) { throw new Error(`modules[${i}].report_file unreadable: ${entry.report_file} (${err.code || err.message})`); }
|
|
648
|
+
try { return JSON.parse(raw); } catch (err) { throw new Error(`modules[${i}].report_file is not valid JSON: ${err.message}`); }
|
|
649
|
+
}
|
|
650
|
+
throw new Error(`modules[${i}] needs report or report_file`);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export function rollupVerdict(modules) {
|
|
654
|
+
const rows = modules.map(({ module, report, href }) => {
|
|
655
|
+
const cv = coverageVerdict(report.coverage, (report.variants || []).length);
|
|
656
|
+
const row = {
|
|
657
|
+
module, href,
|
|
658
|
+
gate: cv ? cv.gate : "fail",
|
|
659
|
+
audited: cv ? cv.audited : 0,
|
|
660
|
+
target: cv ? cv.target : 0,
|
|
661
|
+
pct: cv ? Math.round(cv.pct * 1000) / 10 : 0,
|
|
662
|
+
unaccounted: cv ? cv.unaccountedCount : 0,
|
|
663
|
+
deviations: deviationsOf(report),
|
|
664
|
+
advisories: advisoriesOf(report),
|
|
665
|
+
screens: (report.variants || []).length,
|
|
666
|
+
};
|
|
667
|
+
if (!cv) row.reason = "no coverage declared";
|
|
668
|
+
else if (cv.truncatedInventory) row.reason = "inventory truncated";
|
|
669
|
+
else if (cv.belowFloor) row.reason = "below coverage floor";
|
|
670
|
+
else if (cv.unaccountedCount) row.reason = `${cv.unaccountedCount} target(s) neither audited nor skipped with a reason`;
|
|
671
|
+
return row;
|
|
672
|
+
});
|
|
673
|
+
const failedModules = rows.filter((r) => r.gate === "fail").map((r) => r.module);
|
|
674
|
+
return { gate: failedModules.length ? "fail" : "pass", modules: rows, failedModules };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export function renderRollupHtml(header, verdict) {
|
|
678
|
+
const L = labels(header || {});
|
|
679
|
+
const fail = verdict.gate === "fail";
|
|
680
|
+
const rows = verdict.modules.map((m) => `<tr class="${m.gate === "fail" ? "bad" : ""}">
|
|
681
|
+
<td>${esc(m.module)}</td>
|
|
682
|
+
<td>${m.target ? `<b>${m.audited}</b> / ${m.target} · ${esc(m.pct)}%` : "-"}${m.reason ? `<div class="why">${esc(m.reason === "no coverage declared" ? L.rollupNoCoverage : m.reason)}</div>` : ""}</td>
|
|
683
|
+
<td><span class="badge ${m.gate === "fail" ? "bad" : "good"}">${esc(m.gate.toUpperCase())}</span></td>
|
|
684
|
+
<td>${m.deviations}${m.advisories ? ` <small>(+${m.advisories} ${esc(L.advisoryWord)})</small>` : ""}</td>
|
|
685
|
+
<td>${m.href ? `<a href="${esc(m.href)}">${esc(L.rollupOpen)} ↗</a>` : "-"}</td></tr>`).join("");
|
|
686
|
+
return `<!doctype html><html lang="${esc(L.htmlLang)}"><head><meta charset="utf-8">
|
|
687
|
+
<title>${esc(L.rollupTitle)} - ${esc(header.project)}</title>
|
|
688
|
+
<style>
|
|
689
|
+
body{font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;color:#1a1a1a;margin:0;padding:32px;max-width:1180px;margin-inline:auto;background:#fff}
|
|
690
|
+
h1{font-size:22px;margin:0 0 4px}.sub{color:#666;margin:0 0 20px}
|
|
691
|
+
.gate{border-radius:12px;padding:14px 18px;margin-bottom:22px;font-weight:700}
|
|
692
|
+
.gate small{display:block;font-weight:400;color:#666}
|
|
693
|
+
.gate.bad{border:2px solid #cf222e;background:#fff5f5;color:#cf222e}.gate.good{border:1px solid #0A7D33;background:#f6fff8;color:#0A7D33}
|
|
694
|
+
table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #e5e5e5;vertical-align:top}
|
|
695
|
+
th{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:#666}
|
|
696
|
+
tr.bad td:first-child{border-left:3px solid #cf222e}
|
|
697
|
+
.badge{font-size:11px;font-weight:700;padding:3px 9px;border-radius:10px;color:#fff}.badge.bad{background:#cf222e}.badge.good{background:#0A7D33}
|
|
698
|
+
.why{color:#cf222e;font-size:12px}small{color:#666}
|
|
699
|
+
a{color:#0A66C2;text-decoration:none}
|
|
700
|
+
</style></head><body>
|
|
701
|
+
<h1>${esc(L.rollupTitle)}</h1>
|
|
702
|
+
<p class="sub">${esc(header.project)} · ${esc(header.platform || "")} · ${esc(header.timestamp || "")}</p>
|
|
703
|
+
<div class="gate ${fail ? "bad" : "good"}">${esc(fail ? L.rollupGateFail : L.rollupGatePass)}<small>${esc(L.rollupGateHint)}</small></div>
|
|
704
|
+
<table><thead><tr><th>${esc(L.thModule)}</th><th>${esc(L.thCoverage)}</th><th>${esc(L.thGate)}</th><th>${esc(L.thDeviations)}</th><th>${esc(L.thReport)}</th></tr></thead>
|
|
705
|
+
<tbody>${rows}</tbody></table>
|
|
706
|
+
</body></html>`;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export async function writeRollup({ rollup = {}, modules, outDir, formats = ["html"] }) {
|
|
710
|
+
if (!Array.isArray(modules) || !modules.length) throw new Error("modules must be a non-empty array");
|
|
711
|
+
const names = new Set();
|
|
712
|
+
const loaded = modules.map((entry, i) => {
|
|
713
|
+
const report = loadModuleReport(entry, i);
|
|
714
|
+
if (names.has(entry.module)) throw new Error(`duplicate module: ${entry.module}`);
|
|
715
|
+
names.add(entry.module);
|
|
716
|
+
return { module: entry.module, report, href: entry.href || null };
|
|
717
|
+
});
|
|
718
|
+
const slugs = new Set();
|
|
719
|
+
for (const m of loaded) {
|
|
720
|
+
if (m.href) continue;
|
|
721
|
+
let slug = slugOf(m.module);
|
|
722
|
+
for (let n = 2; slugs.has(slug); n++) slug = `${slugOf(m.module)}-${n}`;
|
|
723
|
+
slugs.add(slug);
|
|
724
|
+
const dir = join(outDir, slug);
|
|
725
|
+
mkdirSync(dir, { recursive: true });
|
|
726
|
+
await writeReport({ report: { lang: rollup.lang, labels: rollup.labels, ...m.report, module: m.report.module || m.module }, outDir: dir, formats: ["html"] });
|
|
727
|
+
m.href = `${slug}/report.html`;
|
|
728
|
+
}
|
|
729
|
+
const verdict = rollupVerdict(loaded);
|
|
730
|
+
const out = { rollup: verdict };
|
|
731
|
+
if (verdict.gate === "fail") {
|
|
732
|
+
out.coverageError = `Roll-up gate FAILED: ${verdict.failedModules.length} of ${loaded.length} module(s) failed their coverage gate: ${verdict.failedModules.join(", ")}.`;
|
|
733
|
+
}
|
|
734
|
+
const html = renderRollupHtml(rollup, verdict);
|
|
735
|
+
out.html = join(outDir, "index.html");
|
|
736
|
+
writeFileSync(out.html, html, "utf-8");
|
|
737
|
+
if (formats.includes("pdf")) {
|
|
738
|
+
const pdfPath = join(outDir, "index.pdf");
|
|
739
|
+
try {
|
|
740
|
+
const { chromium } = await import("playwright");
|
|
741
|
+
await renderPdf({ html, pdfPath, chromium });
|
|
742
|
+
out.pdf = pdfPath;
|
|
743
|
+
} catch (err) {
|
|
744
|
+
out.pdfError = `PDF skipped (${err.message}).`;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return out;
|
|
748
|
+
}
|
|
@@ -33,7 +33,7 @@ export function rg(pattern, path, { multiline = false, files = false, globs = RG
|
|
|
33
33
|
? ["--files", ...globs, "-g", pattern]
|
|
34
34
|
: ["-n", "--no-heading", "--no-messages", ...(multiline ? ["-U", "--multiline-dotall"] : []), ...globs, "-e", pattern];
|
|
35
35
|
try {
|
|
36
|
-
const out = execSync(`rg ${base.map((a) => `'${String(a).replace(/'/g, "'\\''")}'`).join(" ")} '${path.replace(/'/g, "'\\''")}'`,
|
|
36
|
+
const out = execSync(`rg ${base.map((a) => `'${String(a).replace(/'/g, "'\\''")}'`).join(" ")} -- '${path.replace(/'/g, "'\\''")}'`,
|
|
37
37
|
{ encoding: "utf-8", timeout: 45000, maxBuffer: 32 * 1024 * 1024 });
|
|
38
38
|
const lines = out.split("\n").filter(Boolean);
|
|
39
39
|
if (files) return lines.map((f) => ({ file: f, line: 0, text: "" }));
|