chati-dev 3.2.5 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/LICENSE +96 -0
  2. package/bin/chati.js +46 -0
  3. package/framework/agents/build/dev.md +122 -1
  4. package/framework/agents/deploy/devops.md +128 -3
  5. package/framework/agents/discover/brief.md +77 -15
  6. package/framework/agents/discover/brownfield-wu.md +2 -2
  7. package/framework/agents/discover/greenfield-wu.md +3 -3
  8. package/framework/agents/plan/architect.md +2 -2
  9. package/framework/agents/plan/detail.md +3 -3
  10. package/framework/agents/plan/phases.md +127 -2
  11. package/framework/agents/plan/tasks.md +127 -2
  12. package/framework/agents/plan/ux.md +269 -22
  13. package/framework/agents/quality/qa-implementation.md +172 -8
  14. package/framework/agents/quality/qa-planning.md +147 -2
  15. package/framework/config.yaml +9 -5
  16. package/framework/constitution.md +7 -1
  17. package/framework/context/quality.md +1 -1
  18. package/framework/context/root.md +1 -1
  19. package/framework/hooks/constitution-guard.js +18 -2
  20. package/framework/hooks/mode-governance.js +3 -3
  21. package/framework/hooks/read-protection.js +10 -2
  22. package/framework/i18n/en.yaml +6 -0
  23. package/framework/i18n/es.yaml +6 -0
  24. package/framework/i18n/fr.yaml +6 -0
  25. package/framework/i18n/pt.yaml +6 -0
  26. package/framework/orchestrator/chati.md +102 -6
  27. package/framework/schemas/task.schema.json +1 -1
  28. package/framework/tasks/architect-dep-audit.md +128 -0
  29. package/framework/tasks/architect-stack-selection.md +28 -0
  30. package/framework/workflows/brownfield-fullstack.yaml +2 -2
  31. package/framework/workflows/brownfield-service.yaml +2 -2
  32. package/framework/workflows/brownfield-ui.yaml +2 -2
  33. package/framework/workflows/greenfield-fullstack.yaml +6 -2
  34. package/framework/workflows/quick-flow.yaml +7 -5
  35. package/framework/workflows/standard-flow.yaml +171 -0
  36. package/package.json +4 -2
  37. package/src/api/index.js +129 -0
  38. package/src/autonomy/build-loop.js +93 -6
  39. package/src/autonomy/build-state.js +20 -2
  40. package/src/autonomy/cause-analyzer.js +177 -0
  41. package/src/autonomy/escalation.js +214 -0
  42. package/src/autonomy/safety-net.js +23 -5
  43. package/src/autonomy/worktree-manager.js +245 -0
  44. package/src/config/agent-customizer.js +227 -0
  45. package/src/config/ide-configs.js +57 -27
  46. package/src/decision/analyzer.js +148 -0
  47. package/src/decision/registry-healer.js +38 -21
  48. package/src/extensions/loader.js +151 -0
  49. package/src/extensions/registry.js +134 -0
  50. package/src/gates/circuit-breaker.js +32 -0
  51. package/src/gates/g3-implementation.js +30 -4
  52. package/src/gates/g4-qa-implementation.js +34 -5
  53. package/src/gates/gate-base.js +9 -0
  54. package/src/health/auto-fix.js +216 -0
  55. package/src/installer/core.js +24 -11
  56. package/src/installer/provider-overlay.js +82 -0
  57. package/src/installer/templates.js +22 -10
  58. package/src/installer/transaction.js +3 -2
  59. package/src/installer/validator.js +74 -0
  60. package/src/intelligence/context-status.js +9 -5
  61. package/src/intelligence/document-sharder.js +221 -0
  62. package/src/intelligence/elicitation.js +265 -0
  63. package/src/intelligence/timeline.js +5 -0
  64. package/src/memory/gotchas.js +78 -2
  65. package/src/merger/semantic-merger.js +292 -0
  66. package/src/orchestrator/agent-selector.js +20 -0
  67. package/src/orchestrator/handoff-engine.js +77 -0
  68. package/src/orchestrator/index.js +0 -8
  69. package/src/orchestrator/intent-classifier.js +182 -0
  70. package/src/orchestrator/pipeline-manager.js +125 -1
  71. package/src/orchestrator/session-manager.js +164 -2
  72. package/src/quality/metrics-collector.js +283 -0
  73. package/src/quality/test-runner.js +368 -0
  74. package/src/telemetry/collector.js +83 -0
  75. package/src/telemetry/config.js +119 -0
  76. package/src/telemetry/index.js +11 -0
  77. package/src/telemetry/schema.js +104 -0
  78. package/src/telemetry/sender.js +60 -0
  79. package/src/terminal/cli-registry.js +7 -1
  80. package/src/terminal/cost-tracker.js +197 -0
  81. package/src/terminal/handoff-parser.js +61 -4
  82. package/src/terminal/prompt-builder.js +56 -18
  83. package/src/terminal/rate-limiter.js +172 -0
  84. package/src/terminal/run-agent.js +39 -0
  85. package/src/terminal/run-parallel.js +22 -1
  86. package/src/terminal/spawner.js +181 -3
  87. package/src/upgrade/migrator.js +2 -2
  88. package/src/utils/event-bus.js +126 -0
  89. package/src/utils/file-lock.js +291 -0
  90. package/src/utils/schema-validator.js +226 -0
  91. package/src/wizard/i18n.js +11 -0
  92. package/src/wizard/index.js +42 -20
  93. package/src/wizard/questions.js +200 -39
  94. package/src/autonomy/execution-profile.js +0 -151
  95. package/src/intelligence/file-tracker.js +0 -117
  96. package/src/memory/gotchas-auto-capture.js +0 -253
  97. package/src/orchestrator/pipeline-state.js +0 -223
  98. package/src/terminal/wave-analyzer.js +0 -143
@@ -0,0 +1,283 @@
1
+ /**
2
+ * @fileoverview Quality metrics collector and trend analysis.
3
+ *
4
+ * Persists quality metrics over time and calculates trends
5
+ * (improving, stable, declining) via linear regression.
6
+ *
7
+ * Constitution Article XIV — Framework Registry Governance.
8
+ */
9
+
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
11
+ import { join, dirname } from 'path';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Constants
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** Metrics storage filename. */
18
+ const METRICS_FILE = 'quality-history.json';
19
+
20
+ /** Metrics directory relative to project root. */
21
+ const METRICS_DIR = '.chati/metrics';
22
+
23
+ /** Maximum metrics entries to keep per project. */
24
+ export const MAX_METRICS_ENTRIES = 500;
25
+
26
+ /** Valid metric types. */
27
+ export const METRIC_TYPES = {
28
+ QA_SCORE: 'qa_score',
29
+ HEALTH_SCORE: 'health_score',
30
+ TEST_COUNT: 'test_count',
31
+ COVERAGE: 'coverage',
32
+ LINT_ERRORS: 'lint_errors',
33
+ BUILD_TIME: 'build_time',
34
+ };
35
+
36
+ /** Trend direction values. */
37
+ export const TREND_DIRECTION = {
38
+ IMPROVING: 'improving',
39
+ STABLE: 'stable',
40
+ DECLINING: 'declining',
41
+ };
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Helpers
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Get the metrics file path for a project.
49
+ *
50
+ * @param {string} projectDir
51
+ * @returns {string}
52
+ */
53
+ function getMetricsPath(projectDir) {
54
+ return join(projectDir, METRICS_DIR, METRICS_FILE);
55
+ }
56
+
57
+ /**
58
+ * Load metrics from disk.
59
+ *
60
+ * @param {string} projectDir
61
+ * @returns {{ metrics: object[] }}
62
+ */
63
+ function loadMetrics(projectDir) {
64
+ const filePath = getMetricsPath(projectDir);
65
+ if (!existsSync(filePath)) {
66
+ return { metrics: [] };
67
+ }
68
+
69
+ try {
70
+ const raw = readFileSync(filePath, 'utf-8');
71
+ const data = JSON.parse(raw);
72
+ return { metrics: Array.isArray(data.metrics) ? data.metrics : [] };
73
+ } catch {
74
+ return { metrics: [] };
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Save metrics to disk.
80
+ *
81
+ * @param {string} projectDir
82
+ * @param {{ metrics: object[] }} data
83
+ */
84
+ function saveMetrics(projectDir, data) {
85
+ const filePath = getMetricsPath(projectDir);
86
+ const dir = dirname(filePath);
87
+
88
+ if (!existsSync(dir)) {
89
+ mkdirSync(dir, { recursive: true });
90
+ }
91
+
92
+ // Trim to MAX_METRICS_ENTRIES
93
+ if (data.metrics.length > MAX_METRICS_ENTRIES) {
94
+ data.metrics = data.metrics.slice(-MAX_METRICS_ENTRIES);
95
+ }
96
+
97
+ writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Public API
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /**
105
+ * @typedef {object} MetricEntry
106
+ * @property {string} type - Metric type (from METRIC_TYPES)
107
+ * @property {number} value - Metric value
108
+ * @property {string} [agent] - Agent that produced the metric
109
+ * @property {string} [sessionId] - Session identifier
110
+ * @property {string} timestamp - ISO timestamp
111
+ */
112
+
113
+ /**
114
+ * Record a quality metric.
115
+ *
116
+ * @param {string} projectDir
117
+ * @param {{ type: string, value: number, agent?: string, sessionId?: string }} metric
118
+ * @returns {{ recorded: boolean, total: number }}
119
+ */
120
+ export function recordMetric(projectDir, metric) {
121
+ if (!metric || typeof metric.value !== 'number') {
122
+ return { recorded: false, total: 0 };
123
+ }
124
+
125
+ const validTypes = Object.values(METRIC_TYPES);
126
+ if (!validTypes.includes(metric.type)) {
127
+ return { recorded: false, total: 0 };
128
+ }
129
+
130
+ const data = loadMetrics(projectDir);
131
+
132
+ data.metrics.push({
133
+ type: metric.type,
134
+ value: metric.value,
135
+ agent: metric.agent || null,
136
+ sessionId: metric.sessionId || null,
137
+ timestamp: new Date().toISOString(),
138
+ });
139
+
140
+ saveMetrics(projectDir, data);
141
+
142
+ return { recorded: true, total: data.metrics.length };
143
+ }
144
+
145
+ /**
146
+ * Get metrics history with optional filtering.
147
+ *
148
+ * @param {string} projectDir
149
+ * @param {{ type?: string, agent?: string, limit?: number, since?: string }} [options={}]
150
+ * @returns {{ metrics: MetricEntry[], trend: { direction: string, slope: number, confidence: number } }}
151
+ */
152
+ export function getMetricsHistory(projectDir, options = {}) {
153
+ const data = loadMetrics(projectDir);
154
+ let metrics = data.metrics;
155
+
156
+ // Filter by type
157
+ if (options.type) {
158
+ metrics = metrics.filter(m => m.type === options.type);
159
+ }
160
+
161
+ // Filter by agent
162
+ if (options.agent) {
163
+ metrics = metrics.filter(m => m.agent === options.agent);
164
+ }
165
+
166
+ // Filter by time
167
+ if (options.since) {
168
+ const sinceDate = new Date(options.since).getTime();
169
+ metrics = metrics.filter(m => new Date(m.timestamp).getTime() >= sinceDate);
170
+ }
171
+
172
+ // Apply limit
173
+ if (options.limit && options.limit > 0) {
174
+ metrics = metrics.slice(-options.limit);
175
+ }
176
+
177
+ // Calculate trend
178
+ const trend = calculateTrend(metrics);
179
+
180
+ return { metrics, trend };
181
+ }
182
+
183
+ /**
184
+ * Calculate trend from a series of metrics using linear regression.
185
+ *
186
+ * @param {MetricEntry[]} metrics
187
+ * @returns {{ direction: string, slope: number, confidence: number }}
188
+ */
189
+ export function calculateTrend(metrics) {
190
+ if (!metrics || metrics.length < 3) {
191
+ return { direction: TREND_DIRECTION.STABLE, slope: 0, confidence: 0 };
192
+ }
193
+
194
+ const values = metrics.map(m => m.value);
195
+ const n = values.length;
196
+
197
+ // Simple linear regression: y = mx + b
198
+ let sumX = 0;
199
+ let sumY = 0;
200
+ let sumXY = 0;
201
+ let sumX2 = 0;
202
+ let sumY2 = 0;
203
+
204
+ for (let i = 0; i < n; i++) {
205
+ sumX += i;
206
+ sumY += values[i];
207
+ sumXY += i * values[i];
208
+ sumX2 += i * i;
209
+ sumY2 += values[i] * values[i];
210
+ }
211
+
212
+ const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
213
+
214
+ // Calculate R² (coefficient of determination)
215
+ const meanY = sumY / n;
216
+ let ssRes = 0;
217
+ let ssTot = 0;
218
+
219
+ for (let i = 0; i < n; i++) {
220
+ const predicted = meanY + slope * (i - (n - 1) / 2);
221
+ ssRes += (values[i] - predicted) ** 2;
222
+ ssTot += (values[i] - meanY) ** 2;
223
+ }
224
+
225
+ const r2 = ssTot > 0 ? 1 - ssRes / ssTot : 0;
226
+ const confidence = Math.max(0, Math.min(1, Math.abs(r2)));
227
+
228
+ // Determine direction
229
+ // Normalize slope by mean to get relative change
230
+ const normalizedSlope = meanY !== 0 ? slope / Math.abs(meanY) : slope;
231
+ const threshold = 0.02; // 2% change per step is significant
232
+
233
+ let direction;
234
+ if (normalizedSlope > threshold && confidence > 0.3) {
235
+ direction = TREND_DIRECTION.IMPROVING;
236
+ } else if (normalizedSlope < -threshold && confidence > 0.3) {
237
+ direction = TREND_DIRECTION.DECLINING;
238
+ } else {
239
+ direction = TREND_DIRECTION.STABLE;
240
+ }
241
+
242
+ return {
243
+ direction,
244
+ slope: Math.round(slope * 1000) / 1000,
245
+ confidence: Math.round(confidence * 100) / 100,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Get a quality dashboard summary.
251
+ *
252
+ * @param {string} projectDir
253
+ * @returns {{ latestScores: Record<string, number>, trends: Record<string, object>, totalMetrics: number, lastUpdated: string|null }}
254
+ */
255
+ export function getQualityDashboard(projectDir) {
256
+ const data = loadMetrics(projectDir);
257
+ const metrics = data.metrics;
258
+
259
+ if (metrics.length === 0) {
260
+ return { latestScores: {}, trends: {}, totalMetrics: 0, lastUpdated: null };
261
+ }
262
+
263
+ // Get latest value for each metric type
264
+ const latestScores = {};
265
+ const trends = {};
266
+
267
+ for (const type of Object.values(METRIC_TYPES)) {
268
+ const typeMetrics = metrics.filter(m => m.type === type);
269
+ if (typeMetrics.length > 0) {
270
+ latestScores[type] = typeMetrics[typeMetrics.length - 1].value;
271
+ trends[type] = calculateTrend(typeMetrics);
272
+ }
273
+ }
274
+
275
+ const lastUpdated = metrics[metrics.length - 1]?.timestamp || null;
276
+
277
+ return {
278
+ latestScores,
279
+ trends,
280
+ totalMetrics: metrics.length,
281
+ lastUpdated,
282
+ };
283
+ }
@@ -0,0 +1,368 @@
1
+ /**
2
+ * @fileoverview Real code execution validation.
3
+ *
4
+ * Detects test runners, executes tests and lint commands,
5
+ * and parses output for structured results.
6
+ *
7
+ * Constitution Article XIV — Framework Registry Governance.
8
+ */
9
+
10
+ import { existsSync, readFileSync } from 'fs';
11
+ import { join } from 'path';
12
+ import { execSync } from 'child_process';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Test Runner Detection
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * @typedef {object} TestDetection
20
+ * @property {string|null} command - Test command to run
21
+ * @property {string} runner - Detected runner name (node:test|jest|vitest|mocha|unknown)
22
+ * @property {boolean} detected - Whether a test setup was found
23
+ */
24
+
25
+ /**
26
+ * Detect the test command and runner for a project.
27
+ *
28
+ * Detection order:
29
+ * 1. package.json scripts.test
30
+ * 2. vitest.config.* presence
31
+ * 3. jest.config.* presence
32
+ * 4. .mocharc.* presence
33
+ *
34
+ * @param {string} projectDir - Project root directory
35
+ * @returns {TestDetection}
36
+ */
37
+ export function detectTestCommand(projectDir) {
38
+ const pkgPath = join(projectDir, 'package.json');
39
+
40
+ // Check package.json scripts.test
41
+ if (existsSync(pkgPath)) {
42
+ try {
43
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
44
+ const testScript = pkg.scripts?.test;
45
+
46
+ if (testScript && testScript !== 'echo "Error: no test specified" && exit 1') {
47
+ const runner = detectRunnerFromScript(testScript);
48
+ return { command: 'npm test', runner, detected: true };
49
+ }
50
+ } catch {
51
+ // Malformed package.json — continue detection
52
+ }
53
+ }
54
+
55
+ // Check for vitest.config.*
56
+ const vitestConfigs = ['vitest.config.ts', 'vitest.config.js', 'vitest.config.mts'];
57
+ for (const cfg of vitestConfigs) {
58
+ if (existsSync(join(projectDir, cfg))) {
59
+ return { command: 'npx vitest run', runner: 'vitest', detected: true };
60
+ }
61
+ }
62
+
63
+ // Check for jest.config.*
64
+ const jestConfigs = ['jest.config.ts', 'jest.config.js', 'jest.config.json'];
65
+ for (const cfg of jestConfigs) {
66
+ if (existsSync(join(projectDir, cfg))) {
67
+ return { command: 'npx jest', runner: 'jest', detected: true };
68
+ }
69
+ }
70
+
71
+ // Check for .mocharc.*
72
+ const mochaConfigs = ['.mocharc.yml', '.mocharc.yaml', '.mocharc.json', '.mocharc.js'];
73
+ for (const cfg of mochaConfigs) {
74
+ if (existsSync(join(projectDir, cfg))) {
75
+ return { command: 'npx mocha', runner: 'mocha', detected: true };
76
+ }
77
+ }
78
+
79
+ return { command: null, runner: 'unknown', detected: false };
80
+ }
81
+
82
+ /**
83
+ * Detect runner from a test script string.
84
+ *
85
+ * @param {string} script
86
+ * @returns {string}
87
+ */
88
+ function detectRunnerFromScript(script) {
89
+ if (/node\s+--test/.test(script)) return 'node:test';
90
+ if (/vitest/.test(script)) return 'vitest';
91
+ if (/jest/.test(script)) return 'jest';
92
+ if (/mocha/.test(script)) return 'mocha';
93
+ if (/tap/.test(script)) return 'tap';
94
+ return 'unknown';
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Test Execution
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /**
102
+ * @typedef {object} TestResult
103
+ * @property {boolean} success - All tests passed
104
+ * @property {number} total - Total test count
105
+ * @property {number} passed - Tests passed
106
+ * @property {number} failed - Tests failed
107
+ * @property {number} skipped - Tests skipped
108
+ * @property {number} duration - Execution time in ms
109
+ * @property {string} rawOutput - Full command output
110
+ * @property {string} runner - Detected runner
111
+ */
112
+
113
+ /**
114
+ * Run tests in a project directory.
115
+ *
116
+ * @param {string} projectDir
117
+ * @param {{ timeout?: number, command?: string }} [options={}]
118
+ * @returns {TestResult}
119
+ */
120
+ export function runTests(projectDir, options = {}) {
121
+ const detection = detectTestCommand(projectDir);
122
+ const command = options.command || detection.command;
123
+ const timeout = options.timeout || 120_000;
124
+
125
+ if (!command) {
126
+ return {
127
+ success: false,
128
+ total: 0,
129
+ passed: 0,
130
+ failed: 0,
131
+ skipped: 0,
132
+ duration: 0,
133
+ rawOutput: 'No test command detected',
134
+ runner: 'unknown',
135
+ };
136
+ }
137
+
138
+ const start = Date.now();
139
+ let rawOutput = '';
140
+ let exitCode = 0;
141
+
142
+ try {
143
+ rawOutput = execSync(command, {
144
+ cwd: projectDir,
145
+ encoding: 'utf-8',
146
+ timeout,
147
+ stdio: ['pipe', 'pipe', 'pipe'],
148
+ });
149
+ } catch (err) {
150
+ exitCode = err.status || 1;
151
+ rawOutput = (err.stdout || '') + (err.stderr || '');
152
+ }
153
+
154
+ const duration = Date.now() - start;
155
+ const parsed = parseTestOutput(rawOutput, detection.runner);
156
+
157
+ return {
158
+ success: exitCode === 0 && parsed.failed === 0,
159
+ total: parsed.total,
160
+ passed: parsed.passed,
161
+ failed: parsed.failed,
162
+ skipped: parsed.skipped,
163
+ duration,
164
+ rawOutput: rawOutput.slice(0, 5000),
165
+ runner: detection.runner,
166
+ };
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Lint Execution
171
+ // ---------------------------------------------------------------------------
172
+
173
+ /**
174
+ * @typedef {object} LintResult
175
+ * @property {boolean} success - No lint errors
176
+ * @property {number} errors - Error count
177
+ * @property {number} warnings - Warning count
178
+ * @property {string} rawOutput - Full command output
179
+ */
180
+
181
+ /**
182
+ * Run lint in a project directory.
183
+ *
184
+ * @param {string} projectDir
185
+ * @param {{ timeout?: number, command?: string }} [options={}]
186
+ * @returns {LintResult}
187
+ */
188
+ export function runLint(projectDir, options = {}) {
189
+ const timeout = options.timeout || 60_000;
190
+ let command = options.command;
191
+
192
+ // Auto-detect lint command
193
+ if (!command) {
194
+ const pkgPath = join(projectDir, 'package.json');
195
+ if (existsSync(pkgPath)) {
196
+ try {
197
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
198
+ if (pkg.scripts?.lint) {
199
+ command = 'npm run lint';
200
+ }
201
+ } catch {
202
+ // ignore
203
+ }
204
+ }
205
+ }
206
+
207
+ if (!command) {
208
+ return { success: true, errors: 0, warnings: 0, rawOutput: 'No lint command detected' };
209
+ }
210
+
211
+ let rawOutput = '';
212
+ let exitCode = 0;
213
+
214
+ try {
215
+ rawOutput = execSync(command, {
216
+ cwd: projectDir,
217
+ encoding: 'utf-8',
218
+ timeout,
219
+ stdio: ['pipe', 'pipe', 'pipe'],
220
+ });
221
+ } catch (err) {
222
+ exitCode = err.status || 1;
223
+ rawOutput = (err.stdout || '') + (err.stderr || '');
224
+ }
225
+
226
+ const { errors, warnings } = parseLintOutput(rawOutput);
227
+
228
+ return {
229
+ success: exitCode === 0 && errors === 0,
230
+ errors,
231
+ warnings,
232
+ rawOutput: rawOutput.slice(0, 5000),
233
+ };
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Output Parsing
238
+ // ---------------------------------------------------------------------------
239
+
240
+ /**
241
+ * Parse test output to extract counts.
242
+ *
243
+ * @param {string} output
244
+ * @param {string} runner
245
+ * @returns {{ total: number, passed: number, failed: number, skipped: number }}
246
+ */
247
+ export function parseTestOutput(output, runner) {
248
+ if (!output) {
249
+ return { total: 0, passed: 0, failed: 0, skipped: 0 };
250
+ }
251
+
252
+ switch (runner) {
253
+ case 'node:test':
254
+ return parseNodeTestOutput(output);
255
+ case 'jest':
256
+ return parseJestOutput(output);
257
+ case 'vitest':
258
+ return parseVitestOutput(output);
259
+ case 'mocha':
260
+ return parseMochaOutput(output);
261
+ default:
262
+ return parseGenericOutput(output);
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Parse node:test TAP-like output.
268
+ */
269
+ function parseNodeTestOutput(output) {
270
+ const testsMatch = output.match(/# tests (\d+)/);
271
+ const passMatch = output.match(/# pass (\d+)/);
272
+ const failMatch = output.match(/# fail (\d+)/);
273
+ const skipMatch = output.match(/# skipped (\d+)/);
274
+
275
+ const total = testsMatch ? parseInt(testsMatch[1], 10) : 0;
276
+ const passed = passMatch ? parseInt(passMatch[1], 10) : 0;
277
+ const failed = failMatch ? parseInt(failMatch[1], 10) : 0;
278
+ const skipped = skipMatch ? parseInt(skipMatch[1], 10) : 0;
279
+
280
+ return { total, passed, failed, skipped };
281
+ }
282
+
283
+ /**
284
+ * Parse Jest output.
285
+ */
286
+ function parseJestOutput(output) {
287
+ // Jest format: "Tests: 3 failed, 47 passed, 50 total"
288
+ const totalMatch = output.match(/(\d+)\s+total/);
289
+ const passedMatch = output.match(/(\d+)\s+passed/);
290
+ const failMatch = output.match(/(\d+)\s+failed/);
291
+ const skipMatch = output.match(/(\d+)\s+skipped/);
292
+
293
+ const total = totalMatch ? parseInt(totalMatch[1], 10) : 0;
294
+ const passed = passedMatch ? parseInt(passedMatch[1], 10) : 0;
295
+ const failed = failMatch ? parseInt(failMatch[1], 10) : 0;
296
+ const skipped = skipMatch ? parseInt(skipMatch[1], 10) : 0;
297
+
298
+ return { total, passed, failed, skipped };
299
+ }
300
+
301
+ /**
302
+ * Parse Vitest output.
303
+ */
304
+ function parseVitestOutput(output) {
305
+ const passMatch = output.match(/(\d+)\s+passed/);
306
+ const failMatch = output.match(/(\d+)\s+failed/);
307
+ const skipMatch = output.match(/(\d+)\s+skipped/);
308
+
309
+ const passed = passMatch ? parseInt(passMatch[1], 10) : 0;
310
+ const failed = failMatch ? parseInt(failMatch[1], 10) : 0;
311
+ const skipped = skipMatch ? parseInt(skipMatch[1], 10) : 0;
312
+ const total = passed + failed + skipped;
313
+
314
+ return { total, passed, failed, skipped };
315
+ }
316
+
317
+ /**
318
+ * Parse Mocha output.
319
+ */
320
+ function parseMochaOutput(output) {
321
+ const passMatch = output.match(/(\d+)\s+passing/);
322
+ const failMatch = output.match(/(\d+)\s+failing/);
323
+ const pendMatch = output.match(/(\d+)\s+pending/);
324
+
325
+ const passed = passMatch ? parseInt(passMatch[1], 10) : 0;
326
+ const failed = failMatch ? parseInt(failMatch[1], 10) : 0;
327
+ const skipped = pendMatch ? parseInt(pendMatch[1], 10) : 0;
328
+ const total = passed + failed + skipped;
329
+
330
+ return { total, passed, failed, skipped };
331
+ }
332
+
333
+ /**
334
+ * Generic fallback parser.
335
+ */
336
+ function parseGenericOutput(output) {
337
+ const passMatch = output.match(/(\d+)\s+pass/i);
338
+ const failMatch = output.match(/(\d+)\s+fail/i);
339
+
340
+ const passed = passMatch ? parseInt(passMatch[1], 10) : 0;
341
+ const failed = failMatch ? parseInt(failMatch[1], 10) : 0;
342
+ const total = passed + failed;
343
+
344
+ return { total, passed, failed, skipped: 0 };
345
+ }
346
+
347
+ /**
348
+ * Parse lint output for error/warning counts.
349
+ *
350
+ * @param {string} output
351
+ * @returns {{ errors: number, warnings: number }}
352
+ */
353
+ function parseLintOutput(output) {
354
+ // ESLint format: "X problems (Y errors, Z warnings)"
355
+ const eslintMatch = output.match(/(\d+)\s+problems?\s*\((\d+)\s+errors?,\s*(\d+)\s+warnings?\)/);
356
+ if (eslintMatch) {
357
+ return {
358
+ errors: parseInt(eslintMatch[2], 10),
359
+ warnings: parseInt(eslintMatch[3], 10),
360
+ };
361
+ }
362
+
363
+ // Count error/warning lines
364
+ const errorLines = (output.match(/error/gi) || []).length;
365
+ const warningLines = (output.match(/warning/gi) || []).length;
366
+
367
+ return { errors: errorLines, warnings: warningLines };
368
+ }