hauntview 0.0.0-stage → 0.0.6

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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +358 -2
  3. package/VERSION +1 -0
  4. package/dist/cli.js +8184 -0
  5. package/package.json +120 -4
  6. package/scripts/postinstall.mjs +47 -0
  7. package/src/core/browser-launch.ts +60 -0
  8. package/src/core/browser-pool.ts +267 -0
  9. package/src/core/concurrency-resolver.ts +154 -0
  10. package/src/core/finding-classifier.ts +346 -0
  11. package/src/core/index.ts +8 -0
  12. package/src/core/profile-manager.ts +499 -0
  13. package/src/core/report-store.ts +117 -0
  14. package/src/core/screenshot-manager.ts +312 -0
  15. package/src/core/system-capability.ts +161 -0
  16. package/src/core/visual-diff.ts +230 -0
  17. package/src/engine.ts +473 -0
  18. package/src/profiles/accessibility-cognitive.ts +261 -0
  19. package/src/profiles/accessibility-keyboard.ts +225 -0
  20. package/src/profiles/accessibility-screenreader.ts +243 -0
  21. package/src/profiles/accessibility-visual.ts +257 -0
  22. package/src/profiles/broken-links.ts +261 -0
  23. package/src/profiles/browser-edge.ts +192 -0
  24. package/src/profiles/browser-firefox.ts +254 -0
  25. package/src/profiles/browser-legacy.ts +213 -0
  26. package/src/profiles/console-errors.ts +192 -0
  27. package/src/profiles/desktop-4k.ts +273 -0
  28. package/src/profiles/desktop.ts +192 -0
  29. package/src/profiles/font-failure.ts +148 -0
  30. package/src/profiles/form-resilience.ts +285 -0
  31. package/src/profiles/i18n-cjk.ts +218 -0
  32. package/src/profiles/i18n-mixed.ts +255 -0
  33. package/src/profiles/i18n-rtl.ts +236 -0
  34. package/src/profiles/image-failure.ts +178 -0
  35. package/src/profiles/index.ts +42 -0
  36. package/src/profiles/js-disabled.ts +179 -0
  37. package/src/profiles/mobile.ts +175 -0
  38. package/src/profiles/network-resilience.ts +208 -0
  39. package/src/profiles/offline.ts +165 -0
  40. package/src/profiles/performance.ts +244 -0
  41. package/src/profiles/reduced-motion.ts +144 -0
  42. package/src/profiles/security-hygiene.ts +206 -0
  43. package/src/profiles/tablet.ts +176 -0
  44. package/src/reports/factory.ts +56 -0
  45. package/src/reports/html-generator.ts +1105 -0
  46. package/src/reports/index.ts +9 -0
  47. package/src/reports/markdown-generator.ts +349 -0
  48. package/src/reports/types.ts +34 -0
  49. package/src/reports/zip-generator.ts +85 -0
  50. package/src/types/contracts.ts +238 -0
  51. package/src/types/finding-utils.ts +121 -0
  52. package/src/types/index.ts +8 -0
  53. package/src/types/profiles.ts +175 -0
  54. package/src/types/results.ts +135 -0
  55. package/src/version.ts +5 -0
package/package.json CHANGED
@@ -1,6 +1,122 @@
1
1
  {
2
2
  "name": "hauntview",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.6",
4
+ "type": "module",
5
+ "description": "Local-first website resilience, accessibility, failure-mode and visual regression testing with Playwright and MCP.",
6
+ "bin": {
7
+ "haunt": "dist/cli.js",
8
+ "hauntview": "dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "src/core",
13
+ "!src/core/__tests__",
14
+ "src/engine.ts",
15
+ "src/profiles",
16
+ "!src/profiles/__tests__",
17
+ "src/reports",
18
+ "src/types",
19
+ "src/version.ts",
20
+ "scripts/postinstall.mjs",
21
+ "VERSION",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "engines": {
26
+ "node": ">=22.12.0",
27
+ "npm": ">=10.0.0"
28
+ },
29
+ "scripts": {
30
+ "dev": "node init && next dev --hostname 127.0.0.1",
31
+ "postinstall": "node scripts/postinstall.mjs",
32
+ "build:cli": "node scripts/build-cli.mjs",
33
+ "build": "node init && node scripts/build-cli.mjs && next build",
34
+ "start": "node init && next start --hostname 0.0.0.0",
35
+ "lint": "eslint .",
36
+ "lint:fix": "eslint . --fix",
37
+ "typecheck": "tsc --noEmit",
38
+ "format": "prettier --write .",
39
+ "format:check": "prettier --check .",
40
+ "check": "pnpm run typecheck && pnpm run lint && pnpm run format:check",
41
+ "test": "pnpm run test:unit",
42
+ "test:unit": "jest --runInBand",
43
+ "test:watch": "jest --watch",
44
+ "test:coverage": "jest --coverage --runInBand",
45
+ "test:all": "pnpm run test:unit && pnpm run test:e2e",
46
+ "test:e2e": "playwright test",
47
+ "test:e2e:ui": "playwright test --ui",
48
+ "test:e2e:headed": "playwright test --headed",
49
+ "test:report": "playwright show-report",
50
+ "browsers:install": "playwright install chromium firefox webkit",
51
+ "mcp": "tsx src/mcp/server.ts",
52
+ "presubmit": "node scripts/presubmit.mjs",
53
+ "version:bump": "node scripts/bump-version.mjs",
54
+ "version:check": "node scripts/check-version.mjs",
55
+ "setup:hooks": "husky",
56
+ "prepare": "husky"
57
+ },
58
+ "lint-staged": {
59
+ "*.{ts,tsx,js,jsx,mjs,cjs}": [
60
+ "eslint --fix",
61
+ "prettier --write",
62
+ "eslint"
63
+ ],
64
+ "*.{json,md,mdx,css,yml,yaml}": [
65
+ "prettier --write"
66
+ ]
67
+ },
68
+ "dependencies": {
69
+ "@axe-core/playwright": "^4.14.0",
70
+ "@heroicons/react": "^2.2.0",
71
+ "@inquirer/prompts": "^8.7.5",
72
+ "@modelcontextprotocol/server": "^2.4.0",
73
+ "@sparticuz/chromium": "^153.0.0",
74
+ "@types/jszip": "^3.4.1",
75
+ "better-sqlite3": "^13.0.3",
76
+ "class-variance-authority": "^0.7.1",
77
+ "clsx": "^2.1.1",
78
+ "commander": "^15.0.0",
79
+ "ipaddr.js": "^2.5.0",
80
+ "jszip": "^3.10.2",
81
+ "lucide-react": "^1.69.0",
82
+ "next": "16.4.3",
83
+ "nextjs-toploader": "^3.9.17",
84
+ "p-limit": "^7.3.3",
85
+ "pixelmatch": "^8.0.0",
86
+ "playwright": "^1.65.0",
87
+ "pngjs": "^7.0.0",
88
+ "react": "19.3.1",
89
+ "react-dom": "19.3.1",
90
+ "sonner": "^2.0.8",
91
+ "tailwind-merge": "^3.7.0",
92
+ "zod": "^4.6.7",
93
+ "zustand": "^5.0.16"
94
+ },
95
+ "devDependencies": {
96
+ "@jest/globals": "^29.7.0",
97
+ "@playwright/test": "^1.65.0",
98
+ "@tailwindcss/postcss": "^4.3.4",
99
+ "@types/better-sqlite3": "^9.6.0",
100
+ "@types/jest": "^29.5.14",
101
+ "@types/node": "^24.19.2",
102
+ "@types/pngjs": "^6.0.5",
103
+ "@types/react": "^19.3.0",
104
+ "@types/react-dom": "^19.3.0",
105
+ "esbuild": "^0.28.2",
106
+ "eslint": "^10.14.0",
107
+ "eslint-config-next": "16.4.3",
108
+ "eslint-config-prettier": "^10.1.8",
109
+ "fast-check": "^4.10.2",
110
+ "husky": "^9.1.7",
111
+ "jest": "^29.7.0",
112
+ "jest-environment-node": "^29.7.0",
113
+ "lint-staged": "^17.6.0",
114
+ "postcss": "^8.5.32",
115
+ "prettier": "^3.9.10",
116
+ "prettier-plugin-tailwindcss": "^0.6.14",
117
+ "tailwindcss": "^4.3.4",
118
+ "ts-jest": "^29.4.15",
119
+ "tsx": "^4.23.15",
120
+ "typescript": "^5.9.3"
121
+ }
122
+ }
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * postinstall.mjs
4
+ *
5
+ * Installs Playwright's Chromium browser in local development only,
6
+ * and only when it isn't already installed for the current Playwright version.
7
+ *
8
+ * Skip conditions:
9
+ * - RENDER=true → Render build/runtime (uses @sparticuz/chromium instead)
10
+ * - CI=true → GitHub Actions and most hosted CI (no browsers needed for lint/build)
11
+ * - SKIP_PLAYWRIGHT_INSTALL=1 → manual opt-out
12
+ *
13
+ * Idempotency:
14
+ * We ask `playwright install --dry-run chromium` first. If it exits 0 with
15
+ * no output (nothing to install), we skip. This avoids re-downloading on
16
+ * every `pnpm install` when the binary is already cached.
17
+ */
18
+
19
+ import { execSync, spawnSync } from 'node:child_process';
20
+
21
+ const isRender = process.env.RENDER === 'true';
22
+ const isCI = process.env.CI === 'true';
23
+ const isSkipped = process.env.SKIP_PLAYWRIGHT_INSTALL === '1';
24
+
25
+ if (isRender || isCI || isSkipped) {
26
+ const reason = isRender ? 'RENDER=true' : isCI ? 'CI=true' : 'SKIP_PLAYWRIGHT_INSTALL=1';
27
+ console.log(`[postinstall] Skipping Playwright install (${reason}).`);
28
+ process.exit(0);
29
+ }
30
+
31
+ // Check if Chromium is already installed for this exact Playwright version.
32
+ // `playwright install --dry-run` prints what *would* be downloaded.
33
+ // If stdout is empty (or only whitespace), everything is already cached.
34
+ const dryRun = spawnSync('pnpm', ['exec', 'playwright', 'install', '--dry-run', 'chromium'], {
35
+ encoding: 'utf8',
36
+ });
37
+
38
+ const dryRunOutput = (dryRun.stdout ?? '').trim();
39
+ const alreadyInstalled = dryRun.status === 0 && dryRunOutput === '';
40
+
41
+ if (alreadyInstalled) {
42
+ console.log('[postinstall] Playwright Chromium already installed — nothing to do.');
43
+ process.exit(0);
44
+ }
45
+
46
+ console.log('[postinstall] Installing Playwright Chromium...');
47
+ execSync('pnpm exec playwright install chromium', { stdio: 'inherit' });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * browser-launch.ts
3
+ *
4
+ * Resolves the correct Chromium launch options for the current environment.
5
+ *
6
+ * - Production / Render: uses @sparticuz/chromium, which ships its own binary
7
+ * compatible with serverless/constrained Linux environments (0.5 CPU, 512MB RAM).
8
+ * - Local development: returns undefined executablePath so Playwright uses its own bundled binary.
9
+ */
10
+
11
+ export interface BrowserLaunchConfig {
12
+ executablePath?: string;
13
+ args: string[];
14
+ }
15
+
16
+ /**
17
+ * Returns Chromium launch options (executablePath + required flags) for the current environment.
18
+ */
19
+ export async function getChromiumLaunchOptions(): Promise<BrowserLaunchConfig> {
20
+ const isRender = process.env.RENDER === 'true';
21
+ const isCI = process.env.CI === 'true';
22
+
23
+ if (isRender || isCI) {
24
+ // Dynamically import to avoid loading the binary in dev where it's unneeded.
25
+ const { default: sparticuzChromium } = await import('@sparticuz/chromium');
26
+
27
+ sparticuzChromium.setGraphicsMode = false;
28
+
29
+ const executablePath = await sparticuzChromium.executablePath();
30
+ const args = [
31
+ ...sparticuzChromium.args,
32
+ '--headless=new',
33
+ '--no-sandbox',
34
+ '--disable-setuid-sandbox',
35
+ '--disable-dev-shm-usage',
36
+ '--disable-gpu',
37
+ '--single-process',
38
+ '--no-zygote',
39
+ ];
40
+
41
+ return {
42
+ executablePath,
43
+ args,
44
+ };
45
+ }
46
+
47
+ // Local development: standard Playwright flags
48
+ return {
49
+ executablePath: undefined,
50
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Backward-compatible helper returning just the executable path.
56
+ */
57
+ export async function getChromiumExecutablePath(): Promise<string | undefined> {
58
+ const config = await getChromiumLaunchOptions();
59
+ return config.executablePath;
60
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * BrowserPool - Manages Playwright browser instances and page lifecycle
3
+ * Handles browser instance pooling, page reuse, and resource cleanup
4
+ */
5
+
6
+ import { Browser, BrowserContext, Page } from 'playwright';
7
+ import { BrowserPoolConfig, ProfileContext } from '../types';
8
+
9
+ interface PooledBrowser {
10
+ browser: Browser;
11
+ pages: Page[];
12
+ activePages: Set<Page>;
13
+ lastUsed: number;
14
+ }
15
+
16
+ interface PooledPage {
17
+ page: Page;
18
+ context: BrowserContext;
19
+ profileContext?: ProfileContext;
20
+ inUse: boolean;
21
+ lastUsed?: number;
22
+ }
23
+
24
+ export class BrowserPool {
25
+ private browsers: Map<string, PooledBrowser> = new Map();
26
+ private pagePool: PooledPage[] = [];
27
+ private config: BrowserPoolConfig;
28
+ private cleanupInterval: NodeJS.Timeout | null = null;
29
+
30
+ constructor(config: BrowserPoolConfig) {
31
+ this.config = {
32
+ maxBrowsers: config.maxBrowsers ?? 3,
33
+ maxPagesPerBrowser: config.maxPagesPerBrowser ?? 5,
34
+ idleTimeout: config.idleTimeout ?? 30000,
35
+ launchOptions: config.launchOptions,
36
+ };
37
+
38
+ // Start cleanup interval
39
+ this.startCleanupInterval();
40
+ }
41
+
42
+ /**
43
+ * Acquire a page from the pool or create a new one
44
+ */
45
+ async acquirePage(
46
+ browserKey: string,
47
+ profileContext: ProfileContext,
48
+ playwrightBrowser: Browser
49
+ ): Promise<Page> {
50
+ let pooledBrowser = this.browsers.get(browserKey);
51
+ if (!pooledBrowser) {
52
+ this.registerBrowser(browserKey, playwrightBrowser);
53
+ pooledBrowser = this.browsers.get(browserKey)!;
54
+ }
55
+
56
+ // Clean up closed pages
57
+ pooledBrowser.pages = pooledBrowser.pages.filter((p) => !p.isClosed());
58
+ for (const p of pooledBrowser.activePages) {
59
+ if (p.isClosed()) {
60
+ pooledBrowser.activePages.delete(p);
61
+ }
62
+ }
63
+
64
+ // If active pages count exceeds limit, clean up idle pages
65
+ if (pooledBrowser.pages.length >= this.config.maxPagesPerBrowser) {
66
+ const idlePages = this.pagePool.filter((p) => !p.inUse);
67
+ for (const idle of idlePages) {
68
+ try {
69
+ if (!idle.page.isClosed()) {
70
+ await idle.page.close();
71
+ }
72
+ await idle.context.close();
73
+ } catch {
74
+ // Already closed
75
+ }
76
+ this.pagePool = this.pagePool.filter((p) => p !== idle);
77
+ pooledBrowser.pages = pooledBrowser.pages.filter((p) => p !== idle.page);
78
+ pooledBrowser.activePages.delete(idle.page);
79
+ }
80
+ }
81
+
82
+ const context = await playwrightBrowser.newContext({
83
+ viewport: {
84
+ width: profileContext.viewport.width,
85
+ height: profileContext.viewport.height,
86
+ },
87
+ isMobile: profileContext.viewport.isMobile,
88
+ hasTouch: profileContext.viewport.hasTouch,
89
+ userAgent: profileContext.userAgent,
90
+ locale: profileContext.locale,
91
+ timezoneId: profileContext.timezone,
92
+ });
93
+
94
+ const page = await context.newPage();
95
+ const pooledPage: PooledPage = {
96
+ page,
97
+ context,
98
+ profileContext,
99
+ inUse: true,
100
+ lastUsed: Date.now(),
101
+ };
102
+
103
+ pooledBrowser.pages.push(page);
104
+ pooledBrowser.activePages.add(page);
105
+ this.pagePool.push(pooledPage);
106
+
107
+ page.on('close', () => {
108
+ pooledBrowser?.activePages.delete(page);
109
+ });
110
+
111
+ return page;
112
+ }
113
+
114
+ /**
115
+ * Release a page back to the pool
116
+ */
117
+ releasePage(page: Page): void {
118
+ const pooledPageIndex = this.pagePool.findIndex((p) => p.page === page);
119
+ if (pooledPageIndex !== -1) {
120
+ const [pooledPage] = this.pagePool.splice(pooledPageIndex, 1);
121
+ pooledPage.inUse = false;
122
+
123
+ // Close page and context asynchronously to free browser memory
124
+ Promise.all([
125
+ !page.isClosed() ? page.close().catch(() => {}) : Promise.resolve(),
126
+ pooledPage.context.close().catch(() => {}),
127
+ ]).finally(() => {
128
+ for (const pooledBrowser of this.browsers.values()) {
129
+ pooledBrowser.pages = pooledBrowser.pages.filter((p) => p !== page);
130
+ pooledBrowser.activePages.delete(page);
131
+ }
132
+ });
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Register a browser instance
138
+ */
139
+ registerBrowser(key: string, browser: Browser): void {
140
+ if (this.browsers.has(key)) {
141
+ const existing = this.browsers.get(key)!;
142
+ existing.browser = browser;
143
+ existing.lastUsed = Date.now();
144
+ return;
145
+ }
146
+
147
+ if (this.browsers.size >= this.config.maxBrowsers) {
148
+ throw new Error(`Browser pool full: max ${this.config.maxBrowsers} browsers reached`);
149
+ }
150
+
151
+ this.browsers.set(key, {
152
+ browser,
153
+ pages: [],
154
+ activePages: new Set(),
155
+ lastUsed: Date.now(),
156
+ });
157
+ }
158
+
159
+ /**
160
+ * Close all resources in the pool
161
+ */
162
+ async closeAll(): Promise<void> {
163
+ if (this.cleanupInterval) {
164
+ clearInterval(this.cleanupInterval);
165
+ }
166
+
167
+ // Close all pages
168
+ for (const pooledPage of this.pagePool) {
169
+ try {
170
+ await pooledPage.context.close();
171
+ } catch {
172
+ // Already closed
173
+ }
174
+ }
175
+
176
+ // Close all browsers
177
+ for (const pooledBrowser of this.browsers.values()) {
178
+ try {
179
+ await pooledBrowser.browser.close();
180
+ } catch {
181
+ // Already closed
182
+ }
183
+ }
184
+
185
+ this.pagePool = [];
186
+ this.browsers.clear();
187
+ }
188
+
189
+ /**
190
+ * Get pool statistics
191
+ */
192
+ getStats(): {
193
+ totalBrowsers: number;
194
+ totalPages: number;
195
+ activePages: number;
196
+ availablePages: number;
197
+ } {
198
+ let totalPages = 0;
199
+ let activePages = 0;
200
+
201
+ for (const pooledBrowser of this.browsers.values()) {
202
+ totalPages += pooledBrowser.pages.length;
203
+ activePages += pooledBrowser.activePages.size;
204
+ }
205
+
206
+ return {
207
+ totalBrowsers: this.browsers.size,
208
+ totalPages,
209
+ activePages,
210
+ availablePages: totalPages - activePages,
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Start periodic cleanup of idle resources
216
+ */
217
+ private startCleanupInterval(): void {
218
+ this.cleanupInterval = setInterval(() => {
219
+ this.cleanupIdleResources();
220
+ }, 10000); // Check every 10 seconds
221
+
222
+ // Unref to not keep process alive
223
+ if (this.cleanupInterval.unref) {
224
+ this.cleanupInterval.unref();
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Clean up idle pages and browsers
230
+ */
231
+ private async cleanupIdleResources(): Promise<void> {
232
+ const now = Date.now();
233
+
234
+ // Remove idle pages
235
+ const idlePages = this.pagePool.filter(
236
+ (p) => !p.inUse && now - (p.lastUsed || now) > this.config.idleTimeout
237
+ );
238
+
239
+ for (const pooledPage of idlePages) {
240
+ try {
241
+ await pooledPage.context.close();
242
+ this.pagePool = this.pagePool.filter((p) => p !== pooledPage);
243
+ } catch {
244
+ // Already closed
245
+ }
246
+ }
247
+
248
+ // Remove idle browsers
249
+ for (const [key, pooledBrowser] of this.browsers.entries()) {
250
+ const isIdle =
251
+ pooledBrowser.activePages.size === 0 &&
252
+ now - pooledBrowser.lastUsed > this.config.idleTimeout;
253
+
254
+ if (isIdle) {
255
+ try {
256
+ await pooledBrowser.browser.close();
257
+ this.browsers.delete(key);
258
+
259
+ // Clean up associated pages
260
+ this.pagePool = this.pagePool.filter((p) => !pooledBrowser.pages.includes(p.page));
261
+ } catch {
262
+ // Already closed
263
+ }
264
+ }
265
+ }
266
+ }
267
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * src/core/concurrency-resolver.ts
3
+ *
4
+ * Implements Phase 1 concurrency resolution, hosted-environment guards,
5
+ * and deterministic bounded-concurrency execution pool.
6
+ */
7
+
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import * as os from 'os';
11
+ import { ConcurrencyOptions } from '../types/contracts';
12
+
13
+ export interface ResolvedConcurrency {
14
+ concurrency: boolean;
15
+ concurrencyAmount: number;
16
+ isHosted: boolean;
17
+ downgradedDueToHosted: boolean;
18
+ }
19
+
20
+ /**
21
+ * Detects whether the current process is running in a constrained hosted environment
22
+ * (Render.com or any service with HAUNT_HOSTED=1).
23
+ */
24
+ export function isHostedEnvironment(): boolean {
25
+ return (
26
+ process.env.RENDER === 'true' ||
27
+ process.env.HAUNT_HOSTED === '1' ||
28
+ process.env.HAUNT_HOSTED === 'true'
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Checks whether an operator has explicitly authorized concurrency in a hosted environment.
34
+ */
35
+ export function isConcurrencyAllowedInHosted(): boolean {
36
+ return (
37
+ process.env.HAUNT_ALLOW_CONCURRENCY === '1' || process.env.HAUNT_ALLOW_CONCURRENCY === 'true'
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Attempts to load stored user config from ~/.hauntview/config.json
43
+ */
44
+ export function loadUserConfig(): Partial<ConcurrencyOptions> | null {
45
+ try {
46
+ const configPath = path.join(os.homedir(), '.hauntview', 'config.json');
47
+ if (fs.existsSync(configPath)) {
48
+ const content = fs.readFileSync(configPath, 'utf8');
49
+ return JSON.parse(content);
50
+ }
51
+ } catch {
52
+ // Ignore invalid/unreadable config files
53
+ }
54
+ return null;
55
+ }
56
+
57
+ /**
58
+ * Resolves concurrency options according to the strict 4-level precedence:
59
+ * 1. Explicit per-call options
60
+ * 2. Environment variables: HAUNT_CONCURRENCY and HAUNT_CONCURRENCY_AMOUNT
61
+ * 3. User configuration file (~/.hauntview/config.json)
62
+ * 4. Built-in defaults: concurrency=false, concurrencyAmount=2
63
+ *
64
+ * Hosted Guard: If running in a hosted environment without explicit override,
65
+ * concurrency is forced to false.
66
+ */
67
+ export function resolveConcurrency(
68
+ options?: Partial<ConcurrencyOptions>,
69
+ configOverride?: Partial<ConcurrencyOptions>
70
+ ): ResolvedConcurrency {
71
+ const isHosted = isHostedEnvironment();
72
+ const allowHosted = isConcurrencyAllowedInHosted();
73
+ const userConfig = configOverride ?? loadUserConfig() ?? {};
74
+
75
+ // 1. Resolve raw concurrency boolean
76
+ let rawConcurrency: boolean;
77
+ if (options?.concurrency !== undefined) {
78
+ rawConcurrency = Boolean(options.concurrency);
79
+ } else if (process.env.HAUNT_CONCURRENCY !== undefined) {
80
+ rawConcurrency =
81
+ process.env.HAUNT_CONCURRENCY === 'true' || process.env.HAUNT_CONCURRENCY === '1';
82
+ } else if (userConfig.concurrency !== undefined) {
83
+ rawConcurrency = Boolean(userConfig.concurrency);
84
+ } else {
85
+ rawConcurrency = false;
86
+ }
87
+
88
+ // 2. Resolve raw concurrency amount (int >= 1, default 2)
89
+ let rawAmount: number;
90
+ if (options?.concurrencyAmount !== undefined) {
91
+ rawAmount = Math.max(1, Math.floor(Number(options.concurrencyAmount)));
92
+ } else if (process.env.HAUNT_CONCURRENCY_AMOUNT !== undefined) {
93
+ const parsed = parseInt(process.env.HAUNT_CONCURRENCY_AMOUNT, 10);
94
+ rawAmount = Number.isNaN(parsed) || parsed < 1 ? 2 : parsed;
95
+ } else if (userConfig.concurrencyAmount !== undefined) {
96
+ rawAmount = Math.max(1, Math.floor(Number(userConfig.concurrencyAmount)));
97
+ } else {
98
+ rawAmount = 2;
99
+ }
100
+
101
+ // 3. Apply Hosted Guard
102
+ let effectiveConcurrency = rawConcurrency;
103
+ let downgradedDueToHosted = false;
104
+
105
+ if (isHosted && !allowHosted && rawConcurrency) {
106
+ effectiveConcurrency = false;
107
+ downgradedDueToHosted = true;
108
+ }
109
+
110
+ return {
111
+ concurrency: effectiveConcurrency,
112
+ concurrencyAmount: rawAmount,
113
+ isHosted,
114
+ downgradedDueToHosted,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Executes a list of async task factories with a bounded concurrency pool.
120
+ * Guarantees:
121
+ * - Active concurrency never exceeds `concurrencyLimit`.
122
+ * - Result ordering strictly matches the input task order.
123
+ * - Failure isolation: exceptions handled via `onFailure` without aborting other tasks.
124
+ */
125
+ export async function executeWithConcurrency<T>(
126
+ tasks: (() => Promise<T>)[],
127
+ concurrencyLimit: number,
128
+ options?: { onFailure?: (index: number, error: unknown) => T }
129
+ ): Promise<T[]> {
130
+ const limit = Math.max(1, concurrencyLimit);
131
+ const results: T[] = new Array(tasks.length);
132
+ let nextTaskIndex = 0;
133
+
134
+ async function worker() {
135
+ while (nextTaskIndex < tasks.length) {
136
+ const currentIndex = nextTaskIndex++;
137
+ try {
138
+ results[currentIndex] = await tasks[currentIndex]();
139
+ } catch (err) {
140
+ if (options?.onFailure) {
141
+ results[currentIndex] = options.onFailure(currentIndex, err);
142
+ } else {
143
+ throw err;
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ const workerCount = Math.min(limit, tasks.length);
150
+ const workers = Array.from({ length: workerCount }, () => worker());
151
+
152
+ await Promise.all(workers);
153
+ return results;
154
+ }