@lumenflow/cli 2.7.0 → 2.9.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/README.md +121 -105
- package/dist/__tests__/agent-spawn-coordination.test.js +451 -0
- package/dist/__tests__/commands/integrate.test.js +165 -0
- package/dist/__tests__/commands.test.js +75 -0
- package/dist/__tests__/doctor.test.js +510 -0
- package/dist/__tests__/gates-config.test.js +0 -1
- package/dist/__tests__/hooks/enforcement.test.js +279 -0
- package/dist/__tests__/init-greenfield.test.js +247 -0
- package/dist/__tests__/init-quick-ref.test.js +0 -1
- package/dist/__tests__/init-template-portability.test.js +0 -1
- package/dist/__tests__/init.test.js +249 -0
- package/dist/__tests__/initiative-e2e.test.js +442 -0
- package/dist/__tests__/initiative-plan-replacement.test.js +0 -1
- package/dist/__tests__/memory-integration.test.js +333 -0
- package/dist/__tests__/release.test.js +1 -1
- package/dist/__tests__/safe-git.test.js +0 -1
- package/dist/__tests__/state-doctor.test.js +54 -0
- package/dist/__tests__/sync-templates.test.js +255 -0
- package/dist/__tests__/wu-create-required-fields.test.js +121 -0
- package/dist/__tests__/wu-done-auto-cleanup.test.js +135 -0
- package/dist/__tests__/wu-lifecycle-integration.test.js +388 -0
- package/dist/backlog-prune.js +0 -1
- package/dist/cli-entry-point.js +0 -1
- package/dist/commands/integrate.js +229 -0
- package/dist/commands.js +171 -0
- package/dist/docs-sync.js +46 -0
- package/dist/doctor.js +479 -10
- package/dist/gates.js +0 -7
- package/dist/hooks/enforcement-checks.js +209 -0
- package/dist/hooks/enforcement-generator.js +365 -0
- package/dist/hooks/enforcement-sync.js +243 -0
- package/dist/hooks/index.js +7 -0
- package/dist/init.js +502 -17
- package/dist/initiative-add-wu.js +0 -2
- package/dist/initiative-create.js +0 -3
- package/dist/initiative-edit.js +0 -5
- package/dist/initiative-plan.js +0 -1
- package/dist/initiative-remove-wu.js +0 -2
- package/dist/lane-health.js +0 -2
- package/dist/lane-suggest.js +0 -1
- package/dist/mem-checkpoint.js +0 -2
- package/dist/mem-cleanup.js +0 -2
- package/dist/mem-context.js +0 -3
- package/dist/mem-create.js +0 -2
- package/dist/mem-delete.js +0 -3
- package/dist/mem-inbox.js +0 -2
- package/dist/mem-index.js +0 -1
- package/dist/mem-init.js +0 -2
- package/dist/mem-profile.js +0 -1
- package/dist/mem-promote.js +0 -1
- package/dist/mem-ready.js +0 -2
- package/dist/mem-signal.js +0 -2
- package/dist/mem-start.js +0 -2
- package/dist/mem-summarize.js +0 -2
- package/dist/metrics-cli.js +1 -1
- package/dist/metrics-snapshot.js +1 -1
- package/dist/onboarding-smoke-test.js +0 -5
- package/dist/orchestrate-init-status.js +0 -1
- package/dist/orchestrate-initiative.js +0 -1
- package/dist/orchestrate-monitor.js +0 -1
- package/dist/plan-create.js +0 -2
- package/dist/plan-edit.js +0 -2
- package/dist/plan-link.js +0 -2
- package/dist/plan-promote.js +0 -2
- package/dist/signal-cleanup.js +0 -4
- package/dist/state-bootstrap.js +0 -1
- package/dist/state-cleanup.js +0 -4
- package/dist/state-doctor-fix.js +5 -8
- package/dist/state-doctor.js +0 -11
- package/dist/sync-templates.js +188 -34
- package/dist/wu-block.js +100 -48
- package/dist/wu-claim.js +1 -22
- package/dist/wu-cleanup.js +0 -1
- package/dist/wu-create.js +0 -2
- package/dist/wu-done-auto-cleanup.js +139 -0
- package/dist/wu-done.js +11 -4
- package/dist/wu-edit.js +0 -12
- package/dist/wu-preflight.js +0 -1
- package/dist/wu-prep.js +0 -1
- package/dist/wu-proto.js +0 -1
- package/dist/wu-spawn.js +0 -3
- package/dist/wu-unblock.js +0 -2
- package/dist/wu-validate.js +0 -1
- package/package.json +9 -7
package/dist/doctor.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
/* eslint-disable no-console -- CLI command uses console for status output */
|
|
2
1
|
/**
|
|
3
2
|
* @file doctor.ts
|
|
4
3
|
* LumenFlow health check command (WU-1177)
|
|
5
4
|
* WU-1191: Lane health check integration
|
|
5
|
+
* WU-1386: Agent-friction checks (managed-file dirty, WU validity, worktree sanity)
|
|
6
6
|
* Verifies all safety components are installed and configured correctly
|
|
7
7
|
*/
|
|
8
8
|
import * as fs from 'node:fs';
|
|
@@ -10,8 +10,20 @@ import * as path from 'node:path';
|
|
|
10
10
|
import { execFileSync } from 'node:child_process';
|
|
11
11
|
import { createWUParser } from '@lumenflow/core';
|
|
12
12
|
import { loadLaneDefinitions, detectLaneOverlaps } from './lane-health.js';
|
|
13
|
+
/**
|
|
14
|
+
* WU-1386: Managed files that should not have uncommitted changes
|
|
15
|
+
*/
|
|
16
|
+
const MANAGED_FILE_PATTERNS = [
|
|
17
|
+
'.lumenflow.config.yaml',
|
|
18
|
+
'.lumenflow.lane-inference.yaml',
|
|
19
|
+
'AGENTS.md',
|
|
20
|
+
'CLAUDE.md',
|
|
21
|
+
];
|
|
22
|
+
/** WU-1386: Managed directories with glob patterns */
|
|
23
|
+
const MANAGED_DIR_PATTERNS = ['docs/04-operations/tasks/'];
|
|
13
24
|
/**
|
|
14
25
|
* CLI option definitions for doctor command
|
|
26
|
+
* WU-1386: Added --deep flag
|
|
15
27
|
*/
|
|
16
28
|
const DOCTOR_OPTIONS = {
|
|
17
29
|
verbose: {
|
|
@@ -24,9 +36,15 @@ const DOCTOR_OPTIONS = {
|
|
|
24
36
|
flags: '--json',
|
|
25
37
|
description: 'Output results as JSON',
|
|
26
38
|
},
|
|
39
|
+
deep: {
|
|
40
|
+
name: 'deep',
|
|
41
|
+
flags: '--deep',
|
|
42
|
+
description: 'Run heavier checks including WU validation (slower)',
|
|
43
|
+
},
|
|
27
44
|
};
|
|
28
45
|
/**
|
|
29
46
|
* Parse doctor command options
|
|
47
|
+
* WU-1386: Added deep flag
|
|
30
48
|
*/
|
|
31
49
|
export function parseDoctorOptions() {
|
|
32
50
|
const opts = createWUParser({
|
|
@@ -37,6 +55,7 @@ export function parseDoctorOptions() {
|
|
|
37
55
|
return {
|
|
38
56
|
verbose: opts.verbose ?? false,
|
|
39
57
|
json: opts.json ?? false,
|
|
58
|
+
deep: opts.deep ?? false,
|
|
40
59
|
};
|
|
41
60
|
}
|
|
42
61
|
/**
|
|
@@ -167,7 +186,6 @@ function getCommandVersion(command, args) {
|
|
|
167
186
|
* Parse semver version string to compare
|
|
168
187
|
*/
|
|
169
188
|
function parseVersion(versionStr) {
|
|
170
|
-
// eslint-disable-next-line sonarjs/slow-regex, sonarjs/prefer-regexp-exec -- Simple semver extraction, no backtracking risk
|
|
171
189
|
const match = versionStr.match(/(\d+)\.(\d+)\.?(\d+)?/);
|
|
172
190
|
if (!match) {
|
|
173
191
|
return [0, 0, 0];
|
|
@@ -254,10 +272,314 @@ function checkPrerequisites() {
|
|
|
254
272
|
},
|
|
255
273
|
};
|
|
256
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* WU-1387: Get git repository root directory
|
|
277
|
+
* This ensures managed-file detection works from subdirectories
|
|
278
|
+
*/
|
|
279
|
+
function getGitRepoRoot(projectDir) {
|
|
280
|
+
try {
|
|
281
|
+
const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
282
|
+
cwd: projectDir,
|
|
283
|
+
encoding: 'utf-8',
|
|
284
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
285
|
+
}).trim();
|
|
286
|
+
return repoRoot;
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* WU-1386: Check for uncommitted changes to managed files
|
|
294
|
+
* WU-1387: Uses git repo root for path resolution (works from subdirectories)
|
|
295
|
+
*/
|
|
296
|
+
async function checkManagedFilesDirty(projectDir) {
|
|
297
|
+
try {
|
|
298
|
+
// WU-1387: Get git repo root to handle subdirectory execution
|
|
299
|
+
const repoRoot = getGitRepoRoot(projectDir);
|
|
300
|
+
if (!repoRoot) {
|
|
301
|
+
return {
|
|
302
|
+
passed: true,
|
|
303
|
+
files: [],
|
|
304
|
+
message: 'Git status check skipped (not a git repository)',
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
// Run git status from repo root, not the passed projectDir
|
|
308
|
+
const statusOutput = execFileSync('git', ['status', '--porcelain'], {
|
|
309
|
+
cwd: repoRoot,
|
|
310
|
+
encoding: 'utf-8',
|
|
311
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
312
|
+
});
|
|
313
|
+
const dirtyFiles = [];
|
|
314
|
+
// Parse status output and check each line
|
|
315
|
+
const lines = statusOutput.split('\n').filter((line) => line.trim());
|
|
316
|
+
for (const line of lines) {
|
|
317
|
+
// Status format: XY filename (where XY is 2-char status)
|
|
318
|
+
const filePath = line.slice(3).trim();
|
|
319
|
+
if (!filePath)
|
|
320
|
+
continue;
|
|
321
|
+
// Check if file matches managed patterns
|
|
322
|
+
const isManaged = MANAGED_FILE_PATTERNS.some((pattern) => filePath === pattern) ||
|
|
323
|
+
MANAGED_DIR_PATTERNS.some((dir) => filePath.startsWith(dir));
|
|
324
|
+
if (isManaged) {
|
|
325
|
+
dirtyFiles.push(filePath);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (dirtyFiles.length > 0) {
|
|
329
|
+
return {
|
|
330
|
+
passed: false,
|
|
331
|
+
files: dirtyFiles,
|
|
332
|
+
message: `${dirtyFiles.length} managed file(s) have uncommitted changes`,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
passed: true,
|
|
337
|
+
files: [],
|
|
338
|
+
message: 'No uncommitted changes to managed files',
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
// Not a git repo or git not available - graceful degradation
|
|
343
|
+
return {
|
|
344
|
+
passed: true,
|
|
345
|
+
files: [],
|
|
346
|
+
message: 'Git status check skipped (not a git repository)',
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function parseWorktreePruneOutput(output) {
|
|
351
|
+
// Parse summary line for orphan directories: "Orphan directories: N"
|
|
352
|
+
const orphanSummaryMatch = output.match(/Orphan directories:\s*(\d+)/);
|
|
353
|
+
const orphansFromSummary = orphanSummaryMatch ? parseInt(orphanSummaryMatch[1], 10) : 0;
|
|
354
|
+
// Also count "orphan director" mentions (the detailed output)
|
|
355
|
+
const orphanDetailMatches = output.match(/orphan director/gi) || [];
|
|
356
|
+
const orphansFromDetail = orphanDetailMatches.length;
|
|
357
|
+
// Use the higher count (summary is authoritative, but detail catches other mentions)
|
|
358
|
+
const orphans = Math.max(orphansFromSummary, orphansFromDetail > 0 ? 1 : 0);
|
|
359
|
+
// Parse specific worktree warnings from wu:prune output
|
|
360
|
+
// These appear as "Stale worktree:", "Blocked worktree:", "Unclaimed worktree:"
|
|
361
|
+
const staleCount = (output.match(/Stale worktree:/gi) || []).length;
|
|
362
|
+
const blockedCount = (output.match(/Blocked worktree:/gi) || []).length;
|
|
363
|
+
const unclaimedCount = (output.match(/Unclaimed worktree:/gi) || []).length;
|
|
364
|
+
// Parse missing tracked worktrees: "Missing tracked worktree" header
|
|
365
|
+
const missingMatch = output.match(/Missing tracked worktree/gi) || [];
|
|
366
|
+
const missingCount = missingMatch.length > 0 ? 1 : 0; // At least one if header present
|
|
367
|
+
// Also count specific missing path lines
|
|
368
|
+
const missingPathMatches = output.match(/worktrees \(tracked by git but directory missing\)/gi);
|
|
369
|
+
const missingFromDetail = missingPathMatches ? missingPathMatches.length : 0;
|
|
370
|
+
// Parse summary warnings and errors
|
|
371
|
+
const warningSummaryMatch = output.match(/Warnings:\s*(\d+)/);
|
|
372
|
+
const warnings = warningSummaryMatch ? parseInt(warningSummaryMatch[1], 10) : 0;
|
|
373
|
+
const errorSummaryMatch = output.match(/Errors:\s*(\d+)/);
|
|
374
|
+
const errors = errorSummaryMatch ? parseInt(errorSummaryMatch[1], 10) : 0;
|
|
375
|
+
return {
|
|
376
|
+
orphans,
|
|
377
|
+
stale: staleCount,
|
|
378
|
+
blocked: blockedCount,
|
|
379
|
+
unclaimed: unclaimedCount,
|
|
380
|
+
missing: Math.max(missingCount, missingFromDetail),
|
|
381
|
+
warnings,
|
|
382
|
+
errors,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* WU-1386: Check worktree sanity by calling wu:prune in dry-run mode
|
|
387
|
+
* WU-1387: Enhanced parsing for orphan, missing, stale, blocked, and unclaimed worktrees
|
|
388
|
+
*/
|
|
389
|
+
async function checkWorktreeSanity(projectDir) {
|
|
390
|
+
try {
|
|
391
|
+
// First check if this is a git repo
|
|
392
|
+
execFileSync('git', ['rev-parse', '--git-dir'], {
|
|
393
|
+
cwd: projectDir,
|
|
394
|
+
encoding: 'utf-8',
|
|
395
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
396
|
+
});
|
|
397
|
+
// Call wu:prune in dry-run mode (default) via pnpm
|
|
398
|
+
// Capture both stdout and stderr to parse the output
|
|
399
|
+
let pruneOutput;
|
|
400
|
+
let commandFailed = false;
|
|
401
|
+
try {
|
|
402
|
+
pruneOutput = execFileSync('pnpm', ['wu:prune'], {
|
|
403
|
+
cwd: projectDir,
|
|
404
|
+
encoding: 'utf-8',
|
|
405
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
406
|
+
timeout: 30000, // 30 second timeout
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
catch (e) {
|
|
410
|
+
const execError = e;
|
|
411
|
+
pruneOutput = (execError.stdout || '') + (execError.stderr || '');
|
|
412
|
+
// Check if this is a "command not found" type error vs wu:prune finding issues
|
|
413
|
+
// If pnpm can't run the script (no package.json, script not defined, etc.), gracefully skip
|
|
414
|
+
const errorMsg = execError.message || pruneOutput || '';
|
|
415
|
+
if (errorMsg.includes('ERR_PNPM') ||
|
|
416
|
+
errorMsg.includes('ENOENT') ||
|
|
417
|
+
errorMsg.includes('Missing script') ||
|
|
418
|
+
errorMsg.includes('command not found') ||
|
|
419
|
+
!pruneOutput.includes('[wu-prune]')) {
|
|
420
|
+
// Command couldn't run - gracefully skip
|
|
421
|
+
return {
|
|
422
|
+
passed: true,
|
|
423
|
+
orphans: 0,
|
|
424
|
+
stale: 0,
|
|
425
|
+
message: 'Worktree check skipped (wu:prune not available)',
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
// Otherwise, wu:prune ran but found issues (non-zero exit)
|
|
429
|
+
commandFailed = true;
|
|
430
|
+
}
|
|
431
|
+
// WU-1387: Enhanced parsing for all worktree issue types
|
|
432
|
+
const parsed = parseWorktreePruneOutput(pruneOutput);
|
|
433
|
+
// Calculate total issues (orphans count as issues, plus stale, blocked, unclaimed, missing)
|
|
434
|
+
const totalIssues = parsed.orphans + parsed.stale + parsed.blocked + parsed.unclaimed + parsed.missing;
|
|
435
|
+
const hasIssues = totalIssues > 0 || parsed.warnings > 0 || parsed.errors > 0;
|
|
436
|
+
const passed = !hasIssues && !commandFailed;
|
|
437
|
+
// Build descriptive message
|
|
438
|
+
let message = 'All worktrees are valid';
|
|
439
|
+
if (!passed) {
|
|
440
|
+
const parts = [];
|
|
441
|
+
if (parsed.orphans > 0)
|
|
442
|
+
parts.push(`${parsed.orphans} orphan(s)`);
|
|
443
|
+
if (parsed.missing > 0)
|
|
444
|
+
parts.push(`${parsed.missing} missing`);
|
|
445
|
+
if (parsed.stale > 0)
|
|
446
|
+
parts.push(`${parsed.stale} stale`);
|
|
447
|
+
if (parsed.blocked > 0)
|
|
448
|
+
parts.push(`${parsed.blocked} blocked`);
|
|
449
|
+
if (parsed.unclaimed > 0)
|
|
450
|
+
parts.push(`${parsed.unclaimed} unclaimed`);
|
|
451
|
+
if (parts.length === 0 && (parsed.warnings > 0 || parsed.errors > 0)) {
|
|
452
|
+
parts.push(`${parsed.warnings} warning(s), ${parsed.errors} error(s)`);
|
|
453
|
+
}
|
|
454
|
+
message = parts.length > 0 ? `Worktree issues: ${parts.join(', ')}` : 'Worktree issues found';
|
|
455
|
+
}
|
|
456
|
+
return {
|
|
457
|
+
passed,
|
|
458
|
+
orphans: parsed.orphans,
|
|
459
|
+
stale: parsed.stale + parsed.blocked + parsed.unclaimed + parsed.missing, // Combined for API compat
|
|
460
|
+
message,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
// Not a git repo or git not available
|
|
465
|
+
return {
|
|
466
|
+
passed: true,
|
|
467
|
+
orphans: 0,
|
|
468
|
+
stale: 0,
|
|
469
|
+
message: 'Worktree check skipped (not a git repository)',
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* WU-1386: Run WU validation (--deep mode only)
|
|
475
|
+
* WU-1387: Sets passed=false with clear message when CLI fails to run
|
|
476
|
+
* Calls wu:validate --all --no-strict for full schema/lint validation in warn-only mode
|
|
477
|
+
*/
|
|
478
|
+
async function checkWUValidity(projectDir) {
|
|
479
|
+
const wuDir = path.join(projectDir, 'docs', '04-operations', 'tasks', 'wu');
|
|
480
|
+
if (!fs.existsSync(wuDir)) {
|
|
481
|
+
return {
|
|
482
|
+
passed: true,
|
|
483
|
+
total: 0,
|
|
484
|
+
valid: 0,
|
|
485
|
+
invalid: 0,
|
|
486
|
+
warnings: 0,
|
|
487
|
+
message: 'No WU directory found',
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
// Call wu:validate --all --no-strict to get warn-only validation
|
|
492
|
+
// This runs the full schema + lint validation, treating warnings as advisory
|
|
493
|
+
let validateOutput;
|
|
494
|
+
let cliError = false;
|
|
495
|
+
let cliErrorMessage = '';
|
|
496
|
+
try {
|
|
497
|
+
validateOutput = execFileSync('pnpm', ['wu:validate', '--all', '--no-strict'], {
|
|
498
|
+
cwd: projectDir,
|
|
499
|
+
encoding: 'utf-8',
|
|
500
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
501
|
+
timeout: 60000, // 60 second timeout for full validation
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
catch (e) {
|
|
505
|
+
// wu:validate exits non-zero if validation fails
|
|
506
|
+
const execError = e;
|
|
507
|
+
validateOutput = (execError.stdout || '') + (execError.stderr || '');
|
|
508
|
+
const errorMsg = execError.message || '';
|
|
509
|
+
// WU-1387: Distinguish between CLI execution failure and validation failure
|
|
510
|
+
// CLI failure = script missing, command not found, ENOENT
|
|
511
|
+
// Validation failure = script ran but found invalid WUs
|
|
512
|
+
if (errorMsg.includes('ERR_PNPM') ||
|
|
513
|
+
errorMsg.includes('ENOENT') ||
|
|
514
|
+
errorMsg.includes('Missing script') ||
|
|
515
|
+
errorMsg.includes('command not found') ||
|
|
516
|
+
execError.code === 'ENOENT' ||
|
|
517
|
+
// If no recognizable wu:validate output, assume CLI failed
|
|
518
|
+
(!validateOutput.includes('[wu:validate]') && !validateOutput.includes('Valid:'))) {
|
|
519
|
+
cliError = true;
|
|
520
|
+
cliErrorMessage = errorMsg.includes('Missing script')
|
|
521
|
+
? 'wu:validate script not found'
|
|
522
|
+
: errorMsg.includes('ENOENT')
|
|
523
|
+
? 'pnpm command not available'
|
|
524
|
+
: 'wu:validate could not run';
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
// WU-1387: If CLI failed to run, report as failure with clear message
|
|
528
|
+
if (cliError) {
|
|
529
|
+
return {
|
|
530
|
+
passed: false,
|
|
531
|
+
total: 0,
|
|
532
|
+
valid: 0,
|
|
533
|
+
invalid: 0,
|
|
534
|
+
warnings: 0,
|
|
535
|
+
message: `WU validation failed: ${cliErrorMessage}`,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
// Parse output for counts
|
|
539
|
+
// wu:validate outputs summary like:
|
|
540
|
+
// ✓ Valid: N
|
|
541
|
+
// ✗ Invalid: N
|
|
542
|
+
// ⚠ Warnings: N
|
|
543
|
+
const validMatch = validateOutput.match(/Valid:\s*(\d+)/);
|
|
544
|
+
const invalidMatch = validateOutput.match(/Invalid:\s*(\d+)/);
|
|
545
|
+
const warningMatch = validateOutput.match(/Warnings:\s*(\d+)/);
|
|
546
|
+
const validCount = validMatch ? parseInt(validMatch[1], 10) : 0;
|
|
547
|
+
const invalidCount = invalidMatch ? parseInt(invalidMatch[1], 10) : 0;
|
|
548
|
+
const warningCount = warningMatch ? parseInt(warningMatch[1], 10) : 0;
|
|
549
|
+
const total = validCount + invalidCount;
|
|
550
|
+
// In warn-only mode (--no-strict), we only fail on actual invalid WUs
|
|
551
|
+
const passed = invalidCount === 0;
|
|
552
|
+
return {
|
|
553
|
+
passed,
|
|
554
|
+
total,
|
|
555
|
+
valid: validCount,
|
|
556
|
+
invalid: invalidCount,
|
|
557
|
+
warnings: warningCount,
|
|
558
|
+
message: passed
|
|
559
|
+
? total > 0
|
|
560
|
+
? `All ${total} WU(s) valid${warningCount > 0 ? ` (${warningCount} warning(s))` : ''}`
|
|
561
|
+
: 'No WUs to validate'
|
|
562
|
+
: `${invalidCount}/${total} WU(s) have issues`,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
catch (e) {
|
|
566
|
+
// WU-1387: Unexpected errors should report failure, not silently pass
|
|
567
|
+
const error = e;
|
|
568
|
+
return {
|
|
569
|
+
passed: false,
|
|
570
|
+
total: 0,
|
|
571
|
+
valid: 0,
|
|
572
|
+
invalid: 0,
|
|
573
|
+
warnings: 0,
|
|
574
|
+
message: `WU validation failed: ${error.message || 'unknown error'}`,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
}
|
|
257
578
|
/**
|
|
258
579
|
* Run all doctor checks
|
|
580
|
+
* WU-1386: Added options parameter for --deep flag
|
|
259
581
|
*/
|
|
260
|
-
export async function runDoctor(projectDir) {
|
|
582
|
+
export async function runDoctor(projectDir, options = {}) {
|
|
261
583
|
const checks = {
|
|
262
584
|
husky: checkHusky(projectDir),
|
|
263
585
|
safeGit: checkSafeGit(projectDir),
|
|
@@ -268,19 +590,137 @@ export async function runDoctor(projectDir) {
|
|
|
268
590
|
};
|
|
269
591
|
const vendorConfigs = checkVendorConfigs(projectDir);
|
|
270
592
|
const prerequisites = checkPrerequisites();
|
|
593
|
+
// WU-1386: Workflow health checks
|
|
594
|
+
const managedFilesDirty = await checkManagedFilesDirty(projectDir);
|
|
595
|
+
const worktreeSanity = await checkWorktreeSanity(projectDir);
|
|
596
|
+
const workflowHealth = {
|
|
597
|
+
managedFilesDirty,
|
|
598
|
+
worktreeSanity,
|
|
599
|
+
};
|
|
600
|
+
// WU-1386: WU validity check only in --deep mode
|
|
601
|
+
if (options.deep) {
|
|
602
|
+
workflowHealth.wuValidity = await checkWUValidity(projectDir);
|
|
603
|
+
}
|
|
271
604
|
// Determine overall status
|
|
272
605
|
// Note: laneHealth is advisory (not included in critical checks)
|
|
273
606
|
const criticalChecks = [checks.husky, checks.safeGit, checks.agentsMd];
|
|
274
607
|
const allCriticalPassed = criticalChecks.every((check) => check.passed);
|
|
608
|
+
// WU-1386: Calculate exit code
|
|
609
|
+
// 0 = healthy (all checks pass, no warnings)
|
|
610
|
+
// 1 = warnings (non-critical issues)
|
|
611
|
+
// 2 = errors (critical safety checks failed)
|
|
612
|
+
let exitCode = 0;
|
|
613
|
+
if (!allCriticalPassed) {
|
|
614
|
+
exitCode = 2;
|
|
615
|
+
}
|
|
616
|
+
else if (!managedFilesDirty.passed ||
|
|
617
|
+
!worktreeSanity.passed ||
|
|
618
|
+
(workflowHealth.wuValidity && !workflowHealth.wuValidity.passed)) {
|
|
619
|
+
exitCode = 1;
|
|
620
|
+
}
|
|
275
621
|
return {
|
|
276
622
|
status: allCriticalPassed ? 'ACTIVE' : 'INCOMPLETE',
|
|
623
|
+
exitCode,
|
|
277
624
|
checks,
|
|
278
625
|
vendorConfigs,
|
|
279
626
|
prerequisites,
|
|
627
|
+
workflowHealth,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* WU-1386: Run doctor for init (non-blocking, warnings only)
|
|
632
|
+
* WU-1387: Shows accurate status including lane health and prerequisite failures
|
|
633
|
+
* This is used after lumenflow init to provide feedback without blocking
|
|
634
|
+
*/
|
|
635
|
+
export async function runDoctorForInit(projectDir) {
|
|
636
|
+
const result = await runDoctor(projectDir, { deep: false });
|
|
637
|
+
// Count warnings and errors using check keys, not messages
|
|
638
|
+
let warnings = 0;
|
|
639
|
+
let errors = 0;
|
|
640
|
+
// Critical checks that count as errors (if they fail, safety is compromised)
|
|
641
|
+
const criticalCheckKeys = ['husky', 'safeGit', 'agentsMd'];
|
|
642
|
+
for (const [key, check] of Object.entries(result.checks)) {
|
|
643
|
+
if (!check.passed) {
|
|
644
|
+
if (criticalCheckKeys.includes(key)) {
|
|
645
|
+
errors++;
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
warnings++;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// WU-1387: Count prerequisite failures as warnings
|
|
653
|
+
for (const [, prereq] of Object.entries(result.prerequisites)) {
|
|
654
|
+
if (!prereq.passed) {
|
|
655
|
+
warnings++;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
// Count workflow health issues as warnings (not errors)
|
|
659
|
+
if (result.workflowHealth) {
|
|
660
|
+
if (!result.workflowHealth.managedFilesDirty.passed)
|
|
661
|
+
warnings++;
|
|
662
|
+
if (!result.workflowHealth.worktreeSanity.passed)
|
|
663
|
+
warnings++;
|
|
664
|
+
}
|
|
665
|
+
// Format concise output
|
|
666
|
+
const lines = [];
|
|
667
|
+
lines.push('[lumenflow doctor] Quick health check...');
|
|
668
|
+
if (result.exitCode === 0 && warnings === 0) {
|
|
669
|
+
lines.push(' All checks passed');
|
|
670
|
+
}
|
|
671
|
+
else {
|
|
672
|
+
// Show critical errors first
|
|
673
|
+
if (!result.checks.husky.passed) {
|
|
674
|
+
lines.push(' Error: Husky hooks not installed');
|
|
675
|
+
}
|
|
676
|
+
if (!result.checks.safeGit.passed) {
|
|
677
|
+
lines.push(' Error: safe-git script not found');
|
|
678
|
+
}
|
|
679
|
+
if (!result.checks.agentsMd.passed) {
|
|
680
|
+
lines.push(' Error: AGENTS.md not found');
|
|
681
|
+
}
|
|
682
|
+
// WU-1387: Show lane health issues
|
|
683
|
+
if (!result.checks.laneHealth.passed) {
|
|
684
|
+
lines.push(` Warning: Lane overlap detected - ${result.checks.laneHealth.message}`);
|
|
685
|
+
}
|
|
686
|
+
// WU-1387: Show prerequisite failures
|
|
687
|
+
if (!result.prerequisites.node.passed) {
|
|
688
|
+
lines.push(` Warning: Node.js version ${result.prerequisites.node.version} (required: ${result.prerequisites.node.required})`);
|
|
689
|
+
}
|
|
690
|
+
if (!result.prerequisites.pnpm.passed) {
|
|
691
|
+
lines.push(` Warning: pnpm version ${result.prerequisites.pnpm.version} (required: ${result.prerequisites.pnpm.required})`);
|
|
692
|
+
}
|
|
693
|
+
if (!result.prerequisites.git.passed) {
|
|
694
|
+
lines.push(` Warning: Git version ${result.prerequisites.git.version} (required: ${result.prerequisites.git.required})`);
|
|
695
|
+
}
|
|
696
|
+
// Workflow health warnings
|
|
697
|
+
if (result.workflowHealth?.managedFilesDirty.files.length) {
|
|
698
|
+
lines.push(` Warning: ${result.workflowHealth.managedFilesDirty.files.length} managed file(s) have uncommitted changes`);
|
|
699
|
+
for (const file of result.workflowHealth.managedFilesDirty.files.slice(0, 3)) {
|
|
700
|
+
lines.push(` -> ${file}`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (result.workflowHealth?.worktreeSanity.orphans) {
|
|
704
|
+
lines.push(` Warning: ${result.workflowHealth.worktreeSanity.orphans} orphan worktree(s) found`);
|
|
705
|
+
}
|
|
706
|
+
if (result.workflowHealth && !result.workflowHealth.worktreeSanity.passed) {
|
|
707
|
+
// WU-1387: Show worktree sanity message if there are issues beyond orphans
|
|
708
|
+
const wsSanity = result.workflowHealth.worktreeSanity;
|
|
709
|
+
if (wsSanity.stale > 0 && wsSanity.orphans === 0) {
|
|
710
|
+
lines.push(` Warning: ${wsSanity.message}`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return {
|
|
715
|
+
blocked: false, // Never blocks init
|
|
716
|
+
warnings,
|
|
717
|
+
errors,
|
|
718
|
+
output: lines.join('\n'),
|
|
280
719
|
};
|
|
281
720
|
}
|
|
282
721
|
/**
|
|
283
722
|
* Format doctor output for terminal display
|
|
723
|
+
* WU-1386: Added workflow health section
|
|
284
724
|
*/
|
|
285
725
|
export function formatDoctorOutput(result) {
|
|
286
726
|
const lines = [];
|
|
@@ -302,6 +742,31 @@ export function formatDoctorOutput(result) {
|
|
|
302
742
|
lines.push('');
|
|
303
743
|
lines.push('Lane Health:');
|
|
304
744
|
lines.push(formatCheck(result.checks.laneHealth));
|
|
745
|
+
// WU-1386: Workflow Health section
|
|
746
|
+
if (result.workflowHealth) {
|
|
747
|
+
lines.push('');
|
|
748
|
+
lines.push('Workflow Health:');
|
|
749
|
+
const mfd = result.workflowHealth.managedFilesDirty;
|
|
750
|
+
const mfdSymbol = mfd.passed ? '✓' : '⚠';
|
|
751
|
+
lines.push(` ${mfdSymbol} ${mfd.message}`);
|
|
752
|
+
if (!mfd.passed && mfd.files.length > 0) {
|
|
753
|
+
for (const file of mfd.files.slice(0, 5)) {
|
|
754
|
+
lines.push(` → ${file}`);
|
|
755
|
+
}
|
|
756
|
+
if (mfd.files.length > 5) {
|
|
757
|
+
lines.push(` → ... and ${mfd.files.length - 5} more`);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
const ws = result.workflowHealth.worktreeSanity;
|
|
761
|
+
const wsSymbol = ws.passed ? '✓' : '⚠';
|
|
762
|
+
lines.push(` ${wsSymbol} ${ws.message}`);
|
|
763
|
+
// WU validity (only in --deep mode)
|
|
764
|
+
if (result.workflowHealth.wuValidity) {
|
|
765
|
+
const wv = result.workflowHealth.wuValidity;
|
|
766
|
+
const wvSymbol = wv.passed ? '✓' : '⚠';
|
|
767
|
+
lines.push(` ${wvSymbol} ${wv.message}`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
305
770
|
// Vendor configs
|
|
306
771
|
lines.push('');
|
|
307
772
|
lines.push('Vendor Configs:');
|
|
@@ -326,7 +791,12 @@ export function formatDoctorOutput(result) {
|
|
|
326
791
|
lines.push('');
|
|
327
792
|
lines.push('─'.repeat(45));
|
|
328
793
|
lines.push(`LumenFlow safety: ${result.status}`);
|
|
329
|
-
|
|
794
|
+
// WU-1386: Show exit code meaning
|
|
795
|
+
if (result.exitCode === 1) {
|
|
796
|
+
lines.push('');
|
|
797
|
+
lines.push('Warnings detected (exit code 1). Use --deep for full WU validation.');
|
|
798
|
+
}
|
|
799
|
+
else if (result.exitCode === 2) {
|
|
330
800
|
lines.push('');
|
|
331
801
|
lines.push('To fix missing components:');
|
|
332
802
|
lines.push(' pnpm install && pnpm prepare # Install Husky hooks');
|
|
@@ -343,26 +813,25 @@ export function formatDoctorJson(result) {
|
|
|
343
813
|
}
|
|
344
814
|
/**
|
|
345
815
|
* CLI entry point
|
|
816
|
+
* WU-1386: Updated to use new exit codes
|
|
346
817
|
*/
|
|
347
818
|
export async function main() {
|
|
348
819
|
const opts = parseDoctorOptions();
|
|
349
820
|
const projectDir = process.cwd();
|
|
350
|
-
const result = await runDoctor(projectDir);
|
|
821
|
+
const result = await runDoctor(projectDir, { deep: opts.deep });
|
|
351
822
|
if (opts.json) {
|
|
352
823
|
console.log(formatDoctorJson(result));
|
|
353
824
|
}
|
|
354
825
|
else {
|
|
355
826
|
console.log(formatDoctorOutput(result));
|
|
356
827
|
}
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
process.exit(1);
|
|
360
|
-
}
|
|
828
|
+
// WU-1386: Use new exit code system
|
|
829
|
+
process.exit(result.exitCode);
|
|
361
830
|
}
|
|
362
831
|
// CLI invocation when run directly
|
|
363
832
|
if (import.meta.main) {
|
|
364
833
|
main().catch((error) => {
|
|
365
834
|
console.error('Doctor failed:', error);
|
|
366
|
-
process.exit(
|
|
835
|
+
process.exit(2);
|
|
367
836
|
});
|
|
368
837
|
}
|
package/dist/gates.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/* eslint-disable no-console -- Gates runner uses console for status output; refactoring to logger is tracked for future work */
|
|
3
2
|
/**
|
|
4
3
|
* Quality Gates Runner
|
|
5
4
|
*
|
|
@@ -469,7 +468,6 @@ export function formatFormatCheckGuidance(files) {
|
|
|
469
468
|
function collectPrettierListDifferent(cwd, files = []) {
|
|
470
469
|
const filesArg = files.length > 0 ? quoteShellArgs(files) : '.';
|
|
471
470
|
const cmd = pnpmCmd(SCRIPTS.PRETTIER, PRETTIER_ARGS.LIST_DIFFERENT, filesArg);
|
|
472
|
-
// eslint-disable-next-line sonarjs/os-command -- Pre-existing: executes trusted pnpm prettier command
|
|
473
471
|
const result = spawnSync(cmd, [], {
|
|
474
472
|
shell: true,
|
|
475
473
|
cwd,
|
|
@@ -534,7 +532,6 @@ function run(cmd, { agentLog } = {}) {
|
|
|
534
532
|
if (!agentLog) {
|
|
535
533
|
console.log(`\n> ${cmd}\n`);
|
|
536
534
|
try {
|
|
537
|
-
// eslint-disable-next-line sonarjs/os-command -- Pre-existing: cmd is built from trusted constants
|
|
538
535
|
execSync(cmd, { stdio: 'inherit', encoding: FILE_SYSTEM.ENCODING });
|
|
539
536
|
return { ok: true, duration: Date.now() - start };
|
|
540
537
|
}
|
|
@@ -543,7 +540,6 @@ function run(cmd, { agentLog } = {}) {
|
|
|
543
540
|
}
|
|
544
541
|
}
|
|
545
542
|
writeSync(agentLog.logFd, `\n> ${cmd}\n\n`);
|
|
546
|
-
// eslint-disable-next-line sonarjs/os-command -- Pre-existing: cmd is built from trusted constants
|
|
547
543
|
const result = spawnSync(cmd, [], {
|
|
548
544
|
shell: true,
|
|
549
545
|
stdio: ['ignore', agentLog.logFd, agentLog.logFd],
|
|
@@ -704,13 +700,11 @@ async function runFormatCheckGate({ agentLog, useAgentMode }) {
|
|
|
704
700
|
return { ok: true, duration: Date.now() - start, fileCount: 0, filesChecked: [] };
|
|
705
701
|
}
|
|
706
702
|
if (plan.mode === 'full') {
|
|
707
|
-
/* eslint-disable sonarjs/no-nested-conditional -- Pre-existing: simple reason mapping, readable as-is */
|
|
708
703
|
const reason = plan.reason === 'prettier-config'
|
|
709
704
|
? ' (prettier config changed)'
|
|
710
705
|
: plan.reason === 'file-list-error'
|
|
711
706
|
? ' (file list unavailable)'
|
|
712
707
|
: '';
|
|
713
|
-
/* eslint-enable sonarjs/no-nested-conditional */
|
|
714
708
|
logLine(`📋 Running full format check${reason}`);
|
|
715
709
|
const result = run(pnpmCmd(SCRIPTS.FORMAT_CHECK), { agentLog });
|
|
716
710
|
return { ...result, duration: Date.now() - start, fileCount: -1 };
|
|
@@ -1408,7 +1402,6 @@ async function executeGates(opts) {
|
|
|
1408
1402
|
// The old pattern fails with pnpm symlinks because process.argv[1] is the symlink
|
|
1409
1403
|
// path but import.meta.url resolves to the real path - they never match
|
|
1410
1404
|
if (import.meta.main) {
|
|
1411
|
-
// eslint-disable-next-line sonarjs/deprecation -- Pre-existing: parseGatesArgs kept for backwards compatibility
|
|
1412
1405
|
const opts = parseGatesArgs();
|
|
1413
1406
|
executeGates({ ...opts, argv: process.argv.slice(2) })
|
|
1414
1407
|
.then((ok) => {
|