@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.
Files changed (32) hide show
  1. package/CHANGELOG.md +192 -5
  2. package/README.md +80 -8
  3. package/README.tr.md +104 -8
  4. package/index.js +526 -162
  5. package/package.json +4 -4
  6. package/tools/context/index.js +34 -18
  7. package/tools/design-check/component-walk.js +359 -0
  8. package/tools/design-check/content-cardinality.js +3 -3
  9. package/tools/design-check/index.js +78 -11
  10. package/tools/design-check/report.js +152 -10
  11. package/tools/design-check/scan.js +1 -1
  12. package/tools/design-check/scenario-inventory.js +404 -50
  13. package/tools/design-check/visual-compare.js +17 -2
  14. package/tools/ios-app-store-audit/context.js +3 -3
  15. package/tools/ios-app-store-audit/exec.js +17 -0
  16. package/tools/ios-app-store-audit/index.js +0 -15
  17. package/tools/ios-app-store-audit/rules/code-signing.js +2 -2
  18. package/tools/ios-app-store-audit/rules/dead-reference.js +2 -2
  19. package/tools/ios-app-store-audit/rules/debug-tool-leak.js +2 -2
  20. package/tools/ios-app-store-audit/rules/embedded-sdk.js +3 -3
  21. package/tools/ios-app-store-audit/rules/extension-signing.js +2 -2
  22. package/tools/ios-app-store-audit/rules/ipv6-compliance.js +2 -2
  23. package/tools/ios-app-store-audit/rules/production-hygiene.js +2 -2
  24. package/tools/ios-app-store-audit/rules/provisioning-profile.js +3 -3
  25. package/tools/ios-app-store-audit/rules/required-reason-api.js +2 -2
  26. package/tools/offload/index.js +7 -3
  27. package/tools/policy/egress-proxy.js +268 -0
  28. package/tools/policy/index.js +283 -0
  29. package/tools/security/cvss.js +108 -0
  30. package/tools/security/deps.js +0 -0
  31. package/tools/security/index.js +115 -0
  32. 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 { writeReport } from "./report.js";
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. Signal-based and generic; every target has file+line evidence. Run right after design_mock_detect; feed targets + truncated into the design_report coverage gate.",
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
- summary: { type: "boolean", description: "Return counts, groups and plan without the per-target list. The full payload measured 71k characters for a 109-target module and exceeded the tool-result cap on every run; 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." },
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
- return JSON.stringify(inventoryScenarios({
139
- repoPath: args.repo_path, platform: args.platform,
140
- extraLaunchArgs: args.extra_launch_args || [], extraTargets: args.extra_targets || [],
141
- ignoreTargets: args.ignore_targets || [], summary: args.summary === true,
142
- }), null, 2);
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. Observed on a real module:
134
+ * Components are NOT all in the audited screen's file; a design system spreads
135
+ * them over library files:
121
136
  *
122
- * CardsFlightCardsCheckinCheckinStart -> 9ipBvrtsq9HlQ175B9k0wx
123
- * CheckinBoardingPassLayout -> ps4xrJzhn7WNbfkZs895Bi
137
+ * ProductCardHeader -> AAAAfileKeyA
138
+ * ProductDetailLayout -> BBBBfileKeyB
124
139
  *
125
- * Every component link was built from the report-level `figmaFileKey`, so a
126
- * component from a second library file got a URL into the wrong file. That link
127
- * still opens - it just resolves to nothing - which is worse than no link,
128
- * because the reader concludes the node was deleted. Prefer the per-finding key;
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">` : ""}<img src="${dataUri(cv.image)}" alt="">${cv.node && url ? "</a>" : ""}<figcaption>${esc(cv.name)}</figcaption></figure>`).join("")}</div>`
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: "" }));