@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,118 @@
1
+ /* eslint-disable @webpieces/max-method-lines -- test describe blocks are inherently large */
2
+ import * as fs from 'fs';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
5
+
6
+ import { runBash } from '../../runner';
7
+
8
+ function makeWorkspace(): string {
9
+ const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-hooks-bash-test-'));
10
+ fs.writeFileSync(
11
+ path.join(ws, 'webpieces.ai-hooks.json'),
12
+ JSON.stringify({
13
+ rules: {
14
+ 'no-any-unknown': { enabled: false },
15
+ 'max-file-lines': { enabled: false },
16
+ 'file-location': { enabled: false },
17
+ 'no-destructure': { enabled: false },
18
+ 'require-return-type': { enabled: false },
19
+ 'no-unmanaged-exceptions': { enabled: false },
20
+ 'catch-error-pattern': { enabled: false },
21
+ 'no-shell-substitution': { enabled: true },
22
+ },
23
+ rulesDir: [],
24
+ }),
25
+ );
26
+ return ws;
27
+ }
28
+
29
+ describe('no-shell-substitution', () => {
30
+ it('blocks $(...) command substitution', () => {
31
+ const ws = makeWorkspace();
32
+ const result = runBash('echo $(date)', ws);
33
+ expect(result).not.toBeNull();
34
+ expect(result!.report).toContain('no-shell-substitution');
35
+ expect(result!.report).toContain('$(...)');
36
+ });
37
+
38
+ it('blocks backtick substitution', () => {
39
+ const ws = makeWorkspace();
40
+ const result = runBash('echo `date`', ws);
41
+ expect(result).not.toBeNull();
42
+ expect(result!.report).toContain('backtick');
43
+ });
44
+
45
+ it('blocks $VAR expansion', () => {
46
+ const ws = makeWorkspace();
47
+ const result = runBash('echo $HOME', ws);
48
+ expect(result).not.toBeNull();
49
+ expect(result!.report).toContain('variable expansion');
50
+ });
51
+
52
+ it('blocks ${VAR} expansion', () => {
53
+ const ws = makeWorkspace();
54
+ const result = runBash('echo ${PATH}', ws);
55
+ expect(result).not.toBeNull();
56
+ expect(result!.report).toContain('variable expansion');
57
+ });
58
+
59
+ it('allows plain commands', () => {
60
+ const ws = makeWorkspace();
61
+ expect(runBash('git status', ws)).toBeNull();
62
+ expect(runBash('ls -la', ws)).toBeNull();
63
+ expect(runBash('pnpm nx build config', ws)).toBeNull();
64
+ });
65
+
66
+ it('allows single-quoted literals containing $ and backticks', () => {
67
+ const ws = makeWorkspace();
68
+ expect(runBash("grep '$pattern' file.txt", ws)).toBeNull();
69
+ expect(runBash("echo 'literal `backtick` here'", ws)).toBeNull();
70
+ });
71
+
72
+ it('allows escaped $ in double-quoted strings', () => {
73
+ const ws = makeWorkspace();
74
+ expect(runBash('echo "price is \\$5"', ws)).toBeNull();
75
+ });
76
+
77
+ it('allows empty-looking commands with $ in non-variable position', () => {
78
+ const ws = makeWorkspace();
79
+ expect(runBash('echo hello', ws)).toBeNull();
80
+ });
81
+
82
+ it('returns null when rule is disabled', () => {
83
+ const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-hooks-bash-disabled-'));
84
+ fs.writeFileSync(
85
+ path.join(ws, 'webpieces.ai-hooks.json'),
86
+ JSON.stringify({
87
+ rules: {
88
+ 'no-any-unknown': { enabled: false },
89
+ 'max-file-lines': { enabled: false },
90
+ 'file-location': { enabled: false },
91
+ 'no-destructure': { enabled: false },
92
+ 'require-return-type': { enabled: false },
93
+ 'no-unmanaged-exceptions': { enabled: false },
94
+ 'catch-error-pattern': { enabled: false },
95
+ 'no-shell-substitution': { enabled: false },
96
+ },
97
+ rulesDir: [],
98
+ }),
99
+ );
100
+ const result = runBash('echo $(date)', ws);
101
+ expect(result).toBeNull();
102
+ });
103
+
104
+ it('returns null when no config in tree', () => {
105
+ const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-hooks-bash-noconfig-'));
106
+ const result = runBash('echo $(date)', ws);
107
+ expect(result).toBeNull();
108
+ });
109
+
110
+ it('reports multiple violation categories for combined command', () => {
111
+ const ws = makeWorkspace();
112
+ const result = runBash('echo $(date) "$HOME" `whoami`', ws);
113
+ expect(result).not.toBeNull();
114
+ expect(result!.report).toContain('$(...)');
115
+ expect(result!.report).toContain('backtick');
116
+ expect(result!.report).toContain('variable expansion');
117
+ });
118
+ });
@@ -0,0 +1,54 @@
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-exc-'));
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 }, 'no-destructure': { enabled: false },
12
+ 'require-return-type': { enabled: false }, 'catch-error-pattern': { enabled: false } },
13
+ rulesDir: [],
14
+ }));
15
+ return dir;
16
+ }
17
+
18
+ describe('no-unmanaged-exceptions rule', () => {
19
+ it('blocks try/catch without disable comment', () => {
20
+ const w = ws();
21
+ const content = 'try {\n doSomething();\n} catch (e) {\n console.error(e);\n}';
22
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [new NormalizedEdit('', content)]), w);
23
+ expect(r).not.toBeNull();
24
+ expect(r!.report).toContain('no-unmanaged-exceptions');
25
+ });
26
+
27
+ it('allows try with eslint-disable-next-line', () => {
28
+ const w = ws();
29
+ const content = '// eslint-disable-next-line @webpieces/no-unmanaged-exceptions\ntry {\n doSomething();\n} catch (e) {}';
30
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [new NormalizedEdit('', content)]), w);
31
+ expect(r).toBeNull();
32
+ });
33
+
34
+ it('allows try with ai-hook-disable', () => {
35
+ const w = ws();
36
+ const content = '// ai-hook-disable no-unmanaged-exceptions -- tested externally\ntry {\n doSomething();\n} catch (e) {}';
37
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [new NormalizedEdit('', content)]), w);
38
+ expect(r).toBeNull();
39
+ });
40
+
41
+ it('does not fire on the word try in a string', () => {
42
+ const w = ws();
43
+ const content = 'const msg = "try again later";';
44
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [new NormalizedEdit('', content)]), w);
45
+ expect(r).toBeNull();
46
+ });
47
+
48
+ it('does not fire on the word try in a comment', () => {
49
+ const w = ws();
50
+ const content = '// try this approach instead\nconst x = 1;';
51
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [new NormalizedEdit('', content)]), w);
52
+ expect(r).toBeNull();
53
+ });
54
+ });
@@ -0,0 +1,79 @@
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(), 'ret-type-'));
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 }, 'no-destructure': { enabled: false },
12
+ 'no-unmanaged-exceptions': { enabled: false } },
13
+ rulesDir: [],
14
+ }));
15
+ return dir;
16
+ }
17
+
18
+ describe('require-return-type rule', () => {
19
+ it('blocks function without return type', () => {
20
+ const w = ws();
21
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [
22
+ new NormalizedEdit('', 'function foo(x: number) {\n return x;\n}'),
23
+ ]), w);
24
+ expect(r).not.toBeNull();
25
+ expect(r!.report).toContain('require-return-type');
26
+ });
27
+
28
+ it('allows function with return type', () => {
29
+ const w = ws();
30
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [
31
+ new NormalizedEdit('', 'function foo(x: number): number {\n return x;\n}'),
32
+ ]), w);
33
+ expect(r).toBeNull();
34
+ });
35
+
36
+ it('blocks async method without return type', () => {
37
+ const w = ws();
38
+ const content = ' async fetchData(id: string) {\n return null;\n }';
39
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [new NormalizedEdit('', content)]), w);
40
+ expect(r).not.toBeNull();
41
+ });
42
+
43
+ it('allows async method with return type', () => {
44
+ const w = ws();
45
+ const content = ' async fetchData(id: string): Promise<string> {\n return "";\n }';
46
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [new NormalizedEdit('', content)]), w);
47
+ expect(r).toBeNull();
48
+ });
49
+
50
+ it('skips constructors', () => {
51
+ const w = ws();
52
+ const content = ' constructor(private x: number) {\n this.x = x;\n }';
53
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [new NormalizedEdit('', content)]), w);
54
+ expect(r).toBeNull();
55
+ });
56
+
57
+ it('blocks arrow function without return type', () => {
58
+ const w = ws();
59
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [
60
+ new NormalizedEdit('', 'const fn = (x: number) => x + 1;'),
61
+ ]), w);
62
+ expect(r).not.toBeNull();
63
+ });
64
+
65
+ it('allows arrow function with return type', () => {
66
+ const w = ws();
67
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [
68
+ new NormalizedEdit('', 'const fn = (x: number): number => x + 1;'),
69
+ ]), w);
70
+ expect(r).toBeNull();
71
+ });
72
+
73
+ it('respects ai-hook-disable', () => {
74
+ const w = ws();
75
+ const content = '// ai-hook-disable require-return-type -- generated\nfunction foo(x: number) {\n return x;\n}';
76
+ const r = run('Write', new NormalizedToolInput(path.join(w, 'f.ts'), [new NormalizedEdit('', content)]), w);
77
+ expect(r).toBeNull();
78
+ });
79
+ });
@@ -0,0 +1,288 @@
1
+ /* eslint-disable @webpieces/max-method-lines -- test describe blocks are inherently large */
2
+ import * as fs from 'fs';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
5
+
6
+ import { run } from '../runner';
7
+ import { NormalizedToolInput, NormalizedEdit } from '../types';
8
+
9
+ function makeWorkspace(): string {
10
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'ai-hooks-runner-test-'));
11
+ }
12
+
13
+ function writeFile(p: string, content: string): void {
14
+ fs.mkdirSync(path.dirname(p), { recursive: true });
15
+ fs.writeFileSync(p, content);
16
+ }
17
+
18
+ describe('core runner', () => {
19
+ it('returns null when no config in tree', () => {
20
+ const ws = makeWorkspace();
21
+ const input = new NormalizedToolInput(
22
+ path.join(ws, 'foo.ts'),
23
+ [new NormalizedEdit('', 'const x: number = 1;')],
24
+ );
25
+ const result = run('Write', input, ws);
26
+ expect(result).toBeNull();
27
+ });
28
+
29
+ it('returns null when all rules disabled', () => {
30
+ const ws = makeWorkspace();
31
+ writeFile(
32
+ path.join(ws, 'webpieces.ai-hooks.json'),
33
+ JSON.stringify({
34
+ rules: {
35
+ 'no-any-unknown': { enabled: false },
36
+ 'max-file-lines': { enabled: false },
37
+ 'file-location': { enabled: false },
38
+ 'no-destructure': { enabled: false },
39
+ 'require-return-type': { enabled: false },
40
+ 'no-unmanaged-exceptions': { enabled: false },
41
+ },
42
+ rulesDir: [],
43
+ }),
44
+ );
45
+ const input = new NormalizedToolInput(
46
+ path.join(ws, 'foo.ts'),
47
+ [new NormalizedEdit('', 'const x: number = 1;')],
48
+ );
49
+ const result = run('Write', input, ws);
50
+ expect(result).toBeNull();
51
+ });
52
+
53
+ it('blocks custom edit-scope rule on proposed content', () => {
54
+ const ws = makeWorkspace();
55
+ const rulesDir = path.join(ws, 'ai-hooks-rules');
56
+ writeFile(
57
+ path.join(rulesDir, 'ban-foo.js'),
58
+ `module.exports = {
59
+ name: 'ban-foo',
60
+ description: 'Ban foo',
61
+ scope: 'edit',
62
+ files: ['**/*.ts'],
63
+ defaultOptions: {},
64
+ fixHint: ['use bar instead'],
65
+ check(ctx) {
66
+ const violations = [];
67
+ ctx.strippedLines.forEach((line, i) => {
68
+ if (/\\bfoo\\b/.test(line)) {
69
+ violations.push({ line: i + 1, snippet: ctx.lines[i].trim(), message: 'foo is banned' });
70
+ }
71
+ });
72
+ return violations;
73
+ },
74
+ };`,
75
+ );
76
+ writeFile(
77
+ path.join(ws, 'webpieces.ai-hooks.json'),
78
+ JSON.stringify({
79
+ rules: {
80
+ 'no-any-unknown': { enabled: false },
81
+ 'max-file-lines': { enabled: false },
82
+ 'file-location': { enabled: false },
83
+ 'no-destructure': { enabled: false },
84
+ 'require-return-type': { enabled: false },
85
+ 'no-unmanaged-exceptions': { enabled: false },
86
+ 'ban-foo': { enabled: true },
87
+ },
88
+ rulesDir: ['ai-hooks-rules'],
89
+ }),
90
+ );
91
+ const input = new NormalizedToolInput(
92
+ path.join(ws, 'evil.ts'),
93
+ [new NormalizedEdit('', 'const x = foo;\nconst y = bar;')],
94
+ );
95
+ const result = run('Write', input, ws);
96
+ expect(result).not.toBeNull();
97
+ expect(result!.report).toContain('ban-foo');
98
+ expect(result!.report).toContain('L1');
99
+ expect(result!.report).toContain('foo is banned');
100
+ expect(result!.report).not.toContain('L2');
101
+ });
102
+
103
+ it('disable directive suppresses violation', () => {
104
+ const ws = makeWorkspace();
105
+ const rulesDir = path.join(ws, 'ai-hooks-rules');
106
+ writeFile(
107
+ path.join(rulesDir, 'ban-foo.js'),
108
+ `module.exports = {
109
+ name: 'ban-foo',
110
+ description: 'Ban foo',
111
+ scope: 'edit',
112
+ files: ['**/*.ts'],
113
+ defaultOptions: {},
114
+ fixHint: [],
115
+ check(ctx) {
116
+ const violations = [];
117
+ ctx.strippedLines.forEach((line, i) => {
118
+ if (/\\bfoo\\b/.test(line) && !ctx.isLineDisabled(i + 1, 'ban-foo')) {
119
+ violations.push({ line: i + 1, snippet: ctx.lines[i].trim(), message: 'no foo' });
120
+ }
121
+ });
122
+ return violations;
123
+ },
124
+ };`,
125
+ );
126
+ writeFile(
127
+ path.join(ws, 'webpieces.ai-hooks.json'),
128
+ JSON.stringify({
129
+ rules: {
130
+ 'no-any-unknown': { enabled: false },
131
+ 'max-file-lines': { enabled: false },
132
+ 'file-location': { enabled: false },
133
+ 'no-destructure': { enabled: false },
134
+ 'require-return-type': { enabled: false },
135
+ 'no-unmanaged-exceptions': { enabled: false },
136
+ 'ban-foo': { enabled: true },
137
+ },
138
+ rulesDir: ['ai-hooks-rules'],
139
+ }),
140
+ );
141
+ const input = new NormalizedToolInput(
142
+ path.join(ws, 'allowed.ts'),
143
+ [new NormalizedEdit('', 'const x = foo; // ai-hook-disable ban-foo -- legacy')],
144
+ );
145
+ const result = run('Write', input, ws);
146
+ expect(result).toBeNull();
147
+ });
148
+
149
+ it('file-scope rule sees projectedFileLines', () => {
150
+ const ws = makeWorkspace();
151
+ const rulesDir = path.join(ws, 'ai-hooks-rules');
152
+ writeFile(
153
+ path.join(rulesDir, 'max-five.js'),
154
+ `module.exports = {
155
+ name: 'max-five',
156
+ description: 'File must be <= 5 lines',
157
+ scope: 'file',
158
+ files: ['**/*.ts'],
159
+ defaultOptions: { limit: 5 },
160
+ fixHint: [],
161
+ check(ctx) {
162
+ if (ctx.projectedFileLines > ctx.options.limit) {
163
+ return [{ line: 1, snippet: '(file too long)', message: 'Projected ' + ctx.projectedFileLines + ' lines exceeds limit ' + ctx.options.limit }];
164
+ }
165
+ return [];
166
+ },
167
+ };`,
168
+ );
169
+ writeFile(
170
+ path.join(ws, 'webpieces.ai-hooks.json'),
171
+ JSON.stringify({
172
+ rules: {
173
+ 'no-any-unknown': { enabled: false },
174
+ 'max-file-lines': { enabled: false },
175
+ 'file-location': { enabled: false },
176
+ 'no-destructure': { enabled: false },
177
+ 'require-return-type': { enabled: false },
178
+ 'no-unmanaged-exceptions': { enabled: false },
179
+ 'max-five': { enabled: true },
180
+ },
181
+ rulesDir: ['ai-hooks-rules'],
182
+ }),
183
+ );
184
+ const longContent = Array(10).fill('const x = 1;').join('\n');
185
+ const input = new NormalizedToolInput(
186
+ path.join(ws, 'big.ts'),
187
+ [new NormalizedEdit('', longContent)],
188
+ );
189
+ const result = run('Write', input, ws);
190
+ expect(result).not.toBeNull();
191
+ expect(result!.report).toContain('max-five');
192
+ expect(result!.report).toContain('Projected 10 lines');
193
+ });
194
+
195
+ it('Edit tool uses new_string as added content', () => {
196
+ const ws = makeWorkspace();
197
+ const target = path.join(ws, 'existing.ts');
198
+ writeFile(target, 'const a = 1;\nconst b = 2;\n');
199
+ const rulesDir = path.join(ws, 'ai-hooks-rules');
200
+ writeFile(
201
+ path.join(rulesDir, 'ban-foo.js'),
202
+ `module.exports = {
203
+ name: 'ban-foo',
204
+ description: 'Ban foo',
205
+ scope: 'edit',
206
+ files: ['**/*.ts'],
207
+ defaultOptions: {},
208
+ fixHint: [],
209
+ check(ctx) {
210
+ const vs = [];
211
+ ctx.strippedLines.forEach((l, i) => {
212
+ if (/foo/.test(l)) vs.push({ line: i+1, snippet: ctx.lines[i].trim(), message: 'no foo' });
213
+ });
214
+ return vs;
215
+ },
216
+ };`,
217
+ );
218
+ writeFile(
219
+ path.join(ws, 'webpieces.ai-hooks.json'),
220
+ JSON.stringify({
221
+ rules: {
222
+ 'no-any-unknown': { enabled: false },
223
+ 'max-file-lines': { enabled: false },
224
+ 'file-location': { enabled: false },
225
+ 'no-destructure': { enabled: false },
226
+ 'require-return-type': { enabled: false },
227
+ 'no-unmanaged-exceptions': { enabled: false },
228
+ 'ban-foo': { enabled: true },
229
+ },
230
+ rulesDir: ['ai-hooks-rules'],
231
+ }),
232
+ );
233
+ const input = new NormalizedToolInput(target, [
234
+ new NormalizedEdit('const a = 1;', 'const a = foo;'),
235
+ ]);
236
+ const result = run('Edit', input, ws);
237
+ expect(result).not.toBeNull();
238
+ expect(result!.report).toContain('ban-foo');
239
+ });
240
+
241
+ it('MultiEdit reports which edit triggered', () => {
242
+ const ws = makeWorkspace();
243
+ const target = path.join(ws, 'multi.ts');
244
+ writeFile(target, 'const a = 1;\nconst b = 2;\nconst c = 3;\n');
245
+ const rulesDir = path.join(ws, 'ai-hooks-rules');
246
+ writeFile(
247
+ path.join(rulesDir, 'ban-foo.js'),
248
+ `module.exports = {
249
+ name: 'ban-foo',
250
+ description: 'Ban foo',
251
+ scope: 'edit',
252
+ files: ['**/*.ts'],
253
+ defaultOptions: {},
254
+ fixHint: [],
255
+ check(ctx) {
256
+ const vs = [];
257
+ ctx.strippedLines.forEach((l, i) => {
258
+ if (/foo/.test(l)) vs.push({ line: i+1, snippet: ctx.lines[i].trim(), message: 'no foo' });
259
+ });
260
+ return vs;
261
+ },
262
+ };`,
263
+ );
264
+ writeFile(
265
+ path.join(ws, 'webpieces.ai-hooks.json'),
266
+ JSON.stringify({
267
+ rules: {
268
+ 'no-any-unknown': { enabled: false },
269
+ 'max-file-lines': { enabled: false },
270
+ 'file-location': { enabled: false },
271
+ 'no-destructure': { enabled: false },
272
+ 'require-return-type': { enabled: false },
273
+ 'no-unmanaged-exceptions': { enabled: false },
274
+ 'ban-foo': { enabled: true },
275
+ },
276
+ rulesDir: ['ai-hooks-rules'],
277
+ }),
278
+ );
279
+ const input = new NormalizedToolInput(target, [
280
+ new NormalizedEdit('const a = 1;', 'const a = 11;'),
281
+ new NormalizedEdit('const b = 2;', 'const b = foo;'),
282
+ new NormalizedEdit('const c = 3;', 'const c = 33;'),
283
+ ]);
284
+ const result = run('MultiEdit', input, ws);
285
+ expect(result).not.toBeNull();
286
+ expect(result!.report).toContain('edit 2/3');
287
+ });
288
+ });
@@ -0,0 +1,109 @@
1
+ /* eslint-disable @webpieces/max-method-lines -- test describe blocks are inherently large */
2
+ import { stripTsNoise } from '../strip-ts-noise';
3
+
4
+ describe('stripTsNoise', () => {
5
+ it('passes code through unchanged', () => {
6
+ const src = 'const x: number = 1;\nconst y = x + 2;\n';
7
+ expect(stripTsNoise(src)).toBe(src);
8
+ });
9
+
10
+ it('preserves line count for all inputs', () => {
11
+ const cases = [
12
+ 'a\nb\nc',
13
+ '// comment\nconst x = 1;',
14
+ '/* multi\n line\n comment */\nconst x = 1;',
15
+ '"string with\\nescaped newline"',
16
+ '`template\nwith\nnewlines`',
17
+ '"multi\nline"\nconst y = 2;',
18
+ ];
19
+ for (const src of cases) {
20
+ const out = stripTsNoise(src);
21
+ expect(out.split('\n').length).toBe(src.split('\n').length);
22
+ expect(out.length).toBe(src.length);
23
+ }
24
+ });
25
+
26
+ it('replaces double-quoted string interior with spaces', () => {
27
+ const src = 'const x = "has any keyword";';
28
+ const out = stripTsNoise(src);
29
+ expect(out).not.toContain('any');
30
+ });
31
+
32
+ it('replaces single-quoted string interior', () => {
33
+ const src = "const x = 'any';";
34
+ const out = stripTsNoise(src);
35
+ expect(out).not.toContain('any');
36
+ });
37
+
38
+ it('handles escaped quotes inside strings', () => {
39
+ const src = 'const x = "has \\"any\\" keyword";';
40
+ const out = stripTsNoise(src);
41
+ expect(out).not.toContain('any');
42
+ });
43
+
44
+ it('replaces line comment body with spaces', () => {
45
+ const src = '// this has any in it\nconst x = 1;';
46
+ const out = stripTsNoise(src);
47
+ expect(out.split('\n')[0]).not.toContain('any');
48
+ });
49
+
50
+ it('replaces block comment body, preserves newlines', () => {
51
+ const src = '/* line1 any\n line2 any */\nconst x = 1;';
52
+ const out = stripTsNoise(src);
53
+ expect(out).not.toContain('any');
54
+ expect(out.split('\n').length).toBe(3);
55
+ });
56
+
57
+ it('strips template literal body but keeps ${} interp as code', () => {
58
+ const src = 'const x = `prefix ${value + 1} suffix any`;';
59
+ const out = stripTsNoise(src);
60
+ expect(out).not.toMatch(/prefix/);
61
+ expect(out).not.toMatch(/ any`/);
62
+ expect(out).toMatch(/\$\{value \+ 1\}/);
63
+ });
64
+
65
+ it('handles nested template inside interpolation', () => {
66
+ const src = 'const x = `outer ${`inner ${y} end`} done`;';
67
+ const out = stripTsNoise(src);
68
+ expect(out).toMatch(/\$\{y\}/);
69
+ expect(out).not.toMatch(/outer/);
70
+ expect(out).not.toMatch(/inner/);
71
+ expect(out).not.toMatch(/end/);
72
+ expect(out).not.toMatch(/done/);
73
+ });
74
+
75
+ it('strips string inside template interpolation', () => {
76
+ const src = 'const x = `prefix ${"some any string"} suffix`;';
77
+ const out = stripTsNoise(src);
78
+ expect(out).not.toContain('some any string');
79
+ });
80
+
81
+ it('keeps any keyword in real code visible', () => {
82
+ const src = 'const x: any = 1;\nconst y: number = 2;';
83
+ const out = stripTsNoise(src);
84
+ expect(out).toContain(': any');
85
+ expect(out).toContain(': number');
86
+ });
87
+
88
+ it('hides any keyword inside comment', () => {
89
+ const src = 'const x: number = 1; // this returns any maybe';
90
+ const out = stripTsNoise(src);
91
+ expect(out).toContain(': number');
92
+ expect(out.indexOf('any')).toBe(-1);
93
+ });
94
+
95
+ it('does not treat division as a comment', () => {
96
+ const src = 'const x = a / b;\nconst y = c / d;';
97
+ expect(stripTsNoise(src)).toBe(src);
98
+ });
99
+
100
+ it('handles empty input', () => {
101
+ expect(stripTsNoise('')).toBe('');
102
+ });
103
+
104
+ it('does not bleed comment into next line', () => {
105
+ const src = '// disable rule\nconst x: any = 1;';
106
+ const out = stripTsNoise(src);
107
+ expect(out).toContain(': any');
108
+ });
109
+ });