chati-dev 3.2.4 → 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.
- package/LICENSE +96 -0
- package/README.md +2 -2
- package/bin/chati.js +46 -0
- package/framework/agents/build/dev.md +122 -1
- package/framework/agents/deploy/devops.md +128 -3
- package/framework/agents/discover/brief.md +77 -15
- package/framework/agents/discover/brownfield-wu.md +2 -2
- package/framework/agents/discover/greenfield-wu.md +3 -3
- package/framework/agents/plan/architect.md +2 -2
- package/framework/agents/plan/detail.md +3 -3
- package/framework/agents/plan/phases.md +127 -2
- package/framework/agents/plan/tasks.md +127 -2
- package/framework/agents/plan/ux.md +269 -22
- package/framework/agents/quality/qa-implementation.md +172 -8
- package/framework/agents/quality/qa-planning.md +147 -2
- package/framework/config.yaml +9 -5
- package/framework/constitution.md +7 -1
- package/framework/context/quality.md +1 -1
- package/framework/context/root.md +1 -1
- package/framework/hooks/constitution-guard.js +18 -2
- package/framework/hooks/mode-governance.js +3 -3
- package/framework/hooks/read-protection.js +10 -2
- package/framework/i18n/en.yaml +6 -0
- package/framework/i18n/es.yaml +6 -0
- package/framework/i18n/fr.yaml +6 -0
- package/framework/i18n/pt.yaml +6 -0
- package/framework/orchestrator/chati.md +102 -6
- package/framework/schemas/task.schema.json +1 -1
- package/framework/tasks/architect-dep-audit.md +128 -0
- package/framework/tasks/architect-stack-selection.md +28 -0
- package/framework/workflows/brownfield-fullstack.yaml +2 -2
- package/framework/workflows/brownfield-service.yaml +2 -2
- package/framework/workflows/brownfield-ui.yaml +2 -2
- package/framework/workflows/greenfield-fullstack.yaml +6 -2
- package/framework/workflows/quick-flow.yaml +7 -5
- package/framework/workflows/standard-flow.yaml +171 -0
- package/package.json +5 -3
- package/src/api/index.js +129 -0
- package/src/autonomy/build-loop.js +93 -6
- package/src/autonomy/build-state.js +20 -2
- package/src/autonomy/cause-analyzer.js +177 -0
- package/src/autonomy/escalation.js +214 -0
- package/src/autonomy/safety-net.js +23 -5
- package/src/autonomy/worktree-manager.js +245 -0
- package/src/config/agent-customizer.js +227 -0
- package/src/config/ide-configs.js +57 -27
- package/src/decision/analyzer.js +148 -0
- package/src/decision/registry-healer.js +38 -21
- package/src/extensions/loader.js +151 -0
- package/src/extensions/registry.js +134 -0
- package/src/gates/circuit-breaker.js +32 -0
- package/src/gates/g3-implementation.js +30 -4
- package/src/gates/g4-qa-implementation.js +34 -5
- package/src/gates/gate-base.js +9 -0
- package/src/health/auto-fix.js +216 -0
- package/src/installer/core.js +24 -11
- package/src/installer/provider-overlay.js +82 -0
- package/src/installer/templates.js +22 -10
- package/src/installer/transaction.js +3 -2
- package/src/installer/validator.js +74 -0
- package/src/intelligence/context-status.js +9 -5
- package/src/intelligence/document-sharder.js +221 -0
- package/src/intelligence/elicitation.js +265 -0
- package/src/intelligence/timeline.js +5 -0
- package/src/memory/gotchas.js +78 -2
- package/src/merger/semantic-merger.js +292 -0
- package/src/orchestrator/agent-selector.js +20 -0
- package/src/orchestrator/handoff-engine.js +77 -0
- package/src/orchestrator/index.js +0 -8
- package/src/orchestrator/intent-classifier.js +182 -0
- package/src/orchestrator/pipeline-manager.js +125 -1
- package/src/orchestrator/session-manager.js +164 -2
- package/src/quality/metrics-collector.js +283 -0
- package/src/quality/test-runner.js +368 -0
- package/src/telemetry/collector.js +83 -0
- package/src/telemetry/config.js +119 -0
- package/src/telemetry/index.js +11 -0
- package/src/telemetry/schema.js +104 -0
- package/src/telemetry/sender.js +60 -0
- package/src/terminal/cli-registry.js +7 -1
- package/src/terminal/cost-tracker.js +197 -0
- package/src/terminal/handoff-parser.js +61 -4
- package/src/terminal/prompt-builder.js +56 -18
- package/src/terminal/rate-limiter.js +172 -0
- package/src/terminal/run-agent.js +39 -0
- package/src/terminal/run-parallel.js +22 -1
- package/src/terminal/spawner.js +181 -3
- package/src/upgrade/migrator.js +2 -2
- package/src/utils/event-bus.js +126 -0
- package/src/utils/file-lock.js +291 -0
- package/src/utils/schema-validator.js +226 -0
- package/src/wizard/i18n.js +11 -0
- package/src/wizard/index.js +42 -20
- package/src/wizard/questions.js +200 -39
- package/src/autonomy/execution-profile.js +0 -151
- package/src/intelligence/file-tracker.js +0 -117
- package/src/memory/gotchas-auto-capture.js +0 -253
- package/src/orchestrator/pipeline-state.js +0 -223
- 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
|
+
}
|