@syntesseraai/opencode-feature-factory 0.2.3 → 0.2.4

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.
@@ -0,0 +1,549 @@
1
+ /**
2
+ * Unit tests for stop-quality-gate module
3
+ *
4
+ * Tests focus on pure functions that can be tested in isolation:
5
+ * - sanitizeOutput: redacts secrets from CI output
6
+ * - isSessionReadOnly: determines if session has write permissions
7
+ */
8
+ import { sanitizeOutput, truncateOutput } from './stop-quality-gate';
9
+ function isSessionReadOnly(permission) {
10
+ if (!permission)
11
+ return false;
12
+ return permission.some((rule) => (rule.permission === 'edit' || rule.permission === 'bash') && rule.action === 'deny');
13
+ }
14
+ describe('sanitizeOutput', () => {
15
+ describe('AWS credentials', () => {
16
+ it('should redact AWS Access Key IDs', () => {
17
+ const input = 'Found credentials: AKIAIOSFODNN7EXAMPLE in config';
18
+ const result = sanitizeOutput(input);
19
+ expect(result).toBe('Found credentials: [REDACTED_AWS_KEY] in config');
20
+ });
21
+ it('should redact multiple AWS keys', () => {
22
+ const input = 'Key1: AKIAIOSFODNN7EXAMPLE Key2: AKIAI44QH8DHBEXAMPLE';
23
+ const result = sanitizeOutput(input);
24
+ expect(result).toBe('Key1: [REDACTED_AWS_KEY] Key2: [REDACTED_AWS_KEY]');
25
+ });
26
+ it('should not redact partial AWS key patterns', () => {
27
+ const input = 'AKIA123'; // Too short
28
+ const result = sanitizeOutput(input);
29
+ expect(result).toBe('AKIA123');
30
+ });
31
+ });
32
+ describe('GitHub tokens', () => {
33
+ it('should redact GitHub Personal Access Tokens (classic)', () => {
34
+ const input = 'Using ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789';
35
+ const result = sanitizeOutput(input);
36
+ expect(result).toBe('Using [REDACTED_GH_TOKEN]');
37
+ });
38
+ it('should redact GitHub Personal Access Tokens (fine-grained)', () => {
39
+ const input = 'Using github_pat_11ABCDEFG_abcdefghijklmnopqrstuvwxyz';
40
+ const result = sanitizeOutput(input);
41
+ expect(result).toBe('Using [REDACTED_GH_TOKEN]');
42
+ });
43
+ it('should redact GitHub OAuth tokens', () => {
44
+ const input = 'oauth=gho_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789';
45
+ const result = sanitizeOutput(input);
46
+ expect(result).toBe('oauth=[REDACTED_GH_TOKEN]');
47
+ });
48
+ it('should redact GitHub App user-to-server tokens', () => {
49
+ const input = 'Using ghu_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789';
50
+ const result = sanitizeOutput(input);
51
+ expect(result).toBe('Using [REDACTED_GH_TOKEN]');
52
+ });
53
+ it('should redact GitHub App server-to-server tokens', () => {
54
+ const input = 'Using ghs_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789';
55
+ const result = sanitizeOutput(input);
56
+ expect(result).toBe('Using [REDACTED_GH_TOKEN]');
57
+ });
58
+ it('should not redact partial GitHub token patterns', () => {
59
+ const input = 'ghp_abc'; // Too short
60
+ const result = sanitizeOutput(input);
61
+ expect(result).toBe('ghp_abc');
62
+ });
63
+ });
64
+ describe('GitLab tokens', () => {
65
+ it('should redact GitLab Personal Access Tokens', () => {
66
+ const input = 'Using glpat-abcdefghij1234567890';
67
+ const result = sanitizeOutput(input);
68
+ expect(result).toBe('Using [REDACTED_GITLAB_TOKEN]');
69
+ });
70
+ it('should redact GitLab tokens with hyphens', () => {
71
+ const input = 'Using glpat-abc-def-ghi-jkl-mnop-qrs';
72
+ const result = sanitizeOutput(input);
73
+ expect(result).toBe('Using [REDACTED_GITLAB_TOKEN]');
74
+ });
75
+ it('should not redact partial GitLab token patterns', () => {
76
+ const input = 'glpat-short'; // Too short
77
+ const result = sanitizeOutput(input);
78
+ expect(result).toBe('glpat-short');
79
+ });
80
+ });
81
+ describe('npm tokens', () => {
82
+ it('should redact npm tokens', () => {
83
+ const input = 'Using npm_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789';
84
+ const result = sanitizeOutput(input);
85
+ expect(result).toBe('Using [REDACTED_NPM_TOKEN]');
86
+ });
87
+ it('should not redact partial npm token patterns', () => {
88
+ const input = 'npm_abc'; // Too short
89
+ const result = sanitizeOutput(input);
90
+ expect(result).toBe('npm_abc');
91
+ });
92
+ });
93
+ describe('Bearer tokens', () => {
94
+ it('should redact Bearer tokens', () => {
95
+ const input = 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9';
96
+ const result = sanitizeOutput(input);
97
+ expect(result).toBe('Authorization: Bearer [REDACTED]');
98
+ });
99
+ it('should handle case-insensitive Bearer', () => {
100
+ const input = 'bearer abc123-token.value';
101
+ const result = sanitizeOutput(input);
102
+ expect(result).toBe('Bearer [REDACTED]');
103
+ });
104
+ });
105
+ describe('API keys', () => {
106
+ it('should redact api_key assignments', () => {
107
+ const input = 'api_key=sk-1234567890abcdef';
108
+ const result = sanitizeOutput(input);
109
+ expect(result).toBe('api_key=[REDACTED]');
110
+ });
111
+ it('should redact api-key with hyphen', () => {
112
+ const input = 'api-key: my-secret-key';
113
+ const result = sanitizeOutput(input);
114
+ expect(result).toBe('api_key=[REDACTED]');
115
+ });
116
+ it('should redact apikey without separator', () => {
117
+ const input = 'apikey=abc123';
118
+ const result = sanitizeOutput(input);
119
+ expect(result).toBe('api_key=[REDACTED]');
120
+ });
121
+ it('should redact quoted api keys', () => {
122
+ const input = "api_key='secret-value'";
123
+ const result = sanitizeOutput(input);
124
+ expect(result).toBe('api_key=[REDACTED]');
125
+ });
126
+ });
127
+ describe('tokens', () => {
128
+ it('should redact token assignments with 8+ char values', () => {
129
+ const input = 'token=ghp_xxxxxxxxxxxxxxxxxxxx';
130
+ const result = sanitizeOutput(input);
131
+ expect(result).toBe('token=[REDACTED]');
132
+ });
133
+ it('should redact tokens plural with 8+ char values', () => {
134
+ const input = 'tokens: secret12345';
135
+ const result = sanitizeOutput(input);
136
+ expect(result).toBe('token=[REDACTED]');
137
+ });
138
+ it('should NOT redact token with short values (less than 8 chars)', () => {
139
+ const input = 'token=abc123';
140
+ const result = sanitizeOutput(input);
141
+ expect(result).toBe('token=abc123');
142
+ });
143
+ it('should NOT redact phrases like "token count: 5" (value too short)', () => {
144
+ const input = 'token count: 5';
145
+ const result = sanitizeOutput(input);
146
+ expect(result).toBe('token count: 5');
147
+ });
148
+ it('should NOT redact "token: abc" (value too short)', () => {
149
+ const input = 'token: abc';
150
+ const result = sanitizeOutput(input);
151
+ expect(result).toBe('token: abc');
152
+ });
153
+ it('should redact actual token values that are 8+ chars', () => {
154
+ const input = 'token=abcd1234efgh';
155
+ const result = sanitizeOutput(input);
156
+ expect(result).toBe('token=[REDACTED]');
157
+ });
158
+ it('should redact quoted tokens with 8+ char values', () => {
159
+ const input = 'token="my-secret-token-value"';
160
+ const result = sanitizeOutput(input);
161
+ expect(result).toBe('token=[REDACTED]');
162
+ });
163
+ });
164
+ describe('passwords', () => {
165
+ it('should redact password assignments', () => {
166
+ const input = 'password=super-secret-123!';
167
+ const result = sanitizeOutput(input);
168
+ expect(result).toBe('password=[REDACTED]');
169
+ });
170
+ it('should redact passwords plural', () => {
171
+ const input = 'passwords: "admin123"';
172
+ const result = sanitizeOutput(input);
173
+ expect(result).toBe('password=[REDACTED]');
174
+ });
175
+ it('should handle special characters in passwords', () => {
176
+ const input = 'password=P@$$w0rd!#%';
177
+ const result = sanitizeOutput(input);
178
+ expect(result).toBe('password=[REDACTED]');
179
+ });
180
+ });
181
+ describe('generic secrets', () => {
182
+ it('should redact secret assignments', () => {
183
+ const input = 'secret=my-app-secret-key';
184
+ const result = sanitizeOutput(input);
185
+ expect(result).toBe('secret=[REDACTED]');
186
+ });
187
+ it('should redact secrets plural', () => {
188
+ const input = 'secrets: "confidential-data"';
189
+ const result = sanitizeOutput(input);
190
+ expect(result).toBe('secret=[REDACTED]');
191
+ });
192
+ });
193
+ describe('base64 strings', () => {
194
+ it('should redact long base64-like strings', () => {
195
+ const base64 = 'YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY3ODk=';
196
+ const input = `Encoded value: ${base64}`;
197
+ const result = sanitizeOutput(input);
198
+ expect(result).toBe('Encoded value: [REDACTED_BASE64]');
199
+ });
200
+ it('should not redact short base64 strings', () => {
201
+ const input = 'Short: abc123';
202
+ const result = sanitizeOutput(input);
203
+ expect(result).toBe('Short: abc123');
204
+ });
205
+ it('should redact base64 without padding', () => {
206
+ const base64 = 'YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY3ODkw';
207
+ const result = sanitizeOutput(base64);
208
+ expect(result).toBe('[REDACTED_BASE64]');
209
+ });
210
+ it('should redact base64 strings up to 500 chars (ReDoS prevention)', () => {
211
+ // Generate a 500-char base64 string
212
+ const base64 = 'A'.repeat(500);
213
+ const result = sanitizeOutput(base64);
214
+ expect(result).toBe('[REDACTED_BASE64]');
215
+ });
216
+ it('should handle base64 strings over 500 chars by matching only first 500 (ReDoS prevention)', () => {
217
+ // Generate a 501-char base64 string - only first 500 chars match
218
+ const base64 = 'A'.repeat(501);
219
+ const result = sanitizeOutput(base64);
220
+ // Pattern matches the first 500 chars, leaving 1 char behind
221
+ expect(result).toBe('[REDACTED_BASE64]A');
222
+ });
223
+ it('should handle base64 at exactly 40 chars (minimum threshold)', () => {
224
+ const base64 = 'A'.repeat(40);
225
+ const result = sanitizeOutput(base64);
226
+ expect(result).toBe('[REDACTED_BASE64]');
227
+ });
228
+ it('should NOT redact base64 strings under 40 chars', () => {
229
+ const base64 = 'A'.repeat(39);
230
+ const result = sanitizeOutput(base64);
231
+ expect(result).toBe(base64);
232
+ });
233
+ });
234
+ describe('multiple secrets', () => {
235
+ it('should redact multiple different secret types', () => {
236
+ const input = `
237
+ AWS_KEY: AKIAIOSFODNN7EXAMPLE
238
+ Auth: Bearer my-jwt-token
239
+ password=admin123
240
+ `;
241
+ const result = sanitizeOutput(input);
242
+ expect(result).toContain('[REDACTED_AWS_KEY]');
243
+ expect(result).toContain('Bearer [REDACTED]');
244
+ expect(result).toContain('password=[REDACTED]');
245
+ expect(result).not.toContain('AKIAIOSFODNN7EXAMPLE');
246
+ expect(result).not.toContain('my-jwt-token');
247
+ expect(result).not.toContain('admin123');
248
+ });
249
+ });
250
+ describe('private keys', () => {
251
+ it('should redact RSA private keys', () => {
252
+ const input = `-----BEGIN RSA PRIVATE KEY-----
253
+ MIIEowIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8PbnGy
254
+ -----END RSA PRIVATE KEY-----`;
255
+ const result = sanitizeOutput(input);
256
+ expect(result).toBe('[REDACTED_PRIVATE_KEY]');
257
+ });
258
+ it('should redact OpenSSH private keys', () => {
259
+ const input = `-----BEGIN OPENSSH PRIVATE KEY-----
260
+ b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAA
261
+ -----END OPENSSH PRIVATE KEY-----`;
262
+ const result = sanitizeOutput(input);
263
+ expect(result).toBe('[REDACTED_PRIVATE_KEY]');
264
+ });
265
+ it('should redact EC private keys', () => {
266
+ const input = `-----BEGIN EC PRIVATE KEY-----
267
+ MHQCAQEEICg7E4NN6YPWoU6/FXa5ON6Pt6LKBfA8WL
268
+ -----END EC PRIVATE KEY-----`;
269
+ const result = sanitizeOutput(input);
270
+ expect(result).toBe('[REDACTED_PRIVATE_KEY]');
271
+ });
272
+ it('should redact generic private keys', () => {
273
+ const input = `-----BEGIN PRIVATE KEY-----
274
+ MIIEvgIBADANBgkqhkiG9w0BAQEFAASC
275
+ -----END PRIVATE KEY-----`;
276
+ const result = sanitizeOutput(input);
277
+ expect(result).toBe('[REDACTED_PRIVATE_KEY]');
278
+ });
279
+ it('should redact private keys embedded in output', () => {
280
+ const input = `Loading configuration...
281
+ -----BEGIN RSA PRIVATE KEY-----
282
+ MIIEowIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8PbnGy
283
+ -----END RSA PRIVATE KEY-----
284
+ Done loading.`;
285
+ const result = sanitizeOutput(input);
286
+ expect(result).toBe(`Loading configuration...
287
+ [REDACTED_PRIVATE_KEY]
288
+ Done loading.`);
289
+ });
290
+ });
291
+ describe('GCP credentials', () => {
292
+ it('should redact GCP API keys', () => {
293
+ const input = 'Using GCP key: AIzaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe';
294
+ const result = sanitizeOutput(input);
295
+ expect(result).toBe('Using GCP key: [REDACTED_GCP_KEY]');
296
+ });
297
+ it('should redact multiple GCP API keys', () => {
298
+ const input = 'Key1: AIzaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe Key2: AIzaSyB-1234567890abcdefghijklmnopqrstu';
299
+ const result = sanitizeOutput(input);
300
+ expect(result).toBe('Key1: [REDACTED_GCP_KEY] Key2: [REDACTED_GCP_KEY]');
301
+ });
302
+ it('should not redact partial GCP API key patterns', () => {
303
+ const input = 'AIza123'; // Too short
304
+ const result = sanitizeOutput(input);
305
+ expect(result).toBe('AIza123');
306
+ });
307
+ it('should redact GCP OAuth tokens', () => {
308
+ const input = 'Authorization: ya29.a0AfH6SMBx-example-token-value_123';
309
+ const result = sanitizeOutput(input);
310
+ expect(result).toBe('Authorization: [REDACTED_GCP_TOKEN]');
311
+ });
312
+ it('should redact GCP OAuth tokens with various characters', () => {
313
+ const input = 'token=ya29.Gl-abc_XYZ-123';
314
+ const result = sanitizeOutput(input);
315
+ expect(result).toBe('token=[REDACTED_GCP_TOKEN]');
316
+ });
317
+ });
318
+ describe('Slack tokens', () => {
319
+ it('should redact Slack bot tokens', () => {
320
+ const input = 'SLACK_BOT_TOKEN=xoxb-123456789012-1234567890123-AbCdEfGhIjKlMnOpQrStUvWx';
321
+ const result = sanitizeOutput(input);
322
+ expect(result).toBe('SLACK_BOT_TOKEN=[REDACTED_SLACK_TOKEN]');
323
+ });
324
+ it('should redact Slack user tokens', () => {
325
+ const input = 'Using xoxp-123456789012-123456789012-123456789012-abcdef1234567890abcdef1234567890';
326
+ const result = sanitizeOutput(input);
327
+ expect(result).toBe('Using [REDACTED_SLACK_TOKEN]');
328
+ });
329
+ it('should redact Slack app tokens', () => {
330
+ const input = 'APP_TOKEN=xapp-1-A0123BCDEFG-1234567890123-abcdefghijklmnopqrstuvwxyz0123456789';
331
+ const result = sanitizeOutput(input);
332
+ expect(result).toBe('APP_TOKEN=[REDACTED_SLACK_TOKEN]');
333
+ });
334
+ it('should redact Slack webhook URLs', () => {
335
+ const input = 'Webhook: https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX';
336
+ const result = sanitizeOutput(input);
337
+ expect(result).toBe('Webhook: https://[REDACTED_SLACK_WEBHOOK]');
338
+ });
339
+ it('should redact multiple different Slack token types', () => {
340
+ const input = `
341
+ Bot: xoxb-123-456-abc
342
+ User: xoxp-789-012-def
343
+ App: xapp-345-ghi
344
+ `;
345
+ const result = sanitizeOutput(input);
346
+ expect(result).toContain('[REDACTED_SLACK_TOKEN]');
347
+ expect(result).not.toContain('xoxb-');
348
+ expect(result).not.toContain('xoxp-');
349
+ expect(result).not.toContain('xapp-');
350
+ });
351
+ });
352
+ describe('Stripe keys', () => {
353
+ it('should redact Stripe live secret keys', () => {
354
+ // 24 chars after sk_live_: 51ABC123DEF456GHI789JKLM
355
+ const input = 'STRIPE_SECRET_KEY=sk_live_51ABC123DEF456GHI789JKLM';
356
+ const result = sanitizeOutput(input);
357
+ expect(result).toBe('STRIPE_SECRET_KEY=[REDACTED_STRIPE_KEY]');
358
+ });
359
+ it('should redact Stripe test secret keys', () => {
360
+ // 24 chars after sk_test_: 51ABC123DEF456GHI789JKLM
361
+ const input = 'Using sk_test_51ABC123DEF456GHI789JKLM for testing';
362
+ const result = sanitizeOutput(input);
363
+ expect(result).toBe('Using [REDACTED_STRIPE_KEY] for testing');
364
+ });
365
+ it('should redact Stripe live restricted keys', () => {
366
+ // 24 chars after rk_live_: 51ABC123DEF456GHI789JKLM
367
+ const input = 'RESTRICTED_KEY=rk_live_51ABC123DEF456GHI789JKLM';
368
+ const result = sanitizeOutput(input);
369
+ expect(result).toBe('RESTRICTED_KEY=[REDACTED_STRIPE_KEY]');
370
+ });
371
+ it('should redact Stripe test restricted keys', () => {
372
+ // 24 chars after rk_test_: 51ABC123DEF456GHI789JKLM
373
+ const input = 'Using rk_test_51ABC123DEF456GHI789JKLM for testing';
374
+ const result = sanitizeOutput(input);
375
+ expect(result).toBe('Using [REDACTED_STRIPE_KEY] for testing');
376
+ });
377
+ it('should not redact Stripe keys that are too short', () => {
378
+ const input = 'sk_live_short'; // Less than 24 characters after prefix
379
+ const result = sanitizeOutput(input);
380
+ expect(result).toBe('sk_live_short');
381
+ });
382
+ it('should redact multiple Stripe keys', () => {
383
+ // 24 chars after each prefix
384
+ const input = 'Live: sk_live_51ABC123DEF456GHI789JKLM Test: sk_test_51XYZ789ABC123DEF456GHIJ';
385
+ const result = sanitizeOutput(input);
386
+ expect(result).toBe('Live: [REDACTED_STRIPE_KEY] Test: [REDACTED_STRIPE_KEY]');
387
+ });
388
+ it('should redact long Stripe keys', () => {
389
+ const input = 'sk_live_51ABC123DEF456GHI789JKLmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOP';
390
+ const result = sanitizeOutput(input);
391
+ expect(result).toBe('[REDACTED_STRIPE_KEY]');
392
+ });
393
+ });
394
+ describe('database connection strings', () => {
395
+ it('should redact PostgreSQL connection strings', () => {
396
+ const input = 'DATABASE_URL=postgres://admin:secretpass123@db.example.com:5432/mydb';
397
+ const result = sanitizeOutput(input);
398
+ expect(result).toBe('DATABASE_URL=[REDACTED_CONNECTION_STRING]');
399
+ });
400
+ it('should redact MongoDB connection strings with +srv', () => {
401
+ const input = 'Connecting to mongodb+srv://user:p@ssw0rd@cluster.mongodb.net/database';
402
+ const result = sanitizeOutput(input);
403
+ expect(result).toBe('Connecting to [REDACTED_CONNECTION_STRING]');
404
+ });
405
+ it('should redact Redis connection strings', () => {
406
+ const input = 'REDIS_URL=redis://default:myredispassword@redis.example.com:6379';
407
+ const result = sanitizeOutput(input);
408
+ expect(result).toBe('REDIS_URL=[REDACTED_CONNECTION_STRING]');
409
+ });
410
+ it('should redact MySQL connection strings', () => {
411
+ const input = 'mysql://root:rootpassword@localhost:3306/testdb';
412
+ const result = sanitizeOutput(input);
413
+ expect(result).toBe('[REDACTED_CONNECTION_STRING]');
414
+ });
415
+ it('should redact rediss (TLS) connection strings', () => {
416
+ const input = 'rediss://user:password@secure-redis.example.com:6380';
417
+ const result = sanitizeOutput(input);
418
+ expect(result).toBe('[REDACTED_CONNECTION_STRING]');
419
+ });
420
+ it('should redact connection strings with URL-encoded passwords', () => {
421
+ const input = 'mongodb://user:p%40ss%23word@host/db';
422
+ const result = sanitizeOutput(input);
423
+ expect(result).toBe('[REDACTED_CONNECTION_STRING]');
424
+ });
425
+ it('should redact PostgreSQL with URL-encoded @ in password', () => {
426
+ const input = 'postgres://admin:secret%40pass@db.example.com:5432/mydb';
427
+ const result = sanitizeOutput(input);
428
+ expect(result).toBe('[REDACTED_CONNECTION_STRING]');
429
+ });
430
+ it('should redact connection strings with multiple URL-encoded characters', () => {
431
+ const input = 'mysql://root:p%40ss%3Dw%26rd%21@localhost:3306/testdb';
432
+ const result = sanitizeOutput(input);
433
+ expect(result).toBe('[REDACTED_CONNECTION_STRING]');
434
+ });
435
+ it('should redact MongoDB+srv with URL-encoded password', () => {
436
+ const input = 'mongodb+srv://user:my%40complex%23pass@cluster.mongodb.net/database';
437
+ const result = sanitizeOutput(input);
438
+ expect(result).toBe('[REDACTED_CONNECTION_STRING]');
439
+ });
440
+ });
441
+ describe('non-secret content', () => {
442
+ it('should preserve normal log output', () => {
443
+ const input = 'Build completed successfully in 2.5s';
444
+ const result = sanitizeOutput(input);
445
+ expect(result).toBe(input);
446
+ });
447
+ it('should preserve error messages without secrets', () => {
448
+ const input = 'Error: Cannot find module "./missing-file"';
449
+ const result = sanitizeOutput(input);
450
+ expect(result).toBe(input);
451
+ });
452
+ it('should preserve stack traces', () => {
453
+ const input = `
454
+ Error: Test failed
455
+ at Object.<anonymous> (/app/test.js:10:5)
456
+ at Module._compile (internal/modules/cjs/loader.js:1085:14)
457
+ `;
458
+ const result = sanitizeOutput(input);
459
+ expect(result).toBe(input);
460
+ });
461
+ });
462
+ });
463
+ describe('isSessionReadOnly', () => {
464
+ it('should return false when no permissions provided', () => {
465
+ expect(isSessionReadOnly(undefined)).toBe(false);
466
+ });
467
+ it('should return false for empty permissions array', () => {
468
+ expect(isSessionReadOnly([])).toBe(false);
469
+ });
470
+ it('should return true when edit permission is denied', () => {
471
+ const permissions = [{ permission: 'edit', pattern: '*', action: 'deny' }];
472
+ expect(isSessionReadOnly(permissions)).toBe(true);
473
+ });
474
+ it('should return true when bash permission is denied', () => {
475
+ const permissions = [{ permission: 'bash', pattern: '*', action: 'deny' }];
476
+ expect(isSessionReadOnly(permissions)).toBe(true);
477
+ });
478
+ it('should return false when edit permission is allowed', () => {
479
+ const permissions = [{ permission: 'edit', pattern: '*', action: 'allow' }];
480
+ expect(isSessionReadOnly(permissions)).toBe(false);
481
+ });
482
+ it('should return false when edit permission requires ask', () => {
483
+ const permissions = [{ permission: 'edit', pattern: '*', action: 'ask' }];
484
+ expect(isSessionReadOnly(permissions)).toBe(false);
485
+ });
486
+ it('should return false for other denied permissions', () => {
487
+ const permissions = [{ permission: 'read', pattern: '*', action: 'deny' }];
488
+ expect(isSessionReadOnly(permissions)).toBe(false);
489
+ });
490
+ it('should check all permissions and return true if any edit/bash is denied', () => {
491
+ const permissions = [
492
+ { permission: 'read', pattern: '*', action: 'allow' },
493
+ { permission: 'write', pattern: '*', action: 'allow' },
494
+ { permission: 'edit', pattern: '*.ts', action: 'deny' },
495
+ ];
496
+ expect(isSessionReadOnly(permissions)).toBe(true);
497
+ });
498
+ it('should return true if bash is denied even if edit is allowed', () => {
499
+ const permissions = [
500
+ { permission: 'edit', pattern: '*', action: 'allow' },
501
+ { permission: 'bash', pattern: '*', action: 'deny' },
502
+ ];
503
+ expect(isSessionReadOnly(permissions)).toBe(true);
504
+ });
505
+ });
506
+ describe('truncateOutput', () => {
507
+ it('should return output unchanged if under maxLines', () => {
508
+ const input = 'line1\nline2\nline3';
509
+ expect(truncateOutput(input, 20)).toBe(input);
510
+ });
511
+ it('should return output unchanged if exactly at maxLines', () => {
512
+ const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`);
513
+ const input = lines.join('\n');
514
+ expect(truncateOutput(input, 20)).toBe(input);
515
+ });
516
+ it('should truncate to last 20 lines by default', () => {
517
+ const lines = Array.from({ length: 30 }, (_, i) => `line${i + 1}`);
518
+ const input = lines.join('\n');
519
+ const result = truncateOutput(input);
520
+ expect(result).toContain('... (10 lines omitted)');
521
+ expect(result).toContain('line11');
522
+ expect(result).toContain('line30');
523
+ expect(result).not.toContain('line1\n');
524
+ expect(result).not.toContain('line10\n');
525
+ });
526
+ it('should truncate to custom maxLines', () => {
527
+ const lines = Array.from({ length: 15 }, (_, i) => `line${i + 1}`);
528
+ const input = lines.join('\n');
529
+ const result = truncateOutput(input, 5);
530
+ expect(result).toContain('... (10 lines omitted)');
531
+ expect(result).toContain('line11');
532
+ expect(result).toContain('line15');
533
+ expect(result).not.toContain('line10\n');
534
+ });
535
+ it('should handle single line output', () => {
536
+ const input = 'single line';
537
+ expect(truncateOutput(input, 20)).toBe(input);
538
+ });
539
+ it('should handle empty output', () => {
540
+ expect(truncateOutput('', 20)).toBe('');
541
+ });
542
+ it('should preserve line content exactly', () => {
543
+ const lines = Array.from({ length: 25 }, (_, i) => `Error at line ${i + 1}: something failed`);
544
+ const input = lines.join('\n');
545
+ const result = truncateOutput(input, 10);
546
+ expect(result).toContain('Error at line 16: something failed');
547
+ expect(result).toContain('Error at line 25: something failed');
548
+ });
549
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Configuration for the StopQualityGate plugin.
3
+ * Read from `qualityGate` in opencode.json or .opencode/opencode.json
4
+ */
5
+ export interface QualityGateConfig {
6
+ /** Custom lint command (e.g., "pnpm -s lint") */
7
+ lint?: string;
8
+ /** Custom build command (e.g., "pnpm -s build") */
9
+ build?: string;
10
+ /** Custom test command (e.g., "pnpm -s test") */
11
+ test?: string;
12
+ /** Working directory relative to repo root (default: ".") */
13
+ cwd?: string;
14
+ /** Order of steps to run (default: ["lint", "build", "test"]) */
15
+ steps?: ('lint' | 'build' | 'test')[];
16
+ /** Whether to use management/ci.sh: "auto" | "always" | "never" (default: "auto") */
17
+ useCiSh?: 'auto' | 'always' | 'never';
18
+ /** Package manager override: "auto" | "pnpm" | "bun" | "yarn" | "npm" (default: "auto") */
19
+ packageManager?: 'auto' | 'pnpm' | 'bun' | 'yarn' | 'npm';
20
+ /** Cache duration in seconds before re-running checks (default: 30) */
21
+ cacheSeconds?: number;
22
+ /** Max lines of output tail to include in failure prompt (default: 160) */
23
+ maxOutputLines?: number;
24
+ /** Max error lines to extract and show (default: 60) */
25
+ maxErrorLines?: number;
26
+ /** Feature flags for discovery */
27
+ include?: {
28
+ /** Include cargo clippy in Rust discovery (default: true) */
29
+ rustClippy?: boolean;
30
+ };
31
+ }
32
+ /**
33
+ * A single command step to execute
34
+ */
35
+ export interface CommandStep {
36
+ /** Name of the step (e.g., "lint", "build", "test", "ci") */
37
+ step: string;
38
+ /** The shell command to run */
39
+ cmd: string;
40
+ }
41
+ /**
42
+ * Result of executing a command step
43
+ */
44
+ export interface StepResult {
45
+ /** Name of the step */
46
+ step: string;
47
+ /** The command that was run */
48
+ cmd: string;
49
+ /** Exit code (0 = success) */
50
+ exitCode: number;
51
+ /** Combined stdout + stderr output */
52
+ output: string;
53
+ }
54
+ /**
55
+ * Per-session state for caching and dirty tracking
56
+ */
57
+ export interface SessionState {
58
+ /** Timestamp of last quality gate run */
59
+ lastRunAt: number;
60
+ /** Results from last run */
61
+ lastResults: StepResult[];
62
+ /** Whether files have been edited since last run */
63
+ dirty: boolean;
64
+ }
65
+ /**
66
+ * Package manager type for Node projects
67
+ */
68
+ export type PackageManager = 'pnpm' | 'bun' | 'yarn' | 'npm';
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,23 +1,25 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@syntesseraai/opencode-feature-factory",
4
- "version": "0.2.3",
4
+ "version": "0.2.4",
5
5
  "description": "OpenCode plugin for Feature Factory agents - provides sub-agents and skills for validation, review, security, and architecture assessment",
6
6
  "type": "module",
7
7
  "license": "MIT",
8
- "main": "./src/index.ts",
9
- "module": "./src/index.ts",
8
+ "main": "./dist/index.js",
9
+ "module": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
10
11
  "bin": {
11
12
  "ff-deploy": "./bin/ff-deploy.js"
12
13
  },
13
14
  "exports": {
14
15
  ".": {
15
- "import": "./src/index.ts",
16
- "default": "./src/index.ts"
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js",
18
+ "default": "./dist/index.js"
17
19
  }
18
20
  },
19
21
  "files": [
20
- "src",
22
+ "dist",
21
23
  "assets",
22
24
  "skills",
23
25
  "agents",