@lumenflow/cli 2.18.2 → 2.19.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 (104) hide show
  1. package/README.md +42 -41
  2. package/dist/delegation-list.js +140 -0
  3. package/dist/delegation-list.js.map +1 -0
  4. package/dist/doctor.js +35 -99
  5. package/dist/doctor.js.map +1 -1
  6. package/dist/gates-plan-resolvers.js +150 -0
  7. package/dist/gates-plan-resolvers.js.map +1 -0
  8. package/dist/gates-runners.js +533 -0
  9. package/dist/gates-runners.js.map +1 -0
  10. package/dist/gates-types.js +3 -0
  11. package/dist/gates-types.js.map +1 -1
  12. package/dist/gates-utils.js +316 -0
  13. package/dist/gates-utils.js.map +1 -0
  14. package/dist/gates.js +44 -1016
  15. package/dist/gates.js.map +1 -1
  16. package/dist/hooks/enforcement-generator.js +16 -880
  17. package/dist/hooks/enforcement-generator.js.map +1 -1
  18. package/dist/hooks/enforcement-sync.js +1 -4
  19. package/dist/hooks/enforcement-sync.js.map +1 -1
  20. package/dist/hooks/generators/auto-checkpoint.js +123 -0
  21. package/dist/hooks/generators/auto-checkpoint.js.map +1 -0
  22. package/dist/hooks/generators/enforce-worktree.js +188 -0
  23. package/dist/hooks/generators/enforce-worktree.js.map +1 -0
  24. package/dist/hooks/generators/index.js +16 -0
  25. package/dist/hooks/generators/index.js.map +1 -0
  26. package/dist/hooks/generators/pre-compact-checkpoint.js +134 -0
  27. package/dist/hooks/generators/pre-compact-checkpoint.js.map +1 -0
  28. package/dist/hooks/generators/require-wu.js +115 -0
  29. package/dist/hooks/generators/require-wu.js.map +1 -0
  30. package/dist/hooks/generators/session-start-recovery.js +101 -0
  31. package/dist/hooks/generators/session-start-recovery.js.map +1 -0
  32. package/dist/hooks/generators/signal-utils.js +52 -0
  33. package/dist/hooks/generators/signal-utils.js.map +1 -0
  34. package/dist/hooks/generators/warn-incomplete.js +65 -0
  35. package/dist/hooks/generators/warn-incomplete.js.map +1 -0
  36. package/dist/init-detection.js +228 -0
  37. package/dist/init-detection.js.map +1 -0
  38. package/dist/init-scaffolding.js +146 -0
  39. package/dist/init-scaffolding.js.map +1 -0
  40. package/dist/init-templates.js +1928 -0
  41. package/dist/init-templates.js.map +1 -0
  42. package/dist/init.js +136 -2425
  43. package/dist/init.js.map +1 -1
  44. package/dist/initiative-edit.js +42 -11
  45. package/dist/initiative-edit.js.map +1 -1
  46. package/dist/initiative-remove-wu.js +0 -0
  47. package/dist/initiative-status.js +29 -2
  48. package/dist/initiative-status.js.map +1 -1
  49. package/dist/mem-context.js +22 -9
  50. package/dist/mem-context.js.map +1 -1
  51. package/dist/orchestrate-init-status.js +32 -1
  52. package/dist/orchestrate-init-status.js.map +1 -1
  53. package/dist/orchestrate-monitor.js +38 -38
  54. package/dist/orchestrate-monitor.js.map +1 -1
  55. package/dist/public-manifest.js +12 -5
  56. package/dist/public-manifest.js.map +1 -1
  57. package/dist/shared-validators.js +1 -0
  58. package/dist/shared-validators.js.map +1 -1
  59. package/dist/spawn-list.js +0 -0
  60. package/dist/wu-claim-branch.js +121 -0
  61. package/dist/wu-claim-branch.js.map +1 -0
  62. package/dist/wu-claim-output.js +83 -0
  63. package/dist/wu-claim-output.js.map +1 -0
  64. package/dist/wu-claim-resume-handler.js +85 -0
  65. package/dist/wu-claim-resume-handler.js.map +1 -0
  66. package/dist/wu-claim-state.js +572 -0
  67. package/dist/wu-claim-state.js.map +1 -0
  68. package/dist/wu-claim-validation.js +439 -0
  69. package/dist/wu-claim-validation.js.map +1 -0
  70. package/dist/wu-claim-worktree.js +221 -0
  71. package/dist/wu-claim-worktree.js.map +1 -0
  72. package/dist/wu-claim.js +54 -1402
  73. package/dist/wu-claim.js.map +1 -1
  74. package/dist/wu-create-content.js +254 -0
  75. package/dist/wu-create-content.js.map +1 -0
  76. package/dist/wu-create-readiness.js +57 -0
  77. package/dist/wu-create-readiness.js.map +1 -0
  78. package/dist/wu-create-validation.js +149 -0
  79. package/dist/wu-create-validation.js.map +1 -0
  80. package/dist/wu-create.js +39 -441
  81. package/dist/wu-create.js.map +1 -1
  82. package/dist/wu-done.js +144 -249
  83. package/dist/wu-done.js.map +1 -1
  84. package/dist/wu-edit-operations.js +432 -0
  85. package/dist/wu-edit-operations.js.map +1 -0
  86. package/dist/wu-edit-validators.js +280 -0
  87. package/dist/wu-edit-validators.js.map +1 -0
  88. package/dist/wu-edit.js +27 -713
  89. package/dist/wu-edit.js.map +1 -1
  90. package/dist/wu-prep.js +32 -2
  91. package/dist/wu-prep.js.map +1 -1
  92. package/dist/wu-repair.js +1 -1
  93. package/dist/wu-repair.js.map +1 -1
  94. package/dist/wu-spawn-prompt-builders.js +1123 -0
  95. package/dist/wu-spawn-prompt-builders.js.map +1 -0
  96. package/dist/wu-spawn-strategy-resolver.js +314 -0
  97. package/dist/wu-spawn-strategy-resolver.js.map +1 -0
  98. package/dist/wu-spawn.js +9 -1398
  99. package/dist/wu-spawn.js.map +1 -1
  100. package/package.json +10 -7
  101. package/templates/core/LUMENFLOW.md.template +29 -99
  102. package/templates/core/ai/onboarding/agent-invocation-guide.md.template +1 -1
  103. package/templates/core/ai/onboarding/quick-ref-commands.md.template +29 -4
  104. package/templates/vendors/claude/.claude/skills/orchestration/SKILL.md.template +8 -8
package/dist/gates.js CHANGED
@@ -4,12 +4,18 @@
4
4
  *
5
5
  * Runs quality gates with support for docs-only mode and incremental linting.
6
6
  *
7
+ * WU-1647: Refactored into focused modules:
8
+ * - gates-utils.ts: Shell helpers, logging, git helpers, WU helpers
9
+ * - gates-plan-resolvers.ts: Pure plan resolution (format, lint, test, spec-linter)
10
+ * - gates-runners.ts: Gate runner functions (format, lint, test, safety, integration, etc.)
11
+ * - gates.ts (this file): Orchestrator, CLI options, main entry point
12
+ *
7
13
  * WU-1304: Optimise ESLint gates performance
8
14
  * - Uses incremental linting (only files changed since branching from main)
9
15
  * - Full lint coverage maintained via CI workflow
10
16
  *
11
17
  * WU-1433: Coverage gate with mode flag
12
- * - Checks coverage thresholds for hex core files (90% for application layer)
18
+ * - Checks coverage thresholds for hex core files (>=90% for application layer)
13
19
  * - Mode: block (default) fails the gate, warn logs warnings only
14
20
  * WU-2334: Changed default from warn to block for TDD enforcement
15
21
  *
@@ -18,8 +24,8 @@
18
24
  * - Fails if any table is missing documentation
19
25
  *
20
26
  * For type:documentation WUs:
21
- * - Run: format:check, spec:linter, backlog-sync
22
- * - Skip: lint, typecheck, supabase-docs:linter, tests, coverage (no code changed)
27
+ * - Run: format:check, spec:linter, backlog-sync
28
+ * - Skip: lint, typecheck, supabase-docs:linter, tests, coverage (no code changed)
23
29
  *
24
30
  * WU-1920: Incremental test execution
25
31
  * - Uses Vitest's --changed flag to run only tests for changed files
@@ -38,45 +44,36 @@
38
44
  * node tools/gates.ts --full-tests # Full tests (bypass incremental)
39
45
  * node tools/gates.ts --coverage-mode=block # Coverage gate in block mode
40
46
  */
41
- import { execSync, spawnSync } from 'node:child_process';
42
- import { closeSync, mkdirSync, openSync, readSync, statSync, writeSync } from 'node:fs';
43
- import { access } from 'node:fs/promises';
44
- import path from 'node:path';
47
+ import { writeSync } from 'node:fs';
45
48
  import { emitGateEvent, getCurrentWU, getCurrentLane } from '@lumenflow/core/telemetry';
46
49
  import { die } from '@lumenflow/core/error-handler';
47
- // WU-1299: Import WU YAML reader to get code_paths for docs-only filtering
48
- import { readWURaw } from '@lumenflow/core/wu-yaml';
49
- import { createWuPaths } from '@lumenflow/core/wu-paths';
50
- import { getChangedLintableFiles, isLintableFile } from '@lumenflow/core/incremental-lint';
51
- import { buildVitestChangedArgs, isCodeFilePath } from '@lumenflow/core/incremental-test';
52
- import { createGitForPath } from '@lumenflow/core/git-adapter';
53
- import { runCoverageGate, COVERAGE_GATE_MODES } from '@lumenflow/core/coverage-gate';
54
- import { buildGatesLogPath, shouldUseGatesAgentMode, updateGatesLatestSymlink, } from '@lumenflow/core/gates-agent-mode';
50
+ import { shouldUseGatesAgentMode, updateGatesLatestSymlink, } from '@lumenflow/core/gates-agent-mode';
55
51
  // WU-2062: Import risk detector for tiered test execution
56
52
  import { detectRiskTier, RISK_TIERS } from '@lumenflow/core/risk-detector';
57
53
  // WU-2252: Import invariants runner for first-check validation
58
54
  import { runInvariants } from '@lumenflow/core/invariants-runner';
59
55
  import { createWUParser } from '@lumenflow/core/arg-parser';
60
- import { validateBacklogSync } from '@lumenflow/core/validators/backlog-sync';
61
- import { runSupabaseDocsLinter } from '@lumenflow/core/validators/supabase-docs-linter';
62
- import { runSystemMapValidation } from '@lumenflow/core/system-map-validator';
63
- // WU-1067: Config-driven gates support (partial implementation - unused imports removed)
64
- // WU-1191: Lane health gate configuration
65
- // WU-1262: Coverage config from methodology policy
66
- // WU-1280: Test policy for tests_required (warn vs block on test failures)
67
- // WU-1356: Configurable package manager and test commands
68
- import { loadLaneHealthConfig, resolveTestPolicy, resolveGatesCommands, resolveTestRunner, } from '@lumenflow/core/gates-config';
69
- // WU-1191: Lane health check
70
- import { runLaneHealthCheck } from './lane-health.js';
71
- // WU-1315: Onboarding smoke test
72
- import { runOnboardingSmokeTestGate } from './onboarding-smoke-test.js';
73
- import { BRANCHES, PACKAGES, PKG_MANAGER, ESLINT_FLAGS, ESLINT_COMMANDS, ESLINT_DEFAULTS, SCRIPTS, CACHE_STRATEGIES, DIRECTORIES, GATE_NAMES, GATE_COMMANDS, EXIT_CODES, FILE_SYSTEM, PRETTIER_ARGS, PRETTIER_FLAGS, } from '@lumenflow/core/wu-constants';
56
+ import { runCoverageGate, COVERAGE_GATE_MODES } from '@lumenflow/core/coverage-gate';
57
+ // WU-1067: Config-driven gates support
58
+ import { loadLaneHealthConfig, resolveTestPolicy, resolveGatesCommands, } from '@lumenflow/core/gates-config';
59
+ import { GATE_NAMES, GATE_COMMANDS, EXIT_CODES } from '@lumenflow/core/wu-constants';
74
60
  // WU-1520: Gates graceful degradation for missing optional scripts
75
61
  import { buildMissingScriptWarning, loadPackageJsonScripts, resolveGateAction, formatGateSummary, } from './gates-graceful-degradation.js';
76
62
  import { runCLI } from './cli-entry-point.js';
77
63
  // WU-1550: Gate registry for declarative gate registration
78
64
  import { GateRegistry } from './gate-registry.js';
79
65
  import { registerDocsOnlyGates, registerCodeGates } from './gate-defaults.js';
66
+ // WU-1315: Onboarding smoke test
67
+ import { runOnboardingSmokeTestGate } from './onboarding-smoke-test.js';
68
+ // ── WU-1647: Import from extracted modules ─────────────────────────────
69
+ import { run, makeGateLogger, readLogTail, createAgentLogContext, emitFormatCheckGuidance, getAllChangedFiles, loadCurrentWUCodePaths, } from './gates-utils.js';
70
+ import { resolveDocsOnlyTestPlan, formatDocsOnlySkipMessage, } from './gates-plan-resolvers.js';
71
+ import { runFormatCheckGate, runIncrementalLint, runChangedTests, runSafetyCriticalTests, runIntegrationTests, runSpecLinterGate, runBacklogSyncGate, runSupabaseDocsGate, runSystemMapGate, runLaneHealthGate, runDocsOnlyFilteredTests, } from './gates-runners.js';
72
+ // ── Re-exports for backward compatibility ──────────────────────────────
73
+ // Tests and other modules import these from gates.ts; preserve the public API.
74
+ export { parsePrettierListOutput, buildPrettierWriteCommand, formatFormatCheckGuidance, parseWUFromBranchName, extractPackagesFromCodePaths, loadCurrentWUCodePaths, } from './gates-utils.js';
75
+ export { isPrettierConfigFile, isTestConfigFile, resolveFormatCheckPlan, resolveLintPlan, resolveTestPlan, resolveDocsOnlyTestPlan, formatDocsOnlySkipMessage, resolveSpecLinterPlan, } from './gates-plan-resolvers.js';
76
+ // ── CLI options ────────────────────────────────────────────────────────
80
77
  /**
81
78
  * WU-1087: Gates-specific option definitions for createWUParser.
82
79
  * Exported for testing and consistency with other CLI commands.
@@ -162,973 +159,7 @@ export function parseGatesOptions() {
162
159
  process.argv = originalArgv;
163
160
  }
164
161
  }
165
- /**
166
- * @deprecated Use parseGatesOptions() instead (WU-1087)
167
- * Kept for backward compatibility during migration.
168
- */
169
- // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Pre-existing: argv kept for backwards compatibility
170
- function parseGatesArgs(argv = process.argv) {
171
- return parseGatesOptions();
172
- }
173
- /**
174
- * Build a pnpm command string
175
- */
176
- function pnpmCmd(...parts) {
177
- return `${PKG_MANAGER} ${parts.join(' ')}`;
178
- }
179
- /**
180
- * Build a pnpm run command string
181
- */
182
- function pnpmRun(script, ...args) {
183
- const argsStr = args.length > 0 ? ` ${args.join(' ')}` : '';
184
- return `${PKG_MANAGER} ${SCRIPTS.RUN} ${script}${argsStr}`;
185
- }
186
- const PRETTIER_CONFIG_FILES = new Set([
187
- '.prettierrc',
188
- '.prettierrc.json',
189
- '.prettierrc.yaml',
190
- '.prettierrc.yml',
191
- '.prettierrc.js',
192
- '.prettierrc.cjs',
193
- '.prettierrc.ts',
194
- 'prettier.config.js',
195
- 'prettier.config.cjs',
196
- 'prettier.config.ts',
197
- 'prettier.config.mjs',
198
- '.prettierignore',
199
- ]);
200
- // WU-1356: Extended to support multiple build tools and test runners
201
- const TEST_CONFIG_BASENAMES = new Set([
202
- 'turbo.json', // Turborepo
203
- 'nx.json', // Nx
204
- 'lerna.json', // Lerna
205
- 'pnpm-lock.yaml',
206
- 'package-lock.json',
207
- 'yarn.lock',
208
- 'bun.lockb',
209
- 'package.json',
210
- ]);
211
- // WU-1356: Extended to support vitest, jest, and mocha config patterns
212
- const TEST_CONFIG_PATTERNS = [
213
- /^vitest\.config\.(ts|mts|js|mjs|cjs)$/i,
214
- /^jest\.config\.(ts|js|mjs|cjs|json)$/i,
215
- /^\.mocharc\.(js|json|yaml|yml)$/i,
216
- // eslint-disable-next-line security/detect-unsafe-regex -- static tsconfig pattern; no backtracking risk
217
- /^tsconfig(\..+)?\.json$/i,
218
- ];
219
- function normalizePath(filePath) {
220
- return filePath.replace(/\\/g, '/');
221
- }
222
- function getBasename(filePath) {
223
- const normalized = normalizePath(filePath);
224
- const parts = normalized.split('/');
225
- return parts[parts.length - 1] || normalized;
226
- }
227
- function quoteShellArgs(files) {
228
- return files.map((file) => `"${file}"`).join(' ');
229
- }
230
- export function isPrettierConfigFile(filePath) {
231
- if (!filePath)
232
- return false;
233
- const basename = getBasename(filePath);
234
- return PRETTIER_CONFIG_FILES.has(basename);
235
- }
236
- export function isTestConfigFile(filePath) {
237
- if (!filePath)
238
- return false;
239
- const basename = getBasename(filePath);
240
- if (TEST_CONFIG_BASENAMES.has(basename)) {
241
- return true;
242
- }
243
- return TEST_CONFIG_PATTERNS.some((pattern) => pattern.test(basename));
244
- }
245
- /* eslint-disable sonarjs/no-duplicate-string -- Pre-existing: format check reasons are intentionally distinct string literals */
246
- export function resolveFormatCheckPlan({ changedFiles, fileListError = false, }) {
247
- if (fileListError) {
248
- return { mode: 'full', files: [], reason: 'file-list-error' };
249
- }
250
- if (changedFiles.some(isPrettierConfigFile)) {
251
- return { mode: 'full', files: [], reason: 'prettier-config' };
252
- }
253
- if (changedFiles.length === 0) {
254
- return { mode: 'skip', files: [] };
255
- }
256
- return { mode: 'incremental', files: changedFiles };
257
- }
258
- export function resolveLintPlan({ isMainBranch, changedFiles, }) {
259
- if (isMainBranch) {
260
- return { mode: 'full', files: [] };
261
- }
262
- const lintTargets = changedFiles.filter((filePath) => {
263
- const normalized = normalizePath(filePath);
264
- return ((normalized.startsWith('apps/') || normalized.startsWith('packages/')) &&
265
- isLintableFile(normalized));
266
- });
267
- if (lintTargets.length === 0) {
268
- return { mode: 'skip', files: [] };
269
- }
270
- return { mode: 'incremental', files: lintTargets };
271
- }
272
- /* eslint-enable sonarjs/no-duplicate-string */
273
- export function resolveTestPlan({ isMainBranch, hasUntrackedCode, hasConfigChange, fileListError, }) {
274
- if (fileListError) {
275
- return { mode: 'full', reason: 'file-list-error' };
276
- }
277
- if (hasUntrackedCode) {
278
- return { mode: 'full', reason: 'untracked-code' };
279
- }
280
- if (hasConfigChange) {
281
- return { mode: 'full', reason: 'test-config' };
282
- }
283
- if (isMainBranch) {
284
- return { mode: 'full' };
285
- }
286
- return { mode: 'incremental' };
287
- }
288
- /**
289
- * WU-1299: Extract package name from a single code path
290
- *
291
- * @param codePath - Single code path to parse
292
- * @returns Package name or null if not a package/app path
293
- */
294
- function extractPackageFromPath(codePath) {
295
- if (!codePath || typeof codePath !== 'string') {
296
- return null;
297
- }
298
- const normalized = codePath.replace(/\\/g, '/');
299
- // Handle packages/@scope/name/... or packages/name/...
300
- if (normalized.startsWith('packages/')) {
301
- const parts = normalized.slice('packages/'.length).split('/');
302
- // Scoped package (@scope/name)
303
- if (parts[0]?.startsWith('@') && parts[1]) {
304
- return `${parts[0]}/${parts[1]}`;
305
- }
306
- // Unscoped package
307
- if (parts[0]) {
308
- return parts[0];
309
- }
310
- }
311
- // WU-1415: Skip apps/ paths - they aren't valid turbo packages for test filtering
312
- // apps/ directories (e.g., apps/docs, apps/github-app) don't have turbo test tasks
313
- // and using directory names as --filter args causes "No package found" errors
314
- return null;
315
- }
316
- /**
317
- * WU-1299: Extract package/app names from code_paths
318
- *
319
- * Parses paths like:
320
- * - packages/@lumenflow/cli/src/file.ts -> @lumenflow/cli
321
- * - apps/web/src/file.ts -> web
322
- *
323
- * @param codePaths - Array of code paths from WU YAML
324
- * @returns Array of unique package/app names
325
- */
326
- export function extractPackagesFromCodePaths(codePaths) {
327
- if (!codePaths || !Array.isArray(codePaths) || codePaths.length === 0) {
328
- return [];
329
- }
330
- const packages = new Set();
331
- for (const codePath of codePaths) {
332
- const pkg = extractPackageFromPath(codePath);
333
- if (pkg) {
334
- packages.add(pkg);
335
- }
336
- }
337
- return Array.from(packages);
338
- }
339
- /**
340
- * WU-1299: Resolve test plan for docs-only mode
341
- *
342
- * When --docs-only is passed, this determines whether to:
343
- * - Skip tests entirely (no code packages in code_paths)
344
- * - Run tests only for packages mentioned in code_paths
345
- *
346
- * @param options - Options including code_paths from WU YAML
347
- * @returns DocsOnlyTestPlan indicating how to handle tests
348
- */
349
- export function resolveDocsOnlyTestPlan({ codePaths }) {
350
- const packages = extractPackagesFromCodePaths(codePaths);
351
- if (packages.length === 0) {
352
- return {
353
- mode: 'skip',
354
- packages: [],
355
- reason: 'no-code-packages',
356
- };
357
- }
358
- return {
359
- mode: 'filtered',
360
- packages,
361
- };
362
- }
363
- /**
364
- * WU-1299: Format message for docs-only test skipping/filtering
365
- *
366
- * Provides clear messaging when tests are skipped or filtered in docs-only mode.
367
- *
368
- * @param plan - The docs-only test plan
369
- * @returns Human-readable message explaining what's happening
370
- */
371
- export function formatDocsOnlySkipMessage(plan) {
372
- if (plan.mode === 'skip') {
373
- return '📝 docs-only mode: skipping all tests (no code packages in code_paths)';
374
- }
375
- const packageList = plan.packages.join(', ');
376
- return `📝 docs-only mode: running tests only for packages in code_paths: ${packageList}`;
377
- }
378
- /**
379
- * WU-1299: Load code_paths from current WU YAML
380
- *
381
- * Attempts to read the WU YAML file for the current WU (detected from git branch)
382
- * and return its code_paths. Returns empty array if WU cannot be determined or
383
- * YAML file doesn't exist.
384
- *
385
- * @param options - Options including optional cwd
386
- * @returns Array of code_paths from WU YAML, or empty array if unavailable
387
- */
388
- export function loadCurrentWUCodePaths(options = {}) {
389
- const cwd = options.cwd ?? process.cwd();
390
- const wuId = getCurrentWU();
391
- if (!wuId) {
392
- return [];
393
- }
394
- try {
395
- const wuPaths = createWuPaths({ projectRoot: cwd });
396
- const wuYamlPath = wuPaths.WU(wuId);
397
- const wuDoc = readWURaw(wuYamlPath);
398
- if (wuDoc && Array.isArray(wuDoc.code_paths)) {
399
- return wuDoc.code_paths.filter((p) => typeof p === 'string');
400
- }
401
- }
402
- catch {
403
- // WU YAML not found or unreadable - return empty array
404
- }
405
- return [];
406
- }
407
- /**
408
- * WU-1299: Run filtered tests for docs-only mode
409
- * WU-1356: Updated to use configured test command
410
- *
411
- * When --docs-only is passed and code_paths contains packages, this runs tests
412
- * only for those specific packages. The filter syntax adapts to the configured
413
- * build tool (turbo, nx, or plain package manager).
414
- *
415
- * @param options - Options including packages to test and agent log context
416
- * @returns Result object with ok status and duration
417
- */
418
- async function runDocsOnlyFilteredTests({ packages, agentLog, cwd = process.cwd(), }) {
419
- const start = Date.now();
420
- const logLine = makeGateLogger({ agentLog, useAgentMode: !!agentLog, cwd });
421
- if (packages.length === 0) {
422
- logLine('📝 docs-only mode: no packages to test, skipping');
423
- return { ok: true, duration: Date.now() - start };
424
- }
425
- logLine(`\n> Tests (docs-only filtered: ${packages.join(', ')})\n`);
426
- // WU-1356: Use configured test command with filter
427
- const gatesCommands = resolveGatesCommands(cwd);
428
- // If there's a configured test_docs_only command, use it
429
- if (gatesCommands.test_docs_only) {
430
- const result = run(gatesCommands.test_docs_only, { agentLog, cwd });
431
- return { ok: result.ok, duration: Date.now() - start };
432
- }
433
- // Otherwise, use the full test command with filter args
434
- // Build filter args for each package (works with turbo, nx, and pnpm/yarn workspaces)
435
- const filterArgs = packages.map((pkg) => `--filter=${pkg}`);
436
- const baseCmd = gatesCommands.test_full;
437
- // Append filter args to the base command
438
- const filteredCmd = `${baseCmd} ${filterArgs.join(' ')}`;
439
- const result = run(filteredCmd, { agentLog });
440
- return { ok: result.ok, duration: Date.now() - start };
441
- }
442
- export function parsePrettierListOutput(output) {
443
- if (!output)
444
- return [];
445
- return output
446
- .split(/\r?\n/)
447
- .map((line) => line.trim())
448
- .filter(Boolean)
449
- .map((line) => line.replace(/^\[error\]\s*/i, '').trim())
450
- .filter((line) => !line.toLowerCase().includes('code style issues found') &&
451
- !line.toLowerCase().includes('all matched files use prettier') &&
452
- !line.toLowerCase().includes('checking formatting'));
453
- }
454
- export function buildPrettierWriteCommand(files) {
455
- const quotedFiles = files.map((file) => `"${file}"`).join(' ');
456
- const base = pnpmCmd(SCRIPTS.PRETTIER, PRETTIER_FLAGS.WRITE);
457
- return quotedFiles ? `${base} ${quotedFiles}` : base;
458
- }
459
- function buildPrettierCheckCommand(files) {
460
- const filesArg = files.length > 0 ? quoteShellArgs(files) : '.';
461
- return pnpmCmd(SCRIPTS.PRETTIER, PRETTIER_ARGS.CHECK, filesArg);
462
- }
463
- export function formatFormatCheckGuidance(files) {
464
- if (!files.length)
465
- return [];
466
- const command = buildPrettierWriteCommand(files);
467
- return [
468
- '',
469
- '❌ format:check failed',
470
- 'Fix with:',
471
- ` ${command}`,
472
- '',
473
- 'Affected files:',
474
- ...files.map((file) => ` - ${file}`),
475
- '',
476
- ];
477
- }
478
- function collectPrettierListDifferent(cwd, files = []) {
479
- const filesArg = files.length > 0 ? quoteShellArgs(files) : '.';
480
- const cmd = pnpmCmd(SCRIPTS.PRETTIER, PRETTIER_ARGS.LIST_DIFFERENT, filesArg);
481
- const result = spawnSync(cmd, [], {
482
- shell: true,
483
- cwd,
484
- encoding: FILE_SYSTEM.ENCODING,
485
- });
486
- const output = `${result.stdout || ''}\n${result.stderr || ''}`;
487
- return parsePrettierListOutput(output);
488
- }
489
- function emitFormatCheckGuidance({ agentLog, useAgentMode, files, cwd, }) {
490
- const formattedFiles = collectPrettierListDifferent(cwd, files ?? []);
491
- if (!formattedFiles.length)
492
- return;
493
- const lines = formatFormatCheckGuidance(formattedFiles);
494
- const logLine = useAgentMode && agentLog
495
- ? (line) => writeSync(agentLog.logFd, `${line}\n`)
496
- : (line) => console.log(line);
497
- for (const line of lines) {
498
- logLine(line);
499
- }
500
- }
501
- function readLogTail(logPath, { maxLines = 40, maxBytes = 64 * 1024 } = {}) {
502
- try {
503
- const stats = statSync(logPath);
504
- const startPos = Math.max(0, stats.size - maxBytes);
505
- const bytesToRead = stats.size - startPos;
506
- const fd = openSync(logPath, 'r');
507
- try {
508
- const buffer = Buffer.alloc(bytesToRead);
509
- readSync(fd, buffer, 0, bytesToRead, startPos);
510
- const text = buffer.toString(FILE_SYSTEM.ENCODING);
511
- const lines = text.split(/\r?\n/).filter(Boolean);
512
- return lines.slice(-maxLines).join('\n');
513
- }
514
- finally {
515
- closeSync(fd);
516
- }
517
- }
518
- catch {
519
- return '';
520
- }
521
- }
522
- function createAgentLogContext({ wuId, lane, cwd, }) {
523
- const logPath = buildGatesLogPath({ cwd, env: process.env, wuId, lane });
524
- mkdirSync(path.dirname(logPath), { recursive: true });
525
- const logFd = openSync(logPath, 'a');
526
- const header = `# gates log\n# lane: ${lane || 'unknown'}\n# wu: ${wuId || 'unknown'}\n# started: ${new Date().toISOString()}\n\n`;
527
- writeSync(logFd, header);
528
- // Ensure we close the FD even if gates exit via die().
529
- process.on('exit', () => {
530
- try {
531
- closeSync(logFd);
532
- }
533
- catch {
534
- // ignore
535
- }
536
- });
537
- return { logPath, logFd };
538
- }
539
- function run(cmd, { agentLog, cwd = process.cwd(), } = {}) {
540
- const start = Date.now();
541
- if (!agentLog) {
542
- console.log(`\n> ${cmd}\n`);
543
- try {
544
- execSync(cmd, {
545
- stdio: 'inherit',
546
- encoding: FILE_SYSTEM.ENCODING,
547
- cwd,
548
- });
549
- return { ok: true, duration: Date.now() - start };
550
- }
551
- catch {
552
- return { ok: false, duration: Date.now() - start };
553
- }
554
- }
555
- writeSync(agentLog.logFd, `\n> ${cmd}\n\n`);
556
- const result = spawnSync(cmd, [], {
557
- shell: true,
558
- stdio: ['ignore', agentLog.logFd, agentLog.logFd],
559
- cwd,
560
- encoding: FILE_SYSTEM.ENCODING,
561
- });
562
- return { ok: result.status === EXIT_CODES.SUCCESS, duration: Date.now() - start };
563
- }
564
- /**
565
- * Parse a WU ID from a branch name.
566
- * Returns canonical upper-case ID (e.g., WU-123) or null when not present.
567
- */
568
- export function parseWUFromBranchName(branchName) {
569
- if (!branchName) {
570
- return null;
571
- }
572
- const match = branchName.match(/wu-(\d+)/i);
573
- if (!match) {
574
- return null;
575
- }
576
- return `WU-${match[1]}`.toUpperCase();
577
- }
578
- /**
579
- * Resolve spec-linter execution strategy.
580
- * If current WU is known, run scoped validation only.
581
- * If unknown, fall back to global validation.
582
- */
583
- export function resolveSpecLinterPlan(wuId) {
584
- if (wuId) {
585
- return {
586
- scopedWuId: wuId,
587
- runGlobal: false,
588
- };
589
- }
590
- return {
591
- scopedWuId: null,
592
- runGlobal: true,
593
- };
594
- }
595
- async function detectCurrentWUForCwd(cwd) {
596
- const workingDir = cwd ?? process.cwd();
597
- try {
598
- const branch = await createGitForPath(workingDir).getCurrentBranch();
599
- const parsed = parseWUFromBranchName(branch);
600
- if (parsed) {
601
- return parsed;
602
- }
603
- }
604
- catch {
605
- // Fall back to legacy process-cwd based resolver below.
606
- }
607
- return getCurrentWU();
608
- }
609
- async function runSpecLinterGate({ agentLog, useAgentMode, cwd }) {
610
- const start = Date.now();
611
- const wuId = await detectCurrentWUForCwd(cwd);
612
- const plan = resolveSpecLinterPlan(wuId);
613
- if (plan.scopedWuId) {
614
- const scopedCmd = pnpmCmd('wu:validate', '--id', plan.scopedWuId);
615
- const scopedResult = run(scopedCmd, { agentLog, cwd });
616
- if (!scopedResult.ok) {
617
- return { ok: false, duration: Date.now() - start };
618
- }
619
- return { ok: true, duration: Date.now() - start };
620
- }
621
- if (!useAgentMode) {
622
- console.log('⚠️ Unable to detect current WU; skipping scoped validation.');
623
- }
624
- else if (agentLog) {
625
- writeSync(agentLog.logFd, '⚠️ Unable to detect current WU; skipping scoped validation.\n');
626
- }
627
- if (!plan.runGlobal) {
628
- return { ok: true, duration: Date.now() - start };
629
- }
630
- const fallbackResult = run(pnpmRun(SCRIPTS.SPEC_LINTER), { agentLog, cwd });
631
- return { ok: fallbackResult.ok, duration: Date.now() - start };
632
- }
633
- function makeGateLogger({ agentLog, useAgentMode }) {
634
- return (line) => {
635
- if (!useAgentMode) {
636
- console.log(line);
637
- return;
638
- }
639
- if (agentLog) {
640
- writeSync(agentLog.logFd, `${line}\n`);
641
- }
642
- };
643
- }
644
- async function runBacklogSyncGate({ agentLog, useAgentMode, cwd }) {
645
- const start = Date.now();
646
- const logLine = makeGateLogger({ agentLog, useAgentMode });
647
- logLine('\n> Backlog sync\n');
648
- const result = await validateBacklogSync({ cwd });
649
- if (result.errors.length > 0) {
650
- logLine('❌ Backlog sync errors:');
651
- result.errors.forEach((error) => logLine(` - ${error}`));
652
- }
653
- if (result.warnings.length > 0) {
654
- logLine('⚠️ Backlog sync warnings:');
655
- result.warnings.forEach((warning) => logLine(` - ${warning}`));
656
- }
657
- logLine(`Backlog sync summary: WU files=${result.wuCount}, Backlog refs=${result.backlogCount}`);
658
- return { ok: result.valid, duration: Date.now() - start };
659
- }
660
- async function runSupabaseDocsGate({ agentLog, useAgentMode, cwd }) {
661
- const start = Date.now();
662
- const logLine = makeGateLogger({ agentLog, useAgentMode });
663
- logLine('\n> Supabase docs linter\n');
664
- const result = await runSupabaseDocsLinter({ cwd, logger: { log: logLine } });
665
- if (result.skipped) {
666
- logLine(`⚠️ ${result.message ?? 'Supabase docs linter skipped.'}`);
667
- }
668
- else if (!result.ok) {
669
- logLine('❌ Supabase docs linter failed.');
670
- (result.errors ?? []).forEach((error) => logLine(` - ${error}`));
671
- }
672
- else {
673
- logLine(result.message ?? 'Supabase docs linter passed.');
674
- }
675
- return { ok: result.ok, duration: Date.now() - start };
676
- }
677
- async function runSystemMapGate({ agentLog, useAgentMode, cwd }) {
678
- const start = Date.now();
679
- const logLine = makeGateLogger({ agentLog, useAgentMode });
680
- logLine('\n> System map validation\n');
681
- const result = await runSystemMapValidation({
682
- cwd,
683
- logger: { log: logLine, warn: logLine, error: logLine },
684
- });
685
- if (!result.valid) {
686
- logLine('❌ System map validation failed');
687
- (result.pathErrors ?? []).forEach((error) => logLine(` - ${error}`));
688
- (result.orphanDocs ?? []).forEach((error) => logLine(` - ${error}`));
689
- (result.audienceErrors ?? []).forEach((error) => logLine(` - ${error}`));
690
- (result.queryErrors ?? []).forEach((error) => logLine(` - ${error}`));
691
- (result.classificationErrors ?? []).forEach((error) => logLine(` - ${error}`));
692
- }
693
- else {
694
- logLine('System map validation passed.');
695
- }
696
- return { ok: result.valid, duration: Date.now() - start };
697
- }
698
- /**
699
- * WU-1191: Run lane health check gate
700
- *
701
- * Checks lane configuration for overlaps and coverage gaps.
702
- * Mode is configurable via gates.lane_health in .lumenflow.config.yaml:
703
- * - 'warn': Log warnings but don't fail (default)
704
- * - 'error': Fail the gate if issues detected
705
- * - 'off': Skip the check entirely
706
- */
707
- async function runLaneHealthGate({ agentLog, useAgentMode, mode, cwd, }) {
708
- const start = Date.now();
709
- const logLine = makeGateLogger({ agentLog, useAgentMode });
710
- // Skip if mode is 'off'
711
- if (mode === 'off') {
712
- logLine('\n> Lane health check (skipped - mode: off)\n');
713
- return { ok: true, duration: Date.now() - start };
714
- }
715
- logLine(`\n> Lane health check (mode: ${mode})\n`);
716
- const report = runLaneHealthCheck({ projectRoot: cwd });
717
- if (!report.healthy) {
718
- logLine('⚠️ Lane health issues detected:');
719
- if (report.overlaps.hasOverlaps) {
720
- logLine(` - ${report.overlaps.overlaps.length} overlapping code_paths`);
721
- }
722
- if (report.gaps.hasGaps) {
723
- logLine(` - ${report.gaps.uncoveredFiles.length} uncovered files`);
724
- }
725
- logLine(` Run 'pnpm lane:health' for full report.`);
726
- if (mode === 'error') {
727
- return { ok: false, duration: Date.now() - start };
728
- }
729
- // mode === 'warn': report but don't fail
730
- logLine(' (mode: warn - not blocking)');
731
- }
732
- else {
733
- logLine('Lane health check passed.');
734
- }
735
- return { ok: true, duration: Date.now() - start };
736
- }
737
- async function filterExistingFiles(files) {
738
- const existingFiles = await Promise.all(files.map(async (file) => {
739
- try {
740
- await access(file);
741
- return file;
742
- }
743
- catch {
744
- return null;
745
- }
746
- }));
747
- return existingFiles.filter((file) => Boolean(file));
748
- }
749
- async function runFormatCheckGate({ agentLog, useAgentMode, cwd }) {
750
- const start = Date.now();
751
- const logLine = makeGateLogger({ agentLog, useAgentMode });
752
- let git;
753
- let isMainBranch = false;
754
- try {
755
- git = createGitForPath(cwd);
756
- const currentBranch = await git.getCurrentBranch();
757
- isMainBranch = currentBranch === BRANCHES.MAIN || currentBranch === BRANCHES.MASTER;
758
- }
759
- catch (error) {
760
- logLine(`⚠️ Failed to determine branch for format check: ${error.message}`);
761
- const result = run(pnpmCmd(SCRIPTS.FORMAT_CHECK), { agentLog, cwd });
762
- return { ...result, duration: Date.now() - start, fileCount: -1 };
763
- }
764
- if (isMainBranch) {
765
- logLine('📋 On main branch - running full format check');
766
- const result = run(pnpmCmd(SCRIPTS.FORMAT_CHECK), { agentLog, cwd });
767
- return { ...result, duration: Date.now() - start, fileCount: -1 };
768
- }
769
- let changedFiles = [];
770
- let fileListError = false;
771
- try {
772
- changedFiles = await getChangedFilesForIncremental({ git });
773
- }
774
- catch (error) {
775
- fileListError = true;
776
- logLine(`⚠️ Failed to determine changed files for format check: ${error.message}`);
777
- }
778
- const plan = resolveFormatCheckPlan({ changedFiles, fileListError });
779
- if (plan.mode === 'skip') {
780
- logLine('\n> format:check (incremental)\n');
781
- logLine('✅ No files changed - skipping format check');
782
- return { ok: true, duration: Date.now() - start, fileCount: 0, filesChecked: [] };
783
- }
784
- if (plan.mode === 'full') {
785
- const reason = plan.reason === 'prettier-config'
786
- ? ' (prettier config changed)'
787
- : plan.reason === 'file-list-error'
788
- ? ' (file list unavailable)'
789
- : '';
790
- logLine(`📋 Running full format check${reason}`);
791
- const result = run(pnpmCmd(SCRIPTS.FORMAT_CHECK), { agentLog, cwd });
792
- return { ...result, duration: Date.now() - start, fileCount: -1 };
793
- }
794
- const existingFiles = await filterExistingFiles(plan.files);
795
- if (existingFiles.length === 0) {
796
- logLine('\n> format:check (incremental)\n');
797
- logLine('✅ All changed files were deleted - skipping format check');
798
- return { ok: true, duration: Date.now() - start, fileCount: 0, filesChecked: [] };
799
- }
800
- logLine(`\n> format:check (incremental: ${existingFiles.length} files)\n`);
801
- const result = run(buildPrettierCheckCommand(existingFiles), { agentLog, cwd });
802
- return {
803
- ...result,
804
- duration: Date.now() - start,
805
- fileCount: existingFiles.length,
806
- filesChecked: existingFiles,
807
- };
808
- }
809
- /**
810
- * Run incremental ESLint on changed files only
811
- * Falls back to full lint if on main branch or if incremental fails
812
- * @returns {{ ok: boolean, duration: number, fileCount: number }}
813
- */
814
- async function runIncrementalLint({ agentLog, cwd, }) {
815
- const start = Date.now();
816
- const logLine = (line) => {
817
- if (!agentLog) {
818
- console.log(line);
819
- return;
820
- }
821
- writeSync(agentLog.logFd, `${line}\n`);
822
- };
823
- try {
824
- // Check if we're on main branch
825
- const git = createGitForPath(cwd);
826
- const currentBranch = await git.getCurrentBranch();
827
- const isMainBranch = currentBranch === BRANCHES.MAIN || currentBranch === BRANCHES.MASTER;
828
- if (isMainBranch) {
829
- logLine('📋 On main branch - running full lint');
830
- const result = run(pnpmCmd(SCRIPTS.LINT), { agentLog, cwd });
831
- return { ...result, fileCount: -1 };
832
- }
833
- const changedFiles = await getChangedLintableFiles({ git });
834
- const plan = resolveLintPlan({ isMainBranch, changedFiles });
835
- if (plan.mode === 'skip') {
836
- logLine('\n> ESLint (incremental)\n');
837
- logLine('✅ No lintable files changed - skipping lint');
838
- return { ok: true, duration: Date.now() - start, fileCount: 0 };
839
- }
840
- if (plan.mode === 'full') {
841
- logLine('📋 Running full lint (incremental plan forced full)');
842
- const result = run(pnpmCmd(SCRIPTS.LINT), { agentLog, cwd });
843
- return { ...result, fileCount: -1 };
844
- }
845
- const existingFiles = await filterExistingFiles(plan.files);
846
- if (existingFiles.length === 0) {
847
- logLine('\n> ESLint (incremental)\n');
848
- logLine('✅ All changed files were deleted - skipping lint');
849
- return { ok: true, duration: Date.now() - start, fileCount: 0 };
850
- }
851
- logLine(`\n> ESLint (incremental: ${existingFiles.length} files)\n`);
852
- logLine(`Files to lint:\n ${existingFiles.join('\n ')}\n`);
853
- const result = spawnSync(PKG_MANAGER, [
854
- ESLINT_COMMANDS.ESLINT,
855
- ESLINT_FLAGS.MAX_WARNINGS,
856
- ESLINT_DEFAULTS.MAX_WARNINGS,
857
- ESLINT_FLAGS.NO_WARN_IGNORED,
858
- ESLINT_FLAGS.CACHE,
859
- ESLINT_FLAGS.CACHE_STRATEGY,
860
- CACHE_STRATEGIES.CONTENT,
861
- ESLINT_FLAGS.CACHE_LOCATION,
862
- '.eslintcache',
863
- ESLINT_FLAGS.PASS_ON_UNPRUNED,
864
- ...existingFiles,
865
- ], agentLog
866
- ? {
867
- stdio: ['ignore', agentLog.logFd, agentLog.logFd],
868
- encoding: FILE_SYSTEM.ENCODING,
869
- cwd,
870
- }
871
- : {
872
- stdio: 'inherit',
873
- encoding: FILE_SYSTEM.ENCODING,
874
- cwd,
875
- });
876
- const duration = Date.now() - start;
877
- return {
878
- ok: result.status === EXIT_CODES.SUCCESS,
879
- duration,
880
- fileCount: existingFiles.length,
881
- };
882
- }
883
- catch (error) {
884
- console.error('⚠️ Incremental lint failed, falling back to full lint:', error.message);
885
- const result = run(pnpmCmd(SCRIPTS.LINT), { agentLog, cwd });
886
- return { ...result, fileCount: -1 };
887
- }
888
- }
889
- /**
890
- * Run changed tests using configured test runner's incremental mode.
891
- * WU-1356: Updated to use configured commands from gates-config.
892
- * Falls back to full test suite if on main branch or if the run fails.
893
- *
894
- * @returns {{ ok: boolean, duration: number, isIncremental: boolean }}
895
- */
896
- async function runChangedTests({ agentLog, cwd, }) {
897
- const start = Date.now();
898
- // eslint-disable-next-line sonarjs/no-identical-functions -- Pre-existing: logLine helper duplicated across gate runners
899
- const logLine = (line) => {
900
- if (!agentLog) {
901
- console.log(line);
902
- return;
903
- }
904
- writeSync(agentLog.logFd, `${line}\n`);
905
- };
906
- // WU-1356: Get configured commands
907
- const gatesCommands = resolveGatesCommands(cwd);
908
- const testRunner = resolveTestRunner(cwd);
909
- try {
910
- const git = createGitForPath(cwd);
911
- const currentBranch = await git.getCurrentBranch();
912
- const isMainBranch = currentBranch === BRANCHES.MAIN || currentBranch === BRANCHES.MASTER;
913
- if (isMainBranch) {
914
- logLine('📋 On main branch - running full test suite');
915
- const result = run(gatesCommands.test_full, { agentLog, cwd });
916
- return { ...result, isIncremental: false };
917
- }
918
- let changedFiles = [];
919
- let fileListError = false;
920
- try {
921
- changedFiles = await getChangedFilesForIncremental({ git });
922
- }
923
- catch (error) {
924
- fileListError = true;
925
- logLine(`⚠️ Failed to determine changed files for tests: ${error.message}`);
926
- }
927
- const hasConfigChange = !fileListError && changedFiles.some(isTestConfigFile);
928
- const untrackedOutput = await git.raw(['ls-files', '--others', '--exclude-standard']);
929
- const untrackedFiles = untrackedOutput
930
- .split(/\r?\n/)
931
- .map((f) => f.trim())
932
- .filter(Boolean);
933
- const untrackedCodeFiles = untrackedFiles.filter(isCodeFilePath);
934
- const hasUntrackedCode = untrackedCodeFiles.length > 0;
935
- const plan = resolveTestPlan({
936
- isMainBranch,
937
- hasUntrackedCode,
938
- hasConfigChange,
939
- fileListError,
940
- });
941
- if (plan.mode === 'full') {
942
- if (plan.reason === 'untracked-code') {
943
- const preview = untrackedCodeFiles.slice(0, 5).join(', ');
944
- logLine(`⚠️ Untracked code files detected (${untrackedCodeFiles.length}): ${preview}${untrackedCodeFiles.length > 5 ? '...' : ''}`);
945
- }
946
- else if (plan.reason === 'test-config') {
947
- logLine('⚠️ Test config changes detected - running full test suite');
948
- }
949
- else if (plan.reason === 'file-list-error') {
950
- logLine('⚠️ Changed file list unavailable - running full test suite');
951
- }
952
- logLine('📋 Running full test suite to avoid missing coverage');
953
- const result = run(gatesCommands.test_full, { agentLog, cwd });
954
- return { ...result, duration: Date.now() - start, isIncremental: false };
955
- }
956
- // WU-1356: Use configured incremental test command
957
- logLine(`\n> Running tests (${testRunner} --changed)\n`);
958
- // If test_incremental is configured, use it directly
959
- if (gatesCommands.test_incremental) {
960
- const result = run(gatesCommands.test_incremental, { agentLog, cwd });
961
- return { ...result, duration: Date.now() - start, isIncremental: true };
962
- }
963
- // Fallback: For vitest, use the built-in changed args helper
964
- if (testRunner === 'vitest') {
965
- const result = run(pnpmCmd('vitest', 'run', ...buildVitestChangedArgs({ baseBranch: 'origin/main' })), { agentLog, cwd });
966
- return { ...result, duration: Date.now() - start, isIncremental: true };
967
- }
968
- // For other runners without configured incremental, fall back to full
969
- logLine('⚠️ No incremental test command configured, running full suite');
970
- const result = run(gatesCommands.test_full, { agentLog, cwd });
971
- return { ...result, duration: Date.now() - start, isIncremental: false };
972
- }
973
- catch (error) {
974
- console.error('⚠️ Changed tests failed, falling back to full suite:', error.message);
975
- const result = run(gatesCommands.test_full, { agentLog, cwd });
976
- return { ...result, isIncremental: false };
977
- }
978
- }
979
- /**
980
- * Safety-critical test file patterns (relative to apps/web).
981
- * These patterns are passed as positional arguments to vitest run.
982
- * Must match the vitest include patterns in the workspace config.
983
- * @type {string[]}
984
- */
985
- const SAFETY_CRITICAL_TEST_FILES = [
986
- // PHI protection tests
987
- 'src/components/ui/__tests__/PHIGuard.test.tsx',
988
- 'src/components/ui/__tests__/WidgetPHIConsentDialog.test.tsx',
989
- 'src/components/ui/__tests__/Composer.phi.test.tsx',
990
- // Privacy detection tests
991
- 'src/lib/llm/__tests__/privacyDetector.test.ts',
992
- // Escalation trigger tests
993
- 'src/lib/llm/__tests__/escalationTrigger.test.ts',
994
- 'src/components/escalation/__tests__/EscalationHistory.test.tsx',
995
- // Constitutional enforcer tests
996
- 'src/lib/llm/__tests__/constitutionalEnforcer.test.ts',
997
- // Safe prompt wrapper tests
998
- 'src/lib/llm/__tests__/safePromptWrapper.test.ts',
999
- // Crisis/emergency handling tests
1000
- 'src/lib/prompts/__tests__/golden-crisis.test.ts',
1001
- ];
1002
- /**
1003
- * WU-2062: Run safety-critical tests
1004
- * These tests ALWAYS run regardless of which files changed.
1005
- * Includes: PHI, escalation, privacy, red-flag, constitutional enforcer tests
1006
- *
1007
- * Runs from apps/web directory with explicit file paths to ensure
1008
- * compatibility with vitest workspace include patterns.
1009
- *
1010
- * @param {object} options - Options
1011
- * @param {object} [options.agentLog] - Agent log context
1012
- * @returns {Promise<{ ok: boolean, duration: number, testCount: number }>}
1013
- */
1014
- async function runSafetyCriticalTests({ agentLog, cwd, }) {
1015
- const start = Date.now();
1016
- // eslint-disable-next-line sonarjs/no-identical-functions -- Pre-existing: logLine helper duplicated across gate runners
1017
- const logLine = (line) => {
1018
- if (!agentLog) {
1019
- console.log(line);
1020
- return;
1021
- }
1022
- writeSync(agentLog.logFd, `${line}\n`);
1023
- };
1024
- // WU-1006: Skip safety-critical tests if apps/web doesn't exist (repo-agnostic)
1025
- const webDir = path.join(cwd, DIRECTORIES.APPS_WEB);
1026
- try {
1027
- await access(webDir);
1028
- }
1029
- catch {
1030
- logLine('\n> Safety-critical tests skipped (apps/web not present)\n');
1031
- return { ok: true, duration: Date.now() - start, testCount: 0 };
1032
- }
1033
- try {
1034
- logLine('\n> Safety-critical tests (always run)\n');
1035
- logLine(`Test files: ${SAFETY_CRITICAL_TEST_FILES.length} files\n`);
1036
- // Run vitest with --project web to target the web workspace
1037
- // Using explicit file paths for compatibility with workspace include patterns
1038
- const result = spawnSync(PKG_MANAGER, [
1039
- 'vitest',
1040
- 'run',
1041
- '--project',
1042
- PACKAGES.WEB,
1043
- '--reporter=verbose',
1044
- ...SAFETY_CRITICAL_TEST_FILES,
1045
- '--passWithNoTests', // Don't fail if some files don't exist
1046
- ], agentLog
1047
- ? {
1048
- stdio: ['ignore', agentLog.logFd, agentLog.logFd],
1049
- encoding: FILE_SYSTEM.ENCODING,
1050
- cwd,
1051
- }
1052
- : {
1053
- stdio: 'inherit',
1054
- encoding: FILE_SYSTEM.ENCODING,
1055
- cwd,
1056
- });
1057
- const duration = Date.now() - start;
1058
- return {
1059
- ok: result.status === EXIT_CODES.SUCCESS,
1060
- duration,
1061
- testCount: SAFETY_CRITICAL_TEST_FILES.length,
1062
- };
1063
- }
1064
- catch (error) {
1065
- console.error('⚠️ Safety-critical tests failed:', error.message);
1066
- return { ok: false, duration: Date.now() - start, testCount: 0 };
1067
- }
1068
- }
1069
- /**
1070
- * WU-2062: Run integration tests for high-risk changes
1071
- * Only runs when auth, PHI, RLS, or migration files are modified.
1072
- *
1073
- * @param {object} options - Options
1074
- * @param {object} [options.agentLog] - Agent log context
1075
- * @returns {Promise<{ ok: boolean, duration: number }>}
1076
- */
1077
- async function runIntegrationTests({ agentLog, cwd, }) {
1078
- const start = Date.now();
1079
- // eslint-disable-next-line sonarjs/no-identical-functions -- Pre-existing: logLine helper duplicated across gate runners
1080
- const logLine = (line) => {
1081
- if (!agentLog) {
1082
- console.log(line);
1083
- return;
1084
- }
1085
- writeSync(agentLog.logFd, `${line}\n`);
1086
- };
1087
- try {
1088
- logLine('\n> Integration tests (high-risk changes detected)\n');
1089
- // WU-1415: vitest doesn't support --include flag
1090
- // Pass glob patterns as positional arguments instead
1091
- const result = run(`RUN_INTEGRATION_TESTS=1 ${pnpmCmd('vitest', 'run', "'**/*.integration.*'", "'**/golden-*.test.*'")}`, { agentLog, cwd });
1092
- const duration = Date.now() - start;
1093
- return {
1094
- ok: result.ok,
1095
- duration,
1096
- };
1097
- }
1098
- catch (error) {
1099
- console.error('⚠️ Integration tests failed:', error.message);
1100
- return { ok: false, duration: Date.now() - start };
1101
- }
1102
- }
1103
- async function getChangedFilesForIncremental({ git, baseBranch = 'origin/main', }) {
1104
- const mergeBase = await git.mergeBase('HEAD', baseBranch);
1105
- const committedOutput = await git.raw(['diff', '--name-only', `${mergeBase}...HEAD`]);
1106
- const committedFiles = committedOutput
1107
- .split('\n')
1108
- .map((f) => f.trim())
1109
- .filter(Boolean);
1110
- const unstagedOutput = await git.raw(['diff', '--name-only']);
1111
- const unstagedFiles = unstagedOutput
1112
- .split('\n')
1113
- .map((f) => f.trim())
1114
- .filter(Boolean);
1115
- const untrackedOutput = await git.raw(['ls-files', '--others', '--exclude-standard']);
1116
- const untrackedFiles = untrackedOutput
1117
- .split('\n')
1118
- .map((f) => f.trim())
1119
- .filter(Boolean);
1120
- return [...new Set([...committedFiles, ...unstagedFiles, ...untrackedFiles])];
1121
- }
1122
- async function getAllChangedFiles(options = {}) {
1123
- const { git = createGitForPath(options.cwd ?? process.cwd()) } = options;
1124
- try {
1125
- return await getChangedFilesForIncremental({ git });
1126
- }
1127
- catch (error) {
1128
- console.error('⚠️ Failed to get changed files:', error.message);
1129
- return [];
1130
- }
1131
- }
162
+ // ── Public API ─────────────────────────────────────────────────────────
1132
163
  /**
1133
164
  * Run gates for a specific working directory without mutating global process cwd.
1134
165
  */
@@ -1144,7 +175,7 @@ export async function runGates(options = {}) {
1144
175
  return false;
1145
176
  }
1146
177
  }
1147
- // Main execution
178
+ // ── Main orchestrator ──────────────────────────────────────────────────
1148
179
  // eslint-disable-next-line sonarjs/cognitive-complexity -- Pre-existing: main() orchestrates multi-step gate workflow
1149
180
  async function executeGates(opts) {
1150
181
  const cwd = opts.cwd ?? process.cwd();
@@ -1161,7 +192,6 @@ async function executeGates(opts) {
1161
192
  // WU-2244: Full coverage flag forces full test suite and coverage gate (deterministic)
1162
193
  const isFullCoverage = opts.fullCoverage || false;
1163
194
  // WU-1262: Resolve coverage config from methodology policy
1164
- // This derives coverage threshold and mode from methodology.testing setting
1165
195
  // WU-1280: Use resolveTestPolicy to also get tests_required for test failure handling
1166
196
  const resolvedTestPolicy = resolveTestPolicy(cwd);
1167
197
  // WU-1433: Coverage gate mode (warn or block)
@@ -1170,7 +200,6 @@ async function executeGates(opts) {
1170
200
  const coverageMode = opts.coverageMode || resolvedTestPolicy.mode || COVERAGE_GATE_MODES.BLOCK;
1171
201
  const coverageThreshold = resolvedTestPolicy.threshold;
1172
202
  // WU-1280: Determine if tests are required (affects whether test failures block or warn)
1173
- // When tests_required=false (methodology.testing: none), test failures produce warnings only
1174
203
  const testsRequired = resolvedTestPolicy.tests_required;
1175
204
  // WU-1191: Lane health gate mode (warn, error, or off)
1176
205
  const laneHealthMode = loadLaneHealthConfig(cwd);
@@ -1182,7 +211,7 @@ async function executeGates(opts) {
1182
211
  // WU-1520: Track gate results for summary
1183
212
  const gateResults = [];
1184
213
  if (useAgentMode) {
1185
- console.log(`🧾 gates (agent mode): output -> ${agentLog.logPath} (use --verbose for streaming)\n`);
214
+ console.log(`\uD83E\uDDFE gates (agent mode): output -> ${agentLog.logPath} (use --verbose for streaming)\n`);
1186
215
  }
1187
216
  // WU-2062: Detect risk tier from changed files (unless explicit --docs-only flag)
1188
217
  let riskTier = null;
@@ -1194,14 +223,14 @@ async function executeGates(opts) {
1194
223
  const logLine = useAgentMode
1195
224
  ? (line) => writeSync(agentLog.logFd, `${line}\n`)
1196
225
  : (line) => console.log(line);
1197
- logLine(`\n🎯 Risk tier detected: ${riskTier.tier}`);
226
+ logLine(`\n\uD83C\uDFAF Risk tier detected: ${riskTier.tier}`);
1198
227
  if (riskTier.highRiskPaths.length > 0) {
1199
228
  logLine(` High-risk paths: ${riskTier.highRiskPaths.slice(0, 3).join(', ')}${riskTier.highRiskPaths.length > 3 ? '...' : ''}`);
1200
229
  }
1201
230
  logLine('');
1202
231
  }
1203
232
  catch (error) {
1204
- console.error('⚠️ Risk detection failed, defaulting to standard tier:', error.message);
233
+ console.error('\u26A0\uFE0F Risk detection failed, defaulting to standard tier:', error.message);
1205
234
  riskTier = {
1206
235
  tier: RISK_TIERS.STANDARD,
1207
236
  isDocsOnly: false,
@@ -1219,9 +248,6 @@ async function executeGates(opts) {
1219
248
  docsOnlyTestPlan = resolveDocsOnlyTestPlan({ codePaths });
1220
249
  }
1221
250
  // WU-1550: Build gate list via GateRegistry (declarative, Open-Closed Principle)
1222
- // New gates can be added by calling registry.register() without modifying this function.
1223
- // WU-2252: Invariants gate runs FIRST and is included in both docs-only and regular modes
1224
- // WU-1520: scriptName field maps gates to their package.json script for existence checking
1225
251
  const gateRegistry = new GateRegistry();
1226
252
  if (effectiveDocsOnly) {
1227
253
  registerDocsOnlyGates(gateRegistry, {
@@ -1242,8 +268,6 @@ async function executeGates(opts) {
1242
268
  });
1243
269
  }
1244
270
  // WU-1550: Inject run functions for gates that need them.
1245
- // The registry stores declarative metadata; run functions are bound here
1246
- // because they depend on local gate-runner functions in this module.
1247
271
  const gateRunFunctions = {
1248
272
  [GATE_NAMES.FORMAT_CHECK]: runFormatCheckGate,
1249
273
  [GATE_NAMES.SPEC_LINTER]: runSpecLinterGate,
@@ -1275,7 +299,7 @@ async function executeGates(opts) {
1275
299
  // WU-1299: Show clear messaging about what's being skipped/run in docs-only mode
1276
300
  const docsOnlyMessage = docsOnlyTestPlan && docsOnlyTestPlan.mode === 'filtered'
1277
301
  ? formatDocsOnlySkipMessage(docsOnlyTestPlan)
1278
- : '📝 Docs-only mode: skipping lint, typecheck, and all tests (no code packages in code_paths)';
302
+ : '\uD83D\uDCDD Docs-only mode: skipping lint, typecheck, and all tests (no code packages in code_paths)';
1279
303
  if (!useAgentMode) {
1280
304
  console.log(`${docsOnlyMessage}\n`);
1281
305
  }
@@ -1306,7 +330,7 @@ async function executeGates(opts) {
1306
330
  }
1307
331
  if (gateAction === 'fail') {
1308
332
  const logLine = makeGateLogger({ agentLog, useAgentMode, cwd });
1309
- logLine(`\n "${gateScriptName}" script not found in package.json (--strict mode)\n`);
333
+ logLine(`\n\u274C "${gateScriptName}" script not found in package.json (--strict mode)\n`);
1310
334
  gateResults.push({
1311
335
  name: gate.name,
1312
336
  status: 'failed',
@@ -1347,7 +371,11 @@ async function executeGates(opts) {
1347
371
  }
1348
372
  else if (gate.cmd === GATE_COMMANDS.INCREMENTAL_TEST) {
1349
373
  // WU-1920: Special handling for changed tests
1350
- result = await runChangedTests({ agentLog, cwd });
374
+ result = await runChangedTests({
375
+ agentLog,
376
+ cwd,
377
+ scopedTestPaths: opts.scopedTestPaths,
378
+ });
1351
379
  lastTestResult = result;
1352
380
  }
1353
381
  else if (gate.cmd === GATE_COMMANDS.TIERED_TEST) {
@@ -1358,7 +386,7 @@ async function executeGates(opts) {
1358
386
  // WU-1920: Skip coverage gate when tests were changed (partial coverage)
1359
387
  // WU-2244: --full-coverage overrides incremental skip behavior
1360
388
  if (!isFullCoverage && lastTestResult?.isIncremental) {
1361
- const msg = '⏭️ Skipping coverage gate (changed tests - coverage is partial)';
389
+ const msg = '\u23ED\uFE0F Skipping coverage gate (changed tests - coverage is partial)';
1362
390
  if (!useAgentMode) {
1363
391
  console.log(`\n${msg}\n`);
1364
392
  }
@@ -1419,7 +447,7 @@ async function executeGates(opts) {
1419
447
  if (!result.ok) {
1420
448
  // WU-2315: Warn-only gates log warning but don't block
1421
449
  if (gate.warnOnly) {
1422
- const warnMsg = `⚠️ ${gate.name} failed (warn-only, not blocking)`;
450
+ const warnMsg = `\u26A0\uFE0F ${gate.name} failed (warn-only, not blocking)`;
1423
451
  if (!useAgentMode) {
1424
452
  console.log(`\n${warnMsg}\n`);
1425
453
  }
@@ -1448,7 +476,7 @@ async function executeGates(opts) {
1448
476
  logLine(`\n${formatGateSummary(gateResults)}\n`);
1449
477
  if (useAgentMode) {
1450
478
  const tail = readLogTail(agentLog.logPath);
1451
- console.error(`\n ${gate.name} failed (agent mode). Log: ${agentLog.logPath}\n`);
479
+ console.error(`\n\u274C ${gate.name} failed (agent mode). Log: ${agentLog.logPath}\n`);
1452
480
  if (tail) {
1453
481
  console.error(`Last log lines:\n${tail}\n`);
1454
482
  }
@@ -1470,16 +498,16 @@ async function executeGates(opts) {
1470
498
  const summaryLogLine = makeGateLogger({ agentLog, useAgentMode, cwd });
1471
499
  summaryLogLine(`\n${formatGateSummary(gateResults)}`);
1472
500
  if (!useAgentMode) {
1473
- console.log('\n All gates passed!\n');
501
+ console.log('\n\u2705 All gates passed!\n');
1474
502
  }
1475
503
  else {
1476
- console.log(`✅ All gates passed (agent mode). Log: ${agentLog.logPath}\n`);
504
+ console.log(`\u2705 All gates passed (agent mode). Log: ${agentLog.logPath}\n`);
1477
505
  }
1478
506
  return true;
1479
507
  }
1480
508
  // WU-1537: Wrap executeGates in a standard main() for runCLI consistency
1481
509
  async function main() {
1482
- const opts = parseGatesArgs();
510
+ const opts = parseGatesOptions();
1483
511
  const ok = await executeGates({ ...opts, argv: process.argv.slice(2) });
1484
512
  if (!ok) {
1485
513
  process.exit(EXIT_CODES.ERROR);