@lumenflow/cli 1.0.0 → 1.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/dist/__tests__/flow-report.test.js +24 -0
- package/dist/__tests__/metrics-snapshot.test.js +24 -0
- package/dist/agent-issues-query.js +251 -0
- package/dist/agent-log-issue.js +67 -0
- package/dist/agent-session-end.js +36 -0
- package/dist/agent-session.js +46 -0
- package/dist/flow-bottlenecks.js +183 -0
- package/dist/flow-report.js +311 -0
- package/dist/gates.js +126 -49
- package/dist/init.js +297 -0
- package/dist/initiative-bulk-assign-wus.js +315 -0
- package/dist/initiative-create.js +3 -7
- package/dist/initiative-edit.js +3 -3
- package/dist/metrics-snapshot.js +314 -0
- package/dist/orchestrate-init-status.js +64 -0
- package/dist/orchestrate-initiative.js +100 -0
- package/dist/orchestrate-monitor.js +90 -0
- package/dist/wu-claim.js +313 -116
- package/dist/wu-cleanup.js +49 -3
- package/dist/wu-create.js +195 -121
- package/dist/wu-delete.js +241 -0
- package/dist/wu-done.js +146 -23
- package/dist/wu-edit.js +152 -61
- package/dist/wu-infer-lane.js +2 -2
- package/dist/wu-spawn.js +77 -158
- package/dist/wu-unlock-lane.js +158 -0
- package/package.json +30 -10
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Flow Report Generator CLI (WU-1018)
|
|
4
|
+
*
|
|
5
|
+
* Generates DORA/SPACE flow reports from telemetry and WU data.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* pnpm flow:report # Default: last 7 days, JSON output
|
|
9
|
+
* pnpm flow:report --days 30 # Last 30 days
|
|
10
|
+
* pnpm flow:report --format table # Table output
|
|
11
|
+
* pnpm flow:report --start 2026-01-01 --end 2026-01-15
|
|
12
|
+
*
|
|
13
|
+
* @module flow-report
|
|
14
|
+
* @see {@link @lumenflow/metrics/flow/generate-flow-report}
|
|
15
|
+
*/
|
|
16
|
+
import { readFile } from 'node:fs/promises';
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import fg from 'fast-glob';
|
|
20
|
+
import { parse as parseYaml } from 'yaml';
|
|
21
|
+
import { Command } from 'commander';
|
|
22
|
+
import { generateFlowReport, TELEMETRY_PATHS, } from '@lumenflow/metrics';
|
|
23
|
+
import { die } from '@lumenflow/core/dist/error-handler.js';
|
|
24
|
+
/** Log prefix for console output */
|
|
25
|
+
const LOG_PREFIX = '[flow:report]';
|
|
26
|
+
/** Default report window in days */
|
|
27
|
+
const DEFAULT_DAYS = 7;
|
|
28
|
+
/** Output format options */
|
|
29
|
+
const OUTPUT_FORMATS = {
|
|
30
|
+
JSON: 'json',
|
|
31
|
+
TABLE: 'table',
|
|
32
|
+
};
|
|
33
|
+
/** WU directory relative to repo root */
|
|
34
|
+
const WU_DIR = 'docs/04-operations/tasks/wu';
|
|
35
|
+
/**
|
|
36
|
+
* Parse command line arguments
|
|
37
|
+
*/
|
|
38
|
+
function parseArgs() {
|
|
39
|
+
const program = new Command()
|
|
40
|
+
.name('flow-report')
|
|
41
|
+
.description('Generate DORA/SPACE flow report from telemetry and WU data')
|
|
42
|
+
.option('--start <date>', 'Start date (YYYY-MM-DD)')
|
|
43
|
+
.option('--end <date>', 'End date (YYYY-MM-DD), defaults to today')
|
|
44
|
+
.option('--days <number>', `Days to report (default: ${DEFAULT_DAYS})`, String(DEFAULT_DAYS))
|
|
45
|
+
.option('--format <type>', `Output format: json, table (default: json)`, OUTPUT_FORMATS.JSON)
|
|
46
|
+
.exitOverride();
|
|
47
|
+
try {
|
|
48
|
+
program.parse(process.argv);
|
|
49
|
+
return program.opts();
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
const error = err;
|
|
53
|
+
if (error.code === 'commander.helpDisplayed' || error.code === 'commander.version') {
|
|
54
|
+
process.exit(0);
|
|
55
|
+
}
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Calculate date range from options
|
|
61
|
+
*/
|
|
62
|
+
function calculateDateRange(opts) {
|
|
63
|
+
const end = opts.end ? new Date(opts.end) : new Date();
|
|
64
|
+
end.setHours(23, 59, 59, 999);
|
|
65
|
+
let start;
|
|
66
|
+
if (opts.start) {
|
|
67
|
+
start = new Date(opts.start);
|
|
68
|
+
start.setHours(0, 0, 0, 0);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
const days = parseInt(opts.days ?? String(DEFAULT_DAYS), 10);
|
|
72
|
+
start = new Date(end);
|
|
73
|
+
start.setDate(start.getDate() - days);
|
|
74
|
+
start.setHours(0, 0, 0, 0);
|
|
75
|
+
}
|
|
76
|
+
return { start, end };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Read NDJSON file and parse events
|
|
80
|
+
*/
|
|
81
|
+
async function readNDJSON(filePath) {
|
|
82
|
+
if (!existsSync(filePath)) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
const content = await readFile(filePath, { encoding: 'utf-8' });
|
|
86
|
+
const lines = content.split('\n').filter((line) => line.trim());
|
|
87
|
+
const events = [];
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
try {
|
|
90
|
+
events.push(JSON.parse(line));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Skip invalid JSON lines
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return events;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Transform raw gate event to GateTelemetryEvent type
|
|
100
|
+
*/
|
|
101
|
+
function transformGateEvent(raw) {
|
|
102
|
+
if (typeof raw.timestamp !== 'string' ||
|
|
103
|
+
typeof raw.gate_name !== 'string' ||
|
|
104
|
+
typeof raw.passed !== 'boolean' ||
|
|
105
|
+
typeof raw.duration_ms !== 'number') {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
timestamp: raw.timestamp,
|
|
110
|
+
wuId: raw.wu_id ?? null,
|
|
111
|
+
lane: raw.lane ?? null,
|
|
112
|
+
gateName: raw.gate_name,
|
|
113
|
+
passed: raw.passed,
|
|
114
|
+
durationMs: raw.duration_ms,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Transform raw LLM event to LLMTelemetryEvent type
|
|
119
|
+
*/
|
|
120
|
+
function transformLLMEvent(raw) {
|
|
121
|
+
if (typeof raw.timestamp !== 'string' ||
|
|
122
|
+
typeof raw.event_type !== 'string' ||
|
|
123
|
+
typeof raw.classification_type !== 'string') {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const eventType = raw.event_type;
|
|
127
|
+
if (eventType !== 'llm.classification.start' &&
|
|
128
|
+
eventType !== 'llm.classification.complete' &&
|
|
129
|
+
eventType !== 'llm.classification.error') {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
timestamp: raw.timestamp,
|
|
134
|
+
eventType,
|
|
135
|
+
classificationType: raw.classification_type,
|
|
136
|
+
durationMs: raw.duration_ms,
|
|
137
|
+
tokensUsed: raw.tokens_used,
|
|
138
|
+
estimatedCostUsd: raw.estimated_cost_usd,
|
|
139
|
+
confidence: raw.confidence,
|
|
140
|
+
fallbackUsed: raw.fallback_used,
|
|
141
|
+
fallbackReason: raw.fallback_reason,
|
|
142
|
+
errorType: raw.error_type,
|
|
143
|
+
errorMessage: raw.error_message,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Load gate telemetry events from file
|
|
148
|
+
*/
|
|
149
|
+
async function loadGateEvents(baseDir) {
|
|
150
|
+
const gatesPath = join(baseDir, TELEMETRY_PATHS.GATES);
|
|
151
|
+
const rawEvents = await readNDJSON(gatesPath);
|
|
152
|
+
return rawEvents.map(transformGateEvent).filter((e) => e !== null);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Load LLM telemetry events from file
|
|
156
|
+
*/
|
|
157
|
+
async function loadLLMEvents(baseDir) {
|
|
158
|
+
const llmPath = join(baseDir, TELEMETRY_PATHS.LLM_CLASSIFICATION);
|
|
159
|
+
const rawEvents = await readNDJSON(llmPath);
|
|
160
|
+
return rawEvents.map(transformLLMEvent).filter((e) => e !== null);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Load completed WUs from YAML files
|
|
164
|
+
*/
|
|
165
|
+
async function loadCompletedWUs(baseDir, start, end) {
|
|
166
|
+
const wuDir = join(baseDir, WU_DIR);
|
|
167
|
+
const wuFiles = await fg('WU-*.yaml', { cwd: wuDir, absolute: true });
|
|
168
|
+
const completedWUs = [];
|
|
169
|
+
for (const file of wuFiles) {
|
|
170
|
+
try {
|
|
171
|
+
const content = await readFile(file, { encoding: 'utf-8' });
|
|
172
|
+
const wu = parseYaml(content);
|
|
173
|
+
if (wu.status !== 'done' || !wu.completed_at) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const completedAt = new Date(wu.completed_at);
|
|
177
|
+
if (completedAt < start || completedAt > end) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
completedWUs.push({
|
|
181
|
+
id: wu.id,
|
|
182
|
+
title: wu.title,
|
|
183
|
+
lane: wu.lane,
|
|
184
|
+
status: 'done',
|
|
185
|
+
claimedAt: wu.claimed_at ? new Date(wu.claimed_at) : undefined,
|
|
186
|
+
completedAt,
|
|
187
|
+
cycleTimeHours: calculateCycleTime(wu),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// Skip invalid WU files
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return completedWUs;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Calculate cycle time in hours from WU data
|
|
198
|
+
*/
|
|
199
|
+
function calculateCycleTime(wu) {
|
|
200
|
+
if (!wu.claimed_at || !wu.completed_at) {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
const claimed = new Date(wu.claimed_at);
|
|
204
|
+
const completed = new Date(wu.completed_at);
|
|
205
|
+
const diffMs = completed.getTime() - claimed.getTime();
|
|
206
|
+
const diffHours = diffMs / (1000 * 60 * 60);
|
|
207
|
+
return Math.round(diffHours * 10) / 10; // Round to 1 decimal
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Filter events by date range
|
|
211
|
+
*/
|
|
212
|
+
function filterByDateRange(events, start, end) {
|
|
213
|
+
return events.filter((event) => {
|
|
214
|
+
const eventDate = new Date(event.timestamp);
|
|
215
|
+
return eventDate >= start && eventDate <= end;
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Format report as table output
|
|
220
|
+
*/
|
|
221
|
+
function formatAsTable(report) {
|
|
222
|
+
const lines = [];
|
|
223
|
+
lines.push('═══════════════════════════════════════════════════════════════');
|
|
224
|
+
lines.push(` FLOW REPORT: ${report.range.start} to ${report.range.end}`);
|
|
225
|
+
lines.push('═══════════════════════════════════════════════════════════════');
|
|
226
|
+
lines.push('');
|
|
227
|
+
// Gates section
|
|
228
|
+
lines.push('┌─────────────────────────────────────────────────────────────┐');
|
|
229
|
+
lines.push('│ GATES │');
|
|
230
|
+
lines.push('├─────────────────────────────────────────────────────────────┤');
|
|
231
|
+
lines.push(`│ Pass Rate: ${report.gates.passRate}% | Total: ${report.gates.total} | P95: ${report.gates.p95}ms │`);
|
|
232
|
+
lines.push(`│ Passed: ${report.gates.passed} | Failed: ${report.gates.failed}`);
|
|
233
|
+
lines.push('├─────────────────────────────────────────────────────────────┤');
|
|
234
|
+
lines.push('│ By Name: │');
|
|
235
|
+
for (const [name, stats] of Object.entries(report.gates.byName)) {
|
|
236
|
+
lines.push(`│ ${name.padEnd(20)} ${stats.passRate}% (${stats.passed}/${stats.total})`);
|
|
237
|
+
}
|
|
238
|
+
lines.push('└─────────────────────────────────────────────────────────────┘');
|
|
239
|
+
lines.push('');
|
|
240
|
+
// WUs section
|
|
241
|
+
lines.push('┌─────────────────────────────────────────────────────────────┐');
|
|
242
|
+
lines.push('│ WORK UNITS │');
|
|
243
|
+
lines.push('├─────────────────────────────────────────────────────────────┤');
|
|
244
|
+
lines.push(`│ Completed: ${report.wus.completed}`);
|
|
245
|
+
lines.push('├─────────────────────────────────────────────────────────────┤');
|
|
246
|
+
for (const wu of report.wus.list.slice(0, 10)) {
|
|
247
|
+
lines.push(`│ ${wu.wuId.padEnd(8)} ${wu.completedDate} ${wu.lane.padEnd(15)} ${wu.title.slice(0, 25)}`);
|
|
248
|
+
}
|
|
249
|
+
if (report.wus.list.length > 10) {
|
|
250
|
+
lines.push(`│ ... and ${report.wus.list.length - 10} more`);
|
|
251
|
+
}
|
|
252
|
+
lines.push('└─────────────────────────────────────────────────────────────┘');
|
|
253
|
+
lines.push('');
|
|
254
|
+
// LLM section
|
|
255
|
+
lines.push('┌─────────────────────────────────────────────────────────────┐');
|
|
256
|
+
lines.push('│ LLM CLASSIFICATION │');
|
|
257
|
+
lines.push('├─────────────────────────────────────────────────────────────┤');
|
|
258
|
+
lines.push(`│ Total: ${report.llm.totalClassifications} | Error Rate: ${report.llm.errorRate}%`);
|
|
259
|
+
lines.push(`│ Avg Latency: ${report.llm.avgLatencyMs}ms | P95: ${report.llm.p95LatencyMs}ms`);
|
|
260
|
+
lines.push(`│ Tokens: ${report.llm.totalTokens} | Cost: $${report.llm.totalCostUsd.toFixed(4)}`);
|
|
261
|
+
lines.push('└─────────────────────────────────────────────────────────────┘');
|
|
262
|
+
return lines.join('\n');
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Main function
|
|
266
|
+
*/
|
|
267
|
+
async function main() {
|
|
268
|
+
const opts = parseArgs();
|
|
269
|
+
const baseDir = process.cwd();
|
|
270
|
+
console.log(`${LOG_PREFIX} Generating flow report...`);
|
|
271
|
+
const { start, end } = calculateDateRange(opts);
|
|
272
|
+
console.log(`${LOG_PREFIX} Date range: ${start.toISOString().split('T')[0]} to ${end.toISOString().split('T')[0]}`);
|
|
273
|
+
// Load telemetry and WU data
|
|
274
|
+
const [gateEvents, llmEvents, completedWUs] = await Promise.all([
|
|
275
|
+
loadGateEvents(baseDir),
|
|
276
|
+
loadLLMEvents(baseDir),
|
|
277
|
+
loadCompletedWUs(baseDir, start, end),
|
|
278
|
+
]);
|
|
279
|
+
// Filter events by date range
|
|
280
|
+
const filteredGateEvents = filterByDateRange(gateEvents, start, end);
|
|
281
|
+
const filteredLLMEvents = filterByDateRange(llmEvents, start, end);
|
|
282
|
+
console.log(`${LOG_PREFIX} Found ${filteredGateEvents.length} gate events`);
|
|
283
|
+
console.log(`${LOG_PREFIX} Found ${filteredLLMEvents.length} LLM events`);
|
|
284
|
+
console.log(`${LOG_PREFIX} Found ${completedWUs.length} completed WUs`);
|
|
285
|
+
// Generate report
|
|
286
|
+
const input = {
|
|
287
|
+
gateEvents: filteredGateEvents,
|
|
288
|
+
llmEvents: filteredLLMEvents,
|
|
289
|
+
completedWUs,
|
|
290
|
+
dateRange: {
|
|
291
|
+
start: start.toISOString().split('T')[0],
|
|
292
|
+
end: end.toISOString().split('T')[0],
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
const report = generateFlowReport(input);
|
|
296
|
+
// Output report
|
|
297
|
+
console.log('');
|
|
298
|
+
if (opts.format === OUTPUT_FORMATS.TABLE) {
|
|
299
|
+
console.log(formatAsTable(report));
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
console.log(JSON.stringify(report, null, 2));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// Guard main() for testability (WU-1366)
|
|
306
|
+
import { fileURLToPath } from 'node:url';
|
|
307
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
308
|
+
main().catch((err) => {
|
|
309
|
+
die(`Flow report failed: ${err.message}`);
|
|
310
|
+
});
|
|
311
|
+
}
|
package/dist/gates.js
CHANGED
|
@@ -42,10 +42,11 @@ import { execSync, spawnSync } from 'node:child_process';
|
|
|
42
42
|
import { closeSync, mkdirSync, openSync, readSync, statSync, writeSync } from 'node:fs';
|
|
43
43
|
import { access } from 'node:fs/promises';
|
|
44
44
|
import path from 'node:path';
|
|
45
|
+
import { fileURLToPath } from 'node:url';
|
|
45
46
|
import { emitGateEvent, getCurrentWU, getCurrentLane } from '@lumenflow/core/dist/telemetry.js';
|
|
46
47
|
import { die } from '@lumenflow/core/dist/error-handler.js';
|
|
47
48
|
import { getChangedLintableFiles, convertToPackageRelativePaths, } from '@lumenflow/core/dist/incremental-lint.js';
|
|
48
|
-
import {
|
|
49
|
+
import { isCodeFilePath } from '@lumenflow/core/dist/incremental-test.js';
|
|
49
50
|
import { getGitForCwd } from '@lumenflow/core/dist/git-adapter.js';
|
|
50
51
|
import { runCoverageGate, COVERAGE_GATE_MODES } from '@lumenflow/core/dist/coverage-gate.js';
|
|
51
52
|
import { buildGatesLogPath, shouldUseGatesAgentMode, updateGatesLatestSymlink, } from '@lumenflow/core/dist/gates-agent-mode.js';
|
|
@@ -55,42 +56,35 @@ import { detectRiskTier, RISK_TIERS, } from '@lumenflow/core/dist/risk-detector.
|
|
|
55
56
|
// WU-2252: Import invariants runner for first-check validation
|
|
56
57
|
import { runInvariants } from '@lumenflow/core/dist/invariants-runner.js';
|
|
57
58
|
import { Command } from 'commander';
|
|
58
|
-
import { BRANCHES, PACKAGES, PKG_MANAGER, PKG_FLAGS, ESLINT_FLAGS, ESLINT_COMMANDS, ESLINT_DEFAULTS, SCRIPTS, CACHE_STRATEGIES, DIRECTORIES, GATE_NAMES, GATE_COMMANDS, TOOL_PATHS, CLI_MODES, EXIT_CODES, FILE_SYSTEM, } from '@lumenflow/core/dist/wu-constants.js';
|
|
59
|
+
import { BRANCHES, PACKAGES, PKG_MANAGER, PKG_FLAGS, ESLINT_FLAGS, ESLINT_COMMANDS, ESLINT_DEFAULTS, SCRIPTS, CACHE_STRATEGIES, DIRECTORIES, GATE_NAMES, GATE_COMMANDS, TOOL_PATHS, CLI_MODES, EXIT_CODES, FILE_SYSTEM, PRETTIER_ARGS, PRETTIER_FLAGS, } from '@lumenflow/core/dist/wu-constants.js';
|
|
59
60
|
// WU-2457: Add Commander.js for --help support
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const isFullLint = opts.fullLint || false;
|
|
88
|
-
const isFullTests = opts.fullTests || false;
|
|
89
|
-
// WU-2244: Full coverage flag forces full test suite and coverage gate (deterministic)
|
|
90
|
-
const isFullCoverage = opts.fullCoverage || false;
|
|
91
|
-
// WU-1433: Coverage gate mode (warn or block)
|
|
92
|
-
// WU-2334: Default changed from WARN to BLOCK for TDD enforcement
|
|
93
|
-
const coverageMode = opts.coverageMode || COVERAGE_GATE_MODES.BLOCK;
|
|
61
|
+
function parseGatesArgs(argv = process.argv) {
|
|
62
|
+
// WU-2465: Pre-filter argv to handle pnpm's `--` separator
|
|
63
|
+
// When invoked via `pnpm gates -- --docs-only`, pnpm passes ["--", "--docs-only"]
|
|
64
|
+
// Commander treats `--` as "everything after is positional", causing errors.
|
|
65
|
+
// Solution: Remove standalone `--` from argv before parsing.
|
|
66
|
+
const filteredArgv = argv.filter((arg, index, arr) => {
|
|
67
|
+
// Keep `--` only if it's followed by a non-option (actual positional arg)
|
|
68
|
+
// Remove it if it's followed by an option (starts with -)
|
|
69
|
+
if (arg === '--') {
|
|
70
|
+
const nextArg = arr[index + 1];
|
|
71
|
+
return nextArg && !nextArg.startsWith('-');
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
});
|
|
75
|
+
const program = new Command()
|
|
76
|
+
.name('gates')
|
|
77
|
+
.description('Run quality gates with support for docs-only mode, incremental linting, and tiered testing')
|
|
78
|
+
.option('--docs-only', 'Run docs-only gates (format, spec-linter, prompts-lint, backlog-sync)')
|
|
79
|
+
.option('--full-lint', 'Run full lint instead of incremental')
|
|
80
|
+
.option('--full-tests', 'Run full test suite instead of incremental')
|
|
81
|
+
.option('--full-coverage', 'Force full test suite and coverage gate (implies --full-tests)')
|
|
82
|
+
.option('--coverage-mode <mode>', 'Coverage gate mode: "warn" logs warnings, "block" fails gate (default)', 'block')
|
|
83
|
+
.option('--verbose', 'Stream output in agent mode instead of logging to file')
|
|
84
|
+
.helpOption('-h, --help', 'Display help for command');
|
|
85
|
+
program.parse(filteredArgv);
|
|
86
|
+
return program.opts();
|
|
87
|
+
}
|
|
94
88
|
/**
|
|
95
89
|
* Build a pnpm command string
|
|
96
90
|
*/
|
|
@@ -104,6 +98,60 @@ function pnpmRun(script, ...args) {
|
|
|
104
98
|
const argsStr = args.length > 0 ? ` ${args.join(' ')}` : '';
|
|
105
99
|
return `${PKG_MANAGER} ${SCRIPTS.RUN} ${script}${argsStr}`;
|
|
106
100
|
}
|
|
101
|
+
export function parsePrettierListOutput(output) {
|
|
102
|
+
if (!output)
|
|
103
|
+
return [];
|
|
104
|
+
return output
|
|
105
|
+
.split(/\r?\n/)
|
|
106
|
+
.map((line) => line.trim())
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
.map((line) => line.replace(/^\[error\]\s*/i, '').trim())
|
|
109
|
+
.filter((line) => !line.toLowerCase().includes('code style issues found') &&
|
|
110
|
+
!line.toLowerCase().includes('all matched files use prettier') &&
|
|
111
|
+
!line.toLowerCase().includes('checking formatting'));
|
|
112
|
+
}
|
|
113
|
+
export function buildPrettierWriteCommand(files) {
|
|
114
|
+
const quotedFiles = files.map((file) => `"${file}"`).join(' ');
|
|
115
|
+
const base = pnpmCmd(SCRIPTS.PRETTIER, PRETTIER_FLAGS.WRITE);
|
|
116
|
+
return quotedFiles ? `${base} ${quotedFiles}` : base;
|
|
117
|
+
}
|
|
118
|
+
export function formatFormatCheckGuidance(files) {
|
|
119
|
+
if (!files.length)
|
|
120
|
+
return [];
|
|
121
|
+
const command = buildPrettierWriteCommand(files);
|
|
122
|
+
return [
|
|
123
|
+
'',
|
|
124
|
+
'❌ format:check failed',
|
|
125
|
+
'Fix with:',
|
|
126
|
+
` ${command}`,
|
|
127
|
+
'',
|
|
128
|
+
'Affected files:',
|
|
129
|
+
...files.map((file) => ` - ${file}`),
|
|
130
|
+
'',
|
|
131
|
+
];
|
|
132
|
+
}
|
|
133
|
+
function collectPrettierListDifferent(cwd) {
|
|
134
|
+
const cmd = pnpmCmd(SCRIPTS.PRETTIER, PRETTIER_ARGS.LIST_DIFFERENT, '.');
|
|
135
|
+
const result = spawnSync(cmd, [], {
|
|
136
|
+
shell: true,
|
|
137
|
+
cwd,
|
|
138
|
+
encoding: FILE_SYSTEM.ENCODING,
|
|
139
|
+
});
|
|
140
|
+
const output = `${result.stdout || ''}\n${result.stderr || ''}`;
|
|
141
|
+
return parsePrettierListOutput(output);
|
|
142
|
+
}
|
|
143
|
+
function emitFormatCheckGuidance({ agentLog, useAgentMode, }) {
|
|
144
|
+
const files = collectPrettierListDifferent(process.cwd());
|
|
145
|
+
if (!files.length)
|
|
146
|
+
return;
|
|
147
|
+
const lines = formatFormatCheckGuidance(files);
|
|
148
|
+
const logLine = useAgentMode && agentLog
|
|
149
|
+
? (line) => writeSync(agentLog.logFd, `${line}\n`)
|
|
150
|
+
: (line) => console.log(line);
|
|
151
|
+
for (const line of lines) {
|
|
152
|
+
logLine(line);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
107
155
|
/**
|
|
108
156
|
* Build a pnpm --filter command string
|
|
109
157
|
*/
|
|
@@ -184,6 +232,15 @@ async function runIncrementalLint({ agentLog, } = {}) {
|
|
|
184
232
|
}
|
|
185
233
|
writeSync(agentLog.logFd, `${line}\n`);
|
|
186
234
|
};
|
|
235
|
+
// WU-1006: Skip incremental lint if apps/web doesn't exist (repo-agnostic)
|
|
236
|
+
const webDir = path.join(process.cwd(), DIRECTORIES.APPS_WEB);
|
|
237
|
+
try {
|
|
238
|
+
await access(webDir);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
logLine('\n> ESLint (incremental) skipped (apps/web not present)\n');
|
|
242
|
+
return { ok: true, duration: Date.now() - start, fileCount: 0 };
|
|
243
|
+
}
|
|
187
244
|
try {
|
|
188
245
|
// Check if we're on main branch
|
|
189
246
|
const git = getGitForCwd();
|
|
@@ -298,15 +355,11 @@ async function runChangedTests({ agentLog, } = {}) {
|
|
|
298
355
|
const result = run(pnpmCmd('turbo', 'run', 'test'), { agentLog });
|
|
299
356
|
return { ...result, duration: Date.now() - start, isIncremental: false };
|
|
300
357
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
307
|
-
logLine('\n> Vitest (changed: turbo --affected)\n');
|
|
308
|
-
const result = run(pnpmCmd('turbo', 'run', 'test:changed', '--affected'), { agentLog });
|
|
309
|
-
return { ...result, duration: Date.now() - start, isIncremental: true };
|
|
358
|
+
// WU-1006: Use turbo for tests (repo-agnostic)
|
|
359
|
+
// Previously used --project tools and test:changed which don't exist in all repos
|
|
360
|
+
logLine('\n> Running tests (turbo run test)\n');
|
|
361
|
+
const result = run(pnpmCmd('turbo', 'run', 'test'), { agentLog });
|
|
362
|
+
return { ...result, duration: Date.now() - start, isIncremental: false };
|
|
310
363
|
}
|
|
311
364
|
catch (error) {
|
|
312
365
|
console.error('⚠️ Changed tests failed, falling back to full suite:', error.message);
|
|
@@ -359,6 +412,15 @@ async function runSafetyCriticalTests({ agentLog, } = {}) {
|
|
|
359
412
|
}
|
|
360
413
|
writeSync(agentLog.logFd, `${line}\n`);
|
|
361
414
|
};
|
|
415
|
+
// WU-1006: Skip safety-critical tests if apps/web doesn't exist (repo-agnostic)
|
|
416
|
+
const webDir = path.join(process.cwd(), DIRECTORIES.APPS_WEB);
|
|
417
|
+
try {
|
|
418
|
+
await access(webDir);
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
logLine('\n> Safety-critical tests skipped (apps/web not present)\n');
|
|
422
|
+
return { ok: true, duration: Date.now() - start, testCount: 0 };
|
|
423
|
+
}
|
|
362
424
|
try {
|
|
363
425
|
logLine('\n> Safety-critical tests (always run)\n');
|
|
364
426
|
logLine(`Test files: ${SAFETY_CRITICAL_TEST_FILES.length} files\n`);
|
|
@@ -466,6 +528,16 @@ const agentLog = useAgentMode ? createAgentLogContext({ wuId: wu_id, lane }) : n
|
|
|
466
528
|
// Main execution
|
|
467
529
|
// eslint-disable-next-line sonarjs/cognitive-complexity -- Pre-existing: main() orchestrates multi-step gate workflow
|
|
468
530
|
async function main() {
|
|
531
|
+
const opts = parseGatesArgs();
|
|
532
|
+
// Parse command line arguments (now via Commander)
|
|
533
|
+
const isDocsOnly = opts.docsOnly || false;
|
|
534
|
+
const isFullLint = opts.fullLint || false;
|
|
535
|
+
const isFullTests = opts.fullTests || false;
|
|
536
|
+
// WU-2244: Full coverage flag forces full test suite and coverage gate (deterministic)
|
|
537
|
+
const isFullCoverage = opts.fullCoverage || false;
|
|
538
|
+
// WU-1433: Coverage gate mode (warn or block)
|
|
539
|
+
// WU-2334: Default changed from WARN to BLOCK for TDD enforcement
|
|
540
|
+
const coverageMode = opts.coverageMode || COVERAGE_GATE_MODES.BLOCK;
|
|
469
541
|
if (useAgentMode) {
|
|
470
542
|
console.log(`🧾 gates (agent mode): output -> ${agentLog.logPath} (use --verbose for streaming)\n`);
|
|
471
543
|
}
|
|
@@ -656,6 +728,9 @@ async function main() {
|
|
|
656
728
|
}
|
|
657
729
|
continue;
|
|
658
730
|
}
|
|
731
|
+
if (gate.name === GATE_NAMES.FORMAT_CHECK) {
|
|
732
|
+
emitFormatCheckGuidance({ agentLog, useAgentMode });
|
|
733
|
+
}
|
|
659
734
|
if (useAgentMode) {
|
|
660
735
|
const tail = readLogTail(agentLog.logPath);
|
|
661
736
|
console.error(`\n❌ ${gate.name} failed (agent mode). Log: ${agentLog.logPath}\n`);
|
|
@@ -678,7 +753,9 @@ async function main() {
|
|
|
678
753
|
}
|
|
679
754
|
process.exit(EXIT_CODES.SUCCESS);
|
|
680
755
|
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
756
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
757
|
+
main().catch((error) => {
|
|
758
|
+
console.error('Gates failed:', error);
|
|
759
|
+
process.exit(EXIT_CODES.ERROR);
|
|
760
|
+
});
|
|
761
|
+
}
|