@lumenflow/cli 1.0.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 (129) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +116 -0
  3. package/dist/gates.d.ts +41 -0
  4. package/dist/gates.d.ts.map +1 -0
  5. package/dist/gates.js +684 -0
  6. package/dist/gates.js.map +1 -0
  7. package/dist/initiative-add-wu.d.ts +22 -0
  8. package/dist/initiative-add-wu.d.ts.map +1 -0
  9. package/dist/initiative-add-wu.js +234 -0
  10. package/dist/initiative-add-wu.js.map +1 -0
  11. package/dist/initiative-create.d.ts +28 -0
  12. package/dist/initiative-create.d.ts.map +1 -0
  13. package/dist/initiative-create.js +172 -0
  14. package/dist/initiative-create.js.map +1 -0
  15. package/dist/initiative-edit.d.ts +34 -0
  16. package/dist/initiative-edit.d.ts.map +1 -0
  17. package/dist/initiative-edit.js +440 -0
  18. package/dist/initiative-edit.js.map +1 -0
  19. package/dist/initiative-list.d.ts +12 -0
  20. package/dist/initiative-list.d.ts.map +1 -0
  21. package/dist/initiative-list.js +101 -0
  22. package/dist/initiative-list.js.map +1 -0
  23. package/dist/initiative-status.d.ts +11 -0
  24. package/dist/initiative-status.d.ts.map +1 -0
  25. package/dist/initiative-status.js +221 -0
  26. package/dist/initiative-status.js.map +1 -0
  27. package/dist/mem-checkpoint.d.ts +16 -0
  28. package/dist/mem-checkpoint.d.ts.map +1 -0
  29. package/dist/mem-checkpoint.js +237 -0
  30. package/dist/mem-checkpoint.js.map +1 -0
  31. package/dist/mem-cleanup.d.ts +29 -0
  32. package/dist/mem-cleanup.d.ts.map +1 -0
  33. package/dist/mem-cleanup.js +267 -0
  34. package/dist/mem-cleanup.js.map +1 -0
  35. package/dist/mem-create.d.ts +17 -0
  36. package/dist/mem-create.d.ts.map +1 -0
  37. package/dist/mem-create.js +265 -0
  38. package/dist/mem-create.js.map +1 -0
  39. package/dist/mem-inbox.d.ts +35 -0
  40. package/dist/mem-inbox.d.ts.map +1 -0
  41. package/dist/mem-inbox.js +373 -0
  42. package/dist/mem-inbox.js.map +1 -0
  43. package/dist/mem-init.d.ts +15 -0
  44. package/dist/mem-init.d.ts.map +1 -0
  45. package/dist/mem-init.js +146 -0
  46. package/dist/mem-init.js.map +1 -0
  47. package/dist/mem-ready.d.ts +16 -0
  48. package/dist/mem-ready.d.ts.map +1 -0
  49. package/dist/mem-ready.js +224 -0
  50. package/dist/mem-ready.js.map +1 -0
  51. package/dist/mem-signal.d.ts +16 -0
  52. package/dist/mem-signal.d.ts.map +1 -0
  53. package/dist/mem-signal.js +204 -0
  54. package/dist/mem-signal.js.map +1 -0
  55. package/dist/mem-start.d.ts +16 -0
  56. package/dist/mem-start.d.ts.map +1 -0
  57. package/dist/mem-start.js +158 -0
  58. package/dist/mem-start.js.map +1 -0
  59. package/dist/mem-summarize.d.ts +22 -0
  60. package/dist/mem-summarize.d.ts.map +1 -0
  61. package/dist/mem-summarize.js +213 -0
  62. package/dist/mem-summarize.js.map +1 -0
  63. package/dist/mem-triage.d.ts +22 -0
  64. package/dist/mem-triage.d.ts.map +1 -0
  65. package/dist/mem-triage.js +328 -0
  66. package/dist/mem-triage.js.map +1 -0
  67. package/dist/spawn-list.d.ts +16 -0
  68. package/dist/spawn-list.d.ts.map +1 -0
  69. package/dist/spawn-list.js +140 -0
  70. package/dist/spawn-list.js.map +1 -0
  71. package/dist/wu-block.d.ts +16 -0
  72. package/dist/wu-block.d.ts.map +1 -0
  73. package/dist/wu-block.js +241 -0
  74. package/dist/wu-block.js.map +1 -0
  75. package/dist/wu-claim.d.ts +32 -0
  76. package/dist/wu-claim.d.ts.map +1 -0
  77. package/dist/wu-claim.js +1106 -0
  78. package/dist/wu-claim.js.map +1 -0
  79. package/dist/wu-cleanup.d.ts +17 -0
  80. package/dist/wu-cleanup.d.ts.map +1 -0
  81. package/dist/wu-cleanup.js +194 -0
  82. package/dist/wu-cleanup.js.map +1 -0
  83. package/dist/wu-create.d.ts +38 -0
  84. package/dist/wu-create.d.ts.map +1 -0
  85. package/dist/wu-create.js +520 -0
  86. package/dist/wu-create.js.map +1 -0
  87. package/dist/wu-deps.d.ts +13 -0
  88. package/dist/wu-deps.d.ts.map +1 -0
  89. package/dist/wu-deps.js +119 -0
  90. package/dist/wu-deps.js.map +1 -0
  91. package/dist/wu-done.d.ts +153 -0
  92. package/dist/wu-done.d.ts.map +1 -0
  93. package/dist/wu-done.js +2096 -0
  94. package/dist/wu-done.js.map +1 -0
  95. package/dist/wu-edit.d.ts +29 -0
  96. package/dist/wu-edit.d.ts.map +1 -0
  97. package/dist/wu-edit.js +852 -0
  98. package/dist/wu-edit.js.map +1 -0
  99. package/dist/wu-infer-lane.d.ts +17 -0
  100. package/dist/wu-infer-lane.d.ts.map +1 -0
  101. package/dist/wu-infer-lane.js +135 -0
  102. package/dist/wu-infer-lane.js.map +1 -0
  103. package/dist/wu-preflight.d.ts +47 -0
  104. package/dist/wu-preflight.d.ts.map +1 -0
  105. package/dist/wu-preflight.js +167 -0
  106. package/dist/wu-preflight.js.map +1 -0
  107. package/dist/wu-prune.d.ts +16 -0
  108. package/dist/wu-prune.d.ts.map +1 -0
  109. package/dist/wu-prune.js +259 -0
  110. package/dist/wu-prune.js.map +1 -0
  111. package/dist/wu-repair.d.ts +60 -0
  112. package/dist/wu-repair.d.ts.map +1 -0
  113. package/dist/wu-repair.js +226 -0
  114. package/dist/wu-repair.js.map +1 -0
  115. package/dist/wu-spawn-completion.d.ts +10 -0
  116. package/dist/wu-spawn-completion.js +30 -0
  117. package/dist/wu-spawn.d.ts +168 -0
  118. package/dist/wu-spawn.d.ts.map +1 -0
  119. package/dist/wu-spawn.js +1327 -0
  120. package/dist/wu-spawn.js.map +1 -0
  121. package/dist/wu-unblock.d.ts +16 -0
  122. package/dist/wu-unblock.d.ts.map +1 -0
  123. package/dist/wu-unblock.js +234 -0
  124. package/dist/wu-unblock.js.map +1 -0
  125. package/dist/wu-validate.d.ts +16 -0
  126. package/dist/wu-validate.d.ts.map +1 -0
  127. package/dist/wu-validate.js +193 -0
  128. package/dist/wu-validate.js.map +1 -0
  129. package/package.json +92 -0
package/dist/gates.js ADDED
@@ -0,0 +1,684 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Quality Gates Runner
4
+ *
5
+ * Runs quality gates with support for docs-only mode and incremental linting.
6
+ *
7
+ * WU-1304: Optimise ESLint gates performance
8
+ * - Uses incremental linting (only files changed since branching from main)
9
+ * - Full lint coverage maintained via CI workflow
10
+ *
11
+ * WU-1433: Coverage gate with mode flag
12
+ * - Checks coverage thresholds for hex core files (≄90% for application layer)
13
+ * - Mode: block (default) fails the gate, warn logs warnings only
14
+ * WU-2334: Changed default from warn to block for TDD enforcement
15
+ *
16
+ * WU-1610: Supabase docs linter
17
+ * - Verifies every table in migrations is documented in schema.md
18
+ * - Fails if any table is missing documentation
19
+ *
20
+ * For type:documentation WUs:
21
+ * - āœ… Run: format:check, spec:linter, prompts:lint, backlog-sync
22
+ * - āŒ Skip: lint, typecheck, supabase-docs:linter, tests, coverage (no code changed)
23
+ *
24
+ * WU-1920: Incremental test execution
25
+ * - Uses Vitest's --changed flag to run only tests for changed files
26
+ * - Full test suite maintained via CI workflow and --full-tests flag
27
+ *
28
+ * WU-2062: Tiered test execution for faster wu:done
29
+ * - Safety-critical tests (PHI, escalation, red-flag) ALWAYS run
30
+ * - Docs-only WUs: lint/typecheck only (auto-detected or --docs-only flag)
31
+ * - High-risk WUs (auth, PHI, RLS, migrations): run integration tests
32
+ * - Standard WUs: changed tests + safety-critical tests
33
+ *
34
+ * Usage:
35
+ * node tools/gates.mjs # Tiered gates (default)
36
+ * node tools/gates.mjs --docs-only # Docs-only gates
37
+ * node tools/gates.mjs --full-lint # Full lint (bypass incremental)
38
+ * node tools/gates.mjs --full-tests # Full tests (bypass incremental)
39
+ * node tools/gates.mjs --coverage-mode=block # Coverage gate in block mode
40
+ */
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';
45
+ import { emitGateEvent, getCurrentWU, getCurrentLane } from '@lumenflow/core/dist/telemetry.js';
46
+ import { die } from '@lumenflow/core/dist/error-handler.js';
47
+ import { getChangedLintableFiles, convertToPackageRelativePaths, } from '@lumenflow/core/dist/incremental-lint.js';
48
+ import { buildVitestChangedArgs, isCodeFilePath } from '@lumenflow/core/dist/incremental-test.js';
49
+ import { getGitForCwd } from '@lumenflow/core/dist/git-adapter.js';
50
+ import { runCoverageGate, COVERAGE_GATE_MODES } from '@lumenflow/core/dist/coverage-gate.js';
51
+ import { buildGatesLogPath, shouldUseGatesAgentMode, updateGatesLatestSymlink, } from '@lumenflow/core/dist/gates-agent-mode.js';
52
+ // WU-2062: Import risk detector for tiered test execution
53
+ // eslint-disable-next-line no-unused-vars -- Pre-existing: SAFETY_CRITICAL_TEST_PATTERNS imported for future use
54
+ import { detectRiskTier, RISK_TIERS, } from '@lumenflow/core/dist/risk-detector.js';
55
+ // WU-2252: Import invariants runner for first-check validation
56
+ import { runInvariants } from '@lumenflow/core/dist/invariants-runner.js';
57
+ 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
+ // WU-2457: Add Commander.js for --help support
60
+ // WU-2465: Pre-filter argv to handle pnpm's `--` separator
61
+ // When invoked via `pnpm gates -- --docs-only`, pnpm passes ["--", "--docs-only"]
62
+ // Commander treats `--` as "everything after is positional", causing errors.
63
+ // Solution: Remove standalone `--` from argv before parsing.
64
+ const filteredArgv = process.argv.filter((arg, index, arr) => {
65
+ // Keep `--` only if it's followed by a non-option (actual positional arg)
66
+ // Remove it if it's followed by an option (starts with -)
67
+ if (arg === '--') {
68
+ const nextArg = arr[index + 1];
69
+ return nextArg && !nextArg.startsWith('-');
70
+ }
71
+ return true;
72
+ });
73
+ const program = new Command()
74
+ .name('gates')
75
+ .description('Run quality gates with support for docs-only mode, incremental linting, and tiered testing')
76
+ .option('--docs-only', 'Run docs-only gates (format, spec-linter, prompts-lint, backlog-sync)')
77
+ .option('--full-lint', 'Run full lint instead of incremental')
78
+ .option('--full-tests', 'Run full test suite instead of incremental')
79
+ .option('--full-coverage', 'Force full test suite and coverage gate (implies --full-tests)')
80
+ .option('--coverage-mode <mode>', 'Coverage gate mode: "warn" logs warnings, "block" fails gate (default)', 'block')
81
+ .option('--verbose', 'Stream output in agent mode instead of logging to file')
82
+ .helpOption('-h, --help', 'Display help for command');
83
+ program.parse(filteredArgv);
84
+ const opts = program.opts();
85
+ // Parse command line arguments (now via Commander)
86
+ const isDocsOnly = opts.docsOnly || false;
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;
94
+ /**
95
+ * Build a pnpm command string
96
+ */
97
+ function pnpmCmd(...parts) {
98
+ return `${PKG_MANAGER} ${parts.join(' ')}`;
99
+ }
100
+ /**
101
+ * Build a pnpm run command string
102
+ */
103
+ function pnpmRun(script, ...args) {
104
+ const argsStr = args.length > 0 ? ` ${args.join(' ')}` : '';
105
+ return `${PKG_MANAGER} ${SCRIPTS.RUN} ${script}${argsStr}`;
106
+ }
107
+ /**
108
+ * Build a pnpm --filter command string
109
+ */
110
+ function pnpmFilter(pkg, script) {
111
+ return `${PKG_MANAGER} ${PKG_FLAGS.FILTER} ${pkg} ${script}`;
112
+ }
113
+ function readLogTail(logPath, { maxLines = 40, maxBytes = 64 * 1024 } = {}) {
114
+ try {
115
+ const stats = statSync(logPath);
116
+ const startPos = Math.max(0, stats.size - maxBytes);
117
+ const bytesToRead = stats.size - startPos;
118
+ const fd = openSync(logPath, 'r');
119
+ try {
120
+ const buffer = Buffer.alloc(bytesToRead);
121
+ readSync(fd, buffer, 0, bytesToRead, startPos);
122
+ const text = buffer.toString(FILE_SYSTEM.ENCODING);
123
+ const lines = text.split(/\r?\n/).filter(Boolean);
124
+ return lines.slice(-maxLines).join('\n');
125
+ }
126
+ finally {
127
+ closeSync(fd);
128
+ }
129
+ }
130
+ catch {
131
+ return '';
132
+ }
133
+ }
134
+ function createAgentLogContext({ wuId, lane }) {
135
+ const cwd = process.cwd();
136
+ const logPath = buildGatesLogPath({ cwd, env: process.env, wuId, lane });
137
+ mkdirSync(path.dirname(logPath), { recursive: true });
138
+ const logFd = openSync(logPath, 'a');
139
+ const header = `# gates log\n# lane: ${lane || 'unknown'}\n# wu: ${wuId || 'unknown'}\n# started: ${new Date().toISOString()}\n\n`;
140
+ writeSync(logFd, header);
141
+ // Ensure we close the FD even if gates exit via die().
142
+ process.on('exit', () => {
143
+ try {
144
+ closeSync(logFd);
145
+ }
146
+ catch {
147
+ // ignore
148
+ }
149
+ });
150
+ return { logPath, logFd };
151
+ }
152
+ function run(cmd, { agentLog } = {}) {
153
+ const start = Date.now();
154
+ if (!agentLog) {
155
+ console.log(`\n> ${cmd}\n`);
156
+ try {
157
+ execSync(cmd, { stdio: 'inherit', encoding: FILE_SYSTEM.ENCODING });
158
+ return { ok: true, duration: Date.now() - start };
159
+ }
160
+ catch {
161
+ return { ok: false, duration: Date.now() - start };
162
+ }
163
+ }
164
+ writeSync(agentLog.logFd, `\n> ${cmd}\n\n`);
165
+ const result = spawnSync(cmd, [], {
166
+ shell: true,
167
+ stdio: ['ignore', agentLog.logFd, agentLog.logFd],
168
+ cwd: process.cwd(),
169
+ encoding: FILE_SYSTEM.ENCODING,
170
+ });
171
+ return { ok: result.status === EXIT_CODES.SUCCESS, duration: Date.now() - start };
172
+ }
173
+ /**
174
+ * Run incremental ESLint on changed files only
175
+ * Falls back to full lint if on main branch or if incremental fails
176
+ * @returns {{ ok: boolean, duration: number, fileCount: number }}
177
+ */
178
+ async function runIncrementalLint({ agentLog, } = {}) {
179
+ const start = Date.now();
180
+ const logLine = (line) => {
181
+ if (!agentLog) {
182
+ console.log(line);
183
+ return;
184
+ }
185
+ writeSync(agentLog.logFd, `${line}\n`);
186
+ };
187
+ try {
188
+ // Check if we're on main branch
189
+ const git = getGitForCwd();
190
+ const currentBranch = await git.getCurrentBranch();
191
+ if (currentBranch === BRANCHES.MAIN || currentBranch === BRANCHES.MASTER) {
192
+ logLine('šŸ“‹ On main branch - running full lint');
193
+ const result = run(pnpmFilter(PACKAGES.WEB, SCRIPTS.LINT), { agentLog });
194
+ return { ...result, fileCount: -1 };
195
+ }
196
+ // Get changed files in apps/web
197
+ const changedFiles = await getChangedLintableFiles({
198
+ git,
199
+ filterPath: DIRECTORIES.APPS_WEB,
200
+ });
201
+ if (changedFiles.length === 0) {
202
+ logLine('\n> ESLint (incremental)\n');
203
+ logLine('āœ… No lintable files changed - skipping lint');
204
+ return { ok: true, duration: Date.now() - start, fileCount: 0 };
205
+ }
206
+ // Filter to files that still exist (in case of deletions)
207
+ const existingFiles = (await Promise.all(changedFiles.map(async (f) => {
208
+ try {
209
+ await access(f);
210
+ return f;
211
+ }
212
+ catch {
213
+ return null;
214
+ }
215
+ }))).filter(Boolean);
216
+ if (existingFiles.length === 0) {
217
+ logLine('\n> ESLint (incremental)\n');
218
+ logLine('āœ… All changed files were deleted - skipping lint');
219
+ return { ok: true, duration: Date.now() - start, fileCount: 0 };
220
+ }
221
+ // WU-2571: Convert repo-relative paths to package-relative paths
222
+ // ESLint runs from apps/web/ where repo-relative paths don't exist
223
+ const packageRelativeFiles = convertToPackageRelativePaths(existingFiles, DIRECTORIES.APPS_WEB);
224
+ logLine(`\n> ESLint (incremental: ${packageRelativeFiles.length} files)\n`);
225
+ logLine(`Files to lint:\n ${packageRelativeFiles.join('\n ')}\n`);
226
+ // WU-2571: Run ESLint from apps/web directory with package-relative paths
227
+ const webDir = path.join(process.cwd(), DIRECTORIES.APPS_WEB);
228
+ const result = spawnSync(PKG_MANAGER, [
229
+ ESLINT_COMMANDS.ESLINT,
230
+ ESLINT_FLAGS.MAX_WARNINGS,
231
+ ESLINT_DEFAULTS.MAX_WARNINGS,
232
+ ESLINT_FLAGS.NO_WARN_IGNORED,
233
+ ESLINT_FLAGS.CACHE,
234
+ ESLINT_FLAGS.CACHE_STRATEGY,
235
+ CACHE_STRATEGIES.CONTENT,
236
+ ESLINT_FLAGS.CACHE_LOCATION,
237
+ '.eslintcache',
238
+ ESLINT_FLAGS.PASS_ON_UNPRUNED,
239
+ ...packageRelativeFiles,
240
+ ], agentLog
241
+ ? {
242
+ stdio: ['ignore', agentLog.logFd, agentLog.logFd],
243
+ encoding: FILE_SYSTEM.ENCODING,
244
+ cwd: webDir,
245
+ }
246
+ : {
247
+ stdio: 'inherit',
248
+ encoding: FILE_SYSTEM.ENCODING,
249
+ cwd: webDir,
250
+ });
251
+ const duration = Date.now() - start;
252
+ return {
253
+ ok: result.status === EXIT_CODES.SUCCESS,
254
+ duration,
255
+ fileCount: packageRelativeFiles.length,
256
+ };
257
+ }
258
+ catch (error) {
259
+ console.error('āš ļø Incremental lint failed, falling back to full lint:', error.message);
260
+ const result = run(pnpmFilter(PACKAGES.WEB, SCRIPTS.LINT), { agentLog });
261
+ return { ...result, fileCount: -1 };
262
+ }
263
+ }
264
+ /**
265
+ * Run changed tests using Vitest's --changed flag from the repo root.
266
+ * Falls back to full test suite if on main branch or if the run fails.
267
+ *
268
+ * @returns {{ ok: boolean, duration: number, isIncremental: boolean }}
269
+ */
270
+ async function runChangedTests({ agentLog, } = {}) {
271
+ const start = Date.now();
272
+ // eslint-disable-next-line sonarjs/no-identical-functions -- Pre-existing: logLine helper duplicated across gate runners
273
+ const logLine = (line) => {
274
+ if (!agentLog) {
275
+ console.log(line);
276
+ return;
277
+ }
278
+ writeSync(agentLog.logFd, `${line}\n`);
279
+ };
280
+ try {
281
+ const git = getGitForCwd();
282
+ const currentBranch = await git.getCurrentBranch();
283
+ if (currentBranch === BRANCHES.MAIN || currentBranch === BRANCHES.MASTER) {
284
+ logLine('šŸ“‹ On main branch - running full test suite');
285
+ const result = run(pnpmCmd('turbo', 'run', 'test'), { agentLog });
286
+ return { ...result, isIncremental: false };
287
+ }
288
+ const untrackedOutput = await git.raw(['ls-files', '--others', '--exclude-standard']);
289
+ const untrackedFiles = untrackedOutput
290
+ .split(/\r?\n/)
291
+ .map((f) => f.trim())
292
+ .filter(Boolean);
293
+ const untrackedCodeFiles = untrackedFiles.filter(isCodeFilePath);
294
+ if (untrackedCodeFiles.length > 0) {
295
+ const preview = untrackedCodeFiles.slice(0, 5).join(', ');
296
+ logLine(`āš ļø Untracked code files detected (${untrackedCodeFiles.length}): ${preview}${untrackedCodeFiles.length > 5 ? '...' : ''}`);
297
+ logLine('šŸ“‹ Running full test suite to avoid missing coverage');
298
+ const result = run(pnpmCmd('turbo', 'run', 'test'), { agentLog });
299
+ return { ...result, duration: Date.now() - start, isIncremental: false };
300
+ }
301
+ logLine('\n> Vitest (changed: tools project)\n');
302
+ const toolsArgs = ['--project', 'tools', ...buildVitestChangedArgs()];
303
+ const toolsResult = run(pnpmCmd('vitest', ...toolsArgs), { agentLog });
304
+ if (!toolsResult.ok) {
305
+ return { ...toolsResult, duration: Date.now() - start, isIncremental: true };
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 };
310
+ }
311
+ catch (error) {
312
+ console.error('āš ļø Changed tests failed, falling back to full suite:', error.message);
313
+ const result = run(pnpmCmd('turbo', 'run', 'test'), { agentLog });
314
+ return { ...result, isIncremental: false };
315
+ }
316
+ }
317
+ /**
318
+ * Safety-critical test file patterns (relative to apps/web).
319
+ * These patterns are passed as positional arguments to vitest run.
320
+ * Must match the vitest include patterns in the workspace config.
321
+ * @type {string[]}
322
+ */
323
+ const SAFETY_CRITICAL_TEST_FILES = [
324
+ // PHI protection tests
325
+ 'src/components/ui/__tests__/PHIGuard.test.tsx',
326
+ 'src/components/ui/__tests__/WidgetPHIConsentDialog.test.tsx',
327
+ 'src/components/ui/__tests__/Composer.phi.test.tsx',
328
+ // Privacy detection tests
329
+ 'src/lib/llm/__tests__/privacyDetector.test.ts',
330
+ // Escalation trigger tests
331
+ 'src/lib/llm/__tests__/escalationTrigger.test.ts',
332
+ 'src/components/escalation/__tests__/EscalationHistory.test.tsx',
333
+ // Constitutional enforcer tests
334
+ 'src/lib/llm/__tests__/constitutionalEnforcer.test.ts',
335
+ // Safe prompt wrapper tests
336
+ 'src/lib/llm/__tests__/safePromptWrapper.test.ts',
337
+ // Crisis/emergency handling tests
338
+ 'src/lib/prompts/__tests__/golden-crisis.test.ts',
339
+ ];
340
+ /**
341
+ * WU-2062: Run safety-critical tests
342
+ * These tests ALWAYS run regardless of which files changed.
343
+ * Includes: PHI, escalation, privacy, red-flag, constitutional enforcer tests
344
+ *
345
+ * Runs from apps/web directory with explicit file paths to ensure
346
+ * compatibility with vitest workspace include patterns.
347
+ *
348
+ * @param {object} options - Options
349
+ * @param {object} [options.agentLog] - Agent log context
350
+ * @returns {Promise<{ ok: boolean, duration: number, testCount: number }>}
351
+ */
352
+ async function runSafetyCriticalTests({ agentLog, } = {}) {
353
+ const start = Date.now();
354
+ // eslint-disable-next-line sonarjs/no-identical-functions -- Pre-existing: logLine helper duplicated across gate runners
355
+ const logLine = (line) => {
356
+ if (!agentLog) {
357
+ console.log(line);
358
+ return;
359
+ }
360
+ writeSync(agentLog.logFd, `${line}\n`);
361
+ };
362
+ try {
363
+ logLine('\n> Safety-critical tests (always run)\n');
364
+ logLine(`Test files: ${SAFETY_CRITICAL_TEST_FILES.length} files\n`);
365
+ // Run vitest with --project web to target the web workspace
366
+ // Using explicit file paths for compatibility with workspace include patterns
367
+ const result = spawnSync(PKG_MANAGER, [
368
+ 'vitest',
369
+ 'run',
370
+ '--project',
371
+ PACKAGES.WEB,
372
+ '--reporter=verbose',
373
+ ...SAFETY_CRITICAL_TEST_FILES,
374
+ '--passWithNoTests', // Don't fail if some files don't exist
375
+ ], agentLog
376
+ ? {
377
+ stdio: ['ignore', agentLog.logFd, agentLog.logFd],
378
+ encoding: FILE_SYSTEM.ENCODING,
379
+ cwd: process.cwd(),
380
+ }
381
+ : {
382
+ stdio: 'inherit',
383
+ encoding: FILE_SYSTEM.ENCODING,
384
+ cwd: process.cwd(),
385
+ });
386
+ const duration = Date.now() - start;
387
+ return {
388
+ ok: result.status === EXIT_CODES.SUCCESS,
389
+ duration,
390
+ testCount: SAFETY_CRITICAL_TEST_FILES.length,
391
+ };
392
+ }
393
+ catch (error) {
394
+ console.error('āš ļø Safety-critical tests failed:', error.message);
395
+ return { ok: false, duration: Date.now() - start, testCount: 0 };
396
+ }
397
+ }
398
+ /**
399
+ * WU-2062: Run integration tests for high-risk changes
400
+ * Only runs when auth, PHI, RLS, or migration files are modified.
401
+ *
402
+ * @param {object} options - Options
403
+ * @param {object} [options.agentLog] - Agent log context
404
+ * @returns {Promise<{ ok: boolean, duration: number }>}
405
+ */
406
+ async function runIntegrationTests({ agentLog, } = {}) {
407
+ const start = Date.now();
408
+ // eslint-disable-next-line sonarjs/no-identical-functions -- Pre-existing: logLine helper duplicated across gate runners
409
+ const logLine = (line) => {
410
+ if (!agentLog) {
411
+ console.log(line);
412
+ return;
413
+ }
414
+ writeSync(agentLog.logFd, `${line}\n`);
415
+ };
416
+ try {
417
+ logLine('\n> Integration tests (high-risk changes detected)\n');
418
+ const result = run(`RUN_INTEGRATION_TESTS=1 ${pnpmCmd('vitest', 'run', "--include='**/*.integration.*'", "--include='**/golden-*.test.*'")}`, { agentLog });
419
+ const duration = Date.now() - start;
420
+ return {
421
+ ok: result.ok,
422
+ duration,
423
+ };
424
+ }
425
+ catch (error) {
426
+ console.error('āš ļø Integration tests failed:', error.message);
427
+ return { ok: false, duration: Date.now() - start };
428
+ }
429
+ }
430
+ async function getAllChangedFiles(options = {}) {
431
+ const { git = getGitForCwd() } = options;
432
+ try {
433
+ // Get merge base
434
+ const mergeBase = await git.mergeBase('HEAD', 'origin/main');
435
+ // Get committed changes
436
+ const committedOutput = await git.raw(['diff', '--name-only', `${mergeBase}...HEAD`]);
437
+ const committedFiles = committedOutput
438
+ .split('\n')
439
+ .map((f) => f.trim())
440
+ .filter(Boolean);
441
+ // Get unstaged changes
442
+ const unstagedOutput = await git.raw(['diff', '--name-only']);
443
+ const unstagedFiles = unstagedOutput
444
+ .split('\n')
445
+ .map((f) => f.trim())
446
+ .filter(Boolean);
447
+ // Get untracked files
448
+ const untrackedOutput = await git.raw(['ls-files', '--others', '--exclude-standard']);
449
+ const untrackedFiles = untrackedOutput
450
+ .split('\n')
451
+ .map((f) => f.trim())
452
+ .filter(Boolean);
453
+ // Combine and deduplicate
454
+ return [...new Set([...committedFiles, ...unstagedFiles, ...untrackedFiles])];
455
+ }
456
+ catch (error) {
457
+ console.error('āš ļø Failed to get changed files:', error.message);
458
+ return [];
459
+ }
460
+ }
461
+ // Get context for telemetry
462
+ const wu_id = getCurrentWU();
463
+ const lane = getCurrentLane();
464
+ const useAgentMode = shouldUseGatesAgentMode({ argv: process.argv.slice(2), env: process.env });
465
+ const agentLog = useAgentMode ? createAgentLogContext({ wuId: wu_id, lane }) : null;
466
+ // Main execution
467
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- Pre-existing: main() orchestrates multi-step gate workflow
468
+ async function main() {
469
+ if (useAgentMode) {
470
+ console.log(`🧾 gates (agent mode): output -> ${agentLog.logPath} (use --verbose for streaming)\n`);
471
+ }
472
+ // WU-2062: Detect risk tier from changed files (unless explicit --docs-only flag)
473
+ let riskTier = null;
474
+ let changedFiles = [];
475
+ if (!isDocsOnly) {
476
+ try {
477
+ changedFiles = await getAllChangedFiles();
478
+ riskTier = detectRiskTier({ changedFiles });
479
+ const logLine = useAgentMode
480
+ ? (line) => writeSync(agentLog.logFd, `${line}\n`)
481
+ : (line) => console.log(line);
482
+ logLine(`\nšŸŽÆ Risk tier detected: ${riskTier.tier}`);
483
+ if (riskTier.highRiskPaths.length > 0) {
484
+ logLine(` High-risk paths: ${riskTier.highRiskPaths.slice(0, 3).join(', ')}${riskTier.highRiskPaths.length > 3 ? '...' : ''}`);
485
+ }
486
+ logLine('');
487
+ }
488
+ catch (error) {
489
+ console.error('āš ļø Risk detection failed, defaulting to standard tier:', error.message);
490
+ riskTier = {
491
+ tier: RISK_TIERS.STANDARD,
492
+ isDocsOnly: false,
493
+ shouldRunIntegration: false,
494
+ highRiskPaths: [],
495
+ };
496
+ }
497
+ }
498
+ // Determine effective docs-only mode (explicit flag OR detected from changed files)
499
+ const effectiveDocsOnly = isDocsOnly || (riskTier && riskTier.isDocsOnly);
500
+ // Determine which gates to run
501
+ // WU-2252: Invariants gate runs FIRST and is included in both docs-only and regular modes
502
+ const gates = effectiveDocsOnly
503
+ ? [
504
+ // WU-2252: Invariants check runs first (non-bypassable)
505
+ { name: GATE_NAMES.INVARIANTS, cmd: GATE_COMMANDS.INVARIANTS },
506
+ { name: GATE_NAMES.FORMAT_CHECK, cmd: pnpmCmd(SCRIPTS.FORMAT_CHECK) },
507
+ { name: GATE_NAMES.SPEC_LINTER, cmd: pnpmRun(SCRIPTS.SPEC_LINTER) },
508
+ {
509
+ name: GATE_NAMES.PROMPTS_LINT,
510
+ cmd: pnpmRun(SCRIPTS.PROMPTS_LINT, CLI_MODES.LOCAL, '--quiet'),
511
+ },
512
+ { name: GATE_NAMES.BACKLOG_SYNC, cmd: TOOL_PATHS.VALIDATE_BACKLOG_SYNC },
513
+ // WU-2315: System map validation (warn-only until orphan docs are indexed)
514
+ {
515
+ name: GATE_NAMES.SYSTEM_MAP_VALIDATE,
516
+ cmd: TOOL_PATHS.SYSTEM_MAP_VALIDATE,
517
+ warnOnly: true,
518
+ },
519
+ ]
520
+ : [
521
+ // WU-2252: Invariants check runs first (non-bypassable)
522
+ { name: GATE_NAMES.INVARIANTS, cmd: GATE_COMMANDS.INVARIANTS },
523
+ { name: GATE_NAMES.FORMAT_CHECK, cmd: pnpmCmd(SCRIPTS.FORMAT_CHECK) },
524
+ {
525
+ name: GATE_NAMES.LINT,
526
+ cmd: isFullLint ? pnpmFilter(PACKAGES.WEB, SCRIPTS.LINT) : GATE_COMMANDS.INCREMENTAL,
527
+ },
528
+ { name: GATE_NAMES.TYPECHECK, cmd: pnpmCmd(SCRIPTS.TYPECHECK) },
529
+ { name: GATE_NAMES.SPEC_LINTER, cmd: pnpmRun(SCRIPTS.SPEC_LINTER) },
530
+ {
531
+ name: GATE_NAMES.PROMPTS_LINT,
532
+ cmd: pnpmRun(SCRIPTS.PROMPTS_LINT, CLI_MODES.LOCAL, '--quiet'),
533
+ },
534
+ { name: GATE_NAMES.BACKLOG_SYNC, cmd: TOOL_PATHS.VALIDATE_BACKLOG_SYNC },
535
+ { name: GATE_NAMES.SUPABASE_DOCS_LINTER, cmd: TOOL_PATHS.SUPABASE_DOCS_LINTER },
536
+ // WU-2315: System map validation (warn-only until orphan docs are indexed)
537
+ {
538
+ name: GATE_NAMES.SYSTEM_MAP_VALIDATE,
539
+ cmd: TOOL_PATHS.SYSTEM_MAP_VALIDATE,
540
+ warnOnly: true,
541
+ },
542
+ // WU-2062: Safety-critical tests ALWAYS run
543
+ { name: GATE_NAMES.SAFETY_CRITICAL_TEST, cmd: GATE_COMMANDS.SAFETY_CRITICAL_TEST },
544
+ // WU-1920: Use changed tests by default, full suite with --full-tests
545
+ // WU-2244: --full-coverage implies --full-tests for accurate coverage
546
+ {
547
+ name: GATE_NAMES.TEST,
548
+ cmd: isFullTests || isFullCoverage
549
+ ? pnpmCmd('turbo', 'run', 'test')
550
+ : GATE_COMMANDS.INCREMENTAL_TEST,
551
+ },
552
+ // WU-2062: Integration tests only for high-risk changes
553
+ ...(riskTier && riskTier.shouldRunIntegration
554
+ ? [{ name: GATE_NAMES.INTEGRATION_TEST, cmd: GATE_COMMANDS.TIERED_TEST }]
555
+ : []),
556
+ // WU-1433: Coverage gate with configurable mode (warn/block)
557
+ { name: GATE_NAMES.COVERAGE, cmd: GATE_COMMANDS.COVERAGE_GATE },
558
+ ];
559
+ if (effectiveDocsOnly) {
560
+ if (!useAgentMode) {
561
+ console.log('šŸ“ Docs-only mode: skipping lint, typecheck, and tests\n');
562
+ }
563
+ else {
564
+ writeSync(agentLog.logFd, 'šŸ“ Docs-only mode: skipping lint, typecheck, and tests\n');
565
+ }
566
+ }
567
+ // Run all gates sequentially
568
+ // WU-1920: Track last test result to skip coverage gate on changed tests
569
+ let lastTestResult = null;
570
+ for (const gate of gates) {
571
+ let result;
572
+ if (gate.cmd === GATE_COMMANDS.INVARIANTS) {
573
+ // WU-2252: Invariants check runs first (non-bypassable)
574
+ const logLine = useAgentMode
575
+ ? (line) => writeSync(agentLog.logFd, `${line}\n`)
576
+ : (line) => console.log(line);
577
+ logLine('\n> Invariants check\n');
578
+ const invariantsResult = runInvariants({ baseDir: process.cwd(), silent: false });
579
+ result = {
580
+ ok: invariantsResult.success,
581
+ duration: 0, // runInvariants doesn't track duration
582
+ };
583
+ if (!result.ok) {
584
+ logLine('');
585
+ logLine(invariantsResult.formatted);
586
+ }
587
+ }
588
+ else if (gate.cmd === GATE_COMMANDS.INCREMENTAL) {
589
+ // Special handling for incremental lint
590
+ result = await runIncrementalLint({ agentLog });
591
+ }
592
+ else if (gate.cmd === GATE_COMMANDS.SAFETY_CRITICAL_TEST) {
593
+ // WU-2062: Safety-critical tests always run
594
+ result = await runSafetyCriticalTests({ agentLog });
595
+ }
596
+ else if (gate.cmd === GATE_COMMANDS.INCREMENTAL_TEST) {
597
+ // WU-1920: Special handling for changed tests
598
+ result = await runChangedTests({ agentLog });
599
+ lastTestResult = result;
600
+ }
601
+ else if (gate.cmd === GATE_COMMANDS.TIERED_TEST) {
602
+ // WU-2062: Integration tests for high-risk changes
603
+ result = await runIntegrationTests({ agentLog });
604
+ }
605
+ else if (gate.cmd === GATE_COMMANDS.COVERAGE_GATE) {
606
+ // WU-1920: Skip coverage gate when tests were changed (partial coverage)
607
+ // WU-2244: --full-coverage overrides incremental skip behavior
608
+ if (!isFullCoverage && lastTestResult?.isIncremental) {
609
+ const msg = 'ā­ļø Skipping coverage gate (changed tests - coverage is partial)';
610
+ if (!useAgentMode) {
611
+ console.log(`\n${msg}\n`);
612
+ }
613
+ else {
614
+ writeSync(agentLog.logFd, `\n${msg}\n\n`);
615
+ }
616
+ continue;
617
+ }
618
+ // WU-1433: Special handling for coverage gate
619
+ if (!useAgentMode) {
620
+ console.log(`\n> Coverage gate (mode: ${coverageMode})\n`);
621
+ }
622
+ else {
623
+ writeSync(agentLog.logFd, `\n> Coverage gate (mode: ${coverageMode})\n\n`);
624
+ }
625
+ result = await runCoverageGate({
626
+ mode: coverageMode,
627
+ logger: useAgentMode
628
+ ? {
629
+ log: (msg) => {
630
+ writeSync(agentLog.logFd, `${msg}\n`);
631
+ },
632
+ }
633
+ : console,
634
+ });
635
+ }
636
+ else {
637
+ result = run(gate.cmd, { agentLog });
638
+ }
639
+ // Emit telemetry event
640
+ emitGateEvent({
641
+ wu_id,
642
+ lane,
643
+ gate_name: gate.name,
644
+ passed: result.ok,
645
+ duration_ms: result.duration,
646
+ });
647
+ if (!result.ok) {
648
+ // WU-2315: Warn-only gates log warning but don't block
649
+ if (gate.warnOnly) {
650
+ const warnMsg = `āš ļø ${gate.name} failed (warn-only, not blocking)`;
651
+ if (!useAgentMode) {
652
+ console.log(`\n${warnMsg}\n`);
653
+ }
654
+ else {
655
+ writeSync(agentLog.logFd, `\n${warnMsg}\n\n`);
656
+ }
657
+ continue;
658
+ }
659
+ if (useAgentMode) {
660
+ const tail = readLogTail(agentLog.logPath);
661
+ console.error(`\nāŒ ${gate.name} failed (agent mode). Log: ${agentLog.logPath}\n`);
662
+ if (tail) {
663
+ console.error(`Last log lines:\n${tail}\n`);
664
+ }
665
+ }
666
+ die(`${gate.name} failed`);
667
+ }
668
+ }
669
+ // WU-2064: Create/update gates-latest.log symlink for easy agent access
670
+ if (agentLog) {
671
+ updateGatesLatestSymlink({ logPath: agentLog.logPath, cwd: process.cwd(), env: process.env });
672
+ }
673
+ if (!useAgentMode) {
674
+ console.log('\nāœ… All gates passed!\n');
675
+ }
676
+ else {
677
+ console.log(`āœ… All gates passed (agent mode). Log: ${agentLog.logPath}\n`);
678
+ }
679
+ process.exit(EXIT_CODES.SUCCESS);
680
+ }
681
+ main().catch((error) => {
682
+ console.error('Gates failed:', error);
683
+ process.exit(EXIT_CODES.ERROR);
684
+ });