tlc-claude-code 2.0.1 → 2.2.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 (109) hide show
  1. package/.claude/agents/builder.md +144 -0
  2. package/.claude/agents/planner.md +143 -0
  3. package/.claude/agents/reviewer.md +160 -0
  4. package/.claude/commands/tlc/build.md +4 -0
  5. package/.claude/commands/tlc/deploy.md +194 -2
  6. package/.claude/commands/tlc/e2e-verify.md +214 -0
  7. package/.claude/commands/tlc/guard.md +191 -0
  8. package/.claude/commands/tlc/help.md +32 -0
  9. package/.claude/commands/tlc/init.md +73 -37
  10. package/.claude/commands/tlc/llm.md +19 -4
  11. package/.claude/commands/tlc/preflight.md +134 -0
  12. package/.claude/commands/tlc/review-plan.md +363 -0
  13. package/.claude/commands/tlc/review.md +172 -57
  14. package/.claude/commands/tlc/watchci.md +159 -0
  15. package/.claude/hooks/tlc-block-tools.sh +41 -0
  16. package/.claude/hooks/tlc-capture-exchange.sh +50 -0
  17. package/.claude/hooks/tlc-post-build.sh +38 -0
  18. package/.claude/hooks/tlc-post-push.sh +22 -0
  19. package/.claude/hooks/tlc-prompt-guard.sh +69 -0
  20. package/.claude/hooks/tlc-session-init.sh +123 -0
  21. package/CLAUDE.md +13 -0
  22. package/bin/install.js +268 -2
  23. package/bin/postinstall.js +102 -24
  24. package/bin/setup-autoupdate.js +206 -0
  25. package/bin/setup-autoupdate.test.js +124 -0
  26. package/bin/tlc.js +0 -0
  27. package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
  28. package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
  29. package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
  30. package/dashboard-web/dist/index.html +2 -2
  31. package/docker-compose.dev.yml +18 -12
  32. package/package.json +4 -2
  33. package/scripts/project-docs.js +1 -1
  34. package/server/index.js +228 -2
  35. package/server/lib/capture-bridge.js +242 -0
  36. package/server/lib/capture-bridge.test.js +363 -0
  37. package/server/lib/capture-guard.js +140 -0
  38. package/server/lib/capture-guard.test.js +182 -0
  39. package/server/lib/command-runner.js +159 -0
  40. package/server/lib/command-runner.test.js +92 -0
  41. package/server/lib/cost-tracker.test.js +49 -12
  42. package/server/lib/deploy/runners/dependency-runner.js +106 -0
  43. package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
  44. package/server/lib/deploy/runners/secrets-runner.js +174 -0
  45. package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
  46. package/server/lib/deploy/security-gates.js +11 -24
  47. package/server/lib/deploy/security-gates.test.js +9 -2
  48. package/server/lib/deploy-engine.js +182 -0
  49. package/server/lib/deploy-engine.test.js +147 -0
  50. package/server/lib/docker-api.js +137 -0
  51. package/server/lib/docker-api.test.js +202 -0
  52. package/server/lib/docker-client.js +297 -0
  53. package/server/lib/docker-client.test.js +308 -0
  54. package/server/lib/input-sanitizer.js +86 -0
  55. package/server/lib/input-sanitizer.test.js +117 -0
  56. package/server/lib/launchd-agent.js +225 -0
  57. package/server/lib/launchd-agent.test.js +185 -0
  58. package/server/lib/memory-api.js +3 -1
  59. package/server/lib/memory-api.test.js +3 -5
  60. package/server/lib/memory-bridge-e2e.test.js +160 -0
  61. package/server/lib/memory-committer.js +18 -4
  62. package/server/lib/memory-committer.test.js +21 -0
  63. package/server/lib/memory-hooks-capture.test.js +69 -4
  64. package/server/lib/memory-hooks-integration.test.js +98 -0
  65. package/server/lib/memory-hooks.js +42 -4
  66. package/server/lib/memory-store-adapter.js +105 -0
  67. package/server/lib/memory-store-adapter.test.js +141 -0
  68. package/server/lib/memory-wiring-e2e.test.js +93 -0
  69. package/server/lib/nginx-config.js +114 -0
  70. package/server/lib/nginx-config.test.js +82 -0
  71. package/server/lib/ollama-health.js +91 -0
  72. package/server/lib/ollama-health.test.js +74 -0
  73. package/server/lib/orchestration/agent-dispatcher.js +114 -0
  74. package/server/lib/orchestration/agent-dispatcher.test.js +110 -0
  75. package/server/lib/orchestration/orchestrator.js +130 -0
  76. package/server/lib/orchestration/orchestrator.test.js +192 -0
  77. package/server/lib/orchestration/tmux-manager.js +101 -0
  78. package/server/lib/orchestration/tmux-manager.test.js +109 -0
  79. package/server/lib/orchestration/worktree-manager.js +132 -0
  80. package/server/lib/orchestration/worktree-manager.test.js +129 -0
  81. package/server/lib/port-guard.js +44 -0
  82. package/server/lib/port-guard.test.js +65 -0
  83. package/server/lib/project-scanner.js +37 -2
  84. package/server/lib/project-scanner.test.js +152 -0
  85. package/server/lib/remember-command.js +2 -0
  86. package/server/lib/remember-command.test.js +23 -0
  87. package/server/lib/review/plan-reviewer.js +260 -0
  88. package/server/lib/review/plan-reviewer.test.js +269 -0
  89. package/server/lib/review/review-schemas.js +173 -0
  90. package/server/lib/review/review-schemas.test.js +152 -0
  91. package/server/lib/security/crypto-utils.test.js +2 -2
  92. package/server/lib/semantic-recall.js +1 -1
  93. package/server/lib/semantic-recall.test.js +17 -0
  94. package/server/lib/ssh-client.js +184 -0
  95. package/server/lib/ssh-client.test.js +127 -0
  96. package/server/lib/vps-api.js +184 -0
  97. package/server/lib/vps-api.test.js +208 -0
  98. package/server/lib/vps-bootstrap.js +124 -0
  99. package/server/lib/vps-bootstrap.test.js +79 -0
  100. package/server/lib/vps-monitor.js +126 -0
  101. package/server/lib/vps-monitor.test.js +98 -0
  102. package/server/lib/workspace-api.js +182 -1
  103. package/server/lib/workspace-api.test.js +474 -0
  104. package/server/package-lock.json +737 -0
  105. package/server/package.json +3 -0
  106. package/server/setup.sh +271 -271
  107. package/dashboard-web/dist/assets/index-Uhc49PE-.css +0 -1
  108. package/dashboard-web/dist/assets/index-W36XHPC5.js +0 -431
  109. package/dashboard-web/dist/assets/index-W36XHPC5.js.map +0 -1
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Command Runner — execute TLC commands via container, Claude Code, or queue
3
+ * Phase 80 Task 8
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const { execSync, spawn } = require('child_process');
9
+
10
+ const VALID_COMMANDS = ['build', 'deploy', 'test', 'plan', 'verify', 'review', 'status'];
11
+
12
+ /**
13
+ * Create command runner
14
+ * @param {Object} [options]
15
+ * @param {Function} [options._checkDocker] - Check if tlc-standalone image exists
16
+ * @param {Function} [options._checkClaude] - Check if Claude Code is running
17
+ * @returns {Object} Command runner API
18
+ */
19
+ function createCommandRunner(options = {}) {
20
+ const checkDocker = options._checkDocker || defaultCheckDocker;
21
+ const checkClaude = options._checkClaude || defaultCheckClaude;
22
+
23
+ async function defaultCheckDocker() {
24
+ try {
25
+ execSync('docker image inspect tlc-standalone 2>/dev/null', { stdio: 'pipe' });
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ function defaultCheckClaude() {
33
+ try {
34
+ const result = execSync('pgrep -f "claude" 2>/dev/null', { stdio: 'pipe' });
35
+ return result.toString().trim().length > 0;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Detect best execution method
43
+ * @param {string} projectPath
44
+ * @returns {Promise<string>} 'container' | 'claude-code' | 'queue'
45
+ */
46
+ async function detectExecutionMethod(projectPath) {
47
+ if (await checkDocker()) return 'container';
48
+ if (checkClaude()) return 'claude-code';
49
+ return 'queue';
50
+ }
51
+
52
+ /**
53
+ * Execute command via Docker container
54
+ * @param {string} projectPath
55
+ * @param {string} command
56
+ * @param {Function} onOutput
57
+ * @returns {Promise<{ exitCode: number }>}
58
+ */
59
+ async function executeViaContainer(projectPath, command, onOutput) {
60
+ return new Promise((resolve, reject) => {
61
+ const proc = spawn('docker', [
62
+ 'run', '--rm',
63
+ '-v', `${projectPath}:/project`,
64
+ '-w', '/project',
65
+ 'tlc-standalone',
66
+ 'tlc', command,
67
+ ]);
68
+
69
+ proc.stdout.on('data', (data) => onOutput && onOutput(data.toString()));
70
+ proc.stderr.on('data', (data) => onOutput && onOutput(data.toString()));
71
+ proc.on('close', (code) => resolve({ exitCode: code }));
72
+ proc.on('error', (err) => reject(err));
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Queue command as task in PLAN.md
78
+ * @param {string} projectPath
79
+ * @param {string} command
80
+ */
81
+ async function queueCommand(projectPath, command) {
82
+ const timestamp = new Date().toISOString();
83
+ const entry = `\n### Queued: tlc ${command}\n_Queued at ${timestamp}_\n`;
84
+
85
+ // Find most recent plan file
86
+ const planDir = path.join(projectPath, '.planning', 'phases');
87
+ if (fs.existsSync(planDir)) {
88
+ const plans = fs.readdirSync(planDir).filter(f => f.endsWith('-PLAN.md')).sort((a, b) => {
89
+ const numA = parseInt(a.match(/^(\d+)/)?.[1] || '0', 10);
90
+ const numB = parseInt(b.match(/^(\d+)/)?.[1] || '0', 10);
91
+ return numA - numB;
92
+ });
93
+ if (plans.length > 0) {
94
+ const planPath = path.join(planDir, plans[plans.length - 1]);
95
+ fs.appendFileSync(planPath, entry);
96
+ }
97
+ }
98
+
99
+ // Log to history
100
+ logCommand(projectPath, { command, timestamp, method: 'queue' });
101
+
102
+ return { queued: true, method: 'queue', command };
103
+ }
104
+
105
+ /**
106
+ * Log a command to history
107
+ */
108
+ function logCommand(projectPath, entry) {
109
+ const histPath = path.join(projectPath, '.tlc', 'command-history.json');
110
+ const dir = path.dirname(histPath);
111
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
112
+
113
+ let history = [];
114
+ try {
115
+ if (fs.existsSync(histPath)) {
116
+ history = JSON.parse(fs.readFileSync(histPath, 'utf8'));
117
+ }
118
+ } catch {}
119
+ history.push(entry);
120
+ // Keep last 100
121
+ if (history.length > 100) history = history.slice(-100);
122
+ fs.writeFileSync(histPath, JSON.stringify(history, null, 2));
123
+ }
124
+
125
+ /**
126
+ * Get command history for a project
127
+ * @param {string} projectPath
128
+ * @returns {Array}
129
+ */
130
+ function getCommandHistory(projectPath) {
131
+ const histPath = path.join(projectPath, '.tlc', 'command-history.json');
132
+ try {
133
+ if (fs.existsSync(histPath)) {
134
+ return JSON.parse(fs.readFileSync(histPath, 'utf8'));
135
+ }
136
+ } catch {}
137
+ return [];
138
+ }
139
+
140
+ /**
141
+ * Validate command type
142
+ * @param {string} command
143
+ * @returns {boolean}
144
+ */
145
+ function validateCommand(command) {
146
+ if (!command || typeof command !== 'string') return false;
147
+ return VALID_COMMANDS.includes(command);
148
+ }
149
+
150
+ return {
151
+ detectExecutionMethod,
152
+ executeViaContainer,
153
+ queueCommand,
154
+ getCommandHistory,
155
+ validateCommand,
156
+ };
157
+ }
158
+
159
+ module.exports = { createCommandRunner };
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+
6
+ const { createCommandRunner } = await import('./command-runner.js');
7
+
8
+ describe('CommandRunner', () => {
9
+ let runner;
10
+ let tempDir;
11
+
12
+ beforeEach(() => {
13
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cmd-runner-test-'));
14
+ runner = createCommandRunner({
15
+ _checkDocker: vi.fn().mockResolvedValue(false),
16
+ _checkClaude: vi.fn().mockReturnValue(false),
17
+ });
18
+ });
19
+
20
+ afterEach(() => {
21
+ fs.rmSync(tempDir, { recursive: true, force: true });
22
+ });
23
+
24
+ describe('detectExecutionMethod', () => {
25
+ it('returns container when tlc-standalone image exists', async () => {
26
+ const r = createCommandRunner({
27
+ _checkDocker: vi.fn().mockResolvedValue(true),
28
+ _checkClaude: vi.fn().mockReturnValue(false),
29
+ });
30
+ const method = await r.detectExecutionMethod(tempDir);
31
+ expect(method).toBe('container');
32
+ });
33
+
34
+ it('returns claude-code when Claude Code process running', async () => {
35
+ const r = createCommandRunner({
36
+ _checkDocker: vi.fn().mockResolvedValue(false),
37
+ _checkClaude: vi.fn().mockReturnValue(true),
38
+ });
39
+ const method = await r.detectExecutionMethod(tempDir);
40
+ expect(method).toBe('claude-code');
41
+ });
42
+
43
+ it('returns queue as fallback', async () => {
44
+ const method = await runner.detectExecutionMethod(tempDir);
45
+ expect(method).toBe('queue');
46
+ });
47
+ });
48
+
49
+ describe('queueCommand', () => {
50
+ it('appends task to PLAN.md', async () => {
51
+ const planDir = path.join(tempDir, '.planning', 'phases');
52
+ fs.mkdirSync(planDir, { recursive: true });
53
+ fs.writeFileSync(path.join(planDir, '1-PLAN.md'), '# Phase 1\n');
54
+
55
+ const result = await runner.queueCommand(tempDir, 'build');
56
+ expect(result.queued).toBe(true);
57
+ expect(result.method).toBe('queue');
58
+ });
59
+ });
60
+
61
+ describe('getCommandHistory', () => {
62
+ it('returns recent commands', () => {
63
+ const histDir = path.join(tempDir, '.tlc');
64
+ fs.mkdirSync(histDir, { recursive: true });
65
+ fs.writeFileSync(path.join(histDir, 'command-history.json'), JSON.stringify([
66
+ { command: 'build', timestamp: '2026-02-18T00:00:00Z', method: 'queue' },
67
+ ]));
68
+
69
+ const history = runner.getCommandHistory(tempDir);
70
+ expect(history).toHaveLength(1);
71
+ expect(history[0].command).toBe('build');
72
+ });
73
+
74
+ it('returns empty array when no history', () => {
75
+ const history = runner.getCommandHistory(tempDir);
76
+ expect(history).toEqual([]);
77
+ });
78
+ });
79
+
80
+ describe('validateCommand', () => {
81
+ it('accepts valid commands', () => {
82
+ expect(runner.validateCommand('build')).toBe(true);
83
+ expect(runner.validateCommand('deploy')).toBe(true);
84
+ expect(runner.validateCommand('test')).toBe(true);
85
+ });
86
+
87
+ it('rejects invalid commands', () => {
88
+ expect(runner.validateCommand('')).toBe(false);
89
+ expect(runner.validateCommand(null)).toBe(false);
90
+ });
91
+ });
92
+ });
@@ -151,21 +151,58 @@ describe('Cost Tracker', () => {
151
151
 
152
152
  describe('getWeeklyCost', () => {
153
153
  it('aggregates days in week', () => {
154
- // Record costs for multiple days
154
+ // Use explicit UTC day strings for Mon–Fri of the current UTC week so
155
+ // the recorded entries are immune to local-vs-UTC boundary differences.
156
+ // We record two entries and then recompute the expected weekly total using
157
+ // the same week-window logic the implementation uses, so the assertion is
158
+ // always self-consistent regardless of timezone or day-of-week.
155
159
  const now = new Date();
156
- const today = now.toISOString().split('T')[0];
157
-
158
- recordCost(tracker, {
159
- agentId: 'agent-1',
160
- sessionId: 'session-1',
161
- model: 'claude-3-opus',
162
- provider: 'anthropic',
163
- cost: 1.00,
164
- timestamp: now.toISOString(),
165
- });
160
+ const weekStart = new Date(now);
161
+ weekStart.setDate(now.getDate() - now.getDay());
162
+ weekStart.setHours(0, 0, 0, 0);
163
+
164
+ // Pick up to two UTC day strings from the past 7 days that fall within
165
+ // the implementation's [weekStart, now] window so we always have at
166
+ // least one in-range day (or zero if the implementation window is empty,
167
+ // in which case the expected total is also 0).
168
+ const candidateDays = [];
169
+ for (let offset = 1; offset <= 6; offset++) {
170
+ const d = new Date(now);
171
+ d.setUTCDate(now.getUTCDate() - offset);
172
+ d.setUTCHours(12, 0, 0, 0); // noon UTC avoids any day-rollover at midnight
173
+ const dayStr = d.toISOString().split('T')[0];
174
+ const dayDate = new Date(dayStr);
175
+ if (dayDate >= weekStart && dayDate <= now) {
176
+ candidateDays.push(dayStr);
177
+ if (candidateDays.length >= 2) break;
178
+ }
179
+ }
180
+
181
+ // Always record one entry for the exact current moment so that entry is
182
+ // trivially <= now; whether it passes the weekStart check determines the
183
+ // expected total.
184
+ const nowDayStr = now.toISOString().split('T')[0];
185
+
186
+ const allDays = [...new Set([...candidateDays, nowDayStr])];
187
+ let expectedTotal = 0;
188
+ for (const dayStr of allDays) {
189
+ const dayDate = new Date(dayStr);
190
+ const cost = 1.00;
191
+ recordCost(tracker, {
192
+ agentId: 'agent-1',
193
+ sessionId: 'session-1',
194
+ model: 'claude-3-opus',
195
+ provider: 'anthropic',
196
+ cost,
197
+ timestamp: `${dayStr}T12:00:00.000Z`,
198
+ });
199
+ if (dayDate >= weekStart && dayDate <= now) {
200
+ expectedTotal += cost;
201
+ }
202
+ }
166
203
 
167
204
  const weeklyCost = getWeeklyCost(tracker);
168
- assert.ok(weeklyCost >= 1.00);
205
+ assert.strictEqual(weeklyCost, expectedTotal);
169
206
  });
170
207
  });
171
208
 
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Dependency Runner
3
+ *
4
+ * Scans project dependencies using npm audit.
5
+ * Returns findings with severity, package name, and fix availability.
6
+ */
7
+
8
+ import { execFile as defaultExecFile } from 'node:child_process';
9
+ import { promisify } from 'node:util';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ const execFileAsync = promisify(defaultExecFile);
14
+
15
+ /** Severity levels ordered from lowest to highest */
16
+ const SEVERITY_ORDER = ['info', 'low', 'moderate', 'high', 'critical'];
17
+
18
+ /**
19
+ * Check if a severity meets or exceeds the threshold
20
+ * @param {string} severity - The vulnerability severity
21
+ * @param {string} threshold - The minimum severity to fail on
22
+ * @returns {boolean} True if severity >= threshold
23
+ */
24
+ function meetsThreshold(severity, threshold) {
25
+ return SEVERITY_ORDER.indexOf(severity) >= SEVERITY_ORDER.indexOf(threshold);
26
+ }
27
+
28
+ /**
29
+ * Create a dependency scanning runner
30
+ * @param {Object} [deps] - Injectable dependencies for testing
31
+ * @param {Function} [deps.exec] - Command executor (replaces execFile)
32
+ * @param {Object} [deps.fs] - File system module
33
+ * @param {string} [deps.severityThreshold] - Minimum severity to fail on (default: 'high')
34
+ * @returns {Function} Runner function: (projectPath, options) => { passed, findings, error? }
35
+ */
36
+ export function createDependencyRunner(deps = {}) {
37
+ const {
38
+ exec: execFn,
39
+ fs: fsMod = fs,
40
+ severityThreshold = 'high',
41
+ } = deps;
42
+
43
+ return async (projectPath, options = {}) => {
44
+ // Check if package.json exists
45
+ const pkgPath = path.join(projectPath, 'package.json');
46
+ if (!fsMod.existsSync(pkgPath)) {
47
+ return { passed: true, findings: [] };
48
+ }
49
+
50
+ let stdout;
51
+ try {
52
+ if (execFn) {
53
+ // Injected exec for testing
54
+ const result = await execFn('npm', ['audit', '--json'], { cwd: projectPath });
55
+ stdout = result.stdout;
56
+ } else {
57
+ // Real exec
58
+ try {
59
+ const result = await execFileAsync('npm', ['audit', '--json'], { cwd: projectPath });
60
+ stdout = result.stdout;
61
+ } catch (execErr) {
62
+ // npm audit exits with code 1 when vulnerabilities found — that's expected
63
+ if (execErr.stdout) {
64
+ stdout = execErr.stdout;
65
+ } else {
66
+ throw execErr;
67
+ }
68
+ }
69
+ }
70
+ } catch (err) {
71
+ return { passed: false, findings: [], error: err.message };
72
+ }
73
+
74
+ // Parse the JSON output
75
+ let auditData;
76
+ try {
77
+ auditData = JSON.parse(stdout);
78
+ } catch {
79
+ return { passed: false, findings: [], error: 'Failed to parse npm audit output' };
80
+ }
81
+
82
+ const vulnerabilities = auditData.vulnerabilities || {};
83
+ const findings = [];
84
+ let hasFailingSeverity = false;
85
+
86
+ for (const [name, vuln] of Object.entries(vulnerabilities)) {
87
+ const finding = {
88
+ severity: vuln.severity,
89
+ package: vuln.name || name,
90
+ title: vuln.title || 'Unknown vulnerability',
91
+ url: vuln.url || '',
92
+ fixAvailable: Boolean(vuln.fixAvailable),
93
+ };
94
+ findings.push(finding);
95
+
96
+ if (meetsThreshold(vuln.severity, severityThreshold)) {
97
+ hasFailingSeverity = true;
98
+ }
99
+ }
100
+
101
+ return {
102
+ passed: !hasFailingSeverity,
103
+ findings,
104
+ };
105
+ };
106
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Dependency Runner Tests
3
+ */
4
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
5
+ import { createDependencyRunner } from './dependency-runner.js';
6
+
7
+ describe('dependency-runner', () => {
8
+ let execMock;
9
+ let fsMock;
10
+ let runner;
11
+
12
+ beforeEach(() => {
13
+ execMock = vi.fn();
14
+ fsMock = { existsSync: vi.fn().mockReturnValue(true) };
15
+ runner = createDependencyRunner({ exec: execMock, fs: fsMock });
16
+ });
17
+
18
+ it('passes when npm audit returns no vulnerabilities', async () => {
19
+ execMock.mockResolvedValue({
20
+ stdout: JSON.stringify({ vulnerabilities: {} }),
21
+ exitCode: 0,
22
+ });
23
+
24
+ const result = await runner('/test/project', {});
25
+ expect(result.passed).toBe(true);
26
+ expect(result.findings).toEqual([]);
27
+ });
28
+
29
+ it('fails when npm audit finds high-severity vulnerability', async () => {
30
+ execMock.mockResolvedValue({
31
+ stdout: JSON.stringify({
32
+ vulnerabilities: {
33
+ 'bad-pkg': {
34
+ name: 'bad-pkg',
35
+ severity: 'high',
36
+ title: 'Prototype Pollution',
37
+ url: 'https://npmjs.com/advisories/123',
38
+ fixAvailable: true,
39
+ },
40
+ },
41
+ }),
42
+ exitCode: 1,
43
+ });
44
+
45
+ const result = await runner('/test/project', {});
46
+ expect(result.passed).toBe(false);
47
+ expect(result.findings).toHaveLength(1);
48
+ expect(result.findings[0].severity).toBe('high');
49
+ expect(result.findings[0].package).toBe('bad-pkg');
50
+ expect(result.findings[0].title).toBe('Prototype Pollution');
51
+ });
52
+
53
+ it('passes when only low/moderate vulnerabilities found', async () => {
54
+ execMock.mockResolvedValue({
55
+ stdout: JSON.stringify({
56
+ vulnerabilities: {
57
+ 'ok-pkg': {
58
+ name: 'ok-pkg',
59
+ severity: 'moderate',
60
+ title: 'ReDoS',
61
+ url: 'https://npmjs.com/advisories/456',
62
+ fixAvailable: true,
63
+ },
64
+ },
65
+ }),
66
+ exitCode: 1,
67
+ });
68
+
69
+ const result = await runner('/test/project', {});
70
+ expect(result.passed).toBe(true);
71
+ expect(result.findings).toHaveLength(1);
72
+ });
73
+
74
+ it('fails when critical vulnerability found', async () => {
75
+ execMock.mockResolvedValue({
76
+ stdout: JSON.stringify({
77
+ vulnerabilities: {
78
+ 'evil-pkg': {
79
+ name: 'evil-pkg',
80
+ severity: 'critical',
81
+ title: 'RCE',
82
+ url: 'https://npmjs.com/advisories/789',
83
+ fixAvailable: false,
84
+ },
85
+ },
86
+ }),
87
+ exitCode: 1,
88
+ });
89
+
90
+ const result = await runner('/test/project', {});
91
+ expect(result.passed).toBe(false);
92
+ expect(result.findings[0].severity).toBe('critical');
93
+ });
94
+
95
+ it('handles missing package.json', async () => {
96
+ fsMock.existsSync.mockReturnValue(false);
97
+
98
+ const result = await runner('/test/project', {});
99
+ expect(result.passed).toBe(true);
100
+ expect(result.findings).toEqual([]);
101
+ });
102
+
103
+ it('handles npm audit JSON parse error', async () => {
104
+ execMock.mockResolvedValue({
105
+ stdout: 'not valid json',
106
+ exitCode: 1,
107
+ });
108
+
109
+ const result = await runner('/test/project', {});
110
+ expect(result.passed).toBe(false);
111
+ expect(result.error).toBeDefined();
112
+ });
113
+
114
+ it('handles npm audit process error', async () => {
115
+ execMock.mockRejectedValue(new Error('npm not found'));
116
+
117
+ const result = await runner('/test/project', {});
118
+ expect(result.passed).toBe(false);
119
+ expect(result.error).toContain('npm not found');
120
+ });
121
+
122
+ it('respects configurable severity threshold', async () => {
123
+ execMock.mockResolvedValue({
124
+ stdout: JSON.stringify({
125
+ vulnerabilities: {
126
+ 'bad-pkg': {
127
+ name: 'bad-pkg',
128
+ severity: 'high',
129
+ title: 'Prototype Pollution',
130
+ url: 'https://npmjs.com/advisories/123',
131
+ fixAvailable: true,
132
+ },
133
+ },
134
+ }),
135
+ exitCode: 1,
136
+ });
137
+
138
+ const lenientRunner = createDependencyRunner({
139
+ exec: execMock,
140
+ fs: fsMock,
141
+ severityThreshold: 'critical',
142
+ });
143
+
144
+ const result = await lenientRunner('/test/project', {});
145
+ expect(result.passed).toBe(true);
146
+ expect(result.findings).toHaveLength(1);
147
+ });
148
+ });