@capagents/forgeqe 0.0.0-stage → 1.0.1

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 (220) hide show
  1. package/HOW_TO_USE.md +1639 -0
  2. package/README.md +338 -2
  3. package/bin/forgeqe.d.ts +3 -0
  4. package/bin/forgeqe.js +188 -0
  5. package/docs/ARCHITECTURE.md +408 -0
  6. package/docs/security-data-flow.html +1870 -0
  7. package/docs/user-guide.html +2094 -0
  8. package/evals/domain/access-role-change.yaml +37 -0
  9. package/evals/domain/banking-payment-transfer.yaml +41 -0
  10. package/evals/domain/gdpr-account-erasure.yaml +30 -0
  11. package/evals/domain/insurance-claim-fnol.yaml +30 -0
  12. package/evals/domain/insurance-policy-renewal.yaml +37 -0
  13. package/evals/domain/lending-loan-application.yaml +36 -0
  14. package/forgeqe-architecture.html +718 -0
  15. package/forgeqe.example.yaml +203 -0
  16. package/knowledge/README.md +18 -0
  17. package/knowledge/business-rules.md +10 -0
  18. package/knowledge/compliance.md +16 -0
  19. package/knowledge/environments.md +5 -0
  20. package/knowledge/glossary.md +5 -0
  21. package/knowledge/product.md +16 -0
  22. package/knowledge/roles.md +9 -0
  23. package/knowledge/test-data.md +7 -0
  24. package/llms.example.json +51 -0
  25. package/package.json +63 -4
  26. package/prompts/README.md +34 -0
  27. package/prompts/api-scenarios.md +21 -0
  28. package/prompts/api-tests.md +39 -0
  29. package/prompts/brain.md +166 -0
  30. package/prompts/browser-capture.md +7 -0
  31. package/prompts/browser-case-report.md +7 -0
  32. package/prompts/browser-checks.md +14 -0
  33. package/prompts/browser-efficiency.md +7 -0
  34. package/prompts/browser-error-recovery.md +15 -0
  35. package/prompts/browser-extra.md +5 -0
  36. package/prompts/browser-goal-verification.md +13 -0
  37. package/prompts/browser-skill-select.md +6 -0
  38. package/prompts/domain-rule.md +13 -0
  39. package/prompts/eval-judge.md +19 -0
  40. package/prompts/explore-charters.md +27 -0
  41. package/prompts/find-tests.md +28 -0
  42. package/prompts/heal.md +40 -0
  43. package/prompts/pr-review.md +29 -0
  44. package/prompts/skill-rule.md +6 -0
  45. package/prompts/test-agent.md +34 -0
  46. package/prompts/test-reviewer.md +30 -0
  47. package/prompts/test-rules.md +28 -0
  48. package/py/forgeqe_engine/__init__.py +3 -0
  49. package/py/forgeqe_engine/__main__.py +3 -0
  50. package/py/forgeqe_engine/brand.py +453 -0
  51. package/py/forgeqe_engine/brand_assets.py +6 -0
  52. package/py/forgeqe_engine/browser.py +196 -0
  53. package/py/forgeqe_engine/capture.py +538 -0
  54. package/py/forgeqe_engine/checks.py +98 -0
  55. package/py/forgeqe_engine/cli.py +188 -0
  56. package/py/forgeqe_engine/diagnostics.py +609 -0
  57. package/py/forgeqe_engine/llm.py +195 -0
  58. package/py/forgeqe_engine/logins.py +251 -0
  59. package/py/forgeqe_engine/loops.py +224 -0
  60. package/py/forgeqe_engine/memory.py +1004 -0
  61. package/py/forgeqe_engine/network.py +244 -0
  62. package/py/forgeqe_engine/prompts.py +20 -0
  63. package/py/forgeqe_engine/replay.py +240 -0
  64. package/py/forgeqe_engine/report.py +173 -0
  65. package/py/forgeqe_engine/service.py +1335 -0
  66. package/py/forgeqe_engine/settings.py +93 -0
  67. package/py/forgeqe_engine/skills.py +88 -0
  68. package/py/forgeqe_engine/summary.py +239 -0
  69. package/py/forgeqe_engine/urls.py +81 -0
  70. package/py/forgeqe_engine/workflow_yaml.py +689 -0
  71. package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
  72. package/skills/dynamics-365/SKILL.md +21 -0
  73. package/skills/playwright-typescript/SKILL.md +35 -0
  74. package/skills/writing-skills/SKILL.md +30 -0
  75. package/src/api/plan.ts +246 -0
  76. package/src/api/run.ts +204 -0
  77. package/src/api/scenario.ts +502 -0
  78. package/src/api/schema.ts +170 -0
  79. package/src/api/spec.ts +624 -0
  80. package/src/api/store.ts +232 -0
  81. package/src/api/traffic.ts +281 -0
  82. package/src/api/types.ts +163 -0
  83. package/src/api/write.ts +272 -0
  84. package/src/auth/signin.ts +494 -0
  85. package/src/auth/store.ts +170 -0
  86. package/src/brain/index.ts +189 -0
  87. package/src/brain/prompt.ts +19 -0
  88. package/src/capabilities/docs.ts +101 -0
  89. package/src/capabilities/guide.ts +455 -0
  90. package/src/capabilities/registry.ts +343 -0
  91. package/src/capabilities/render.ts +106 -0
  92. package/src/capabilities/status.ts +137 -0
  93. package/src/card.ts +11 -0
  94. package/src/cases/ado.ts +325 -0
  95. package/src/cases/api.ts +117 -0
  96. package/src/cases/bugs.ts +223 -0
  97. package/src/cases/confluence.ts +111 -0
  98. package/src/cases/context.ts +223 -0
  99. package/src/cases/dashboards.ts +137 -0
  100. package/src/cases/failure.ts +97 -0
  101. package/src/cases/files.ts +295 -0
  102. package/src/cases/goal.ts +53 -0
  103. package/src/cases/http.ts +111 -0
  104. package/src/cases/index.ts +131 -0
  105. package/src/cases/jira.ts +122 -0
  106. package/src/cases/qtest.ts +108 -0
  107. package/src/cases/quality.ts +190 -0
  108. package/src/cases/rag.ts +100 -0
  109. package/src/cases/readiness.ts +284 -0
  110. package/src/cases/save.ts +149 -0
  111. package/src/cases/sprints.ts +148 -0
  112. package/src/cases/testrail.ts +176 -0
  113. package/src/cases/types.ts +107 -0
  114. package/src/cases/wiki.ts +105 -0
  115. package/src/cases/xray.ts +206 -0
  116. package/src/cases/zephyr.ts +153 -0
  117. package/src/childEnv.ts +86 -0
  118. package/src/cli/args.ts +114 -0
  119. package/src/cli/help.ts +167 -0
  120. package/src/cli/index.ts +1142 -0
  121. package/src/cli/terminal.ts +187 -0
  122. package/src/code/areas.ts +337 -0
  123. package/src/code/attention.ts +214 -0
  124. package/src/code/find.ts +540 -0
  125. package/src/code/handoff.ts +541 -0
  126. package/src/code/history.ts +170 -0
  127. package/src/code/impact.ts +231 -0
  128. package/src/code/index.ts +34 -0
  129. package/src/code/livebrowser.ts +67 -0
  130. package/src/code/loop.ts +603 -0
  131. package/src/code/notes.ts +75 -0
  132. package/src/code/profile.ts +226 -0
  133. package/src/code/prompt.ts +303 -0
  134. package/src/code/prreview.ts +649 -0
  135. package/src/code/prtests.ts +103 -0
  136. package/src/code/replay.ts +772 -0
  137. package/src/code/review.ts +202 -0
  138. package/src/code/runtime.ts +781 -0
  139. package/src/code/slices.ts +159 -0
  140. package/src/code/testrun.ts +187 -0
  141. package/src/code/traceability.ts +100 -0
  142. package/src/code/worktree.ts +153 -0
  143. package/src/commands.ts +101 -0
  144. package/src/config.ts +970 -0
  145. package/src/detail.ts +65 -0
  146. package/src/domain.ts +19 -0
  147. package/src/engine/bridge.ts +359 -0
  148. package/src/engine/mock.ts +102 -0
  149. package/src/engine/setup.ts +298 -0
  150. package/src/entra.ts +154 -0
  151. package/src/env.ts +24 -0
  152. package/src/evals/domain.ts +338 -0
  153. package/src/explore/charters.ts +151 -0
  154. package/src/explore/run.ts +280 -0
  155. package/src/explore/story.ts +106 -0
  156. package/src/guard.ts +51 -0
  157. package/src/heal/loop.ts +357 -0
  158. package/src/heal/prompt.ts +143 -0
  159. package/src/heal/report.ts +95 -0
  160. package/src/index.ts +12 -0
  161. package/src/init.ts +102 -0
  162. package/src/knowledge.ts +114 -0
  163. package/src/legacyEnv.ts +9 -0
  164. package/src/llm.ts +61 -0
  165. package/src/locale.ts +25 -0
  166. package/src/logins.ts +148 -0
  167. package/src/managed.ts +101 -0
  168. package/src/mcp/apitools.ts +335 -0
  169. package/src/mcp/captools.ts +25 -0
  170. package/src/mcp/casetools.ts +223 -0
  171. package/src/mcp/commands.ts +421 -0
  172. package/src/mcp/exploretools.ts +129 -0
  173. package/src/mcp/external.ts +172 -0
  174. package/src/mcp/git.ts +78 -0
  175. package/src/mcp/notifytools.ts +49 -0
  176. package/src/mcp/prtools.ts +261 -0
  177. package/src/mcp/qualitytools.ts +154 -0
  178. package/src/mcp/repotests.ts +286 -0
  179. package/src/mcp/server.ts +234 -0
  180. package/src/mcp/tools.ts +810 -0
  181. package/src/memory.ts +250 -0
  182. package/src/money.ts +39 -0
  183. package/src/monitor/alerts.ts +94 -0
  184. package/src/monitor/background.ts +68 -0
  185. package/src/monitor/run.ts +263 -0
  186. package/src/monitor/state.ts +105 -0
  187. package/src/notify/events.ts +144 -0
  188. package/src/notify/notice.ts +177 -0
  189. package/src/prices.ts +175 -0
  190. package/src/print.ts +54 -0
  191. package/src/privacy.ts +35 -0
  192. package/src/prompts.ts +165 -0
  193. package/src/quality/budgets.ts +249 -0
  194. package/src/quality/history.ts +149 -0
  195. package/src/quality/visual.ts +201 -0
  196. package/src/report/data.ts +475 -0
  197. package/src/report/html.ts +106 -0
  198. package/src/report/template.html +84 -0
  199. package/src/run.ts +243 -0
  200. package/src/sessionLog.ts +216 -0
  201. package/src/sessionLogins.ts +94 -0
  202. package/src/skills/registry.ts +217 -0
  203. package/src/skills/scaffold.ts +65 -0
  204. package/src/skills/select.ts +37 -0
  205. package/src/skills/view.ts +44 -0
  206. package/src/suite/report.ts +135 -0
  207. package/src/suite/run.ts +303 -0
  208. package/src/summary.ts +188 -0
  209. package/src/tools.ts +130 -0
  210. package/src/tui/App.tsx +983 -0
  211. package/src/tui/clipboard.ts +36 -0
  212. package/src/tui/components.tsx +263 -0
  213. package/src/tui/dialogs.ts +65 -0
  214. package/src/tui/main.tsx +20 -0
  215. package/src/tui/text.ts +28 -0
  216. package/src/tui/theme.ts +104 -0
  217. package/src/tui/transcript.tsx +344 -0
  218. package/src/types.ts +146 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
@@ -0,0 +1,249 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import { DEFAULT_VISUAL, visualConfig, visualLine, type VisualConfig, type VisualReport } from "./visual.ts"
5
+
6
+ /**
7
+ * Accessibility (WCAG) and performance (Core Web Vitals) of the pages a run visited, judged against budgets. A run
8
+ * fails on quality only when a budget set in forgeqe.yaml is exceeded; without budgets both are reported, never
9
+ * failed. Accessibility comes from axe-core, run on each page with the rules of the chosen standard; timings come
10
+ * from the browser's own performance entries.
11
+ */
12
+
13
+ export const STANDARDS: Record<string, { label: string; tags: string[] }> = {
14
+ wcag2a: { label: "WCAG 2.x A", tags: ["wcag2a"] },
15
+ wcag21aa: { label: "WCAG 2.1 AA", tags: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] },
16
+ wcag22aa: { label: "WCAG 2.2 AA", tags: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"] },
17
+ wcag21aaa: { label: "WCAG 2.1 AAA", tags: ["wcag2a", "wcag2aa", "wcag2aaa", "wcag21a", "wcag21aa", "wcag21aaa"] },
18
+ section508: { label: "Section 508", tags: ["section508", "wcag2a", "wcag2aa"] },
19
+ en301549: { label: "EN 301 549", tags: ["EN-301-549", "wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] },
20
+ "best-practice": { label: "Best practice", tags: ["best-practice"] },
21
+ }
22
+
23
+ export const DISCLAIMER = "Automated checks find a part of the WCAG problems (often 30–50%); they are a pre-check, not conformance. A manual review still matters."
24
+
25
+ export type Impact = "critical" | "serious" | "moderate" | "minor"
26
+ export const IMPACTS: Impact[] = ["critical", "serious", "moderate", "minor"]
27
+
28
+ export type A11yBudget = Partial<Record<Impact, number>> & { /** Rules that must not fail at all. */ rules?: string[]; /** Rules left out of the budget. */ ignore?: string[] }
29
+
30
+ export const METRICS = ["lcp_ms", "cls", "inp_ms", "fcp_ms", "ttfb_ms", "load_ms"] as const
31
+ export type Metric = (typeof METRICS)[number]
32
+ export type PerfLimits = Partial<Record<Metric, number>>
33
+ export type PerfBudget = PerfLimits & { /** Limits for pages whose path starts with the key (/checkout). */ pages?: Record<string, PerfLimits> }
34
+
35
+ /** Google's "good" thresholds: `performance: web-vitals` in forgeqe.yaml. */
36
+ export const WEB_VITALS: PerfLimits = { lcp_ms: 2500, cls: 0.1, inp_ms: 200, fcp_ms: 1800, ttfb_ms: 800 }
37
+
38
+ export type QualityConfig = {
39
+ /** Screenshots of each page compared with approved baselines (quality.visual). */
40
+ visual: VisualConfig
41
+ /** A key of STANDARDS (default wcag21aa). */
42
+ standard: string
43
+ /** Also run axe-core's best-practice rules (reported, counted in budgets like the others). */
44
+ bestPractice: boolean
45
+ /** axe (default) or basic: ForgeQE's own checks when axe-core cannot be used. */
46
+ engine: "axe" | "basic"
47
+ /** visual: the most a page may differ from its baseline, in percent of its pixels. */
48
+ budgets: { accessibility?: A11yBudget; performance?: PerfBudget; visual?: number }
49
+ }
50
+
51
+ export const DEFAULT_QUALITY: QualityConfig = { standard: "wcag21aa", bestPractice: false, engine: "axe", budgets: {}, visual: DEFAULT_VISUAL }
52
+
53
+ export function axeTags(q: QualityConfig): string[] {
54
+ const tags = STANDARDS[q.standard]?.tags || STANDARDS.wcag21aa.tags
55
+ return q.bestPractice && !tags.includes("best-practice") ? [...tags, "best-practice"] : tags
56
+ }
57
+
58
+ const num = (v: unknown) => (v === undefined || v === null || v === "" || Number.isNaN(Number(v)) ? undefined : Number(v))
59
+
60
+ function limits(raw: Record<string, unknown>): PerfLimits {
61
+ const out: PerfLimits = {}
62
+ for (const m of METRICS) {
63
+ const v = num(raw[m] ?? raw[m.replace(/_ms$/, "")])
64
+ if (v !== undefined) out[m] = v
65
+ }
66
+ return out
67
+ }
68
+
69
+ /** quality: in forgeqe.yaml. */
70
+ export function qualityConfig(raw: unknown): QualityConfig {
71
+ const q = (raw && typeof raw === "object" ? raw : {}) as Record<string, any>
72
+ const a11y = (q.accessibility || {}) as Record<string, any>
73
+ const budgets = (q.budgets || {}) as Record<string, any>
74
+ const standard = String(a11y.standard || "wcag21aa").toLowerCase().replace(/[^a-z0-9-]/g, "")
75
+ if (!STANDARDS[standard]) throw new Error(`quality.accessibility.standard "${a11y.standard}" is not one of ${Object.keys(STANDARDS).join(", ")}.`)
76
+ const out: QualityConfig = { standard, bestPractice: a11y.best_practice === true, engine: a11y.engine === "basic" ? "basic" : "axe", budgets: {}, visual: visualConfig(q.visual) }
77
+ const vb = budgets.visual
78
+ const vlimit = vb === true ? 0.1 : num(vb && typeof vb === "object" ? vb.max_diff_percent : vb)
79
+ if (vlimit !== undefined) out.budgets.visual = vlimit
80
+ const ab = budgets.accessibility
81
+ if (ab && typeof ab === "object") {
82
+ const b: A11yBudget = {}
83
+ for (const i of IMPACTS) if (num(ab[i]) !== undefined) b[i] = num(ab[i])
84
+ if (Array.isArray(ab.rules)) b.rules = ab.rules.map(String)
85
+ if (Array.isArray(ab.ignore)) b.ignore = ab.ignore.map(String)
86
+ out.budgets.accessibility = b
87
+ } else if (ab === true) out.budgets.accessibility = { critical: 0, serious: 0 }
88
+ const pb = budgets.performance
89
+ if (pb === "web-vitals" || pb === true) out.budgets.performance = { ...WEB_VITALS }
90
+ else if (pb && typeof pb === "object") {
91
+ const base = pb.preset === "web-vitals" ? { ...WEB_VITALS } : {}
92
+ out.budgets.performance = { ...base, ...limits(pb), ...(pb.pages && typeof pb.pages === "object" ? { pages: Object.fromEntries(Object.entries(pb.pages as Record<string, Record<string, unknown>>).map(([k, v]) => [k, limits(v || {})])) } : {}) }
93
+ }
94
+ return out
95
+ }
96
+
97
+ // -- reading a run --------------------------------------------------------------------------------
98
+
99
+ export type A11yIssue = { rule: string; wcag?: string; level?: string; impact: Impact; help: string; helpUrl?: string; count: number; examples?: { html?: string; target?: string; text?: string }[] }
100
+ export type A11yPage = { url: string; title?: string; engine?: string; tags?: string[]; passed?: string[]; incomplete?: string[]; issues: A11yIssue[] }
101
+ export type PerfPage = { url: string; lcp?: number | null; cls?: number | null; inp?: number | null; fcp?: number | null; ttfb?: number | null; dcl?: number | null; load?: number | null; requests?: number; kb?: number }
102
+
103
+ const readJson = <T>(file: string): T | null => {
104
+ try {
105
+ return existsSync(file) ? (JSON.parse(readFileSync(file, "utf8")) as T) : null
106
+ } catch {
107
+ return null
108
+ }
109
+ }
110
+
111
+ export const readA11y = (runDir: string) => readJson<A11yPage[]>(path.join(runDir, "accessibility.json")) || []
112
+ export const readPerf = (runDir: string) => readJson<PerfPage[]>(path.join(runDir, "performance.json")) || []
113
+
114
+ const VALUE: Record<Metric, keyof PerfPage> = { lcp_ms: "lcp", cls: "cls", inp_ms: "inp", fcp_ms: "fcp", ttfb_ms: "ttfb", load_ms: "load" }
115
+ export const METRIC_LABEL: Record<Metric, string> = { lcp_ms: "LCP", cls: "CLS", inp_ms: "INP", fcp_ms: "FCP", ttfb_ms: "TTFB", load_ms: "Load" }
116
+ export const fmtMetric = (m: Metric, v: number) => (m === "cls" ? v.toFixed(3).replace(/0+$/, "").replace(/\.$/, "") : v >= 1000 ? `${(v / 1000).toFixed(1)}s` : `${Math.round(v)}ms`)
117
+
118
+ export type Breach = { kind: "accessibility" | "performance" | "visual"; page: string; text: string }
119
+
120
+ export type QualityReport = {
121
+ standard: string
122
+ engine: string
123
+ accessibility: {
124
+ pages: number
125
+ /** Each rule that failed somewhere: on how many pages, how many elements. */
126
+ failed: { rule: string; wcag?: string; level?: string; impact: Impact; help: string; helpUrl?: string; pages: number; count: number }[]
127
+ /** Rules that passed on every page they ran on. */
128
+ passed: string[]
129
+ byImpact: Record<Impact, number>
130
+ }
131
+ performance: { pages: (PerfPage & { over: string[] })[] }
132
+ /** Each page against its visual baseline. */
133
+ visual?: VisualReport
134
+ budgets: { accessibility: boolean; performance: boolean; visual?: boolean }
135
+ breaches: Breach[]
136
+ }
137
+
138
+ const pathOf = (url: string) => {
139
+ try {
140
+ return new URL(url).pathname
141
+ } catch {
142
+ return url
143
+ }
144
+ }
145
+
146
+ /** The limits for a page: the budget's, with the longest matching path prefix's on top. */
147
+ export function limitsFor(b: PerfBudget, url: string): PerfLimits {
148
+ const p = pathOf(url)
149
+ const { pages, ...base } = b
150
+ const match = Object.keys(pages || {})
151
+ .filter((k) => p === k || p.startsWith(k.endsWith("/") ? k : `${k}/`) || (k !== "/" && p.startsWith(k)))
152
+ .sort((a, b) => b.length - a.length)[0]
153
+ return { ...base, ...(match ? pages![match] : {}) }
154
+ }
155
+
156
+ /** Judge a run's pages: WCAG rules passed and failed, Core Web Vitals, and every budget exceeded. */
157
+ export function evaluateQuality(runDir: string, q: QualityConfig): QualityReport | null {
158
+ const a11y = readA11y(runDir)
159
+ const perf = readPerf(runDir)
160
+ if (!a11y.length && !perf.length) return null
161
+ const ignore = new Set(q.budgets.accessibility?.ignore || [])
162
+ const failed = new Map<string, QualityReport["accessibility"]["failed"][number]>()
163
+ const passedAnywhere = new Set<string>()
164
+ const byImpact: Record<Impact, number> = { critical: 0, serious: 0, moderate: 0, minor: 0 }
165
+ const breaches: Breach[] = []
166
+ for (const page of a11y) {
167
+ for (const r of page.passed || []) passedAnywhere.add(r)
168
+ const counted: Record<Impact, A11yIssue[]> = { critical: [], serious: [], moderate: [], minor: [] }
169
+ for (const i of page.issues) {
170
+ const impact = (IMPACTS.includes(i.impact) ? i.impact : "minor") as Impact
171
+ byImpact[impact] += i.count
172
+ const f = failed.get(i.rule) || { rule: i.rule, wcag: i.wcag, level: i.level, impact, help: i.help, helpUrl: i.helpUrl, pages: 0, count: 0 }
173
+ f.pages++
174
+ f.count += i.count
175
+ failed.set(i.rule, f)
176
+ if (!ignore.has(i.rule)) counted[impact].push(i)
177
+ }
178
+ const b = q.budgets.accessibility
179
+ if (b) {
180
+ for (const impact of IMPACTS) {
181
+ const limit = b[impact]
182
+ const n = counted[impact].reduce((a, i) => a + i.count, 0)
183
+ if (limit !== undefined && n > limit)
184
+ breaches.push({ kind: "accessibility", page: page.url, text: `${n} ${impact} accessibility violation${n === 1 ? "" : "s"} (budget ${limit}): ${counted[impact].map((i) => `${i.rule}${i.wcag ? ` (WCAG ${i.wcag})` : ""}`).join(", ")}` })
185
+ }
186
+ for (const rule of b.rules || []) {
187
+ const hit = page.issues.find((i) => i.rule === rule)
188
+ if (hit) breaches.push({ kind: "accessibility", page: page.url, text: `${rule} fails on ${hit.count} element${hit.count === 1 ? "" : "s"} (budget: must pass)` })
189
+ }
190
+ }
191
+ }
192
+ const perfPages = perf.map((p) => {
193
+ const over: string[] = []
194
+ if (q.budgets.performance) {
195
+ const lim = limitsFor(q.budgets.performance, p.url)
196
+ for (const m of METRICS) {
197
+ const v = p[VALUE[m]] as number | null | undefined
198
+ const limit = lim[m]
199
+ if (limit === undefined || v === null || v === undefined) continue
200
+ if (v > limit) over.push(`${METRIC_LABEL[m]} ${fmtMetric(m, v)} (budget ${fmtMetric(m, limit)})`)
201
+ }
202
+ }
203
+ if (over.length) breaches.push({ kind: "performance", page: p.url, text: over.join(", ") })
204
+ return { ...p, over }
205
+ })
206
+ const engine = a11y.find((p) => p.engine)?.engine || (a11y.length ? "forgeqe" : "")
207
+ return {
208
+ standard: STANDARDS[q.standard]?.label || q.standard,
209
+ engine,
210
+ accessibility: {
211
+ pages: a11y.length,
212
+ failed: [...failed.values()].sort((a, b) => IMPACTS.indexOf(a.impact) - IMPACTS.indexOf(b.impact) || b.count - a.count),
213
+ passed: [...passedAnywhere].filter((r) => !failed.has(r)).sort(),
214
+ byImpact,
215
+ },
216
+ performance: { pages: perfPages },
217
+ budgets: { accessibility: Boolean(q.budgets.accessibility), performance: Boolean(q.budgets.performance) },
218
+ breaches,
219
+ }
220
+ }
221
+
222
+ /** A report with no accessibility or timing data, for a run that only has visual captures. */
223
+ export function emptyReport(q: QualityConfig): QualityReport {
224
+ return {
225
+ standard: STANDARDS[q.standard]?.label || q.standard,
226
+ engine: "",
227
+ accessibility: { pages: 0, failed: [], passed: [], byImpact: { critical: 0, serious: 0, moderate: 0, minor: 0 } },
228
+ performance: { pages: [] },
229
+ budgets: { accessibility: Boolean(q.budgets.accessibility), performance: Boolean(q.budgets.performance) },
230
+ breaches: [],
231
+ }
232
+ }
233
+
234
+ /** One line for summaries: WCAG rules, vitals, and budgets. */
235
+ export function qualityLine(r: QualityReport): string {
236
+ const parts: string[] = []
237
+ if (r.accessibility.pages) {
238
+ const n = r.accessibility.failed.length
239
+ parts.push(`${r.standard}: ${r.accessibility.passed.length} rules passed, ${n} failed${n ? ` (${IMPACTS.filter((i) => r.accessibility.byImpact[i]).map((i) => `${r.accessibility.byImpact[i]} ${i}`).join(", ")})` : ""}`)
240
+ }
241
+ const timed = r.performance.pages.filter((p) => p.lcp != null || p.cls != null)
242
+ if (timed.length) {
243
+ const worst = (k: "lcp" | "cls" | "inp") => Math.max(...timed.map((p) => Number(p[k] ?? 0)))
244
+ parts.push(`worst LCP ${fmtMetric("lcp_ms", worst("lcp"))}, CLS ${fmtMetric("cls", worst("cls"))}${timed.some((p) => p.inp) ? `, INP ${fmtMetric("inp_ms", worst("inp"))}` : ""}`)
245
+ }
246
+ if (r.visual?.pages.length) parts.push(`visual: ${visualLine(r.visual)}`)
247
+ if (r.budgets.accessibility || r.budgets.performance || r.budgets.visual) parts.push(r.breaches.length ? `${r.breaches.length} budget${r.breaches.length === 1 ? "" : "s"} exceeded` : "within budgets")
248
+ return parts.join(" · ")
249
+ }
@@ -0,0 +1,149 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import type { Card } from "../card.ts"
5
+ import { fmtMetric, IMPACTS, type Impact, type Metric, type QualityReport } from "./budgets.ts"
6
+
7
+ /**
8
+ * Each page's accessibility and timings over time, so a run shows what got better or worse: in
9
+ * quality-history.json in the reports folder (keep it between CI runs as a cache to track a pipeline's trend).
10
+ */
11
+
12
+ export const HISTORY_NAME = "quality-history.json"
13
+ const KEEP = 50
14
+ const WINDOW = 10
15
+
16
+ type Entry = { at: string; run: string; a11y?: Record<Impact, number> & { rules: string[] }; perf?: Partial<Record<"lcp" | "cls" | "inp" | "fcp" | "ttfb" | "load", number>> }
17
+ type HistoryFile = { version: 1; pages: Record<string, Entry[]> }
18
+
19
+ export const historyFile = (outDir: string) => path.join(outDir, HISTORY_NAME)
20
+
21
+ /** A page's key: its origin and path, without query or hash. */
22
+ export function pageKey(url: string): string {
23
+ try {
24
+ const u = new URL(url)
25
+ return `${u.origin}${u.pathname.replace(/\/+$/, "") || "/"}`
26
+ } catch {
27
+ return url
28
+ }
29
+ }
30
+
31
+ export function readQualityHistory(file: string): HistoryFile {
32
+ try {
33
+ const d = JSON.parse(readFileSync(file, "utf8")) as HistoryFile
34
+ return { version: 1, pages: d?.pages && typeof d.pages === "object" ? d.pages : {} }
35
+ } catch {
36
+ return { version: 1, pages: {} }
37
+ }
38
+ }
39
+
40
+ /** Add one run's pages. */
41
+ export function recordQuality(file: string, report: QualityReport, a11yPages: { url: string; issues: { rule: string; impact: string; count: number }[] }[], run: string, at = new Date().toISOString()): void {
42
+ const h = readQualityHistory(file)
43
+ const entries = new Map<string, Entry>()
44
+ const entry = (url: string) => {
45
+ const k = pageKey(url)
46
+ const e = entries.get(k) || { at, run }
47
+ entries.set(k, e)
48
+ return e
49
+ }
50
+ for (const p of a11yPages) {
51
+ const counts: Record<Impact, number> = { critical: 0, serious: 0, moderate: 0, minor: 0 }
52
+ for (const i of p.issues) counts[(IMPACTS.includes(i.impact as Impact) ? i.impact : "minor") as Impact] += i.count
53
+ entry(p.url).a11y = { ...counts, rules: [...new Set(p.issues.map((i) => i.rule))].sort() }
54
+ }
55
+ for (const p of report.performance.pages) {
56
+ const perf: Entry["perf"] = {}
57
+ for (const k of ["lcp", "cls", "inp", "fcp", "ttfb", "load"] as const) if (typeof p[k] === "number") perf[k] = p[k] as number
58
+ if (Object.keys(perf).length) entry(p.url).perf = perf
59
+ }
60
+ for (const [k, e] of entries) h.pages[k] = [...(h.pages[k] || []), e].slice(-KEEP)
61
+ mkdirSync(path.dirname(file), { recursive: true })
62
+ writeFileSync(file, `${JSON.stringify(h, null, 1)}\n`)
63
+ }
64
+
65
+ const median = (xs: number[]) => {
66
+ const s = [...xs].sort((a, b) => a - b)
67
+ return s.length ? (s.length % 2 ? s[(s.length - 1) / 2] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2) : NaN
68
+ }
69
+
70
+ /** How much worse than the usual value counts as a regression: a share, and an absolute amount so noise on fast pages does not count. */
71
+ const WORSE: Record<"lcp" | "cls" | "inp" | "fcp" | "ttfb" | "load", { share: number; min: number; metric: Metric }> = {
72
+ lcp: { share: 0.2, min: 300, metric: "lcp_ms" },
73
+ cls: { share: 0.25, min: 0.05, metric: "cls" },
74
+ inp: { share: 0.25, min: 50, metric: "inp_ms" },
75
+ fcp: { share: 0.2, min: 300, metric: "fcp_ms" },
76
+ ttfb: { share: 0.3, min: 200, metric: "ttfb_ms" },
77
+ load: { share: 0.25, min: 500, metric: "load_ms" },
78
+ }
79
+
80
+ export type PageTrend = {
81
+ page: string
82
+ runs: number
83
+ /** Accessibility violations by impact, oldest first, last WINDOW runs. */
84
+ a11y: { latest?: Record<Impact, number>; trail: number[]; newRules: string[]; fixedRules: string[] }
85
+ perf: { latest: Entry["perf"]; usual: Entry["perf"]; worse: string[]; better: string[] }
86
+ }
87
+
88
+ /** Each page's trend: the latest run against the one before (rules) and against the usual values of the last runs (timings). */
89
+ export function qualityTrends(file: string, filter?: string): PageTrend[] {
90
+ const h = readQualityHistory(file)
91
+ const f = (filter || "").toLowerCase()
92
+ return Object.entries(h.pages)
93
+ .filter(([k]) => !f || k.toLowerCase().includes(f))
94
+ .map(([page, entries]) => {
95
+ const withA11y = entries.filter((e) => e.a11y)
96
+ const last = withA11y.at(-1)?.a11y
97
+ const prev = withA11y.at(-2)?.a11y
98
+ const withPerf = entries.filter((e) => e.perf)
99
+ const latest = withPerf.at(-1)?.perf || {}
100
+ const before = withPerf.slice(-WINDOW - 1, -1)
101
+ const usual: Entry["perf"] = {}
102
+ const worse: string[] = []
103
+ const better: string[] = []
104
+ for (const k of Object.keys(WORSE) as (keyof typeof WORSE)[]) {
105
+ const xs = before.map((e) => e.perf?.[k]).filter((v): v is number => typeof v === "number")
106
+ const now = latest[k]
107
+ if (!xs.length || typeof now !== "number") continue
108
+ const m = median(xs)
109
+ usual[k] = m
110
+ const { share, min, metric } = WORSE[k]
111
+ if (now - m > Math.max(min, m * share)) worse.push(`${k.toUpperCase()} ${fmtMetric(metric, now)} (usually ${fmtMetric(metric, m)})`)
112
+ else if (m - now > Math.max(min, m * share)) better.push(`${k.toUpperCase()} ${fmtMetric(metric, now)} (usually ${fmtMetric(metric, m)})`)
113
+ }
114
+ return {
115
+ page,
116
+ runs: entries.length,
117
+ a11y: {
118
+ ...(last ? { latest: { critical: last.critical, serious: last.serious, moderate: last.moderate, minor: last.minor } } : {}),
119
+ trail: withA11y.slice(-WINDOW).map((e) => IMPACTS.reduce((a, i) => a + (e.a11y![i] || 0), 0)),
120
+ newRules: last && prev ? last.rules.filter((r) => !prev.rules.includes(r)) : [],
121
+ fixedRules: last && prev ? prev.rules.filter((r) => !last.rules.includes(r)) : [],
122
+ },
123
+ perf: { latest, usual, worse, better },
124
+ }
125
+ })
126
+ .sort((a, b) => b.perf.worse.length + b.a11y.newRules.length - (a.perf.worse.length + a.a11y.newRules.length) || b.runs - a.runs)
127
+ }
128
+
129
+ export function trendsCard(trends: PageTrend[], file: string, cwd: string): Card {
130
+ const body: string[] = []
131
+ for (const t of trends.slice(0, 20)) {
132
+ const a = t.a11y.latest
133
+ const total = a ? IMPACTS.reduce((s, i) => s + a[i], 0) : undefined
134
+ body.push(`▸ ${t.page} · ${t.runs} run${t.runs === 1 ? "" : "s"}`)
135
+ if (a) body.push(` accessibility: ${total} violation${total === 1 ? "" : "s"}${total ? ` (${IMPACTS.filter((i) => a[i]).map((i) => `${a[i]} ${i}`).join(", ")})` : ""} trend ${t.a11y.trail.join(" → ")}`)
136
+ if (t.a11y.newRules.length) body.push(` ✗ new: ${t.a11y.newRules.join(", ")}`)
137
+ if (t.a11y.fixedRules.length) body.push(` ✓ fixed: ${t.a11y.fixedRules.join(", ")}`)
138
+ const l = t.perf.latest || {}
139
+ const shown = (["lcp", "cls", "inp", "fcp", "ttfb"] as const).filter((k) => typeof l[k] === "number").map((k) => `${k.toUpperCase()} ${fmtMetric(WORSE[k].metric, l[k]!)}`)
140
+ if (shown.length) body.push(` timings: ${shown.join(", ")}`)
141
+ for (const w of t.perf.worse) body.push(` ✗ slower: ${w}`)
142
+ for (const b of t.perf.better) body.push(` ✓ faster: ${b}`)
143
+ }
144
+ if (trends.length > 20) body.push(`and ${trends.length - 20} more pages`)
145
+ const rel = path.relative(cwd, file)
146
+ body.push("", `History ${rel && !rel.startsWith("..") ? rel : file}`)
147
+ const regress = trends.filter((t) => t.perf.worse.length || t.a11y.newRules.length).length
148
+ return { status: regress ? "fail" : trends.length ? "ok" : "stopped", title: trends.length ? `Accessibility and performance over time: ${regress ? `${regress} page${regress === 1 ? "" : "s"} got worse` : "nothing got worse"}` : "No accessibility or performance history yet", stats: `${trends.length} page${trends.length === 1 ? "" : "s"}`, body }
149
+ }
@@ -0,0 +1,201 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import pixelmatch from "pixelmatch"
5
+ import { PNG } from "pngjs"
6
+
7
+ /**
8
+ * Visual comparison: each page a run visits is captured (animations frozen, masked regions hidden) and compared
9
+ * pixel by pixel with its approved baseline. A page with no baseline yet gets its first capture as the baseline. The
10
+ * changed share of pixels is reported for every page; a run fails on it only when quality.budgets.visual sets a
11
+ * limit. Baselines are per page, viewport size, and operating system (fonts render differently), in
12
+ * .forgeqe/visual-baselines/ next to forgeqe.yaml: commit them to share them, and approve a change on purpose.
13
+ */
14
+
15
+ export type VisualConfig = {
16
+ enabled: boolean
17
+ /** Selectors hidden on every page before capture (dates, ads, carousels). */
18
+ mask: string[]
19
+ /** Per path prefix: more selectors to hide, and a page's own limit. */
20
+ pages: Record<string, { mask?: string[]; maxDiffPercent?: number }>
21
+ fullPage: boolean
22
+ /** How different two pixels' colours must be to count (0–1, pixelmatch's threshold). */
23
+ threshold: number
24
+ /** A page's first capture becomes its baseline. */
25
+ autoBaseline: boolean
26
+ baselineDir?: string
27
+ }
28
+
29
+ export const DEFAULT_VISUAL: VisualConfig = { enabled: true, mask: [], pages: {}, fullPage: false, threshold: 0.1, autoBaseline: true }
30
+
31
+ export function visualConfig(raw: unknown): VisualConfig {
32
+ const v = (raw && typeof raw === "object" ? raw : raw === false ? { enabled: false } : {}) as Record<string, any>
33
+ const pages: VisualConfig["pages"] = {}
34
+ for (const [k, p] of Object.entries((v.pages || {}) as Record<string, any>)) {
35
+ pages[k] = { ...(Array.isArray(p?.mask) ? { mask: p.mask.map(String) } : {}), ...(Number.isFinite(Number(p?.max_diff_percent)) && p?.max_diff_percent !== undefined ? { maxDiffPercent: Number(p.max_diff_percent) } : {}) }
36
+ }
37
+ return {
38
+ enabled: v.enabled !== false,
39
+ mask: Array.isArray(v.mask) ? v.mask.map(String) : [],
40
+ pages,
41
+ fullPage: v.full_page === true,
42
+ threshold: Number(v.threshold) > 0 && Number(v.threshold) < 1 ? Number(v.threshold) : 0.1,
43
+ autoBaseline: v.auto_baseline !== false,
44
+ ...(v.baseline_dir ? { baselineDir: String(v.baseline_dir) } : {}),
45
+ }
46
+ }
47
+
48
+ /** What the engine needs: masks by path prefix, and whether to capture the full page. */
49
+ export function engineVisual(v: VisualConfig): { mask: string[]; pages: Record<string, string[]>; full_page: boolean } | undefined {
50
+ if (!v.enabled) return undefined
51
+ return { mask: v.mask, pages: Object.fromEntries(Object.entries(v.pages).filter(([, p]) => p.mask?.length).map(([k, p]) => [k, p.mask!])), full_page: v.fullPage }
52
+ }
53
+
54
+ export type Capture = { page: string; url: string; title?: string; file: string; full_page?: boolean }
55
+ export type VisualStatus = "same" | "changed" | "new" | "no_baseline"
56
+ export type VisualPage = {
57
+ page: string
58
+ url: string
59
+ title?: string
60
+ status: VisualStatus
61
+ /** Share of pixels that differ, in percent (two decimals). */
62
+ diffPercent?: number
63
+ diffPixels?: number
64
+ /** The run's capture, the baseline it was compared with, and the diff image (changed pixels in red). */
65
+ current: string
66
+ baseline?: string
67
+ diff?: string
68
+ size?: string
69
+ /** The page's limit from the budget, when one is set. */
70
+ limit?: number
71
+ over?: boolean
72
+ /** The sizes differ: a taller or wider page (counted as changed pixels). */
73
+ resized?: string
74
+ }
75
+ export type VisualReport = { baselineDir: string; pages: VisualPage[] }
76
+
77
+ export const readCaptures = (runDir: string): Capture[] => {
78
+ try {
79
+ const v = JSON.parse(readFileSync(path.join(runDir, "visual.json"), "utf8"))
80
+ return Array.isArray(v) ? v : []
81
+ } catch {
82
+ return []
83
+ }
84
+ }
85
+
86
+ /** A page's baseline file: host/path, the image size, and the operating system. */
87
+ export function baselineName(page: string, png: { width: number; height: number }, fullPage?: boolean): string {
88
+ let host = "page"
89
+ let p = page
90
+ try {
91
+ const u = new URL(page)
92
+ host = u.host.replace(/[^A-Za-z0-9.-]+/g, "_")
93
+ p = u.pathname
94
+ } catch {}
95
+ const slug = p.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 80) || "home"
96
+ return path.join(host, `${slug}-${fullPage ? `${png.width}w-full` : `${png.width}x${png.height}`}-${process.platform}.png`)
97
+ }
98
+
99
+ function limitFor(v: VisualConfig, budget: number | undefined, url: string): number | undefined {
100
+ let p = url
101
+ try {
102
+ p = new URL(url).pathname
103
+ } catch {}
104
+ const match = Object.keys(v.pages)
105
+ .filter((k) => v.pages[k].maxDiffPercent !== undefined && (p === k || p.startsWith(k.endsWith("/") ? k : `${k}/`) || (k !== "/" && p.startsWith(k))))
106
+ .sort((a, b) => b.length - a.length)[0]
107
+ return match ? v.pages[match].maxDiffPercent : budget
108
+ }
109
+
110
+ const read = (file: string) => PNG.sync.read(readFileSync(file))
111
+
112
+ /** Two images on one canvas of the larger size: pixels only one of them has count as changed. */
113
+ export function diffImages(a: PNG, b: PNG, threshold: number): { pixels: number; total: number; diff: PNG } {
114
+ const width = Math.max(a.width, b.width)
115
+ const height = Math.max(a.height, b.height)
116
+ const pad = (img: PNG) => {
117
+ if (img.width === width && img.height === height) return img.data
118
+ const out = new PNG({ width, height })
119
+ PNG.bitblt(img, out, 0, 0, img.width, img.height, 0, 0)
120
+ return out.data
121
+ }
122
+ const diff = new PNG({ width, height })
123
+ const pixels = pixelmatch(pad(a), pad(b), diff.data, width, height, { threshold })
124
+ return { pixels, total: width * height, diff }
125
+ }
126
+
127
+ /** Compare each page the run captured with its baseline, write the diff images, and make baselines for new pages. */
128
+ export function compareVisual(runDir: string, v: VisualConfig, baselineDir: string, budget?: number): VisualReport | null {
129
+ const captures = readCaptures(runDir)
130
+ if (!captures.length) return null
131
+ const pages: VisualPage[] = []
132
+ for (const c of captures) {
133
+ const current = path.join(runDir, c.file)
134
+ if (!existsSync(current)) continue
135
+ const img = read(current)
136
+ const name = baselineName(c.page, img, c.full_page)
137
+ const baseline = path.join(baselineDir, name)
138
+ const limit = limitFor(v, budget, c.url)
139
+ const base: VisualPage = { page: c.page, url: c.url, ...(c.title ? { title: c.title } : {}), status: "new", current, size: `${img.width}×${img.height}`, ...(limit !== undefined ? { limit } : {}) }
140
+ if (!existsSync(baseline)) {
141
+ if (v.autoBaseline) {
142
+ mkdirSync(path.dirname(baseline), { recursive: true })
143
+ copyFileSync(current, baseline)
144
+ recordApproval(baselineDir, name, c, path.basename(runDir), "first capture")
145
+ pages.push({ ...base, status: "new", baseline })
146
+ } else pages.push({ ...base, status: "no_baseline" })
147
+ continue
148
+ }
149
+ const old = read(baseline)
150
+ const { pixels, total, diff } = diffImages(old, img, v.threshold)
151
+ const percent = Math.round((pixels / total) * 10000) / 100
152
+ const resized = old.width !== img.width || old.height !== img.height ? `${old.width}×${old.height} → ${img.width}×${img.height}` : undefined
153
+ let diffFile: string | undefined
154
+ if (pixels) {
155
+ diffFile = current.replace(/\.png$/, "-diff.png")
156
+ writeFileSync(diffFile, PNG.sync.write(diff))
157
+ }
158
+ pages.push({ ...base, status: pixels ? "changed" : "same", diffPercent: percent, diffPixels: pixels, baseline, ...(diffFile ? { diff: diffFile } : {}), ...(resized ? { resized } : {}), ...(limit !== undefined ? { over: percent > limit } : {}) })
159
+ }
160
+ const report = { baselineDir, pages }
161
+ writeFileSync(path.join(runDir, "visual-result.json"), `${JSON.stringify(report, null, 2)}\n`)
162
+ return report
163
+ }
164
+
165
+ type Approval = { file: string; page: string; run: string; at: string; why: string }
166
+
167
+ function recordApproval(baselineDir: string, name: string, c: Pick<Capture, "page">, run: string, why: string): void {
168
+ const index = path.join(baselineDir, "baselines.json")
169
+ let data: Record<string, Approval> = {}
170
+ try {
171
+ data = JSON.parse(readFileSync(index, "utf8"))
172
+ } catch {}
173
+ data[name.split(path.sep).join("/")] = { file: name.split(path.sep).join("/"), page: c.page, run, at: new Date().toISOString(), why }
174
+ mkdirSync(baselineDir, { recursive: true })
175
+ writeFileSync(index, `${JSON.stringify(Object.fromEntries(Object.entries(data).sort(([a], [b]) => a.localeCompare(b))), null, 2)}\n`)
176
+ }
177
+
178
+ /** Make a run's captures the baselines: every page, or the pages whose URL contains one of `only`. */
179
+ export function approveVisual(runDir: string, baselineDir: string, only: string[] = [], why = "approved"): { page: string; baseline: string }[] {
180
+ const out: { page: string; baseline: string }[] = []
181
+ const filter = only.map((o) => o.toLowerCase())
182
+ for (const c of readCaptures(runDir)) {
183
+ if (filter.length && !filter.some((f) => c.page.toLowerCase().includes(f) || c.url.toLowerCase().includes(f))) continue
184
+ const current = path.join(runDir, c.file)
185
+ if (!existsSync(current)) continue
186
+ const name = baselineName(c.page, read(current), c.full_page)
187
+ const target = path.join(baselineDir, name)
188
+ mkdirSync(path.dirname(target), { recursive: true })
189
+ copyFileSync(current, target)
190
+ recordApproval(baselineDir, name, c, path.basename(runDir), why)
191
+ out.push({ page: c.page, baseline: target })
192
+ }
193
+ return out
194
+ }
195
+
196
+ export function visualLine(r: VisualReport): string {
197
+ const n = (s: VisualStatus) => r.pages.filter((p) => p.status === s).length
198
+ const changed = r.pages.filter((p) => p.status === "changed")
199
+ const most = changed.sort((a, b) => (b.diffPercent || 0) - (a.diffPercent || 0))[0]
200
+ return [`${n("same")} same`, changed.length ? `${changed.length} changed${most ? ` (up to ${most.diffPercent}%)` : ""}` : "", n("new") ? `${n("new")} new baseline${n("new") === 1 ? "" : "s"}` : "", n("no_baseline") ? `${n("no_baseline")} without a baseline` : ""].filter(Boolean).join(", ")
201
+ }