tlc-claude-code 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/builder.md +17 -0
- package/.claude/commands/tlc/audit.md +12 -0
- package/.claude/commands/tlc/build.md +126 -78
- package/.claude/commands/tlc/guard.md +9 -0
- package/.claude/commands/tlc/init.md +12 -1
- package/.claude/commands/tlc/review.md +19 -0
- package/CODING-STANDARDS.md +217 -10
- package/package.json +1 -1
- package/server/lib/careful-patterns.js +142 -0
- package/server/lib/careful-patterns.test.js +164 -0
- package/server/lib/field-report.js +92 -0
- package/server/lib/field-report.test.js +195 -0
- package/server/lib/orchestration/worktree-manager.js +133 -0
- package/server/lib/orchestration/worktree-manager.test.js +198 -0
- package/server/lib/overdrive-command.js +31 -9
- package/server/lib/overdrive-command.test.js +25 -26
- package/server/lib/review-fixer.js +107 -0
- package/server/lib/review-fixer.test.js +152 -0
- package/server/lib/scope-checker.js +127 -0
- package/server/lib/scope-checker.test.js +175 -0
- package/server/lib/skill-validator.js +165 -0
- package/server/lib/skill-validator.test.js +289 -0
- package/server/lib/standards/standards-injector.js +6 -0
- package/server/lib/test-selector.js +127 -0
- package/server/lib/test-selector.test.js +172 -0
- package/server/templates/CLAUDE.md +6 -0
- package/server/templates/CODING-STANDARDS.md +356 -10
package/CODING-STANDARDS.md
CHANGED
|
@@ -241,25 +241,64 @@ if (status === 'pending') { ... } // TypeScript will validate
|
|
|
241
241
|
|
|
242
242
|
---
|
|
243
243
|
|
|
244
|
-
## 6. Configuration: No
|
|
244
|
+
## 6. Configuration: Validated, Centralized, No Direct `process.env`
|
|
245
|
+
|
|
246
|
+
### Centralized Config with Validation
|
|
247
|
+
|
|
248
|
+
All configuration MUST go through a validated config module. Never read `process.env` directly in application code.
|
|
245
249
|
|
|
246
250
|
```typescript
|
|
247
|
-
// ❌ NEVER
|
|
251
|
+
// ❌ NEVER: Direct process.env in services/controllers
|
|
248
252
|
class PaymentService {
|
|
249
|
-
private baseUrl =
|
|
250
|
-
private
|
|
253
|
+
private baseUrl = process.env.STRIPE_BASE_URL || 'https://api.stripe.com';
|
|
254
|
+
private apiKey = process.env.STRIPE_API_KEY; // silently undefined if missing
|
|
251
255
|
}
|
|
252
256
|
|
|
253
|
-
//
|
|
254
|
-
// lib/configuration.ts or shared/config/stripe.config.ts
|
|
257
|
+
// ❌ NEVER: Config without validation
|
|
255
258
|
export const stripeConfig = {
|
|
256
259
|
baseUrl: process.env.STRIPE_BASE_URL || 'https://api.stripe.com',
|
|
257
|
-
apiKey: process.env.STRIPE_API_KEY,
|
|
258
|
-
|
|
259
|
-
|
|
260
|
+
apiKey: process.env.STRIPE_API_KEY, // no validation, no fail-fast
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// ✅ ALWAYS: Validated config module with fail-fast
|
|
264
|
+
// src/config/config.ts
|
|
265
|
+
import { z } from 'zod';
|
|
266
|
+
|
|
267
|
+
const configSchema = z.object({
|
|
268
|
+
stripe: z.object({
|
|
269
|
+
baseUrl: z.string().url().default('https://api.stripe.com'),
|
|
270
|
+
apiKey: z.string().min(1, 'STRIPE_API_KEY is required'),
|
|
271
|
+
timeout: z.coerce.number().int().positive().default(30000),
|
|
272
|
+
}),
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
export type AppConfig = z.infer<typeof configSchema>;
|
|
276
|
+
|
|
277
|
+
export function loadConfig(): AppConfig {
|
|
278
|
+
const result = configSchema.safeParse({
|
|
279
|
+
stripe: {
|
|
280
|
+
baseUrl: process.env.STRIPE_BASE_URL,
|
|
281
|
+
apiKey: process.env.STRIPE_API_KEY,
|
|
282
|
+
timeout: process.env.STRIPE_TIMEOUT,
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
if (!result.success) {
|
|
287
|
+
throw new Error(`Config validation failed:\n${result.error.format()}`);
|
|
288
|
+
}
|
|
289
|
+
return result.data;
|
|
290
|
+
}
|
|
260
291
|
```
|
|
261
292
|
|
|
262
|
-
|
|
293
|
+
### Rules
|
|
294
|
+
|
|
295
|
+
1. **All `process.env` access MUST be in the config module** — never in services, controllers, or middleware.
|
|
296
|
+
2. **All required env vars MUST be validated at startup** — fail fast, not at first request.
|
|
297
|
+
3. **Distinguish required vs optional** — required vars throw on boot; optional have explicit defaults.
|
|
298
|
+
4. **No silent empty-string fallbacks** for secrets or connection strings.
|
|
299
|
+
5. **Config is injected via DI or imported from the config module** — never read from env directly.
|
|
300
|
+
|
|
301
|
+
**Rule**: If it could differ between environments, it's config. If it's required, validate it at boot.
|
|
263
302
|
|
|
264
303
|
---
|
|
265
304
|
|
|
@@ -432,6 +471,13 @@ Before committing any code:
|
|
|
432
471
|
- [ ] Typed errors, not generic throws
|
|
433
472
|
- [ ] Tests co-located with module
|
|
434
473
|
- [ ] Build passes (`npm run build`)
|
|
474
|
+
- [ ] **No direct `process.env`** in services/controllers — config module only
|
|
475
|
+
- [ ] **Config validated at startup** with schema (Zod/Joi)
|
|
476
|
+
- [ ] **Ownership checks** on every data-access endpoint
|
|
477
|
+
- [ ] **No secrets in responses** — API keys, tokens are write-only
|
|
478
|
+
- [ ] **Sensitive data hashed** at rest (OTPs, reset tokens)
|
|
479
|
+
- [ ] **All HTML output escaped** — no raw interpolation of user values
|
|
480
|
+
- [ ] **No manual `new Service()`** — use DI container
|
|
435
481
|
|
|
436
482
|
---
|
|
437
483
|
|
|
@@ -723,6 +769,160 @@ function handleResponse<T>(response: ApiResponse<T>): T {
|
|
|
723
769
|
|
|
724
770
|
---
|
|
725
771
|
|
|
772
|
+
## 21. Security: Authorization, Secrets, and Output Encoding
|
|
773
|
+
|
|
774
|
+
### Resource Ownership Checks
|
|
775
|
+
|
|
776
|
+
Every endpoint that accesses a resource MUST verify the requesting user owns or has permission to access that resource. Authentication alone is not enough.
|
|
777
|
+
|
|
778
|
+
```typescript
|
|
779
|
+
// ❌ WRONG: Only checks authentication, not ownership
|
|
780
|
+
@Get('settings/:merchantId')
|
|
781
|
+
@UseGuards(AuthGuard)
|
|
782
|
+
async getSettings(@Param('merchantId') merchantId: string): Promise<Settings> {
|
|
783
|
+
return this.settingsService.findByMerchant(merchantId); // any authed user can read any merchant
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// ✅ CORRECT: Verifies ownership
|
|
787
|
+
@Get('settings/:merchantId')
|
|
788
|
+
@UseGuards(AuthGuard)
|
|
789
|
+
async getSettings(
|
|
790
|
+
@Param('merchantId') merchantId: string,
|
|
791
|
+
@CurrentUser() user: User,
|
|
792
|
+
): Promise<Settings> {
|
|
793
|
+
if (user.merchantId !== merchantId && user.role !== 'admin') {
|
|
794
|
+
throw new ForbiddenError('Cannot access another merchant\'s settings');
|
|
795
|
+
}
|
|
796
|
+
return this.settingsService.findByMerchant(merchantId);
|
|
797
|
+
}
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
**Rules:**
|
|
801
|
+
1. Every data-access endpoint MUST check that the requesting user owns the resource.
|
|
802
|
+
2. Tests MUST prove that user A cannot read/modify user B's data.
|
|
803
|
+
3. Prefer a reusable ownership guard over per-endpoint checks.
|
|
804
|
+
|
|
805
|
+
### Never Expose Secrets in Responses
|
|
806
|
+
|
|
807
|
+
API keys, webhook secrets, tokens, and credentials MUST never appear in API responses or rendered HTML.
|
|
808
|
+
|
|
809
|
+
```typescript
|
|
810
|
+
// ❌ WRONG: Returning secrets to the client
|
|
811
|
+
return {
|
|
812
|
+
merchantId: merchant.id,
|
|
813
|
+
apiKey: merchant.apiKey, // NEVER
|
|
814
|
+
webhookSecret: merchant.webhookSecret, // NEVER
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
// ✅ CORRECT: Mask or omit secrets
|
|
818
|
+
return {
|
|
819
|
+
merchantId: merchant.id,
|
|
820
|
+
apiKey: mask(merchant.apiKey), // "sk_live_...4x7f"
|
|
821
|
+
webhookSecret: '••••••••', // write-only, never read back
|
|
822
|
+
};
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
**Rules:**
|
|
826
|
+
1. Secrets are **write-only** — accept on create/update, never return in GET responses.
|
|
827
|
+
2. If display is needed, return masked values (first 4 + last 4 chars).
|
|
828
|
+
3. Audit every response DTO and HTML template for leaked credentials.
|
|
829
|
+
|
|
830
|
+
### Hash Sensitive Data at Rest
|
|
831
|
+
|
|
832
|
+
OTP codes, reset tokens, and session secrets MUST be stored as one-way hashes, never plaintext.
|
|
833
|
+
|
|
834
|
+
```typescript
|
|
835
|
+
// ❌ WRONG: Plaintext OTP
|
|
836
|
+
await db.otpSessions.insert({ code: '123456', expiresAt });
|
|
837
|
+
|
|
838
|
+
// ✅ CORRECT: Hashed OTP
|
|
839
|
+
import { createHash } from 'crypto';
|
|
840
|
+
const hashedCode = createHash('sha256').update(code).digest('hex');
|
|
841
|
+
await db.otpSessions.insert({ codeHash: hashedCode, expiresAt });
|
|
842
|
+
|
|
843
|
+
// Verification: hash the input and compare
|
|
844
|
+
function verifyOtp(input: string, stored: string): boolean {
|
|
845
|
+
const inputHash = createHash('sha256').update(input).digest('hex');
|
|
846
|
+
return timingSafeEqual(Buffer.from(inputHash), Buffer.from(stored));
|
|
847
|
+
}
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
### Output Encoding (XSS Prevention)
|
|
851
|
+
|
|
852
|
+
Never interpolate user-controlled values into HTML without escaping. This applies to inline HTML generation, template strings, and server-rendered pages.
|
|
853
|
+
|
|
854
|
+
```typescript
|
|
855
|
+
// ❌ WRONG: Raw interpolation — XSS risk
|
|
856
|
+
const html = `<h1>Welcome, ${user.name}</h1>`;
|
|
857
|
+
const html = `<a href="${redirectUrl}">Continue</a>`;
|
|
858
|
+
|
|
859
|
+
// ✅ CORRECT: Always escape
|
|
860
|
+
import { escapeHtml } from '@/shared/utils/escape';
|
|
861
|
+
|
|
862
|
+
const html = `<h1>Welcome, ${escapeHtml(user.name)}</h1>`;
|
|
863
|
+
const html = `<a href="${escapeHtml(redirectUrl)}">Continue</a>`;
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
```typescript
|
|
867
|
+
// shared/utils/escape.ts
|
|
868
|
+
/**
|
|
869
|
+
* Escapes HTML special characters to prevent XSS.
|
|
870
|
+
*/
|
|
871
|
+
export function escapeHtml(str: string): string {
|
|
872
|
+
return str
|
|
873
|
+
.replace(/&/g, '&')
|
|
874
|
+
.replace(/</g, '<')
|
|
875
|
+
.replace(/>/g, '>')
|
|
876
|
+
.replace(/"/g, '"')
|
|
877
|
+
.replace(/'/g, ''');
|
|
878
|
+
}
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
**Rules:**
|
|
882
|
+
1. **Every** dynamic value in HTML output MUST be escaped.
|
|
883
|
+
2. Prefer a real templating engine over string concatenation for HTML.
|
|
884
|
+
3. Audit merchant-controlled, query-string, and user-input values first.
|
|
885
|
+
4. Do not add new pages using inline HTML string builders — use a proper frontend.
|
|
886
|
+
|
|
887
|
+
---
|
|
888
|
+
|
|
889
|
+
## 22. Dependency Injection: No Manual Instantiation
|
|
890
|
+
|
|
891
|
+
Never manually instantiate services or providers that should be managed by the DI container.
|
|
892
|
+
|
|
893
|
+
```typescript
|
|
894
|
+
// ❌ WRONG: Bypassing DI
|
|
895
|
+
class PaymentService {
|
|
896
|
+
async processPayment(method: string): Promise<void> {
|
|
897
|
+
const provider = method === 'cyberpay'
|
|
898
|
+
? new CyberpayProvider(process.env.CYBERPAY_KEY) // untestable, unmanaged
|
|
899
|
+
: new CodProvider();
|
|
900
|
+
await provider.charge(amount);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// ✅ CORRECT: Provider registry via DI
|
|
905
|
+
@Injectable()
|
|
906
|
+
class PaymentService {
|
|
907
|
+
constructor(
|
|
908
|
+
@Inject('PAYMENT_PROVIDERS') private providers: Map<string, PaymentProvider>,
|
|
909
|
+
) {}
|
|
910
|
+
|
|
911
|
+
async processPayment(method: string): Promise<void> {
|
|
912
|
+
const provider = this.providers.get(method);
|
|
913
|
+
if (!provider) throw new BadRequestError(`Unknown payment method: ${method}`);
|
|
914
|
+
await provider.charge(amount);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
```
|
|
918
|
+
|
|
919
|
+
**Rules:**
|
|
920
|
+
1. All providers/services MUST be registered in the DI container.
|
|
921
|
+
2. Never use `new ServiceClass()` in application code — let the framework manage lifecycle.
|
|
922
|
+
3. Use factory providers or provider registries for dynamic selection.
|
|
923
|
+
|
|
924
|
+
---
|
|
925
|
+
|
|
726
926
|
## AI Instructions
|
|
727
927
|
|
|
728
928
|
When generating code:
|
|
@@ -744,6 +944,13 @@ When generating code:
|
|
|
744
944
|
15. **Never** let folders exceed 15 files - organize into subfolders
|
|
745
945
|
16. **Never** use `any` type - use `unknown` or proper interfaces
|
|
746
946
|
17. **Always** add explicit return types to functions
|
|
947
|
+
18. **Never** read `process.env` outside the config module
|
|
948
|
+
19. **Always** validate config at startup with a schema
|
|
949
|
+
20. **Always** add ownership/authorization checks on data-access endpoints
|
|
950
|
+
21. **Never** return secrets (API keys, tokens) in API responses or HTML
|
|
951
|
+
22. **Always** hash OTPs, reset tokens, and session secrets before storing
|
|
952
|
+
23. **Always** escape dynamic values in HTML output
|
|
953
|
+
24. **Never** use `new ServiceClass()` — register in DI and inject
|
|
747
954
|
|
|
748
955
|
### Cleanup Tasks
|
|
749
956
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Careful Patterns Module
|
|
3
|
+
*
|
|
4
|
+
* Destructive command detection patterns for /tlc:careful
|
|
5
|
+
* and path scope checking for /tlc:freeze.
|
|
6
|
+
*
|
|
7
|
+
* @module careful-patterns
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Destructive command patterns with their metadata.
|
|
14
|
+
* Each entry: { regex, reason, pattern }
|
|
15
|
+
* @type {Array<{regex: RegExp, reason: string, pattern: string}>}
|
|
16
|
+
*/
|
|
17
|
+
const DESTRUCTIVE_PATTERNS = [
|
|
18
|
+
{
|
|
19
|
+
regex: /\bgit\s+push\s+(?:.*\s+)?--force\b/,
|
|
20
|
+
reason: 'Force push',
|
|
21
|
+
pattern: 'git push --force',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
regex: /\bgit\s+push\s+(?:.*\s+)?-f\b/,
|
|
25
|
+
reason: 'Force push',
|
|
26
|
+
pattern: 'git push -f',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
regex: /\bgit\s+reset\s+--hard\b/,
|
|
30
|
+
reason: 'Hard reset',
|
|
31
|
+
pattern: 'git reset --hard',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
regex: /\brm\s+-rf\b/,
|
|
35
|
+
reason: 'Recursive force delete',
|
|
36
|
+
pattern: 'rm -rf',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
regex: /\bdrop\s+table\b/i,
|
|
40
|
+
reason: 'Drop table',
|
|
41
|
+
pattern: 'DROP TABLE',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
regex: /\bdrop\s+database\b/i,
|
|
45
|
+
reason: 'Drop database',
|
|
46
|
+
pattern: 'DROP DATABASE',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
regex: /\btruncate\s+table\b/i,
|
|
50
|
+
reason: 'Truncate table',
|
|
51
|
+
pattern: 'TRUNCATE TABLE',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
regex: /\bgit\s+clean\s+-f/,
|
|
55
|
+
reason: 'Clean untracked files',
|
|
56
|
+
pattern: 'git clean -f',
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Check if a shell command matches destructive patterns.
|
|
62
|
+
*
|
|
63
|
+
* Checks against known dangerous operations like force push,
|
|
64
|
+
* hard reset, recursive delete, and destructive SQL statements.
|
|
65
|
+
* SQL patterns are matched case-insensitively.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} command - Shell command string to check
|
|
68
|
+
* @returns {{ destructive: boolean, reason?: string, pattern?: string }}
|
|
69
|
+
*/
|
|
70
|
+
function isDestructive(command) {
|
|
71
|
+
if (!command || typeof command !== 'string') {
|
|
72
|
+
return { destructive: false };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Check DELETE FROM without WHERE (special case: only destructive without WHERE)
|
|
76
|
+
// Don't early-return on safe DELETE — continue checking for other destructive patterns
|
|
77
|
+
const deleteMatch = command.match(/\bdelete\s+from\b/i);
|
|
78
|
+
if (deleteMatch) {
|
|
79
|
+
const hasWhere = /\bwhere\b/i.test(command);
|
|
80
|
+
if (!hasWhere) {
|
|
81
|
+
return {
|
|
82
|
+
destructive: true,
|
|
83
|
+
reason: 'Delete without WHERE',
|
|
84
|
+
pattern: 'DELETE FROM',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// DELETE FROM ... WHERE is safe, but continue scanning for other patterns
|
|
88
|
+
// (e.g., "DELETE FROM users WHERE id=1; DROP TABLE sessions")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check all other patterns
|
|
92
|
+
for (const entry of DESTRUCTIVE_PATTERNS) {
|
|
93
|
+
if (entry.regex.test(command)) {
|
|
94
|
+
return {
|
|
95
|
+
destructive: true,
|
|
96
|
+
reason: entry.reason,
|
|
97
|
+
pattern: entry.pattern,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { destructive: false };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check if a file path is within an allowed scope directory.
|
|
107
|
+
*
|
|
108
|
+
* Resolves parent traversal (../) before checking containment.
|
|
109
|
+
* Both paths are normalized to prevent bypass via trailing slashes
|
|
110
|
+
* or prefix collisions (e.g., src/auth vs src/authorization).
|
|
111
|
+
*
|
|
112
|
+
* @param {string} filePath - File path to check
|
|
113
|
+
* @param {string} scopeDir - Allowed scope directory
|
|
114
|
+
* @returns {boolean}
|
|
115
|
+
*/
|
|
116
|
+
function isInScope(filePath, scopeDir) {
|
|
117
|
+
if (!filePath || !scopeDir) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
if (typeof filePath !== 'string' || typeof scopeDir !== 'string') {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Normalize both paths to resolve ../ and remove trailing slashes
|
|
125
|
+
const normalizedFile = path.normalize(filePath);
|
|
126
|
+
const normalizedScope = path.normalize(scopeDir);
|
|
127
|
+
|
|
128
|
+
// Exact match
|
|
129
|
+
if (normalizedFile === normalizedScope) {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// File must be under scope directory (with path separator to prevent prefix collisions)
|
|
134
|
+
const scopePrefix = normalizedScope.endsWith(path.sep)
|
|
135
|
+
? normalizedScope
|
|
136
|
+
: normalizedScope + path.sep;
|
|
137
|
+
|
|
138
|
+
return normalizedFile.startsWith(scopePrefix);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
module.exports = { isDestructive, isInScope };
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Careful Patterns Tests - Phase 89 Task 6
|
|
3
|
+
*
|
|
4
|
+
* Destructive command detection and path scope checking
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import { isDestructive, isInScope } from './careful-patterns.js';
|
|
10
|
+
|
|
11
|
+
describe('careful-patterns', () => {
|
|
12
|
+
describe('isDestructive', () => {
|
|
13
|
+
it('detects git push --force as destructive', () => {
|
|
14
|
+
const result = isDestructive('git push --force origin main');
|
|
15
|
+
expect(result.destructive).toBe(true);
|
|
16
|
+
expect(result.reason).toBe('Force push');
|
|
17
|
+
expect(result.pattern).toBe('git push --force');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('detects git push -f as destructive', () => {
|
|
21
|
+
const result = isDestructive('git push -f origin main');
|
|
22
|
+
expect(result.destructive).toBe(true);
|
|
23
|
+
expect(result.reason).toBe('Force push');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('allows normal git push', () => {
|
|
27
|
+
const result = isDestructive('git push origin feature');
|
|
28
|
+
expect(result.destructive).toBe(false);
|
|
29
|
+
expect(result.reason).toBeUndefined();
|
|
30
|
+
expect(result.pattern).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('detects git reset --hard as destructive', () => {
|
|
34
|
+
const result = isDestructive('git reset --hard HEAD~3');
|
|
35
|
+
expect(result.destructive).toBe(true);
|
|
36
|
+
expect(result.reason).toBe('Hard reset');
|
|
37
|
+
expect(result.pattern).toBe('git reset --hard');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('allows git reset --soft', () => {
|
|
41
|
+
const result = isDestructive('git reset --soft HEAD~1');
|
|
42
|
+
expect(result.destructive).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('detects rm -rf as destructive', () => {
|
|
46
|
+
const result = isDestructive('rm -rf /');
|
|
47
|
+
expect(result.destructive).toBe(true);
|
|
48
|
+
expect(result.reason).toBe('Recursive force delete');
|
|
49
|
+
expect(result.pattern).toBe('rm -rf');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('allows simple rm', () => {
|
|
53
|
+
const result = isDestructive('rm file.txt');
|
|
54
|
+
expect(result.destructive).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('detects DROP TABLE as destructive', () => {
|
|
58
|
+
const result = isDestructive('DROP TABLE users');
|
|
59
|
+
expect(result.destructive).toBe(true);
|
|
60
|
+
expect(result.reason).toBe('Drop table');
|
|
61
|
+
expect(result.pattern).toMatch(/drop table/i);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('detects DELETE FROM without WHERE as destructive', () => {
|
|
65
|
+
const result = isDestructive('DELETE FROM users');
|
|
66
|
+
expect(result.destructive).toBe(true);
|
|
67
|
+
expect(result.reason).toBe('Delete without WHERE');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('allows DELETE FROM with WHERE', () => {
|
|
71
|
+
const result = isDestructive('DELETE FROM users WHERE id = 1');
|
|
72
|
+
expect(result.destructive).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('detects DROP after safe DELETE in multi-statement command', () => {
|
|
76
|
+
const result = isDestructive('DELETE FROM users WHERE id = 1; DROP TABLE sessions');
|
|
77
|
+
expect(result.destructive).toBe(true);
|
|
78
|
+
expect(result.reason).toBe('Drop table');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('detects TRUNCATE TABLE as destructive', () => {
|
|
82
|
+
const result = isDestructive('TRUNCATE TABLE sessions');
|
|
83
|
+
expect(result.destructive).toBe(true);
|
|
84
|
+
expect(result.reason).toBe('Truncate table');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('detects git clean -fd as destructive', () => {
|
|
88
|
+
const result = isDestructive('git clean -fd');
|
|
89
|
+
expect(result.destructive).toBe(true);
|
|
90
|
+
expect(result.reason).toBe('Clean untracked files');
|
|
91
|
+
expect(result.pattern).toBe('git clean -f');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('allows npm install', () => {
|
|
95
|
+
const result = isDestructive('npm install express');
|
|
96
|
+
expect(result.destructive).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('detects DROP DATABASE as destructive', () => {
|
|
100
|
+
const result = isDestructive('DROP DATABASE production');
|
|
101
|
+
expect(result.destructive).toBe(true);
|
|
102
|
+
expect(result.reason).toBe('Drop database');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('handles SQL commands case-insensitively', () => {
|
|
106
|
+
expect(isDestructive('drop table users').destructive).toBe(true);
|
|
107
|
+
expect(isDestructive('Drop Table users').destructive).toBe(true);
|
|
108
|
+
expect(isDestructive('DELETE from users').destructive).toBe(true);
|
|
109
|
+
expect(isDestructive('truncate TABLE sessions').destructive).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('returns destructive:false for empty string', () => {
|
|
113
|
+
const result = isDestructive('');
|
|
114
|
+
expect(result.destructive).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('returns destructive:false for null/undefined', () => {
|
|
118
|
+
expect(isDestructive(null).destructive).toBe(false);
|
|
119
|
+
expect(isDestructive(undefined).destructive).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('isInScope', () => {
|
|
124
|
+
it('returns true for file within scope directory', () => {
|
|
125
|
+
expect(isInScope('src/auth/login.ts', 'src/auth')).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('returns false for file outside scope directory', () => {
|
|
129
|
+
expect(isInScope('src/user/user.ts', 'src/auth')).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('returns false for parent traversal attempts', () => {
|
|
133
|
+
expect(isInScope('src/auth/../user/user.ts', 'src/auth')).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('returns true for deeply nested files within scope', () => {
|
|
137
|
+
expect(isInScope('src/auth/deep/nested/file.ts', 'src/auth')).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('returns true for exact directory match', () => {
|
|
141
|
+
expect(isInScope('src/auth', 'src/auth')).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('returns false when file path starts with scope but is a different dir', () => {
|
|
145
|
+
// src/authorization is not inside src/auth
|
|
146
|
+
expect(isInScope('src/authorization/file.ts', 'src/auth')).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('handles trailing slashes consistently', () => {
|
|
150
|
+
expect(isInScope('src/auth/file.ts', 'src/auth/')).toBe(true);
|
|
151
|
+
expect(isInScope('src/auth/file.ts', 'src/auth')).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('returns false for empty inputs', () => {
|
|
155
|
+
expect(isInScope('', 'src/auth')).toBe(false);
|
|
156
|
+
expect(isInScope('src/auth/file.ts', '')).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('returns false for null/undefined inputs', () => {
|
|
160
|
+
expect(isInScope(null, 'src/auth')).toBe(false);
|
|
161
|
+
expect(isInScope('src/auth/file.ts', null)).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field Report Module — Agent Self-Rating
|
|
3
|
+
*
|
|
4
|
+
* After build/review, the agent rates its experience and files structured
|
|
5
|
+
* reports when quality falls below threshold (rating < 8).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Rating threshold — reports are filed when rating is below this value */
|
|
9
|
+
const REPORT_THRESHOLD = 8;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Determine whether a field report should be filed based on the rating.
|
|
13
|
+
* @param {number} rating - Self-assessed quality rating (0–10)
|
|
14
|
+
* @returns {boolean} true if rating < 8, false otherwise
|
|
15
|
+
*/
|
|
16
|
+
function shouldFileReport(rating) {
|
|
17
|
+
if (typeof rating !== 'number') {
|
|
18
|
+
throw new TypeError('rating must be a number');
|
|
19
|
+
}
|
|
20
|
+
return rating < REPORT_THRESHOLD;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a formatted field report from the given parameters.
|
|
25
|
+
* @param {object} params
|
|
26
|
+
* @param {string} params.skill - The TLC skill that was executed (e.g. 'tlc:build')
|
|
27
|
+
* @param {number} params.rating - Self-assessed quality rating (0–10)
|
|
28
|
+
* @param {string} params.issue - Description of the issue encountered
|
|
29
|
+
* @param {string} params.suggestion - Suggested improvement
|
|
30
|
+
* @param {() => Date} [params.now] - Optional clock function for deterministic dates
|
|
31
|
+
* @returns {string} Formatted markdown report
|
|
32
|
+
*/
|
|
33
|
+
function createFieldReport({ skill, rating, issue, suggestion, now }) {
|
|
34
|
+
if (!skill) {
|
|
35
|
+
throw new Error('skill is required');
|
|
36
|
+
}
|
|
37
|
+
if (typeof rating !== 'number') {
|
|
38
|
+
throw new TypeError('rating must be a number');
|
|
39
|
+
}
|
|
40
|
+
if (!issue) {
|
|
41
|
+
throw new Error('issue is required');
|
|
42
|
+
}
|
|
43
|
+
if (!suggestion) {
|
|
44
|
+
throw new Error('suggestion is required');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const date = (now ? now() : new Date()).toISOString();
|
|
48
|
+
const critical = rating === 0 ? ' CRITICAL' : '';
|
|
49
|
+
const heading = `## ${date} ${skill} ${rating}/10${critical} — ${issue}`;
|
|
50
|
+
const body = `**Suggestion:** ${suggestion}`;
|
|
51
|
+
|
|
52
|
+
return `${heading}\n\n${body}\n`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Format a report string as a single markdown section.
|
|
57
|
+
* Ensures the entry ends with exactly one trailing newline.
|
|
58
|
+
* @param {string} report - Raw report content from createFieldReport
|
|
59
|
+
* @returns {string} Formatted markdown section with trailing newline
|
|
60
|
+
*/
|
|
61
|
+
function formatReportEntry(report) {
|
|
62
|
+
if (typeof report !== 'string') {
|
|
63
|
+
throw new TypeError('report must be a string');
|
|
64
|
+
}
|
|
65
|
+
return report.trimEnd() + '\n';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Append a new report to existing file content.
|
|
70
|
+
* If existing content is empty, creates a new document with a header.
|
|
71
|
+
* @param {string} existingContent - Current file content (may be empty)
|
|
72
|
+
* @param {string} newReport - New report to append
|
|
73
|
+
* @returns {string} Combined content
|
|
74
|
+
*/
|
|
75
|
+
function appendReport(existingContent, newReport) {
|
|
76
|
+
if (typeof existingContent !== 'string') {
|
|
77
|
+
throw new TypeError('existingContent must be a string');
|
|
78
|
+
}
|
|
79
|
+
if (typeof newReport !== 'string') {
|
|
80
|
+
throw new TypeError('newReport must be a string');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const entry = formatReportEntry(newReport);
|
|
84
|
+
|
|
85
|
+
if (!existingContent) {
|
|
86
|
+
return `# Field Reports\n\n${entry}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return `${existingContent}\n\n---\n\n${entry}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { shouldFileReport, createFieldReport, formatReportEntry, appendReport };
|