Skip to content

Commit 60d96c8

Browse files
committed
refactor: resolve all 24 CRITICAL cognitive complexity issues (S3776) — 0 criticals remaining
SessionReporter.ts (57→orchestrated): 10 helper methods extracted VisionGate.ts (54+17→all<15): BFS flood-fill + pixel computation helpers GhostVisualizer.ts (34+32+19→all<15): heatmap grid, char patterns, thick dot SemanticMapper.ts (32→helpers): role lookup + selector building ActionExecutor.ts (30+18+16+16→all<15): guard clauses + early returns PageStateCollector.ts (23+21+20+16→all<15): collection functions decomposed FingerprintGenerator.ts (23+16→helpers): validation + weighted pick BrowserManager.ts (19→helpers): resolve/attach/try launch helpers InteractionReliability.ts (18→helpers): node matching extracted AXTreeDiffer.ts (17→helpers): change detection extracted ArtifactBuilder.ts (17→helpers): frame formatting extracted SessionSnapshot.ts (17→helpers): storage restoration + type fix PerceptionStack.ts (16→helpers): bug/screenshot layers RulesEngine.ts (16→helpers): overlap/clipping detection Fix: SessionSnapshot.ts type annotation (string[][] → Array<[string,string]>) Bump to v4.1.0. 917/917 tests passing.
1 parent dcaf9db commit 60d96c8

18 files changed

Lines changed: 1062 additions & 929 deletions

AGENTS.md

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,18 +80,14 @@ radar tasks # Show current radar tasks
8080

8181
SonarQube dashboard (local): http://localhost:7372/dashboard?id=talox
8282

83-
### Current Status (2026-04-15) — v4.0.2
83+
### Current Status (2026-04-15) — v4.1.0
8484

85-
- **318 total issues** (318 src, 0 test blockers)
85+
- **~294 total issues** (all src, 0 test blockers)
8686
- Quality gate: **OK**
8787
- **0 BLOCKER** — all test assertions fixed
88-
- **24 CRITICAL** — cognitive complexity >15 (S3776)
89-
- **62 MAJOR** — code smells
90-
- **232 MINOR** — code smells
91-
- Worst complexity hotspots:
92-
- `SessionReporter.ts:175` (57), `VisionGate.ts:206` (54), `GhostVisualizer.ts:240` (34)
93-
- `SemanticMapper.ts:241` (32), `ActionExecutor.ts:228` (30), `GhostVisualizer.ts:361` (32)
94-
- `FingerprintGenerator.ts:603` (23), `PageStateCollector.ts:417` (23)
88+
- **0 CRITICAL** — all cognitive complexity refactored below 15
89+
- **~62 MAJOR** — code smells
90+
- **~232 MINOR** — code smells
9591

9692
## Build & Test
9793

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,30 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [4.1.0] - 2026-04-15
8+
9+
### Changed
10+
11+
- **24 CRITICAL cognitive complexity issues resolved** (SonarQube S3776) — all functions now under complexity 15:
12+
- `SessionReporter.ts` (57→orchestrated helpers): `toMarkdown()` split into 10 focused methods
13+
- `VisionGate.ts` (54→9, 17→8): `mergeAdjacentRegions()` extracted to BFS flood-fill helpers, `generateDiffHeatmap()` extracted pixel computation
14+
- `GhostVisualizer.ts` (34→helpers, 32→helpers, 19→helpers): heatmap grid, character patterns, thick dot rendering extracted
15+
- `SemanticMapper.ts` (32→helpers): role lookup and selector building extracted
16+
- `ActionExecutor.ts` (30, 18, 16, 16 → all <15): 4 functions refactored with guard clauses and early returns
17+
- `PageStateCollector.ts` (23, 21, 20, 16 → all <15): 4 collection functions decomposed
18+
- `FingerprintGenerator.ts` (23→helpers, 16→helpers): validation and weighted pick decomposed
19+
- `BrowserManager.ts` (19→helpers): launch logic split into resolve/attach/try helpers
20+
- `InteractionReliability.ts` (18→helpers): node matching extracted
21+
- `AXTreeDiffer.ts` (17→helpers): change detection extracted
22+
- `ArtifactBuilder.ts` (17→helpers): frame formatting extracted
23+
- `SessionSnapshot.ts` (17→helpers): storage restoration extracted, type annotation fix
24+
- `PerceptionStack.ts` (16→helpers): bug/screenshot layers extracted
25+
- `RulesEngine.ts` (16→helpers): overlap/clipping detection extracted
26+
27+
### Fixed
28+
29+
- `SessionSnapshot.ts`: Fixed incorrect type annotation in `restoreStorage()` callback (`string[][]``Array<[string, string]>`)
30+
731
## [4.0.2] - 2026-04-15
832

933
### Fixed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
<img src="https://img.shields.io/badge/Playwright-Chromium-45ba4b?style=flat-square&logo=playwright&logoColor=white" alt="Playwright" />
3030
<img src="https://img.shields.io/badge/Node.js-18+-339933?style=flat-square&logo=nodedotjs&logoColor=white" alt="Node.js" />
3131
<img src="https://img.shields.io/badge/License-AGPL--3.0--only-0d9488?style=flat-square&logo=opensourceinitiative&logoColor=white" alt="AGPL-3.0-only" />
32-
<img src="https://img.shields.io/badge/version-4.0.2-0d9488?style=flat-square" alt="version" />
32+
<img src="https://img.shields.io/badge/version-4.1.0-0d9488?style=flat-square" alt="version" />
3333
</p>
3434

3535
<p align="center">

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "talox",
3-
"version": "4.0.2",
3+
"version": "4.1.0",
44
"description": "Local browser runtime for agents. Persistent profiles, deep observability, structured state contracts, and resilient interaction for real-world web UIs.",
55
"type": "module",
66
"main": "dist/index.js",

src/core/AXTreeDiffer.ts

Lines changed: 52 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,57 @@ export class AXTreeDiffer {
3636
}
3737
return map;
3838
}
39+
private detectNodeChanges(beforeNode: TaloxNode, afterNode: TaloxNode): AXTreeChange[] {
40+
const changes: AXTreeChange[] = [];
41+
const beforePos = beforeNode.boundingBox;
42+
const afterPos = afterNode.boundingBox;
43+
const distance = this.computeDistance({ x: beforePos.x, y: beforePos.y }, { x: afterPos.x, y: afterPos.y });
44+
45+
if (distance > 30) {
46+
const direction = this.getMovementDirection(beforePos, afterPos);
47+
changes.push({
48+
type: "moved",
49+
nodeId: afterNode.id,
50+
role: afterNode.role,
51+
name: afterNode.name,
52+
description: `"${afterNode.name}" moved ${direction}`,
53+
previousPosition: beforePos,
54+
currentPosition: afterPos,
55+
});
56+
}
57+
58+
if (beforeNode.name !== afterNode.name) {
59+
changes.push({
60+
type: "changed",
61+
nodeId: afterNode.id,
62+
role: afterNode.role,
63+
name: afterNode.name,
64+
description: `Text in "${afterNode.role}" changed from "${beforeNode.name}" to "${afterNode.name}"`,
65+
previousValue: beforeNode.name,
66+
currentValue: afterNode.name,
67+
});
68+
}
69+
70+
if (
71+
beforeNode.attributes &&
72+
afterNode.attributes &&
73+
JSON.stringify(beforeNode.attributes) !== JSON.stringify(afterNode.attributes)
74+
) {
75+
const changedAttrs = this.getChangedAttributes(beforeNode.attributes, afterNode.attributes);
76+
if (changedAttrs.length > 0) {
77+
changes.push({
78+
type: "changed",
79+
nodeId: afterNode.id,
80+
role: afterNode.role,
81+
name: afterNode.name,
82+
description: `Attributes of "${afterNode.name}" changed: ${changedAttrs.join(", ")}`,
83+
});
84+
}
85+
}
86+
87+
return changes;
88+
}
89+
3990
diff(before: TaloxPageState, after: TaloxPageState): AXTreeDiffResult {
4091
const changes: AXTreeChange[] = [];
4192
const beforeMap = this.getNodeMap(before.nodes);
@@ -58,52 +109,7 @@ export class AXTreeDiffer {
58109
}
59110

60111
matchedIds.add(afterNode.id);
61-
62-
const beforePos = beforeNode.boundingBox;
63-
const afterPos = afterNode.boundingBox;
64-
const distance = this.computeDistance({ x: beforePos.x, y: beforePos.y }, { x: afterPos.x, y: afterPos.y });
65-
66-
if (distance > 30) {
67-
const direction = this.getMovementDirection(beforePos, afterPos);
68-
changes.push({
69-
type: "moved",
70-
nodeId: afterNode.id,
71-
role: afterNode.role,
72-
name: afterNode.name,
73-
description: `"${afterNode.name}" moved ${direction}`,
74-
previousPosition: beforePos,
75-
currentPosition: afterPos,
76-
});
77-
}
78-
79-
if (beforeNode.name !== afterNode.name) {
80-
changes.push({
81-
type: "changed",
82-
nodeId: afterNode.id,
83-
role: afterNode.role,
84-
name: afterNode.name,
85-
description: `Text in "${afterNode.role}" changed from "${beforeNode.name}" to "${afterNode.name}"`,
86-
previousValue: beforeNode.name,
87-
currentValue: afterNode.name,
88-
});
89-
}
90-
91-
if (
92-
beforeNode.attributes &&
93-
afterNode.attributes &&
94-
JSON.stringify(beforeNode.attributes) !== JSON.stringify(afterNode.attributes)
95-
) {
96-
const changedAttrs = this.getChangedAttributes(beforeNode.attributes, afterNode.attributes);
97-
if (changedAttrs.length > 0) {
98-
changes.push({
99-
type: "changed",
100-
nodeId: afterNode.id,
101-
role: afterNode.role,
102-
name: afterNode.name,
103-
description: `Attributes of "${afterNode.name}" changed: ${changedAttrs.join(", ")}`,
104-
});
105-
}
106-
}
112+
changes.push(...this.detectNodeChanges(beforeNode, afterNode));
107113
}
108114

109115
for (const beforeNode of before.nodes) {

src/core/ArtifactBuilder.ts

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,31 @@ export class ArtifactBuilder {
202202
return prettyPrint ? JSON.stringify(exportData, null, 2) : JSON.stringify(exportData);
203203
}
204204

205+
private formatFrameDetails(frame: ActionFrame, lines: string[]): void {
206+
if (Object.keys(frame.details).length > 0) {
207+
lines.push(" Details:");
208+
for (const [key, value] of Object.entries(frame.details)) {
209+
lines.push(` ${key}: ${JSON.stringify(value)}`);
210+
}
211+
}
212+
}
213+
214+
private formatFrameVisualContext(vc: VisualContext, lines: string[]): void {
215+
const posParts: string[] = [];
216+
if (vc.mouseX !== undefined && vc.mouseY !== undefined) {
217+
posParts.push(`Mouse: (${vc.mouseX}, ${vc.mouseY})`);
218+
}
219+
if (vc.viewportWidth !== undefined && vc.viewportHeight !== undefined) {
220+
posParts.push(`Viewport: ${vc.viewportWidth}x${vc.viewportHeight}`);
221+
}
222+
if (vc.scrollPosition !== undefined) {
223+
posParts.push(`Scroll: ${vc.scrollPosition}`);
224+
}
225+
if (posParts.length > 0) {
226+
lines.push(` Visual: ${posParts.join(" | ")}`);
227+
}
228+
}
229+
205230
exportAsText(options: ExportOptions = {}): string {
206231
const { includeVisualContext = true, includePayloads = true } = options;
207232
const frames = this.toActionFrames();
@@ -225,28 +250,12 @@ export class ArtifactBuilder {
225250
lines.push(`[Frame ${frame.frameIndex}] ${timeStr}${durationStr} | ${frame.action}`);
226251
lines.push(` Type: ${frame.type}`);
227252

228-
if (includePayloads && Object.keys(frame.details).length > 0) {
229-
lines.push(" Details:");
230-
for (const [key, value] of Object.entries(frame.details)) {
231-
lines.push(` ${key}: ${JSON.stringify(value)}`);
232-
}
253+
if (includePayloads) {
254+
this.formatFrameDetails(frame, lines);
233255
}
234256

235257
if (includeVisualContext && frame.visualContext) {
236-
const vc = frame.visualContext;
237-
const posParts: string[] = [];
238-
if (vc.mouseX !== undefined && vc.mouseY !== undefined) {
239-
posParts.push(`Mouse: (${vc.mouseX}, ${vc.mouseY})`);
240-
}
241-
if (vc.viewportWidth !== undefined && vc.viewportHeight !== undefined) {
242-
posParts.push(`Viewport: ${vc.viewportWidth}x${vc.viewportHeight}`);
243-
}
244-
if (vc.scrollPosition !== undefined) {
245-
posParts.push(`Scroll: ${vc.scrollPosition}`);
246-
}
247-
if (posParts.length > 0) {
248-
lines.push(` Visual: ${posParts.join(" | ")}`);
249-
}
258+
this.formatFrameVisualContext(frame.visualContext, lines);
250259
}
251260

252261
lines.push("");

src/core/BrowserManager.ts

Lines changed: 48 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -292,41 +292,27 @@ export class BrowserManager {
292292
this.config = { ...this.config, ...config };
293293
}
294294

295-
async launch(
296-
profile: TaloxProfile,
297-
_headed?: boolean,
298-
browserType?: BrowserType,
299-
extraOptions?: any,
300-
): Promise<BrowserContext> {
301-
let actualBrowserType = browserType || this.config.browser.preferred;
302-
303-
// Skip autoDetect on macOS - just use chrome channel directly
304-
if (process.platform !== "darwin") {
305-
if (this.config.browser.autoDetect) {
306-
actualBrowserType = await this.autoDetectBrowser();
307-
}
308-
}
295+
private attachCloseHandler(ctx: BrowserContext): void {
296+
ctx.on("close", () => {
297+
this.contexts.delete(ctx);
298+
if (this.context === ctx) this.context = null;
299+
});
300+
}
309301

310-
// Use Patchright for stealth mode (adaptive behavior)
311-
const isAdaptive = false;
312-
313-
let launcher: any;
314-
if (isAdaptive) {
315-
// Patchright: patched Playwright driver that fixes CDP Runtime.enable leak,
316-
// removes --enable-automation flag, and patches other detection vectors at
317-
// the driver level — no JS injection needed for these signals.
318-
// Only Chromium is supported by Patchright; other browser types fall back to standard.
319-
launcher =
320-
actualBrowserType === "chromium" ? patchrightChromium : ({ firefox, webkit }[actualBrowserType] ?? chromium);
321-
} else {
322-
launcher = {
323-
chromium: chromium,
324-
firefox: firefox,
325-
webkit: webkit,
326-
}[actualBrowserType];
302+
private resolveLauncher(actualBrowserType: BrowserType, _isAdaptive: boolean): any {
303+
// Patchright: patched Playwright driver that fixes CDP Runtime.enable leak,
304+
// removes --enable-automation flag, and patches other detection vectors at
305+
// the driver level — no JS injection needed for these signals.
306+
// Only Chromium is supported by Patchright; other browser types fall back to standard.
307+
if (_isAdaptive) {
308+
return actualBrowserType === "chromium"
309+
? patchrightChromium
310+
: ({ firefox, webkit }[actualBrowserType] ?? chromium);
327311
}
312+
return { chromium, firefox, webkit }[actualBrowserType];
313+
}
328314

329-
// Resolve effective headless value — extraOptions can override (e.g. observe mode forces false)
315+
private buildLaunchOptions(extraOptions: any): any {
330316
const effectiveHeadless =
331317
extraOptions?.headless !== undefined ? extraOptions.headless : this.config.browser.headless;
332318

@@ -342,37 +328,49 @@ export class BrowserManager {
342328
...extraOptions,
343329
};
344330

345-
// Proxy support
346331
if (this.config.browser.proxy) {
347332
launchOptions.proxy = this.config.browser.proxy;
348333
}
349334

335+
return launchOptions;
336+
}
337+
338+
private async tryLaunchContext(launcher: any, userDataDir: string, launchOptions: any): Promise<BrowserContext> {
339+
const ctx = (await launcher.launchPersistentContext(userDataDir, launchOptions)) as BrowserContext;
340+
this.contexts.add(ctx);
341+
this.attachCloseHandler(ctx);
342+
return ctx;
343+
}
344+
345+
async launch(
346+
profile: TaloxProfile,
347+
_headed?: boolean,
348+
browserType?: BrowserType,
349+
extraOptions?: any,
350+
): Promise<BrowserContext> {
351+
let actualBrowserType = browserType || this.config.browser.preferred;
352+
353+
// Skip autoDetect on macOS - just use chrome channel directly
354+
if (process.platform !== "darwin") {
355+
if (this.config.browser.autoDetect) {
356+
actualBrowserType = await this.autoDetectBrowser();
357+
}
358+
}
359+
360+
const launcher = this.resolveLauncher(actualBrowserType, false);
361+
const launchOptions = this.buildLaunchOptions(extraOptions);
362+
350363
// Do not force chrome channel, as it conflicts if the user has Chrome open.
351364
// Use Playwright's bundled Chromium instead.
352365

353366
try {
354-
this.context = (await launcher.launchPersistentContext(profile.userDataDir, launchOptions)) as BrowserContext;
355-
this.contexts.add(this.context!);
356-
357-
// Remove from registry when closed
358-
const ctx = this.context!;
359-
ctx.on("close", () => {
360-
this.contexts.delete(ctx);
361-
if (this.context === ctx) this.context = null;
362-
});
367+
this.context = await this.tryLaunchContext(launcher, profile.userDataDir, launchOptions);
363368
} catch (error: any) {
364369
// Fallback: try without channel if it failed
365370
if (launchOptions.channel === "chrome") {
366371
delete launchOptions.channel;
367372
try {
368-
this.context = (await launcher.launchPersistentContext(profile.userDataDir, launchOptions)) as BrowserContext;
369-
this.contexts.add(this.context!);
370-
// Attach close handler for fallback context too
371-
const fallbackCtx = this.context!;
372-
fallbackCtx.on("close", () => {
373-
this.contexts.delete(fallbackCtx);
374-
if (this.context === fallbackCtx) this.context = null;
375-
});
373+
this.context = await this.tryLaunchContext(launcher, profile.userDataDir, launchOptions);
376374
} catch (fallbackError: any) {
377375
console.error("[DEBUG] Playwright Headed Error:", fallbackError);
378376
throw new Error(`Browser launch failed for ${actualBrowserType}. Please ensure Chrome is installed.`);

0 commit comments

Comments
 (0)