@kintsugi-ai/core 0.1.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 (86) hide show
  1. package/dist/auth.d.ts +57 -0
  2. package/dist/auth.d.ts.map +1 -0
  3. package/dist/auth.js +151 -0
  4. package/dist/auth.js.map +1 -0
  5. package/dist/baseline.d.ts +8 -0
  6. package/dist/baseline.d.ts.map +1 -0
  7. package/dist/baseline.js +114 -0
  8. package/dist/baseline.js.map +1 -0
  9. package/dist/benchmark.d.ts +65 -0
  10. package/dist/benchmark.d.ts.map +1 -0
  11. package/dist/benchmark.js +232 -0
  12. package/dist/benchmark.js.map +1 -0
  13. package/dist/capturer.d.ts +14 -0
  14. package/dist/capturer.d.ts.map +1 -0
  15. package/dist/capturer.js +92 -0
  16. package/dist/capturer.js.map +1 -0
  17. package/dist/classifier.d.ts +62 -0
  18. package/dist/classifier.d.ts.map +1 -0
  19. package/dist/classifier.js +204 -0
  20. package/dist/classifier.js.map +1 -0
  21. package/dist/comparator.d.ts +28 -0
  22. package/dist/comparator.d.ts.map +1 -0
  23. package/dist/comparator.js +184 -0
  24. package/dist/comparator.js.map +1 -0
  25. package/dist/comparator.test.d.ts +2 -0
  26. package/dist/comparator.test.d.ts.map +1 -0
  27. package/dist/comparator.test.js +73 -0
  28. package/dist/comparator.test.js.map +1 -0
  29. package/dist/config.d.ts +6 -0
  30. package/dist/config.d.ts.map +1 -0
  31. package/dist/config.js +79 -0
  32. package/dist/config.js.map +1 -0
  33. package/dist/config.test.d.ts +2 -0
  34. package/dist/config.test.d.ts.map +1 -0
  35. package/dist/config.test.js +67 -0
  36. package/dist/config.test.js.map +1 -0
  37. package/dist/domdiff.d.ts +6 -0
  38. package/dist/domdiff.d.ts.map +1 -0
  39. package/dist/domdiff.js +71 -0
  40. package/dist/domdiff.js.map +1 -0
  41. package/dist/feedback.d.ts +9 -0
  42. package/dist/feedback.d.ts.map +1 -0
  43. package/dist/feedback.js +57 -0
  44. package/dist/feedback.js.map +1 -0
  45. package/dist/flowid.d.ts +3 -0
  46. package/dist/flowid.d.ts.map +1 -0
  47. package/dist/flowid.js +11 -0
  48. package/dist/flowid.js.map +1 -0
  49. package/dist/index.d.ts +14 -0
  50. package/dist/index.d.ts.map +1 -0
  51. package/dist/index.js +14 -0
  52. package/dist/index.js.map +1 -0
  53. package/dist/recorder.d.ts +12 -0
  54. package/dist/recorder.d.ts.map +1 -0
  55. package/dist/recorder.js +86 -0
  56. package/dist/recorder.js.map +1 -0
  57. package/dist/setupPrompt.d.ts +10 -0
  58. package/dist/setupPrompt.d.ts.map +1 -0
  59. package/dist/setupPrompt.js +27 -0
  60. package/dist/setupPrompt.js.map +1 -0
  61. package/dist/types.d.ts +144 -0
  62. package/dist/types.d.ts.map +1 -0
  63. package/dist/types.js +12 -0
  64. package/dist/types.js.map +1 -0
  65. package/dist/vision.d.ts +34 -0
  66. package/dist/vision.d.ts.map +1 -0
  67. package/dist/vision.js +139 -0
  68. package/dist/vision.js.map +1 -0
  69. package/package.json +37 -0
  70. package/src/auth.ts +195 -0
  71. package/src/baseline.ts +125 -0
  72. package/src/benchmark.ts +307 -0
  73. package/src/capturer.ts +105 -0
  74. package/src/classifier.ts +258 -0
  75. package/src/comparator.test.ts +97 -0
  76. package/src/comparator.ts +217 -0
  77. package/src/config.test.ts +80 -0
  78. package/src/config.ts +83 -0
  79. package/src/domdiff.ts +62 -0
  80. package/src/feedback.ts +64 -0
  81. package/src/flowid.ts +14 -0
  82. package/src/index.ts +13 -0
  83. package/src/recorder.ts +96 -0
  84. package/src/setupPrompt.ts +26 -0
  85. package/src/types.ts +144 -0
  86. package/tsconfig.json +9 -0
@@ -0,0 +1,258 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { diffAriaSnapshots } from './domdiff.js';
5
+ import type { ClassifierConfig } from './types.js';
6
+
7
+ export interface ClassifierVerdict {
8
+ intentional: boolean;
9
+ /** 0..1 certainty reported by the classifier */
10
+ confidence: number;
11
+ /** One sentence: what changed and — if not intentional — how to fix it */
12
+ reasoning: string;
13
+ }
14
+
15
+ /** Metadata about the flow being classified (the hosted API uses it for the flow gate + usage log) */
16
+ export interface ClassifyMeta {
17
+ flowName?: string;
18
+ flowHash?: string;
19
+ step?: number;
20
+ }
21
+
22
+ /**
23
+ * Thrown when the hosted API returns 429 — either the free-plan flow limit
24
+ * (distinct flows) or the monthly abuse cap. Carries the payload surfaced
25
+ * in agent feedback and UI ("10 of 10 flows guarded — upgrade for unlimited").
26
+ */
27
+ export class FlowLimitError extends Error {
28
+ readonly kind: 'flow_limit_reached' | 'monthly_cap_reached';
29
+ readonly flowsUsed?: number;
30
+ readonly flowLimit?: number;
31
+ readonly upgradeUrl?: string;
32
+
33
+ constructor(body: { error?: string; flowsUsed?: number; flowLimit?: number; upgradeUrl?: string }) {
34
+ super(body.error ?? 'limit_reached');
35
+ this.name = 'FlowLimitError';
36
+ this.kind = body.error === 'monthly_cap_reached' ? 'monthly_cap_reached' : 'flow_limit_reached';
37
+ this.flowsUsed = body.flowsUsed;
38
+ this.flowLimit = body.flowLimit;
39
+ this.upgradeUrl = body.upgradeUrl;
40
+ }
41
+ }
42
+
43
+ const SYSTEM_PROMPT = [
44
+ 'You are a strict UI regression classifier for an automated QA tool that guards AI coding agents.',
45
+ 'You will be given: (1) a structural diff of the page accessibility tree between the accepted baseline and the current state after a code change, and (2) optional context about what the developer/agent was asked to do in the latest turn.',
46
+ 'The baseline is the ACCEPTED, WORKING state. Removed or broken elements (buttons, forms, prices, text, navigation) are regressions even if the remaining page looks coherent.',
47
+ 'If no context is provided, be conservative: significant structural changes should be classified as not intentional.',
48
+ 'If context IS provided and the diff implements exactly what the context asked for, classify it as intentional with high confidence — a requested change is not a regression.',
49
+ 'Benign changes (reordered identical items, minor text edits, added requested features) are intentional.',
50
+ 'Answer with ONLY a JSON object, no other text:',
51
+ '{"intentional": <true|false>, "confidence": <number between 0 and 1>, "reasoning": "<exactly one sentence: what changed, and if not intentional, the specific fix>"}',
52
+ ].join('\n');
53
+
54
+ /**
55
+ * Classifies a visual change as intentional or a regression. Provider
56
+ * 'kintsugi' POSTs to the hosted classifier API (which owns the model and
57
+ * the flow gate); 'openai-compatible' sends the chat-completions prompt
58
+ * below to any OpenAI-compatible endpoint.
59
+ */
60
+ export async function classifyChange(options: {
61
+ baselineAria: string;
62
+ currentAria: string;
63
+ context?: string;
64
+ config: ClassifierConfig;
65
+ apiToken: string;
66
+ meta?: ClassifyMeta;
67
+ logger?: { info: (msg: string, data?: unknown) => void; warn: (msg: string, data?: unknown) => void };
68
+ }): Promise<ClassifierVerdict> {
69
+ if (options.config.provider === 'kintsugi') {
70
+ return classifyViaKintsugi(options);
71
+ }
72
+ return classifyViaOpenAiCompatible(options);
73
+ }
74
+
75
+ /** Hosted kintsugi API path — the server computes the diff, picks the model, and enforces the flow gate. */
76
+ async function classifyViaKintsugi(options: {
77
+ baselineAria: string;
78
+ currentAria: string;
79
+ context?: string;
80
+ config: ClassifierConfig;
81
+ apiToken: string;
82
+ meta?: ClassifyMeta;
83
+ }): Promise<ClassifierVerdict> {
84
+ const { baselineAria, currentAria, context, config, apiToken, meta } = options;
85
+ const url = `${config.endpoint.replace(/\/$/, '')}/classify`;
86
+ const controller = new AbortController();
87
+ const timer = setTimeout(() => controller.abort(), config.timeoutMs);
88
+ let response: Response;
89
+ try {
90
+ response = await fetch(url, {
91
+ method: 'POST',
92
+ headers: {
93
+ 'Content-Type': 'application/json',
94
+ Authorization: `Bearer ${apiToken}`,
95
+ },
96
+ body: JSON.stringify({ baselineAria, currentAria, context: context ?? null, meta: meta ?? null }),
97
+ signal: controller.signal,
98
+ });
99
+ } finally {
100
+ clearTimeout(timer);
101
+ }
102
+
103
+ if (response.status === 429) {
104
+ const body = await response.json().catch(() => ({}));
105
+ throw new FlowLimitError(body);
106
+ }
107
+ if (!response.ok) {
108
+ const detail = await response.text().catch(() => '');
109
+ throw new Error(`kintsugi api ${response.status}: ${detail.slice(0, 300)}`);
110
+ }
111
+ const payload = (await response.json()) as Partial<ClassifierVerdict>;
112
+ if (typeof payload.intentional !== 'boolean' || typeof payload.confidence !== 'number') {
113
+ throw new Error(`unexpected kintsugi api response: ${JSON.stringify(payload).slice(0, 200)}`);
114
+ }
115
+ return {
116
+ intentional: payload.intentional,
117
+ confidence: payload.confidence,
118
+ reasoning: payload.reasoning ?? '',
119
+ };
120
+ }
121
+
122
+ async function classifyViaOpenAiCompatible(options: {
123
+ baselineAria: string;
124
+ currentAria: string;
125
+ context?: string;
126
+ config: ClassifierConfig;
127
+ apiToken: string;
128
+ logger?: { info: (msg: string, data?: unknown) => void; warn: (msg: string, data?: unknown) => void };
129
+ }): Promise<ClassifierVerdict> {
130
+ const { baselineAria, currentAria, context, config, apiToken, logger } = options;
131
+
132
+ const diff = diffAriaSnapshots(baselineAria, currentAria);
133
+ const userText = [
134
+ 'Accessibility-tree diff (baseline → current). Lines prefixed "REMOVED:" were removed from the page, "ADDED:" were added:',
135
+ diff,
136
+ '',
137
+ context
138
+ ? `Context from the latest turn (what the agent was asked to do):\n"${context}"`
139
+ : 'No context available about the intended change.',
140
+ '',
141
+ 'Classify this change as intentional or not. Reply with ONLY the JSON object.',
142
+ ].join('\n');
143
+
144
+ const body = {
145
+ model: config.model,
146
+ temperature: 0,
147
+ max_tokens: 120,
148
+ messages: [
149
+ { role: 'system', content: SYSTEM_PROMPT },
150
+ { role: 'user', content: userText },
151
+ ],
152
+ };
153
+
154
+ const url = `${config.endpoint.replace(/\/$/, '')}/chat/completions`;
155
+ const controller = new AbortController();
156
+ const timer = setTimeout(() => controller.abort(), config.timeoutMs);
157
+ let response: Response;
158
+ try {
159
+ response = await fetch(url, {
160
+ method: 'POST',
161
+ headers: {
162
+ 'Content-Type': 'application/json',
163
+ Authorization: `Bearer ${apiToken}`,
164
+ },
165
+ body: JSON.stringify(body),
166
+ signal: controller.signal,
167
+ });
168
+ } finally {
169
+ clearTimeout(timer);
170
+ }
171
+
172
+ if (!response.ok) {
173
+ const detail = await response.text().catch(() => '');
174
+ throw new Error(`classifier endpoint ${response.status}: ${detail.slice(0, 300)}`);
175
+ }
176
+
177
+ const payload = JSON.parse(await response.text()) as {
178
+ choices?: { message?: { content?: string } }[];
179
+ };
180
+ const content = payload.choices?.[0]?.message?.content ?? '';
181
+ logger?.info('classifier responded', { content: content.slice(0, 300) });
182
+ return parseVerdict(content);
183
+ }
184
+
185
+ /** Robustly extracts {intentional, confidence, reasoning} from the model output. Fails safe to not-intentional. */
186
+ export function parseVerdict(content: string): ClassifierVerdict {
187
+ const text = content.trim();
188
+
189
+ // Preferred: the JSON object we asked for
190
+ const jsonMatch = text.match(/\{[\s\S]*?\}/);
191
+ if (jsonMatch) {
192
+ try {
193
+ const parsed = JSON.parse(jsonMatch[0]) as {
194
+ intentional?: boolean;
195
+ confidence?: number;
196
+ reasoning?: string;
197
+ };
198
+ if (typeof parsed.intentional === 'boolean') {
199
+ return {
200
+ intentional: parsed.intentional,
201
+ confidence: typeof parsed.confidence === 'number'
202
+ ? Math.min(1, Math.max(0, parsed.confidence))
203
+ : 0.5,
204
+ reasoning: typeof parsed.reasoning === 'string' && parsed.reasoning.trim()
205
+ ? parsed.reasoning.trim()
206
+ : 'No reasoning provided by classifier.',
207
+ };
208
+ }
209
+ } catch {
210
+ // fall through to fail-safe
211
+ }
212
+ }
213
+
214
+ // Unparseable — fail safe: treat as an unresolved regression
215
+ return {
216
+ intentional: false,
217
+ confidence: 0.5,
218
+ reasoning: 'Classifier output was unparseable; treat this change as a regression and inspect the aria diff manually.',
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Resolves the API token: process env first, then the project's
224
+ * `<projectDir>/.kintsugi/.env`, then the machine-global `~/.kintsugi/.env`
225
+ * (so one key works across all projects).
226
+ */
227
+ export function resolveApiToken(tokenEnvVar: string, projectDir?: string): string | undefined {
228
+ if (process.env[tokenEnvVar]) return process.env[tokenEnvVar];
229
+ if (projectDir) loadKintsugiEnv(projectDir);
230
+ loadEnvFile(globalKintsugiEnvPath());
231
+ return process.env[tokenEnvVar];
232
+ }
233
+
234
+ /** Path of the machine-global kintsugi env file — where the hosted API key lives. */
235
+ export function globalKintsugiEnvPath(): string {
236
+ return path.join(os.homedir(), '.kintsugi', '.env');
237
+ }
238
+
239
+ /** Loads KEY=VALUE pairs from <projectDir>/.kintsugi/.env into process.env (never overrides). */
240
+ export function loadKintsugiEnv(projectDir: string): void {
241
+ loadEnvFile(path.join(projectDir, '.kintsugi', '.env'));
242
+ }
243
+
244
+ function loadEnvFile(envPath: string): void {
245
+ let content: string;
246
+ try {
247
+ content = fs.readFileSync(envPath, 'utf-8');
248
+ } catch {
249
+ return;
250
+ }
251
+ for (const line of content.split('\n')) {
252
+ const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
253
+ if (!match) continue;
254
+ const key = match[1];
255
+ const value = match[2].replace(/^["']|["']$/g, '');
256
+ if (!(key in process.env)) process.env[key] = value;
257
+ }
258
+ }
@@ -0,0 +1,97 @@
1
+ import { test, describe } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ createSyntheticUiPng,
5
+ computePixelDiff,
6
+ compareScreenshots,
7
+ diffAriaSnapshots,
8
+ parseVerdict,
9
+ DiffResult
10
+ } from './index.js';
11
+
12
+ describe('Kintsugi Visual Comparator Engine', () => {
13
+ test('identical images return 0% diff and IDENTICAL status', async () => {
14
+ const img1 = createSyntheticUiPng();
15
+ const img2 = createSyntheticUiPng();
16
+
17
+ const pixelResult = await computePixelDiff(img1, img2);
18
+ assert.equal(pixelResult.diffPercent, 0);
19
+
20
+ const comp = await compareScreenshots(img1, img2);
21
+ assert.equal(comp.result, DiffResult.IDENTICAL);
22
+ assert.equal(comp.pixelDiffPercent, 0);
23
+ });
24
+
25
+ test('large pixel diff without a classifier escalates as CHANGED', async () => {
26
+ const baseline = createSyntheticUiPng({ removeButton: false });
27
+ const regressed = createSyntheticUiPng({ removeButton: true });
28
+
29
+ const pixelResult = await computePixelDiff(baseline, regressed);
30
+ assert.ok(pixelResult.diffPercent > 0.5, 'Pixel diff should exceed 0.5%');
31
+
32
+ const comp = await compareScreenshots(baseline, regressed);
33
+ assert.equal(comp.result, DiffResult.CHANGED, 'Missing button should escalate as CHANGED');
34
+ });
35
+
36
+ test('sub-threshold noise is classified as MINOR', async () => {
37
+ const baseline = createSyntheticUiPng();
38
+ const withNoise = createSyntheticUiPng({ addNoise: true, noiseAmount: 0.0002 });
39
+
40
+ const comp = await compareScreenshots(baseline, withNoise);
41
+ assert.ok(
42
+ comp.result === DiffResult.IDENTICAL || comp.result === DiffResult.MINOR,
43
+ 'Subpixel noise should be classified as IDENTICAL or MINOR, not BROKEN'
44
+ );
45
+ });
46
+
47
+ test('aria snapshot diff reports removed and added lines', () => {
48
+ const baseline = [
49
+ '- button "Complete Purchase ($89.00)"',
50
+ '- text "Free shipping"',
51
+ ].join('\n');
52
+ const current = [
53
+ '- text "Free shipping"',
54
+ '- textbox "Email"',
55
+ ].join('\n');
56
+
57
+ const diff = diffAriaSnapshots(baseline, current);
58
+ assert.ok(diff.includes('REMOVED: - button "Complete Purchase ($89.00)"'), 'Removed button should appear as removed');
59
+ assert.ok(diff.includes('textbox "Email"') && diff.includes('ADDED:'), 'Added textbox should appear as added');
60
+ assert.ok(!diff.includes('no line-level differences'));
61
+ });
62
+
63
+ test('aria snapshot diff of identical snapshots reports no differences', () => {
64
+ const snap = '- button "Buy"\n- text "Hello"';
65
+ assert.ok(diffAriaSnapshots(snap, snap).includes('no line-level differences'));
66
+ });
67
+ });
68
+
69
+ describe('Classifier verdict parsing', () => {
70
+ test('parses the JSON verdict format', () => {
71
+ const v = parseVerdict('{"intentional": true, "confidence": 0.92, "reasoning": "Matches the requested recolor."}');
72
+ assert.equal(v.intentional, true);
73
+ assert.equal(v.confidence, 0.92);
74
+ assert.ok(v.reasoning.includes('recolor'));
75
+ });
76
+
77
+ test('parses JSON embedded in chatter', () => {
78
+ const v = parseVerdict('Sure! {"intentional": false, "confidence": 0.85, "reasoning": "The checkout button was removed; re-add it."} hope that helps');
79
+ assert.equal(v.intentional, false);
80
+ assert.equal(v.confidence, 0.85);
81
+ assert.ok(v.reasoning.includes('re-add'));
82
+ });
83
+
84
+ test('defaults missing confidence and reasoning conservatively', () => {
85
+ const v = parseVerdict('{"intentional": true}');
86
+ assert.equal(v.intentional, true);
87
+ assert.equal(v.confidence, 0.5);
88
+ assert.ok(v.reasoning.length > 0);
89
+ });
90
+
91
+ test('fails safe to not-intentional on unparseable output', () => {
92
+ const v = parseVerdict('I cannot tell what changed here, sorry.');
93
+ assert.equal(v.intentional, false);
94
+ assert.ok(v.confidence <= 0.7);
95
+ assert.ok(v.reasoning.length > 0);
96
+ });
97
+ });
@@ -0,0 +1,217 @@
1
+ import pixelmatch from 'pixelmatch';
2
+ import { PNG } from 'pngjs';
3
+ import { promises as fs } from 'fs';
4
+ import path from 'path';
5
+ import { DiffResult, type StepComparisonResult, type StepClassification, type FlowRecording, type CaptureResult, type ComparisonResult, type ClassifierConfig } from './types.js';
6
+ import { classifyChange, FlowLimitError } from './classifier.js';
7
+ import { flowFingerprint } from './flowid.js';
8
+ import { diffAriaSnapshots } from './domdiff.js';
9
+
10
+ /** Everything the comparator needs to consult the classifier for large diffs */
11
+ export interface ClassifierContext {
12
+ config: ClassifierConfig;
13
+ apiToken: string;
14
+ /** What the agent was asked to do in the latest turn (sharpens intentional-vs-regression) */
15
+ context?: string;
16
+ logger?: { info: (msg: string, data?: unknown) => void; warn: (msg: string, data?: unknown) => void };
17
+ }
18
+
19
+ export interface CompareOptions {
20
+ outputDir?: string;
21
+ classifier?: ClassifierContext;
22
+ }
23
+
24
+ export async function computePixelDiff(img1: Buffer, img2: Buffer): Promise<{ diffPercent: number; diffImage: Buffer }> {
25
+ const png1 = PNG.sync.read(img1);
26
+ const png2 = PNG.sync.read(img2);
27
+
28
+ if (png1.width !== png2.width || png1.height !== png2.height) {
29
+ return { diffPercent: 100, diffImage: Buffer.alloc(0) };
30
+ }
31
+
32
+ const { width, height } = png1;
33
+ const diff = new PNG({ width, height });
34
+
35
+ const numDiffPixels = pixelmatch(png1.data, png2.data, diff.data, width, height, { threshold: 0.1 });
36
+ const diffPercent = (numDiffPixels / (width * height)) * 100;
37
+ const diffImage = PNG.sync.write(diff);
38
+
39
+ return { diffPercent, diffImage };
40
+ }
41
+
42
+ /**
43
+ * Pixel-only verdict for one screenshot pair. Diff percentages above the
44
+ * classifier threshold are returned as a tentative CHANGED — compareFlow
45
+ * refines those with the classifier when one is configured.
46
+ */
47
+ export async function compareScreenshots(baseline: Buffer, current: Buffer, stepIndex: number = 0, options?: CompareOptions): Promise<StepComparisonResult> {
48
+ const outputDir = options?.outputDir;
49
+ const { diffPercent, diffImage } = await computePixelDiff(baseline, current);
50
+
51
+ let result: DiffResult;
52
+ if (diffPercent <= 0.05) {
53
+ result = DiffResult.IDENTICAL;
54
+ } else if (diffPercent > (options?.classifier?.config.pixelDiffThreshold ?? 0.5)) {
55
+ result = DiffResult.CHANGED;
56
+ } else {
57
+ // Sub-threshold change: benign (anti-aliasing, subpixel shifts, dynamic content)
58
+ result = DiffResult.MINOR;
59
+ }
60
+
61
+ let diffImagePath;
62
+ if (outputDir && result !== DiffResult.IDENTICAL) {
63
+ diffImagePath = path.join(outputDir, `diff_${stepIndex}.png`);
64
+ await fs.writeFile(diffImagePath, diffImage);
65
+ }
66
+
67
+ return {
68
+ step: stepIndex,
69
+ pixelDiffPercent: diffPercent,
70
+ ssimScore: 1, // deprecated field, kept for output compatibility
71
+ result,
72
+ diffImagePath,
73
+ };
74
+ }
75
+
76
+ export async function compareFlow(baseline: FlowRecording, current: CaptureResult, options?: CompareOptions): Promise<ComparisonResult> {
77
+ const stepResults: StepComparisonResult[] = [];
78
+ const len = Math.min(baseline.screenshotPaths.length, current.screenshots.length);
79
+
80
+ // Pass 1: pixel diff every step (cheap, deterministic)
81
+ for (let i = 0; i < len; i++) {
82
+ const baselineBuffer = await fs.readFile(baseline.screenshotPaths[i]);
83
+ const currentBuffer = current.screenshots[i];
84
+ stepResults.push(await compareScreenshots(baselineBuffer, currentBuffer, i, options));
85
+ }
86
+
87
+ // Pass 2: classify every oversized step — all classifier calls in parallel
88
+ const threshold = options?.classifier?.config.pixelDiffThreshold ?? 0.5;
89
+ const candidates = stepResults.filter(s => s.pixelDiffPercent > threshold);
90
+ const classifications = options?.classifier
91
+ ? await classifySteps(candidates, baseline, current, options.classifier)
92
+ : [];
93
+ for (const c of classifications) {
94
+ const step = stepResults.find(s => s.step === c.step);
95
+ if (step) {
96
+ step.result = c.intentional && c.confidence > (options!.classifier!.config.confidenceThreshold)
97
+ ? DiffResult.INTENTIONAL
98
+ : DiffResult.CHANGED;
99
+ }
100
+ }
101
+
102
+ // Aggregate the worst verdict across steps
103
+ let worstResult = DiffResult.IDENTICAL;
104
+ let failedStep: number | undefined;
105
+ for (const comp of stepResults) {
106
+ if (comp.result === DiffResult.CHANGED || comp.result === DiffResult.BROKEN || comp.result === DiffResult.ERROR) {
107
+ if (worstResult !== DiffResult.CHANGED && worstResult !== DiffResult.BROKEN && worstResult !== DiffResult.ERROR) {
108
+ worstResult = comp.result;
109
+ failedStep = comp.step;
110
+ }
111
+ } else if (comp.result === DiffResult.MINOR && worstResult === DiffResult.IDENTICAL) {
112
+ worstResult = DiffResult.MINOR;
113
+ } else if (comp.result === DiffResult.INTENTIONAL && worstResult === DiffResult.IDENTICAL) {
114
+ worstResult = DiffResult.INTENTIONAL;
115
+ }
116
+ }
117
+
118
+ if (current.errors.length > 0) {
119
+ worstResult = DiffResult.BROKEN;
120
+ failedStep = current.completedSteps;
121
+ }
122
+
123
+ const worstStep = stepResults.find(s => s.step === failedStep) || stepResults[0];
124
+
125
+ // Surface the classifier's explanation in the escalation panel (semanticAnalysis
126
+ // is what the VS Code split-view displays).
127
+ const worstRegression = classifications.find(c => !c.intentional);
128
+
129
+ return {
130
+ result: worstResult,
131
+ pixelDiffPercent: worstStep?.pixelDiffPercent || 0,
132
+ ssimScore: worstStep?.ssimScore || 1,
133
+ diffImagePath: worstStep?.diffImagePath,
134
+ semanticAnalysis: worstRegression?.reasoning,
135
+ failedStep,
136
+ stepResults,
137
+ classifications: classifications.length > 0 ? classifications : undefined,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Classifies all oversized steps in parallel. Fail-safe: a missing aria
143
+ * snapshot or any classifier error yields a not-intentional classification
144
+ * so the step still escalates.
145
+ */
146
+ async function classifySteps(
147
+ candidates: StepComparisonResult[],
148
+ baseline: FlowRecording,
149
+ current: CaptureResult,
150
+ classifier: ClassifierContext
151
+ ): Promise<StepClassification[]> {
152
+ return Promise.all(candidates.map(async (candidate): Promise<StepClassification> => {
153
+ const i = candidate.step;
154
+ const currentAria = current.ariaSnapshots?.[i];
155
+ let baselineAria: string | undefined;
156
+ if (baseline.ariaSnapshotPaths?.[i]) {
157
+ try {
158
+ baselineAria = await fs.readFile(baseline.ariaSnapshotPaths[i], 'utf-8');
159
+ } catch {
160
+ baselineAria = undefined;
161
+ }
162
+ }
163
+
164
+ const base: StepClassification = {
165
+ step: i,
166
+ intentional: false,
167
+ confidence: 0.5,
168
+ reasoning: 'aria snapshots unavailable for this pair — inspect the diff image manually',
169
+ ariaDiff: '(aria snapshots unavailable — re-record the baseline to enable classification)',
170
+ oldImagePath: baseline.screenshotPaths[i],
171
+ newImagePath: current.screenshotPaths[i],
172
+ };
173
+
174
+ if (baselineAria === undefined || currentAria === undefined) {
175
+ classifier.logger?.warn('aria snapshots unavailable for step — escalating without classification', { step: i });
176
+ return base;
177
+ }
178
+
179
+ const ariaDiff = diffAriaSnapshots(baselineAria, currentAria);
180
+ try {
181
+ const verdict = await classifyChange({
182
+ baselineAria,
183
+ currentAria,
184
+ context: classifier.context,
185
+ config: classifier.config,
186
+ apiToken: classifier.apiToken,
187
+ meta: {
188
+ flowName: baseline.metadata.name,
189
+ flowHash: flowFingerprint(baseline.metadata.name, baseline.steps),
190
+ step: i,
191
+ },
192
+ logger: classifier.logger,
193
+ });
194
+ classifier.logger?.info('classification', { step: i, ...verdict });
195
+ return { ...base, ...verdict, ariaDiff };
196
+ } catch (err) {
197
+ if (err instanceof FlowLimitError) {
198
+ classifier.logger?.warn('flow limit reached — step escalates pixel-diff only', {
199
+ step: i,
200
+ kind: err.kind,
201
+ flowsUsed: err.flowsUsed,
202
+ flowLimit: err.flowLimit,
203
+ });
204
+ const where = err.kind === 'monthly_cap_reached'
205
+ ? 'monthly classification cap reached on the free plan'
206
+ : `free plan flow limit reached (${err.flowsUsed ?? '?'}/${err.flowLimit ?? '?'} flows guarded)`;
207
+ return {
208
+ ...base,
209
+ ariaDiff,
210
+ reasoning: `${where} — this flow degrades to pixel-diff only and is treated as a change. Upgrade at ${err.upgradeUrl ?? 'https://kintsugi.dev/upgrade'} to guard it with the classifier.`,
211
+ };
212
+ }
213
+ classifier.logger?.warn('classifier failed — escalating step', { step: i, error: (err as Error).message });
214
+ return { ...base, ariaDiff, reasoning: `classifier failed (${(err as Error).message}) — inspect the aria diff manually` };
215
+ }
216
+ }));
217
+ }
@@ -0,0 +1,80 @@
1
+ import { test, describe } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import fs from 'node:fs/promises';
6
+ import {
7
+ getDefaultConfig,
8
+ saveConfig,
9
+ loadConfig,
10
+ ensureKintsugiDir,
11
+ formatAgentFeedback,
12
+ formatUserSummary,
13
+ DiffResult,
14
+ type ComparisonResult
15
+ } from './index.js';
16
+
17
+ describe('Kintsugi Config & Feedback Formatter', () => {
18
+ test('generates valid default config', () => {
19
+ const config = getDefaultConfig();
20
+ assert.equal(config.version, 1);
21
+ assert.equal(config.viewport.width, 1280);
22
+ assert.equal(config.viewport.height, 720);
23
+ assert.ok(config.thresholds.pixelDiffPercent > 0);
24
+ });
25
+
26
+ test('saves and loads config from disk', async () => {
27
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'kintsugi-test-'));
28
+ try {
29
+ await ensureKintsugiDir(tmpDir);
30
+ const config = getDefaultConfig();
31
+ config.devServerUrl = 'http://localhost:5173';
32
+ await saveConfig(tmpDir, config);
33
+
34
+ const loaded = await loadConfig(tmpDir);
35
+ assert.equal(loaded.devServerUrl, 'http://localhost:5173');
36
+ } finally {
37
+ await fs.rm(tmpDir, { recursive: true, force: true });
38
+ }
39
+ });
40
+
41
+ test('formats actionable agent feedback on regression', () => {
42
+ const mockResult: ComparisonResult = {
43
+ result: DiffResult.BROKEN,
44
+ pixelDiffPercent: 3.42,
45
+ ssimScore: 1,
46
+ failedStep: 2,
47
+ stepResults: [
48
+ {
49
+ step: 2,
50
+ pixelDiffPercent: 3.42,
51
+ ssimScore: 1,
52
+ result: DiffResult.BROKEN,
53
+ }
54
+ ],
55
+ classifications: [
56
+ {
57
+ step: 2,
58
+ intentional: false,
59
+ confidence: 0.9,
60
+ reasoning: 'The checkout button was removed; re-add it to the order summary.',
61
+ ariaDiff: 'REMOVED: - button "Complete Purchase ($89.00)"',
62
+ oldImagePath: '/baseline/step_2.png',
63
+ newImagePath: '/tmp/step_2.png',
64
+ }
65
+ ]
66
+ };
67
+
68
+ const feedback = formatAgentFeedback(mockResult, 'checkout-flow');
69
+ assert.ok(feedback.includes('checkout-flow is broken'), 'Should state the flow is broken');
70
+ assert.ok(feedback.includes('aria diffs:'), 'Should include aria diffs');
71
+ assert.ok(feedback.includes('REMOVED: - button'), 'Should include the aria diff content');
72
+ assert.ok(feedback.includes('image pairs (old image | new image):'), 'Should include image pairs');
73
+ assert.ok(feedback.includes('/baseline/step_2.png | /tmp/step_2.png'), 'Should include both image paths');
74
+ assert.ok(feedback.includes('suggested fixes:'), 'Should include suggested fixes');
75
+ assert.ok(feedback.includes('re-add it to the order summary'), 'Should include the classifier fix');
76
+
77
+ const summary = formatUserSummary(mockResult, 'checkout-flow');
78
+ assert.ok(summary.includes('checkout-flow'), 'Summary should include flow name');
79
+ });
80
+ });