@webpieces/ai-hook-rules 0.0.1

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 (43) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +43 -0
  3. package/bin/setup-ai-hooks.sh +137 -0
  4. package/openclaw.plugin.json +15 -0
  5. package/package.json +37 -0
  6. package/src/adapters/claude-code-hook.ts +117 -0
  7. package/src/adapters/openclaw-plugin.ts +88 -0
  8. package/src/core/__tests__/disable-directives.test.ts +114 -0
  9. package/src/core/__tests__/rules/file-location.test.ts +90 -0
  10. package/src/core/__tests__/rules/max-file-lines.test.ts +53 -0
  11. package/src/core/__tests__/rules/no-any.test.ts +68 -0
  12. package/src/core/__tests__/rules/no-destructure.test.ts +50 -0
  13. package/src/core/__tests__/rules/no-shell-substitution.test.ts +118 -0
  14. package/src/core/__tests__/rules/no-unmanaged-exceptions.test.ts +54 -0
  15. package/src/core/__tests__/rules/require-return-type.test.ts +79 -0
  16. package/src/core/__tests__/runner.test.ts +288 -0
  17. package/src/core/__tests__/strip-ts-noise.test.ts +109 -0
  18. package/src/core/build-context.ts +96 -0
  19. package/src/core/configs/default.ts +19 -0
  20. package/src/core/disable-directives.ts +90 -0
  21. package/src/core/instruct-ai-writer.ts +15 -0
  22. package/src/core/load-config.ts +3 -0
  23. package/src/core/load-rules.ts +130 -0
  24. package/src/core/rejection-log.ts +163 -0
  25. package/src/core/report.ts +35 -0
  26. package/src/core/rules/catch-error-pattern.ts +124 -0
  27. package/src/core/rules/file-location.ts +87 -0
  28. package/src/core/rules/index.ts +11 -0
  29. package/src/core/rules/max-file-lines.ts +137 -0
  30. package/src/core/rules/no-any-unknown.ts +35 -0
  31. package/src/core/rules/no-destructure.ts +34 -0
  32. package/src/core/rules/no-implicit-any.ts +67 -0
  33. package/src/core/rules/no-shell-substitution.ts +71 -0
  34. package/src/core/rules/no-unmanaged-exceptions.ts +48 -0
  35. package/src/core/rules/require-return-type.ts +59 -0
  36. package/src/core/runner.ts +205 -0
  37. package/src/core/strip-ts-noise.ts +103 -0
  38. package/src/core/to-error.ts +35 -0
  39. package/src/core/types.ts +196 -0
  40. package/src/index.ts +14 -0
  41. package/templates/claude-settings-hook.json +15 -0
  42. package/templates/webpieces.ai-hooks.seed.json +16 -0
  43. package/templates/webpieces.exceptions.md +694 -0
@@ -0,0 +1,117 @@
1
+ import { run, runBash } from '../core/runner';
2
+ import { logRejection } from '../core/rejection-log';
3
+ import { NormalizedToolInput, NormalizedEdit, ToolKind } from '../core/types';
4
+ import { toError } from '../core/to-error';
5
+
6
+ const HANDLED_FILE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
7
+
8
+ interface ClaudeCodePayload {
9
+ tool_name: string;
10
+ tool_input: ClaudeCodeToolInput;
11
+ }
12
+
13
+ interface ClaudeCodeToolInput {
14
+ file_path?: string;
15
+ content?: string;
16
+ old_string?: string;
17
+ new_string?: string;
18
+ edits?: ClaudeCodeEditEntry[];
19
+ command?: string;
20
+ }
21
+
22
+ interface ClaudeCodeEditEntry {
23
+ old_string?: string;
24
+ new_string?: string;
25
+ }
26
+
27
+ function readStdin(): Promise<string> {
28
+ return new Promise((resolve) => {
29
+ let data = '';
30
+ process.stdin.setEncoding('utf8');
31
+ process.stdin.on('data', (chunk: string) => { data += chunk; });
32
+ process.stdin.on('end', () => resolve(data));
33
+ process.stdin.on('error', () => resolve(''));
34
+ if (process.stdin.isTTY) resolve('');
35
+ });
36
+ }
37
+
38
+ function safeParse(raw: string): ClaudeCodePayload | null {
39
+ if (!raw || raw.trim() === '') return null;
40
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
41
+ try {
42
+ return JSON.parse(raw) as ClaudeCodePayload;
43
+ } catch (err: unknown) {
44
+ const error = toError(err);
45
+ void error;
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function normalizeToolKind(toolName: string): ToolKind | null {
51
+ if (HANDLED_FILE_TOOLS.has(toolName)) return toolName as ToolKind;
52
+ return null;
53
+ }
54
+
55
+ function normalizeToolInput(toolKind: ToolKind, toolInput: ClaudeCodeToolInput): NormalizedToolInput | null {
56
+ const filePath = toolInput.file_path;
57
+ if (!filePath) return null;
58
+
59
+ if (toolKind === 'Write') {
60
+ return new NormalizedToolInput(filePath, [
61
+ new NormalizedEdit('', toolInput.content || ''),
62
+ ]);
63
+ }
64
+ if (toolKind === 'Edit') {
65
+ return new NormalizedToolInput(filePath, [
66
+ new NormalizedEdit(toolInput.old_string || '', toolInput.new_string || ''),
67
+ ]);
68
+ }
69
+ if (toolKind === 'MultiEdit') {
70
+ const raw = Array.isArray(toolInput.edits) ? toolInput.edits : [];
71
+ const edits = raw.map((e) => new NormalizedEdit(e.old_string || '', e.new_string || ''));
72
+ return new NormalizedToolInput(filePath, edits);
73
+ }
74
+ return null;
75
+ }
76
+
77
+ export async function main(): Promise<void> {
78
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
79
+ try {
80
+ const raw = await readStdin();
81
+ const payload = safeParse(raw);
82
+ if (!payload) { process.exit(0); return; }
83
+
84
+ const cwd = process.cwd();
85
+
86
+ if (payload.tool_name === 'Bash') {
87
+ const command = payload.tool_input.command;
88
+ if (!command || command.trim() === '') { process.exit(0); return; }
89
+ const result = runBash(command, cwd);
90
+ if (!result) { process.exit(0); return; }
91
+ process.stderr.write(result.report);
92
+ process.exit(2);
93
+ return;
94
+ }
95
+
96
+ const toolKind = normalizeToolKind(payload.tool_name);
97
+ if (!toolKind) { process.exit(0); return; }
98
+
99
+ const input = normalizeToolInput(toolKind, payload.tool_input);
100
+ if (!input) { process.exit(0); return; }
101
+
102
+ const result = run(toolKind, input, cwd);
103
+ if (!result) { process.exit(0); return; }
104
+
105
+ logRejection(toolKind, input, result, cwd);
106
+ process.stderr.write(result.report);
107
+ process.exit(2);
108
+ } catch (err: unknown) {
109
+ const error = toError(err);
110
+ process.stderr.write(`[ai-hooks] claude-code adapter crashed (failing open): ${error.message}\n`);
111
+ process.exit(0);
112
+ }
113
+ }
114
+
115
+ if (require.main === module) {
116
+ main();
117
+ }
@@ -0,0 +1,88 @@
1
+ import * as path from 'path';
2
+ import * as fs from 'fs';
3
+
4
+ import { run } from '../core/runner';
5
+ import { NormalizedToolInput, NormalizedEdit, ToolKind } from '../core/types';
6
+ import { toError } from '../core/to-error';
7
+
8
+ interface ToolCallEvent {
9
+ toolName: string;
10
+ // webpieces-disable no-any-unknown -- openclaw SDK passes opaque tool arguments
11
+ arguments: Record<string, unknown>;
12
+ }
13
+
14
+ interface HookContext {
15
+ // webpieces-disable no-any-unknown -- openclaw SDK context shape is opaque
16
+ [key: string]: unknown;
17
+ }
18
+
19
+ class OpenclawHandlerResult {
20
+ readonly status: 'approved' | 'rejected';
21
+ readonly reason: string | undefined;
22
+
23
+ constructor(status: 'approved' | 'rejected', reason?: string) {
24
+ this.status = status;
25
+ this.reason = reason;
26
+ }
27
+ }
28
+
29
+ const TOOL_MAP: Record<string, ToolKind> = {
30
+ 'write': 'Write',
31
+ 'edit': 'Edit',
32
+ };
33
+
34
+ function mapToolName(openclawName: string): ToolKind | null {
35
+ return TOOL_MAP[openclawName] || null;
36
+ }
37
+
38
+ // webpieces-disable no-any-unknown -- openclaw SDK passes opaque tool arguments
39
+ function mapToolInput(toolName: string, args: Record<string, unknown>): NormalizedToolInput | null {
40
+ const filePath = typeof args['path'] === 'string' ? args['path'] as string : null;
41
+ if (!filePath) return null;
42
+
43
+ if (toolName === 'write') {
44
+ const content = typeof args['content'] === 'string' ? args['content'] as string : '';
45
+ return new NormalizedToolInput(filePath, [new NormalizedEdit('', content)]);
46
+ }
47
+ if (toolName === 'edit') {
48
+ const oldStr = typeof args['old_string'] === 'string' ? args['old_string'] as string : '';
49
+ const newStr = typeof args['new_string'] === 'string' ? args['new_string'] as string : '';
50
+ return new NormalizedToolInput(filePath, [new NormalizedEdit(oldStr, newStr)]);
51
+ }
52
+ return null;
53
+ }
54
+
55
+ function findWorkspaceRoot(filePath: string): string | null {
56
+ let dir = path.dirname(filePath);
57
+ while (true) {
58
+ if (fs.existsSync(path.join(dir, 'webpieces.ai-hooks.json'))) return dir;
59
+ const parent = path.dirname(dir);
60
+ if (parent === dir) return null;
61
+ dir = parent;
62
+ }
63
+ }
64
+
65
+ export default async function handler(
66
+ event: ToolCallEvent,
67
+ _context: HookContext,
68
+ ): Promise<OpenclawHandlerResult | undefined> {
69
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
70
+ try {
71
+ const toolKind = mapToolName(event.toolName);
72
+ if (!toolKind) return undefined;
73
+
74
+ const input = mapToolInput(event.toolName, event.arguments);
75
+ if (!input) return undefined;
76
+
77
+ const wsRoot = findWorkspaceRoot(input.filePath);
78
+ if (!wsRoot) return undefined;
79
+
80
+ const result = run(toolKind, input, wsRoot);
81
+ if (!result) return new OpenclawHandlerResult('approved');
82
+ return new OpenclawHandlerResult('rejected', result.report);
83
+ } catch (err: unknown) {
84
+ const error = toError(err);
85
+ console.error(`[ai-hooks] openclaw adapter crashed (failing open): ${error.message}`);
86
+ return undefined;
87
+ }
88
+ }
@@ -0,0 +1,114 @@
1
+ /* eslint-disable @webpieces/max-method-lines -- test describe blocks are inherently large */
2
+ import { parseDirectives } from '../disable-directives';
3
+
4
+ describe('parseDirectives', () => {
5
+ it('inline disable on same line as code', () => {
6
+ const d = parseDirectives('const x: any = 1; // ai-hook-disable no-any-unknown -- legacy');
7
+ expect(d.isLineDisabled(1, 'no-any-unknown')).toBe(true);
8
+ expect(d.isLineDisabled(1, 'no-destructure')).toBe(false);
9
+ });
10
+
11
+ it('line-above disable affects next non-blank line', () => {
12
+ const d = parseDirectives('// ai-hook-disable no-any-unknown -- reason\nconst x: any = 1;');
13
+ expect(d.isLineDisabled(2, 'no-any-unknown')).toBe(true);
14
+ expect(d.isLineDisabled(1, 'no-any-unknown')).toBe(false);
15
+ });
16
+
17
+ it('skips blank lines before target', () => {
18
+ const d = parseDirectives('// ai-hook-disable no-any-unknown -- reason\n\n\nconst x: any = 1;');
19
+ expect(d.isLineDisabled(4, 'no-any-unknown')).toBe(true);
20
+ });
21
+
22
+ it('handles comma-separated rules', () => {
23
+ const d = parseDirectives('const { a } = obj; // ai-hook-disable no-destructure, no-any-unknown -- reason');
24
+ expect(d.isLineDisabled(1, 'no-destructure')).toBe(true);
25
+ expect(d.isLineDisabled(1, 'no-any-unknown')).toBe(true);
26
+ expect(d.isLineDisabled(1, 'other-rule')).toBe(false);
27
+ });
28
+
29
+ it('ai-hook-disable-next explicit form', () => {
30
+ const d = parseDirectives('// ai-hook-disable-next no-any-unknown -- reason\nconst x: any = 1;');
31
+ expect(d.isLineDisabled(2, 'no-any-unknown')).toBe(true);
32
+ });
33
+
34
+ it('ai-hook-disable-file within first 20 lines', () => {
35
+ const src = '// ai-hook-disable-file no-any-unknown -- wraps API\nconst a = 1;\nconst b: any = 2;\nconst c: any = 3;';
36
+ const d = parseDirectives(src);
37
+ expect(d.isLineDisabled(3, 'no-any-unknown')).toBe(true);
38
+ expect(d.isLineDisabled(4, 'no-any-unknown')).toBe(true);
39
+ expect(d.isLineDisabled(3, 'no-destructure')).toBe(false);
40
+ });
41
+
42
+ it('ai-hook-disable-file beyond line 20 is ignored', () => {
43
+ const filler = Array(25).fill('const a = 1;').join('\n');
44
+ const src = filler + '\n// ai-hook-disable-file no-any-unknown -- too late\nconst x: any = 1;';
45
+ const d = parseDirectives(src);
46
+ expect(d.isLineDisabled(27, 'no-any-unknown')).toBe(false);
47
+ });
48
+
49
+ it('ai-hook-disable-all on its own line', () => {
50
+ const d = parseDirectives('// ai-hook-disable-all -- hack\nconst x: any = 1;');
51
+ expect(d.isLineDisabled(2, 'no-any-unknown')).toBe(true);
52
+ expect(d.isLineDisabled(2, 'require-return-type')).toBe(true);
53
+ });
54
+
55
+ it('ai-hook-disable-all inline', () => {
56
+ const d = parseDirectives('const x: any = 1; // ai-hook-disable-all -- hack');
57
+ expect(d.isLineDisabled(1, 'no-any-unknown')).toBe(true);
58
+ expect(d.isLineDisabled(1, 'anything-else')).toBe(true);
59
+ });
60
+
61
+ it('no directives present', () => {
62
+ const d = parseDirectives('const x: any = 1;');
63
+ expect(d.isLineDisabled(1, 'no-any-unknown')).toBe(false);
64
+ });
65
+
66
+ it('star rule name matches anything', () => {
67
+ const d = parseDirectives('const x: any = 1; // ai-hook-disable * -- nuclear');
68
+ expect(d.isLineDisabled(1, 'no-any-unknown')).toBe(true);
69
+ expect(d.isLineDisabled(1, 'require-return-type')).toBe(true);
70
+ });
71
+
72
+ it('directive without reason still parses', () => {
73
+ const d = parseDirectives('const x: any = 1; // ai-hook-disable no-any-unknown');
74
+ expect(d.isLineDisabled(1, 'no-any-unknown')).toBe(true);
75
+ });
76
+
77
+ it('chained disable comments skip each other', () => {
78
+ const src = '// ai-hook-disable no-any-unknown -- r1\n// ai-hook-disable no-destructure -- r2\nconst { a }: any = obj;';
79
+ const d = parseDirectives(src);
80
+ expect(d.isLineDisabled(3, 'no-any-unknown')).toBe(true);
81
+ expect(d.isLineDisabled(3, 'no-destructure')).toBe(true);
82
+ });
83
+
84
+ it('webpieces-disable inline works same as ai-hook-disable', () => {
85
+ const d = parseDirectives('const x: any = 1; // webpieces-disable no-any-unknown -- legacy');
86
+ expect(d.isLineDisabled(1, 'no-any-unknown')).toBe(true);
87
+ expect(d.isLineDisabled(1, 'no-destructure')).toBe(false);
88
+ });
89
+
90
+ it('webpieces-disable on line above', () => {
91
+ const d = parseDirectives('// webpieces-disable no-any-unknown -- reason\nconst x: any = 1;');
92
+ expect(d.isLineDisabled(2, 'no-any-unknown')).toBe(true);
93
+ expect(d.isLineDisabled(1, 'no-any-unknown')).toBe(false);
94
+ });
95
+
96
+ it('webpieces-disable-file within first 20 lines', () => {
97
+ const src = '// webpieces-disable-file no-any-unknown -- wraps API\nconst a = 1;\nconst b: any = 2;';
98
+ const d = parseDirectives(src);
99
+ expect(d.isLineDisabled(3, 'no-any-unknown')).toBe(true);
100
+ });
101
+
102
+ it('webpieces-disable-all suppresses all rules', () => {
103
+ const d = parseDirectives('// webpieces-disable-all -- hack\nconst x: any = 1;');
104
+ expect(d.isLineDisabled(2, 'no-any-unknown')).toBe(true);
105
+ expect(d.isLineDisabled(2, 'require-return-type')).toBe(true);
106
+ });
107
+
108
+ it('webpieces-disable chained with ai-hook-disable', () => {
109
+ const src = '// webpieces-disable no-any-unknown -- r1\n// ai-hook-disable no-destructure -- r2\nconst { a }: any = obj;';
110
+ const d = parseDirectives(src);
111
+ expect(d.isLineDisabled(3, 'no-any-unknown')).toBe(true);
112
+ expect(d.isLineDisabled(3, 'no-destructure')).toBe(true);
113
+ });
114
+ });
@@ -0,0 +1,90 @@
1
+ /* eslint-disable @webpieces/max-method-lines -- test describe blocks are inherently large */
2
+ import { run } from '../../runner';
3
+ import { NormalizedToolInput, NormalizedEdit } from '../../types';
4
+ import * as fs from 'fs';
5
+ import * as os from 'os';
6
+ import * as path from 'path';
7
+
8
+ function ws(): string {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'file-loc-'));
10
+ fs.writeFileSync(path.join(dir, 'webpieces.ai-hooks.json'), JSON.stringify({
11
+ rules: { 'no-any-unknown': { enabled: false }, 'max-file-lines': { enabled: false },
12
+ 'no-destructure': { enabled: false }, 'require-return-type': { enabled: false },
13
+ 'no-unmanaged-exceptions': { enabled: false },
14
+ 'file-location': { enabled: true, allowedRootFiles: ['jest.setup.ts'], excludePaths: ['scripts', 'tmp'] } },
15
+ rulesDir: [],
16
+ }));
17
+ return dir;
18
+ }
19
+
20
+ describe('file-location rule', () => {
21
+ it('blocks Write to root (orphan)', () => {
22
+ const w = ws();
23
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'orphan.ts'), [new NormalizedEdit('', 'const x = 1;')]), w);
24
+ expect(r).not.toBeNull();
25
+ expect(r!.report).toContain('file-location');
26
+ expect(r!.report).toContain('not inside any Nx project');
27
+ });
28
+
29
+ it('allows Write to allowedRootFiles', () => {
30
+ const w = ws();
31
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'jest.setup.ts'), [new NormalizedEdit('', 'const x = 1;')]), w);
32
+ expect(r).toBeNull();
33
+ });
34
+
35
+ it('allows Write under src/ of a project', () => {
36
+ const w = ws();
37
+ const projectDir = path.join(w, 'packages', 'mylib');
38
+ fs.mkdirSync(path.join(projectDir, 'src'), { recursive: true });
39
+ fs.writeFileSync(path.join(projectDir, 'project.json'), '{}');
40
+ const r = run('Write', new NormalizedToolInput(
41
+ path.join(projectDir, 'src', 'foo.ts'),
42
+ [new NormalizedEdit('', 'const x = 1;')],
43
+ ), w);
44
+ expect(r).toBeNull();
45
+ });
46
+
47
+ it('blocks Write outside src/ of a project', () => {
48
+ const w = ws();
49
+ const projectDir = path.join(w, 'packages', 'mylib');
50
+ fs.mkdirSync(path.join(projectDir, 'src'), { recursive: true });
51
+ fs.writeFileSync(path.join(projectDir, 'project.json'), '{}');
52
+ const r = run('Write', new NormalizedToolInput(
53
+ path.join(projectDir, 'stray.ts'),
54
+ [new NormalizedEdit('', 'const x = 1;')],
55
+ ), w);
56
+ expect(r).not.toBeNull();
57
+ expect(r!.report).toContain('outside its src/ directory');
58
+ });
59
+
60
+ it('allows jest.config.ts at project root', () => {
61
+ const w = ws();
62
+ const projectDir = path.join(w, 'packages', 'mylib');
63
+ fs.mkdirSync(path.join(projectDir, 'src'), { recursive: true });
64
+ fs.writeFileSync(path.join(projectDir, 'project.json'), '{}');
65
+ const r = run('Write', new NormalizedToolInput(
66
+ path.join(projectDir, 'jest.config.ts'),
67
+ [new NormalizedEdit('', 'export default {};')],
68
+ ), w);
69
+ expect(r).toBeNull();
70
+ });
71
+
72
+ it('skips excluded top-level dirs', () => {
73
+ const w = ws();
74
+ const r = run('Write', new NormalizedToolInput(
75
+ path.join(w, 'scripts', 'tool.ts'),
76
+ [new NormalizedEdit('', 'const x = 1;')],
77
+ ), w);
78
+ expect(r).toBeNull();
79
+ });
80
+
81
+ it('does not fire on Edit (file already exists)', () => {
82
+ const w = ws();
83
+ const target = path.join(w, 'orphan.ts');
84
+ fs.writeFileSync(target, 'const old = 1;');
85
+ const r = run('Edit', new NormalizedToolInput(target, [
86
+ new NormalizedEdit('const old = 1;', 'const updated = 2;'),
87
+ ]), w);
88
+ expect(r).toBeNull();
89
+ });
90
+ });
@@ -0,0 +1,53 @@
1
+ import { run } from '../../runner';
2
+ import { NormalizedToolInput, NormalizedEdit } from '../../types';
3
+ import * as fs from 'fs';
4
+ import * as os from 'os';
5
+ import * as path from 'path';
6
+
7
+ function ws(limit: number = 10): string {
8
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'max-file-'));
9
+ fs.writeFileSync(path.join(dir, 'webpieces.ai-hooks.json'), JSON.stringify({
10
+ rules: { 'no-any-unknown': { enabled: false }, 'file-location': { enabled: false },
11
+ 'no-destructure': { enabled: false }, 'require-return-type': { enabled: false },
12
+ 'no-unmanaged-exceptions': { enabled: false },
13
+ 'max-file-lines': { enabled: true, limit } },
14
+ rulesDir: [],
15
+ }));
16
+ return dir;
17
+ }
18
+
19
+ describe('max-file-lines rule', () => {
20
+ it('blocks a file that exceeds the limit', () => {
21
+ const w = ws(5);
22
+ const content = Array(10).fill('const x = 1;').join('\n');
23
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'big.ts'), [new NormalizedEdit('', content)]), w);
24
+ expect(r).not.toBeNull();
25
+ expect(r!.report).toContain('max-file-lines');
26
+ expect(r!.report).toContain('10 lines');
27
+ });
28
+
29
+ it('allows a file within the limit', () => {
30
+ const w = ws(20);
31
+ const content = Array(5).fill('const x = 1;').join('\n');
32
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'small.ts'), [new NormalizedEdit('', content)]), w);
33
+ expect(r).toBeNull();
34
+ });
35
+
36
+ it('computes projected lines for Edit (current + added - removed)', () => {
37
+ const w = ws(5);
38
+ const target = path.join(w, 'existing.ts');
39
+ fs.writeFileSync(target, Array(4).fill('const x = 1;').join('\n'));
40
+ const r = run('Edit', new NormalizedToolInput(target, [
41
+ new NormalizedEdit('const x = 1;', 'const a = 1;\nconst b = 2;\nconst c = 3;'),
42
+ ]), w);
43
+ expect(r).not.toBeNull();
44
+ expect(r!.report).toContain('6 lines');
45
+ });
46
+
47
+ it('ignores non-ts files', () => {
48
+ const w = ws(3);
49
+ const content = Array(10).fill('line').join('\n');
50
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'big.md'), [new NormalizedEdit('', content)]), w);
51
+ expect(r).toBeNull();
52
+ });
53
+ });
@@ -0,0 +1,68 @@
1
+ import { run } from '../../runner';
2
+ import { NormalizedToolInput, NormalizedEdit } from '../../types';
3
+ import * as fs from 'fs';
4
+ import * as os from 'os';
5
+ import * as path from 'path';
6
+
7
+ function ws(): string {
8
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'no-any-'));
9
+ fs.writeFileSync(path.join(dir, 'webpieces.ai-hooks.json'), JSON.stringify({
10
+ rules: { 'max-file-lines': { enabled: false }, 'file-location': { enabled: false },
11
+ 'no-destructure': { enabled: false }, 'require-return-type': { enabled: false },
12
+ 'no-unmanaged-exceptions': { enabled: false } },
13
+ rulesDir: [],
14
+ }));
15
+ return dir;
16
+ }
17
+
18
+ describe('no-any rule', () => {
19
+ it('blocks : any', () => {
20
+ const w = ws();
21
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [new NormalizedEdit('', 'const x: any = 1;')]), w);
22
+ expect(r).not.toBeNull();
23
+ expect(r!.report).toContain('no-any-unknown');
24
+ });
25
+
26
+ it('blocks as any', () => {
27
+ const w = ws();
28
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [new NormalizedEdit('', 'const x = y as any;')]), w);
29
+ expect(r).not.toBeNull();
30
+ });
31
+
32
+ it('blocks Array<any>', () => {
33
+ const w = ws();
34
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [new NormalizedEdit('', 'const x: Array<any> = [];')]), w);
35
+ expect(r).not.toBeNull();
36
+ });
37
+
38
+ it('allows any in a string', () => {
39
+ const w = ws();
40
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [new NormalizedEdit('', 'const x = "has any keyword";')]), w);
41
+ expect(r).toBeNull();
42
+ });
43
+
44
+ it('allows any in a comment', () => {
45
+ const w = ws();
46
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [new NormalizedEdit('', '// any is fine here\nconst x = 1;')]), w);
47
+ expect(r).toBeNull();
48
+ });
49
+
50
+ it('respects ai-hook-disable', () => {
51
+ const w = ws();
52
+ const content = '// ai-hook-disable no-any-unknown -- legacy\nconst x: any = 1;';
53
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [new NormalizedEdit('', content)]), w);
54
+ expect(r).toBeNull();
55
+ });
56
+
57
+ it('allows unknown keyword', () => {
58
+ const w = ws();
59
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [new NormalizedEdit('', 'const x: unknown = 1;')]), w);
60
+ expect(r).toBeNull();
61
+ });
62
+
63
+ it('ignores non-ts files', () => {
64
+ const w = ws();
65
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.md'), [new NormalizedEdit('', 'const x: any = 1;')]), w);
66
+ expect(r).toBeNull();
67
+ });
68
+ });
@@ -0,0 +1,50 @@
1
+ import { run } from '../../runner';
2
+ import { NormalizedToolInput, NormalizedEdit } from '../../types';
3
+ import * as fs from 'fs';
4
+ import * as os from 'os';
5
+ import * as path from 'path';
6
+
7
+ function ws(): string {
8
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'no-destr-'));
9
+ fs.writeFileSync(path.join(dir, 'webpieces.ai-hooks.json'), JSON.stringify({
10
+ rules: { 'no-any-unknown': { enabled: false }, 'max-file-lines': { enabled: false },
11
+ 'file-location': { enabled: false }, 'require-return-type': { enabled: false },
12
+ 'no-unmanaged-exceptions': { enabled: false } },
13
+ rulesDir: [],
14
+ }));
15
+ return dir;
16
+ }
17
+
18
+ describe('no-destructure rule', () => {
19
+ it('blocks const { x } = obj', () => {
20
+ const w = ws();
21
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [new NormalizedEdit('', 'const { x } = obj;')]), w);
22
+ expect(r).not.toBeNull();
23
+ expect(r!.report).toContain('no-destructure');
24
+ });
25
+
26
+ it('blocks let { x } = obj', () => {
27
+ const w = ws();
28
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [new NormalizedEdit('', 'let { a, b } = obj;')]), w);
29
+ expect(r).not.toBeNull();
30
+ });
31
+
32
+ it('allows const x = obj.x (no destructure)', () => {
33
+ const w = ws();
34
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [new NormalizedEdit('', 'const x = obj.x;')]), w);
35
+ expect(r).toBeNull();
36
+ });
37
+
38
+ it('respects ai-hook-disable', () => {
39
+ const w = ws();
40
+ const content = '// ai-hook-disable no-destructure -- needed for spread\nconst { a, ...rest } = obj;';
41
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [new NormalizedEdit('', content)]), w);
42
+ expect(r).toBeNull();
43
+ });
44
+
45
+ it('allows object literal (not destructure)', () => {
46
+ const w = ws();
47
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [new NormalizedEdit('', 'const obj = { x: 1, y: 2 };')]), w);
48
+ expect(r).toBeNull();
49
+ });
50
+ });