nfunc-mcp 0.1.0 → 0.3.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 (35) hide show
  1. package/README.md +88 -43
  2. package/dist/index.js +23 -4
  3. package/dist/index.js.map +1 -1
  4. package/dist/mappers/a11yDedupe.d.ts +25 -0
  5. package/dist/mappers/a11yDedupe.js +95 -0
  6. package/dist/mappers/a11yDedupe.js.map +1 -0
  7. package/dist/mappers/compositeScore.d.ts +23 -0
  8. package/dist/mappers/compositeScore.js +107 -0
  9. package/dist/mappers/compositeScore.js.map +1 -0
  10. package/dist/mappers/correlator.js +94 -33
  11. package/dist/mappers/correlator.js.map +1 -1
  12. package/dist/mappers/defectFormatter.js +129 -15
  13. package/dist/mappers/defectFormatter.js.map +1 -1
  14. package/dist/mappers/priorityMapper.d.ts +38 -0
  15. package/dist/mappers/priorityMapper.js +126 -0
  16. package/dist/mappers/priorityMapper.js.map +1 -1
  17. package/dist/tools/accessibility.js +72 -31
  18. package/dist/tools/accessibility.js.map +1 -1
  19. package/dist/tools/lighthouse.d.ts +14 -0
  20. package/dist/tools/lighthouse.js +134 -34
  21. package/dist/tools/lighthouse.js.map +1 -1
  22. package/dist/tools/qaGate.js +117 -70
  23. package/dist/tools/qaGate.js.map +1 -1
  24. package/dist/tools/staticAnalysis.js +15 -67
  25. package/dist/tools/staticAnalysis.js.map +1 -1
  26. package/dist/utils/eslintConfigDetector.d.ts +13 -0
  27. package/dist/utils/eslintConfigDetector.js +65 -2
  28. package/dist/utils/eslintConfigDetector.js.map +1 -1
  29. package/dist/utils/eslintRunner.d.ts +9 -0
  30. package/dist/utils/eslintRunner.js +103 -0
  31. package/dist/utils/eslintRunner.js.map +1 -0
  32. package/dist/utils/outputParsers.d.ts +25 -0
  33. package/dist/utils/outputParsers.js +42 -0
  34. package/dist/utils/outputParsers.js.map +1 -1
  35. package/package.json +2 -2
package/README.md CHANGED
@@ -1,4 +1,6 @@
1
- # qa-mcp
1
+ # nfunc-mcp
2
+
3
+ [![npm](https://img.shields.io/npm/v/nfunc-mcp)](https://www.npmjs.com/package/nfunc-mcp)
2
4
 
3
5
  A local MCP server that gives Claude (or any MCP client) a full non-functional QA toolkit. Run Lighthouse, WCAG accessibility checks, and static code analysis — individually or all at once — and get back structured, prioritised findings you can act on immediately.
4
6
 
@@ -20,6 +22,8 @@ A local MCP server that gives Claude (or any MCP client) a full non-functional Q
20
22
  - [HTML report](#html-report)
21
23
  - [Output shape](#output-shape)
22
24
  7. [Individual tools](#individual-tools)
25
+ - [Mobile vs desktop](#mobile-vs-desktop)
26
+ - [Choosing an accessibility engine](#choosing-an-accessibility-engine)
23
27
  8. [Priority system](#priority-system)
24
28
  9. [Project layout](#project-layout)
25
29
  10. [How to prompt](#how-to-prompt)
@@ -28,7 +32,7 @@ A local MCP server that gives Claude (or any MCP client) a full non-functional Q
28
32
 
29
33
  ## What it does
30
34
 
31
- `qa-mcp` wires four QA tools into Claude's tool-use loop:
35
+ `nfunc-mcp` wires four QA tools into Claude's tool-use loop:
32
36
 
33
37
  | Capability | Tools | What it checks |
34
38
  |---|---|---|
@@ -45,14 +49,14 @@ The `run_qa_gate` orchestrator runs all of them in parallel, cross-correlates fi
45
49
 
46
50
  ## Prerequisites
47
51
 
48
- The following CLI tools must be available on your PATH:
52
+ `nfunc-mcp` is a thin wrapper around four CLI tools. Install the ones you need before registering the server:
49
53
 
50
- ```bash
51
- npm install -g lighthouse # Lighthouse
52
- npm install -g pa11y # pa11y
53
- npm install -g eslint # ESLint
54
- brew install semgrep # Semgrep (or pip install semgrep)
55
- ```
54
+ | Tool | Install | Used by |
55
+ |---|---|---|
56
+ | Lighthouse | `npm install -g lighthouse` | `run_lighthouse`, `run_qa_gate` (URL) |
57
+ | pa11y | `npm install -g pa11y` | `run_accessibility_check`, `run_qa_gate` (URL) |
58
+ | ESLint | `npm install -g eslint` | `run_static_analysis`, `run_qa_gate` (path) |
59
+ | Semgrep | `brew install semgrep` or `pip install semgrep` | `run_static_analysis`, `run_qa_gate` (path) |
56
60
 
57
61
  Verify each is reachable:
58
62
 
@@ -63,56 +67,57 @@ eslint --version
63
67
  semgrep --version
64
68
  ```
65
69
 
66
- If a tool is missing, the gate still runs — that tool's scorecard entry will show `UNAVAILABLE` and its findings are omitted from the report.
70
+ **You don't need all four.** If a tool is missing or not installed, the gate still runs — that tool's scorecard entry shows `UNAVAILABLE` and its findings are skipped. URL-only runs only need Lighthouse and pa11y; path-only runs only need ESLint and Semgrep.
67
71
 
68
72
  ---
69
73
 
70
- ## Install & build
74
+ ## Install & register
71
75
 
72
- ```bash
73
- git clone <repo-url>
74
- cd qa-mcp
75
- npm install
76
- npm run build
77
- ```
78
-
79
- ### Scripts
80
-
81
- | Script | Purpose |
82
- |---|---|
83
- | `npm run build` | Compile TypeScript → `dist/` |
84
- | `npm start` | Run the compiled server |
85
- | `npm run dev` | Run from source with hot reload (`tsx watch`) |
86
-
87
- ---
88
-
89
- ## Register with Claude Code
90
-
91
- ### Option A — CLI (recommended)
76
+ ### Option A — npm (recommended, no cloning needed)
92
77
 
93
78
  ```bash
94
- claude mcp add qa-mcp -- node /absolute/path/to/qa-mcp/dist/index.js
79
+ claude mcp add nfunc-mcp -- npx -y nfunc-mcp
95
80
  ```
96
81
 
97
- ### Option B Manual config
82
+ That's it. `npx` downloads and runs the server on your machine automatically. No repo clone, no build step.
83
+
84
+ ### Option B — Manual config (npm)
98
85
 
99
86
  Add to `~/.claude.json` under `mcpServers`:
100
87
 
101
88
  ```json
102
89
  {
103
90
  "mcpServers": {
104
- "qa-mcp": {
105
- "command": "node",
106
- "args": ["/absolute/path/to/qa-mcp/dist/index.js"]
91
+ "nfunc-mcp": {
92
+ "command": "npx",
93
+ "args": ["-y", "nfunc-mcp"]
107
94
  }
108
95
  }
109
96
  }
110
97
  ```
111
98
 
99
+ ### Option C — From source (contributors / local dev)
100
+
101
+ ```bash
102
+ git clone https://github.com/Hiddensound/NFunc_MCP.git
103
+ cd NFunc_MCP
104
+ npm install
105
+ npm run build
106
+ claude mcp add nfunc-mcp -- node /absolute/path/to/NFunc_MCP/dist/index.js
107
+ ```
108
+
109
+ ### Scripts (source only)
110
+
111
+ | Script | Purpose |
112
+ |---|---|
113
+ | `npm run build` | Compile TypeScript → `dist/` |
114
+ | `npm start` | Run the compiled server |
115
+ | `npm run dev` | Run from source with hot reload (`tsx watch`) |
116
+
112
117
  ### Verify the connection
113
118
 
114
- 1. Run `/mcp` in Claude Code — `qa-mcp` should show as `connected`.
115
- 2. Ask Claude: *"Call the qa-mcp ping tool."*
119
+ 1. Run `/mcp` in Claude Code — `nfunc-mcp` should show as `connected`.
120
+ 2. Ask Claude: *"Call the nfunc-mcp ping tool."*
116
121
  3. Expected response:
117
122
  ```json
118
123
  { "status": "ok", "timestamp": "2026-05-20T12:00:00.000Z" }
@@ -125,10 +130,10 @@ Add to `~/.claude.json` under `mcpServers`:
125
130
  | Tool | Description | Inputs |
126
131
  |---|---|---|
127
132
  | `ping` | Health check — confirms the server is up | — |
128
- | `run_lighthouse` | Full Lighthouse audit for a URL | `url` |
129
- | `run_accessibility_check` | pa11y WCAG audit for a URL | `url`, `standard` (optional), `ignore` (optional) |
133
+ | `run_lighthouse` | Full Lighthouse audit for a URL | `url`, `form_factor` (optional), `categories` (optional), `thresholds` (optional) |
134
+ | `run_accessibility_check` | pa11y WCAG audit for a URL | `url`, `runner` (optional), `standard` (optional), `ignore` (optional) |
130
135
  | `run_static_analysis` | ESLint + Semgrep scan for a local codebase | `path` |
131
- | `run_qa_gate` | All tools in parallel + correlation + HTML report | `url` and/or `path` |
136
+ | `run_qa_gate` | All tools in parallel + correlation + HTML report | `url` and/or `path`, `form_factor` (optional), `a11y_runner` (optional) |
132
137
 
133
138
  ---
134
139
 
@@ -145,6 +150,8 @@ Both inputs are **optional** — provide whichever you have. At least one is req
145
150
  | `url` | string (URL) | You have a running page — production, staging, preview URL, or localhost. Enables Lighthouse and pa11y. |
146
151
  | `path` | string (path) | You have a local codebase. Enables ESLint and Semgrep. |
147
152
  | `context` | string | Optional. Free-text description of the project (e.g. `"React e-commerce checkout"`). Helps Claude interpret results. |
153
+ | `form_factor` | `mobile` \| `desktop` \| `both` | Optional, default `mobile`. Lighthouse device profile — see [Mobile vs desktop](#mobile-vs-desktop). |
154
+ | `a11y_runner` | `htmlcs` \| `axe` \| `both` | Optional, default `htmlcs`. pa11y engine — see [Choosing an accessibility engine](#choosing-an-accessibility-engine). |
148
155
 
149
156
  **URL only** — browser-based checks, static analysis skipped:
150
157
  ```
@@ -297,9 +304,28 @@ Runs a full Lighthouse audit against a URL.
297
304
 
298
305
  ```
299
306
  Run Lighthouse on https://myapp.com
307
+ Run Lighthouse on https://myapp.com for mobile and desktop
300
308
  ```
301
309
 
302
- Returns: `url`, `scores` (per category), `ttfb_ms`, `findings` (priority-ordered).
310
+ Returns: `url`, `form_factor`, `scores` (per category), `ttfb_ms`, `findings` (priority-ordered).
311
+
312
+ #### Mobile vs desktop
313
+
314
+ `form_factor` accepts `desktop` (default), `mobile`, or `both`.
315
+
316
+ **This default deliberately differs from the Lighthouse CLI's**, which is mobile: a 412×823 screen, a mid-range Android user agent, simulated slow 4G, and a **4× CPU slowdown**. That profile is much harsher and reports substantially lower performance scores for the same page, so passing `mobile` here is not a like-for-like comparison with a default run — check `form_factor` in the response before comparing two reports.
317
+
318
+ **The two are not interchangeable.** They render different DOM, so they find different defects — not just different performance numbers. Measured against one commerce category page:
319
+
320
+ | | Mobile | Desktop |
321
+ |---|---|---|
322
+ | performance | 54 | 62 |
323
+ | accessibility | **87** | **73** |
324
+ | seo | 77 | 69 |
325
+
326
+ Five accessibility audits failed on desktop that mobile never reported — `image-alt`, `aria-required-children`, `aria-required-parent`, `aria-allowed-attr`, `aria-valid-attr-value` — while three others failed only on mobile. Neither profile is a superset of the other.
327
+
328
+ With `form_factor: "both"`, the two run concurrently (so it costs little more wall time than one), `scores` is keyed by form factor, and each finding carries `affects_form_factors` and `form_factor_specific` so device-only regressions are obvious at a glance.
303
329
 
304
330
  ### run_accessibility_check
305
331
 
@@ -308,9 +334,28 @@ Runs pa11y against a URL at WCAG 2 AA by default. Returns only violations (error
308
334
  ```
309
335
  Run an accessibility check on https://myapp.com
310
336
  Run accessibility check at AAA standard on https://myapp.com
337
+ Run an accessibility check on https://myapp.com using the axe runner
311
338
  ```
312
339
 
313
- Returns: `url`, `standard`, `violation_count`, `findings`.
340
+ Returns: `url`, `standard`, `runners`, `violation_count`, `raw_violation_count`, `findings`.
341
+
342
+ #### Choosing an accessibility engine
343
+
344
+ `runner` accepts `htmlcs` (default), `axe`, or `both`.
345
+
346
+ | Engine | Strongest at | Severity source |
347
+ |---|---|---|
348
+ | `htmlcs` | WCAG techniques, document structure, form labelling, duplicate ids | WCAG technique class |
349
+ | `axe` | **ARIA** — invalid roles, missing required parent/child relationships, prohibited and unsupported attributes — and computed colour contrast | axe's own `impact` rating |
350
+
351
+ **Reach for `axe` whenever the work under test involves ARIA, a component library, or a design system.** The overlap between the engines is smaller than you would expect. On the same page:
352
+
353
+ - htmlcs found unlabelled inputs, forms with no submit mechanism, and ten duplicate ids that axe did not report.
354
+ - axe found `aria-allowed-attr`, `aria-prohibited-attr`, `aria-required-parent`, `aria-required-children` and `image-alt` failures that htmlcs missed entirely.
355
+
356
+ `both` runs them concurrently and merges the results. Note that an element flagged by both engines appears twice, because they emit different rule codes for the same defect — that is deliberate, since two independent engines agreeing is corroboration worth seeing.
357
+
358
+ Findings from axe carry `axe_impact` in evidence, and `needs_manual_review: true` where axe wants a human to confirm (those are demoted one priority tier — a maybe should not gate a release as hard as a certainty).
314
359
 
315
360
  ### run_static_analysis
316
361
 
package/dist/index.js CHANGED
@@ -1,14 +1,33 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from "fs";
3
+ import { fileURLToPath } from "url";
2
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
6
  import { registerLighthouseTool } from "./tools/lighthouse.js";
5
7
  import { registerAccessibilityTool } from "./tools/accessibility.js";
6
8
  import { registerStaticAnalysisTool } from "./tools/staticAnalysis.js";
7
9
  import { registerQaGateTool } from "./tools/qaGate.js";
8
- const server = new McpServer({
9
- name: "qa-mcp",
10
- version: "0.1.0",
11
- });
10
+ /**
11
+ * Identity comes from package.json rather than being written out here, so the
12
+ * two cannot drift — they already had, declaring qa-mcp/0.1.0 against
13
+ * nfunc-mcp/0.2.0 after the npm rename, and `npm version` would reintroduce
14
+ * the gap on every release if these were hardcoded.
15
+ *
16
+ * "../package.json" resolves to the repo root from both src/index.ts and
17
+ * dist/index.js, and npm always ships package.json, so the same path works in
18
+ * dev, in a local build, and in an installed package. Falls back rather than
19
+ * failing to boot if it is ever unreadable.
20
+ */
21
+ function readIdentity() {
22
+ try {
23
+ const pkg = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
24
+ return { name: pkg.name ?? "nfunc-mcp", version: pkg.version ?? "0.0.0" };
25
+ }
26
+ catch {
27
+ return { name: "nfunc-mcp", version: "0.0.0" };
28
+ }
29
+ }
30
+ const server = new McpServer(readIdentity());
12
31
  server.registerTool("ping", {
13
32
  description: "Health-check tool. Returns ok status and current ISO timestamp.",
14
33
  inputSchema: {},
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACpE,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,EAAE,yBAAyB,EAAE,MAAM,0BAA0B,CAAC;AACrE,OAAO,EAAE,0BAA0B,EAAE,MAAM,2BAA2B,CAAC;AACvE,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAEvD,MAAM,MAAM,GAAG,IAAI,SAAS,CAAC;IAC3B,IAAI,EAAE,QAAQ;IACd,OAAO,EAAE,OAAO;CACjB,CAAC,CAAC;AAEH,MAAM,CAAC,YAAY,CACjB,MAAM,EACN;IACE,WAAW,EAAE,iEAAiE;IAC9E,WAAW,EAAE,EAAE;CAChB,EACD,KAAK,IAAI,EAAE;IACT,MAAM,OAAO,GAAG;QACd,MAAM,EAAE,IAAI;QACZ,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;KACpC,CAAC;IACF,OAAO;QACL,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,EAAE,CAAC;KAC3D,CAAC;AACJ,CAAC,CACF,CAAC;AAEF,sBAAsB,CAAC,MAAM,CAAC,CAAC;AAC/B,yBAAyB,CAAC,MAAM,CAAC,CAAC;AAClC,0BAA0B,CAAC,MAAM,CAAC,CAAC;AACnC,kBAAkB,CAAC,MAAM,CAAC,CAAC;AAE3B,KAAK,UAAU,IAAI;IACjB,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAC;IAC7C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;AAClC,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;IACnB,OAAO,CAAC,KAAK,CAAC,qBAAqB,EAAE,GAAG,CAAC,CAAC;IAC1C,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,YAAY,EAAE,MAAM,IAAI,CAAC;AAClC,OAAO,EAAE,aAAa,EAAE,MAAM,KAAK,CAAC;AACpC,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACpE,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,EAAE,yBAAyB,EAAE,MAAM,0BAA0B,CAAC;AACrE,OAAO,EAAE,0BAA0B,EAAE,MAAM,2BAA2B,CAAC;AACvE,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAEvD;;;;;;;;;;GAUG;AACH,SAAS,YAAY;IACnB,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CACpB,YAAY,CAAC,aAAa,CAAC,IAAI,GAAG,CAAC,iBAAiB,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,MAAM,CAAC,CAC1C,CAAC;QACzC,OAAO,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,IAAI,WAAW,EAAE,OAAO,EAAE,GAAG,CAAC,OAAO,IAAI,OAAO,EAAE,CAAC;IAC5E,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC;IACjD,CAAC;AACH,CAAC;AAED,MAAM,MAAM,GAAG,IAAI,SAAS,CAAC,YAAY,EAAE,CAAC,CAAC;AAE7C,MAAM,CAAC,YAAY,CACjB,MAAM,EACN;IACE,WAAW,EAAE,iEAAiE;IAC9E,WAAW,EAAE,EAAE;CAChB,EACD,KAAK,IAAI,EAAE;IACT,MAAM,OAAO,GAAG;QACd,MAAM,EAAE,IAAI;QACZ,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;KACpC,CAAC;IACF,OAAO;QACL,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,EAAE,CAAC;KAC3D,CAAC;AACJ,CAAC,CACF,CAAC;AAEF,sBAAsB,CAAC,MAAM,CAAC,CAAC;AAC/B,yBAAyB,CAAC,MAAM,CAAC,CAAC;AAClC,0BAA0B,CAAC,MAAM,CAAC,CAAC;AACnC,kBAAkB,CAAC,MAAM,CAAC,CAAC;AAE3B,KAAK,UAAU,IAAI;IACjB,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAC;IAC7C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;AAClC,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;IACnB,OAAO,CAAC,KAAK,CAAC,qBAAqB,EAAE,GAAG,CAAC,CAAC;IAC1C,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
@@ -0,0 +1,25 @@
1
+ import type { Finding } from "../types.js";
2
+ export interface A11yDedupeResult {
3
+ findings: Finding[];
4
+ rawCount: number;
5
+ }
6
+ /**
7
+ * Collapses the two ways pa11y over-reports a single defect.
8
+ *
9
+ * Pass 1 — exact repeats. pa11y emits one violation per occurrence, and for
10
+ * page-wide rules (F77, duplicate id, above all) every occurrence resolves
11
+ * back to the same selector. A real commerce page produced 70 of 85 findings
12
+ * from two defects repeated 35 times each, burying the genuine issues and
13
+ * flooring the qa_gate composite score. Collapsing on (rule_code, selector)
14
+ * removes those while keeping two different elements failing the same rule
15
+ * separate, since they are separate fixes.
16
+ *
17
+ * Pass 2 — systemic defects. One rule failing across many *different*
18
+ * elements, which (rule_code, selector) cannot catch. The axe runner produces
19
+ * these routinely: one mis-authored component reused across a page gave 41
20
+ * distinct aria-allowed-attr findings. See SYSTEMIC_THRESHOLD.
21
+ *
22
+ * Counts survive as evidence.occurrences (written only when > 1), so the
23
+ * volume signal is preserved without N copies of the finding.
24
+ */
25
+ export declare function dedupeA11yFindings(findings: Finding[]): A11yDedupeResult;
@@ -0,0 +1,95 @@
1
+ const PRIORITY_ORDER = { P1: 0, P2: 1, P3: 2 };
2
+ /**
3
+ * Above this many distinct elements failing one rule, the individual findings
4
+ * stop being separately actionable and start being one systemic defect — a
5
+ * component used everywhere, not N unrelated bugs. At or below it, each
6
+ * element keeps its own line.
7
+ *
8
+ * Set at 10 rather than lower because distinct elements failing the same rule
9
+ * are often still separate fixes: ten duplicate ids on a page are ten ids to
10
+ * rename, and collapsing them hides the list a developer needs. It takes a
11
+ * genuine flood — axe reported 41 aria-allowed-attr failures from one reused
12
+ * component — before the group is more useful than its members.
13
+ */
14
+ const SYSTEMIC_THRESHOLD = 10;
15
+ /**
16
+ * Collapses the two ways pa11y over-reports a single defect.
17
+ *
18
+ * Pass 1 — exact repeats. pa11y emits one violation per occurrence, and for
19
+ * page-wide rules (F77, duplicate id, above all) every occurrence resolves
20
+ * back to the same selector. A real commerce page produced 70 of 85 findings
21
+ * from two defects repeated 35 times each, burying the genuine issues and
22
+ * flooring the qa_gate composite score. Collapsing on (rule_code, selector)
23
+ * removes those while keeping two different elements failing the same rule
24
+ * separate, since they are separate fixes.
25
+ *
26
+ * Pass 2 — systemic defects. One rule failing across many *different*
27
+ * elements, which (rule_code, selector) cannot catch. The axe runner produces
28
+ * these routinely: one mis-authored component reused across a page gave 41
29
+ * distinct aria-allowed-attr findings. See SYSTEMIC_THRESHOLD.
30
+ *
31
+ * Counts survive as evidence.occurrences (written only when > 1), so the
32
+ * volume signal is preserved without N copies of the finding.
33
+ */
34
+ export function dedupeA11yFindings(findings) {
35
+ // Pass 1 — exact repeats: same rule on the same selector.
36
+ const byKey = new Map();
37
+ for (const f of findings) {
38
+ const key = `${String(f.evidence["rule_code"] ?? "")}|` +
39
+ `${String(f.evidence["selector"] ?? "")}`;
40
+ const existing = byKey.get(key);
41
+ if (!existing) {
42
+ byKey.set(key, { finding: f, count: 1 });
43
+ continue;
44
+ }
45
+ existing.count += 1;
46
+ // pa11y can grade occurrences of one rule differently (error vs warning);
47
+ // keep the most severe so dedup never downgrades a defect.
48
+ if (PRIORITY_ORDER[f.priority] < PRIORITY_ORDER[existing.finding.priority]) {
49
+ existing.finding = f;
50
+ }
51
+ }
52
+ const exact = Array.from(byKey.values()).map(({ finding, count }) => count > 1
53
+ ? { ...finding, evidence: { ...finding.evidence, occurrences: count } }
54
+ : finding);
55
+ // Pass 2 — systemic defects: one rule failing across many *different*
56
+ // elements. htmlcs rarely does this (its repeats share a selector, which
57
+ // pass 1 already caught) but axe routinely does: a single mis-authored
58
+ // component reused across a page produced 41 separate aria-allowed-attr
59
+ // findings, which drowned everything else and inflated the P1 count.
60
+ const byRule = new Map();
61
+ for (const f of exact) {
62
+ const rule = String(f.evidence["rule_code"] ?? "");
63
+ const group = byRule.get(rule);
64
+ if (group)
65
+ group.push(f);
66
+ else
67
+ byRule.set(rule, [f]);
68
+ }
69
+ const out = [];
70
+ for (const [, group] of byRule) {
71
+ if (group.length <= SYSTEMIC_THRESHOLD) {
72
+ out.push(...group);
73
+ continue;
74
+ }
75
+ const worst = group.reduce((a, b) => PRIORITY_ORDER[a.priority] <= PRIORITY_ORDER[b.priority] ? a : b);
76
+ const elements = group.reduce((n, f) => n + Number(f.evidence["occurrences"] ?? 1), 0);
77
+ out.push({
78
+ ...worst,
79
+ description: `${worst.description} This rule fails on ${elements} elements across the page, ` +
80
+ `which usually means one shared component rather than ${elements} separate defects — ` +
81
+ `fix the component and all of them clear.`,
82
+ evidence: {
83
+ ...worst.evidence,
84
+ occurrences: elements,
85
+ distinct_elements: group.length,
86
+ systemic: true,
87
+ sample_selectors: group
88
+ .slice(0, 10)
89
+ .map((f) => String(f.evidence["selector"] ?? "")),
90
+ },
91
+ });
92
+ }
93
+ return { findings: out, rawCount: findings.length };
94
+ }
95
+ //# sourceMappingURL=a11yDedupe.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"a11yDedupe.js","sourceRoot":"","sources":["../../src/mappers/a11yDedupe.ts"],"names":[],"mappings":"AAEA,MAAM,cAAc,GAA6B,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC;AAOzE;;;;;;;;;;;GAWG;AACH,MAAM,kBAAkB,GAAG,EAAE,CAAC;AAE9B;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,kBAAkB,CAAC,QAAmB;IACpD,0DAA0D;IAC1D,MAAM,KAAK,GAAG,IAAI,GAAG,EAA+C,CAAC;IAErE,KAAK,MAAM,CAAC,IAAI,QAAQ,EAAE,CAAC;QACzB,MAAM,GAAG,GACP,GAAG,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,GAAG;YAC3C,GAAG,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC;QAE5C,MAAM,QAAQ,GAAG,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAChC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,OAAO,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,CAAC;YACzC,SAAS;QACX,CAAC;QAED,QAAQ,CAAC,KAAK,IAAI,CAAC,CAAC;QACpB,0EAA0E;QAC1E,2DAA2D;QAC3D,IAAI,cAAc,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,cAAc,CAAC,QAAQ,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC3E,QAAQ,CAAC,OAAO,GAAG,CAAC,CAAC;QACvB,CAAC;IACH,CAAC;IAED,MAAM,KAAK,GAAG,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,EAAE,EAAE,CAClE,KAAK,GAAG,CAAC;QACP,CAAC,CAAC,EAAE,GAAG,OAAO,EAAE,QAAQ,EAAE,EAAE,GAAG,OAAO,CAAC,QAAQ,EAAE,WAAW,EAAE,KAAK,EAAE,EAAE;QACvE,CAAC,CAAC,OAAO,CACZ,CAAC;IAEF,sEAAsE;IACtE,yEAAyE;IACzE,uEAAuE;IACvE,wEAAwE;IACxE,qEAAqE;IACrE,MAAM,MAAM,GAAG,IAAI,GAAG,EAAqB,CAAC;IAC5C,KAAK,MAAM,CAAC,IAAI,KAAK,EAAE,CAAC;QACtB,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,CAAC;QACnD,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAC/B,IAAI,KAAK;YAAE,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;;YACpB,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;IAC7B,CAAC;IAED,MAAM,GAAG,GAAc,EAAE,CAAC;IAC1B,KAAK,MAAM,CAAC,EAAE,KAAK,CAAC,IAAI,MAAM,EAAE,CAAC;QAC/B,IAAI,KAAK,CAAC,MAAM,IAAI,kBAAkB,EAAE,CAAC;YACvC,GAAG,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC,CAAC;YACnB,SAAS;QACX,CAAC;QACD,MAAM,KAAK,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAClC,cAAc,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,cAAc,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CACjE,CAAC;QACF,MAAM,QAAQ,GAAG,KAAK,CAAC,MAAM,CAC3B,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC,EACpD,CAAC,CACF,CAAC;QACF,GAAG,CAAC,IAAI,CAAC;YACP,GAAG,KAAK;YACR,WAAW,EACT,GAAG,KAAK,CAAC,WAAW,uBAAuB,QAAQ,6BAA6B;gBAChF,wDAAwD,QAAQ,sBAAsB;gBACtF,0CAA0C;YAC5C,QAAQ,EAAE;gBACR,GAAG,KAAK,CAAC,QAAQ;gBACjB,WAAW,EAAE,QAAQ;gBACrB,iBAAiB,EAAE,KAAK,CAAC,MAAM;gBAC/B,QAAQ,EAAE,IAAI;gBACd,gBAAgB,EAAE,KAAK;qBACpB,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC;qBACZ,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,EAAE,CAAC,CAAC;aACpD;SACF,CAAC,CAAC;IACL,CAAC;IAED,OAAO,EAAE,QAAQ,EAAE,GAAG,EAAE,QAAQ,EAAE,QAAQ,CAAC,MAAM,EAAE,CAAC;AACtD,CAAC"}
@@ -0,0 +1,23 @@
1
+ import type { Finding } from "../types.js";
2
+ /**
3
+ * Lighthouse's own category scores ARE a calibrated 0–100 health signal, with
4
+ * per-audit weighting Google already tuned. Re-deriving one by counting
5
+ * findings throws that away and double-counts besides: total-blocking-time,
6
+ * bootup-time, mainthread-work-breakdown, interactive and max-potential-fid are
7
+ * five findings describing one overloaded main thread, and the old formula
8
+ * charged 75 points for it. So use the category scores directly.
9
+ */
10
+ export declare function lighthouseSubScore(categoryScores: Record<string, number>): number | null;
11
+ export declare function a11ySubScore(findings: Finding[]): number;
12
+ export declare function staticSubScore(findings: Finding[]): number;
13
+ export interface SubScores {
14
+ lighthouse: number | null;
15
+ pa11y: number | null;
16
+ static: number | null;
17
+ }
18
+ /**
19
+ * Weighted mean over the tools that produced a score. Weights are renormalised
20
+ * across whatever ran, so a url-only run and a url+path run are on the same
21
+ * scale. Returns null when nothing scored.
22
+ */
23
+ export declare function compositeScore(sub: SubScores): number | null;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Composite health score.
3
+ *
4
+ * The previous formula was a single global subtraction — 100 minus 15 per P1,
5
+ * 7 per P2, 3 per P3 — which had two structural faults:
6
+ *
7
+ * 1. No resolution. Seven P1 findings floored it at 0, so a mediocre page and
8
+ * a catastrophic one were indistinguishable. Real pages routinely exceed
9
+ * that within a single tool.
10
+ * 2. Running more tools lowered the score, because every tool fed the same
11
+ * running subtraction. A url-only run was not comparable to a url+path run,
12
+ * which is exactly backwards: broader checking should not look like worse
13
+ * health.
14
+ *
15
+ * Both are fixed by scoring each tool to its own 0–100 sub-score and taking a
16
+ * weighted mean over only the tools that actually ran.
17
+ */
18
+ const SEVERITY_DAMAGE = { P1: 10, P2: 3, P3: 1 };
19
+ /**
20
+ * Saturating decay. Never reaches 0, always preserves ordering, and each
21
+ * additional finding costs less than the one before — which matches how the
22
+ * information actually behaves: the twentieth P1 tells you far less about the
23
+ * page than the first did.
24
+ *
25
+ * K sets where the curve has resolution. At K = 90 a single P1 scores 89, five
26
+ * P1s score 57, and fifteen P1s score 19 — spread across the range real pages
27
+ * occupy instead of collapsing onto the floor.
28
+ */
29
+ function decay(damage, k) {
30
+ return Math.round(100 * Math.exp(-damage / k));
31
+ }
32
+ function damageOf(findings) {
33
+ return findings.reduce((sum, f) => sum + SEVERITY_DAMAGE[f.priority], 0);
34
+ }
35
+ // Tuned so a page's sub-score lands where a human triager would put it. Static
36
+ // analysis decays faster because code defects are fewer and more directly
37
+ // actionable than page-level accessibility violations.
38
+ const A11Y_K = 90;
39
+ const STATIC_K = 60;
40
+ /**
41
+ * Relative weights of the Lighthouse categories inside the Lighthouse
42
+ * sub-score. Lighthouse publishes per-audit weights but no cross-category
43
+ * weighting, so this is our judgement: performance and accessibility carry the
44
+ * most user impact, SEO and best-practices matter but less directly.
45
+ *
46
+ * Categories not listed here — currently the experimental "agentic-browsing" —
47
+ * get UNKNOWN_CATEGORY_WEIGHT so a new or volatile category cannot dominate the
48
+ * score, while still being visible in the breakdown.
49
+ */
50
+ const CATEGORY_WEIGHTS = {
51
+ performance: 0.3,
52
+ accessibility: 0.3,
53
+ "best-practices": 0.2,
54
+ seo: 0.2,
55
+ };
56
+ const UNKNOWN_CATEGORY_WEIGHT = 0.05;
57
+ /**
58
+ * Lighthouse's own category scores ARE a calibrated 0–100 health signal, with
59
+ * per-audit weighting Google already tuned. Re-deriving one by counting
60
+ * findings throws that away and double-counts besides: total-blocking-time,
61
+ * bootup-time, mainthread-work-breakdown, interactive and max-potential-fid are
62
+ * five findings describing one overloaded main thread, and the old formula
63
+ * charged 75 points for it. So use the category scores directly.
64
+ */
65
+ export function lighthouseSubScore(categoryScores) {
66
+ const entries = Object.entries(categoryScores);
67
+ if (entries.length === 0)
68
+ return null;
69
+ let weighted = 0;
70
+ let totalWeight = 0;
71
+ for (const [name, score] of entries) {
72
+ const w = CATEGORY_WEIGHTS[name] ?? UNKNOWN_CATEGORY_WEIGHT;
73
+ weighted += w * score;
74
+ totalWeight += w;
75
+ }
76
+ return totalWeight > 0 ? Math.round(weighted / totalWeight) : null;
77
+ }
78
+ export function a11ySubScore(findings) {
79
+ return decay(damageOf(findings), A11Y_K);
80
+ }
81
+ export function staticSubScore(findings) {
82
+ return decay(damageOf(findings), STATIC_K);
83
+ }
84
+ // How much each tool contributes to the composite. Static analysis is weighted
85
+ // lowest because a clean scan of a small directory returns 100 and would
86
+ // otherwise flatter a run whose real problems are all in the browser.
87
+ const TOOL_WEIGHTS = { lighthouse: 0.4, pa11y: 0.4, static: 0.2 };
88
+ /**
89
+ * Weighted mean over the tools that produced a score. Weights are renormalised
90
+ * across whatever ran, so a url-only run and a url+path run are on the same
91
+ * scale. Returns null when nothing scored.
92
+ */
93
+ export function compositeScore(sub) {
94
+ const parts = [];
95
+ if (sub.lighthouse !== null)
96
+ parts.push([sub.lighthouse, TOOL_WEIGHTS.lighthouse]);
97
+ if (sub.pa11y !== null)
98
+ parts.push([sub.pa11y, TOOL_WEIGHTS.pa11y]);
99
+ if (sub.static !== null)
100
+ parts.push([sub.static, TOOL_WEIGHTS.static]);
101
+ if (parts.length === 0)
102
+ return null;
103
+ const totalWeight = parts.reduce((s, [, w]) => s + w, 0);
104
+ const weighted = parts.reduce((s, [v, w]) => s + v * w, 0);
105
+ return Math.round(weighted / totalWeight);
106
+ }
107
+ //# sourceMappingURL=compositeScore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compositeScore.js","sourceRoot":"","sources":["../../src/mappers/compositeScore.ts"],"names":[],"mappings":"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"}