@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,87 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+
4
+ import type { FileRule, FileContext, Violation } from '../types';
5
+ import { Violation as V } from '../types';
6
+
7
+ const DEFAULT_EXCLUDE_PATHS = [
8
+ 'node_modules', 'dist', '.nx', '.git',
9
+ 'architecture', 'tmp', 'scripts',
10
+ ];
11
+ const DEFAULT_ALLOWED_ROOT_FILES = ['jest.setup.ts'];
12
+
13
+ function isNodeModulesDir(name: string): boolean {
14
+ return name === 'node_modules' || name.startsWith('node_modules_');
15
+ }
16
+
17
+ function shouldSkipDir(name: string, excludePaths: readonly string[]): boolean {
18
+ if (isNodeModulesDir(name)) return true;
19
+ return excludePaths.indexOf(name) >= 0;
20
+ }
21
+
22
+ function findProjectRoot(filePath: string, workspaceRoot: string): string | null {
23
+ let dir = path.dirname(filePath);
24
+ while (dir !== workspaceRoot && dir.startsWith(workspaceRoot)) {
25
+ if (fs.existsSync(path.join(dir, 'project.json'))) return dir;
26
+ dir = path.dirname(dir);
27
+ }
28
+ return null;
29
+ }
30
+
31
+ const fileLocationRule: FileRule = {
32
+ name: 'file-location',
33
+ description: 'Every .ts file must belong to a project\'s src/ directory.',
34
+ scope: 'file',
35
+ files: ['**/*.ts', '**/*.tsx'],
36
+ defaultOptions: {
37
+ excludePaths: DEFAULT_EXCLUDE_PATHS,
38
+ allowedRootFiles: DEFAULT_ALLOWED_ROOT_FILES,
39
+ },
40
+ fixHint: [
41
+ 'Move the file into an existing project\'s src/ directory, or create a new project with project.json that owns the directory.',
42
+ 'Add the dir to file-location.excludePaths in webpieces.ai-hooks.json',
43
+ ],
44
+
45
+ check(ctx: FileContext): readonly Violation[] {
46
+ if (ctx.tool !== 'Write') return [];
47
+
48
+ const excludePaths = Array.isArray(ctx.options['excludePaths'])
49
+ ? ctx.options['excludePaths'] as string[]
50
+ : DEFAULT_EXCLUDE_PATHS;
51
+ const allowedRootFiles = Array.isArray(ctx.options['allowedRootFiles'])
52
+ ? ctx.options['allowedRootFiles'] as string[]
53
+ : DEFAULT_ALLOWED_ROOT_FILES;
54
+
55
+ const relParts = ctx.relativePath.split(path.sep);
56
+ const topDir = relParts[0];
57
+
58
+ if (topDir && shouldSkipDir(topDir, excludePaths)) return [];
59
+ if (relParts.length === 1 && allowedRootFiles.indexOf(relParts[0]) >= 0) return [];
60
+
61
+ const projectRoot = findProjectRoot(ctx.filePath, ctx.workspaceRoot);
62
+
63
+ if (!projectRoot) {
64
+ return [new V(
65
+ 1,
66
+ ctx.relativePath,
67
+ 'File is not inside any Nx project. Move it into a project\'s src/ directory.',
68
+ )];
69
+ }
70
+
71
+ const relToProject = path.relative(projectRoot, ctx.filePath);
72
+ const fileName = path.basename(ctx.filePath);
73
+ if (fileName === 'jest.config.ts') return [];
74
+ if (!relToProject.startsWith('src' + path.sep) && relToProject !== 'src') {
75
+ const projectName = path.relative(ctx.workspaceRoot, projectRoot);
76
+ return [new V(
77
+ 1,
78
+ ctx.relativePath,
79
+ `File is inside project \`${projectName}\` but outside its src/ directory. Move it into src/.`,
80
+ )];
81
+ }
82
+
83
+ return [];
84
+ },
85
+ };
86
+
87
+ export default fileLocationRule;
@@ -0,0 +1,11 @@
1
+ export const builtInRuleNames: readonly string[] = [
2
+ 'no-any-unknown',
3
+ 'no-implicit-any',
4
+ 'max-file-lines',
5
+ 'file-location',
6
+ 'no-destructure',
7
+ 'require-return-type',
8
+ 'no-unmanaged-exceptions',
9
+ 'catch-error-pattern',
10
+ 'no-shell-substitution',
11
+ ];
@@ -0,0 +1,137 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+
4
+ import type { FileRule, FileContext, Violation } from '../types';
5
+ import { Violation as V } from '../types';
6
+
7
+ const DEFAULT_LIMIT = 900;
8
+ const INSTRUCT_DIR = '.webpieces/instruct-ai';
9
+ const INSTRUCT_FILE = 'webpieces.filesize.md';
10
+
11
+ const maxFileLinesRule: FileRule = {
12
+ name: 'max-file-lines',
13
+ description: 'Cap file length at a configured line limit.',
14
+ scope: 'file',
15
+ files: ['**/*.ts', '**/*.tsx'],
16
+ defaultOptions: { limit: DEFAULT_LIMIT },
17
+ fixHint: [
18
+ 'READ .webpieces/instruct-ai/webpieces.filesize.md for step-by-step refactoring guidance.',
19
+ '// eslint-disable-next-line @webpieces/max-file-lines (also suppresses the eslint rule)',
20
+ ],
21
+
22
+ check(ctx: FileContext): readonly Violation[] {
23
+ const limit = typeof ctx.options['limit'] === 'number'
24
+ ? ctx.options['limit'] as number
25
+ : DEFAULT_LIMIT;
26
+ if (ctx.projectedFileLines <= limit) return [];
27
+ writeInstructionFile(ctx.workspaceRoot);
28
+ return [new V(
29
+ 1,
30
+ `(projected ${String(ctx.projectedFileLines)} lines)`,
31
+ `File will be ${String(ctx.projectedFileLines)} lines, exceeding the ${String(limit)}-line limit. See .webpieces/instruct-ai/webpieces.filesize.md for detailed refactoring instructions.`,
32
+ )];
33
+ },
34
+ };
35
+
36
+ function writeInstructionFile(workspaceRoot: string): void {
37
+ const dir = path.join(workspaceRoot, INSTRUCT_DIR);
38
+ const filePath = path.join(dir, INSTRUCT_FILE);
39
+ if (fs.existsSync(filePath)) return;
40
+ fs.mkdirSync(dir, { recursive: true });
41
+ fs.writeFileSync(filePath, FILESIZE_DOC_CONTENT);
42
+ }
43
+
44
+ // eslint-disable-next-line @webpieces/max-file-lines
45
+ const FILESIZE_DOC_CONTENT = `# AI Agent Instructions: File Too Long
46
+
47
+ **READ THIS FILE to fix files that are too long**
48
+
49
+ ## Core Principle
50
+
51
+ With **stateless systems + dependency injection, refactor is trivial**.
52
+ Pick a method or a few and move to new class XXXXX, then inject XXXXX
53
+ into all users of those methods via the constructor.
54
+ Delete those methods from original class.
55
+
56
+ **99% of files can be less than the configured max lines of code.**
57
+
58
+ Files should contain a SINGLE COHESIVE UNIT.
59
+ - One class per file (Java convention)
60
+ - If class is too large, extract child responsibilities
61
+ - Use dependency injection to compose functionality
62
+
63
+ ## Command: Reduce File Size
64
+
65
+ ### Step 1: Check for Multiple Classes
66
+ If the file contains multiple classes, **SEPARATE each class into its own file**.
67
+
68
+ ### Step 2: Extract Child Responsibilities (if single class is too large)
69
+
70
+ #### Pattern: Create New Service Class with Dependency Injection
71
+
72
+ \`\`\`typescript
73
+ // BAD: UserController.ts (800 lines, single class)
74
+ @provideSingleton()
75
+ @Controller()
76
+ export class UserController {
77
+ // 200 lines: CRUD operations
78
+ // 300 lines: validation logic
79
+ // 200 lines: notification logic
80
+ }
81
+
82
+ // GOOD: Extract validation service
83
+ // 1. Create UserValidationService.ts
84
+ @provideSingleton()
85
+ export class UserValidationService {
86
+ validateUserData(data: UserData): ValidationResult { /* ... */ }
87
+ validateEmail(email: string): boolean { /* ... */ }
88
+ }
89
+
90
+ // 2. Inject into UserController.ts
91
+ @provideSingleton()
92
+ @Controller()
93
+ export class UserController {
94
+ constructor(
95
+ @inject(TYPES.UserValidationService)
96
+ private validator: UserValidationService
97
+ ) {}
98
+ }
99
+ \`\`\`
100
+
101
+ ## AI Agent Action Steps
102
+
103
+ 1. **ANALYZE** the file structure:
104
+ - Count classes (if >1, separate immediately)
105
+ - Identify logical responsibilities within single class
106
+
107
+ 2. **IDENTIFY** "child code" to extract:
108
+ - Validation logic -> ValidationService
109
+ - Notification logic -> NotificationService
110
+ - Data transformation -> TransformerService
111
+ - External API calls -> ApiService
112
+ - Business rules -> RulesEngine
113
+
114
+ 3. **CREATE** new service file(s):
115
+ - Add \`@provideSingleton()\` decorator
116
+ - Move child methods to new class
117
+
118
+ 4. **UPDATE** dependency injection:
119
+ - Inject new service into original class constructor
120
+ - Replace direct method calls with \`this.serviceName.method()\`
121
+
122
+ 5. **VERIFY** file sizes:
123
+ - Original file should now be under the limit
124
+ - Each extracted file should be under the limit
125
+
126
+ ## Escape Hatch
127
+
128
+ If refactoring is genuinely not feasible, add a disable comment:
129
+
130
+ \`\`\`typescript
131
+ // eslint-disable-next-line @webpieces/max-file-lines
132
+ \`\`\`
133
+
134
+ Remember: Find the "child code" and pull it down into a new class. Once moved, the code's purpose becomes clear, making it easy to rename to a logical name.
135
+ `;
136
+
137
+ export default maxFileLinesRule;
@@ -0,0 +1,35 @@
1
+ import type { EditRule, EditContext, Violation } from '../types';
2
+ import { Violation as V } from '../types';
3
+
4
+ const ANY_PATTERN =
5
+ /(?::\s*any\b|\bas\s+any\b|<any>|any\[\]|Array<any>|Promise<any>|Map<[^,<>]+,\s*any\s*>|Record<[^,<>]+,\s*any\s*>|Set<any>)/;
6
+
7
+ const noAnyRule: EditRule = {
8
+ name: 'no-any-unknown',
9
+ description: 'Disallow the `any` keyword. Use concrete types or interfaces.',
10
+ scope: 'edit',
11
+ files: ['**/*.ts', '**/*.tsx'],
12
+ defaultOptions: {},
13
+ fixHint: [
14
+ 'Prefer: interface MyData { ... } or class MyData { ... }',
15
+ '// webpieces-disable no-any-unknown -- <one-line reason>',
16
+ ],
17
+
18
+ check(ctx: EditContext): readonly Violation[] {
19
+ const violations: V[] = [];
20
+ for (let i = 0; i < ctx.strippedLines.length; i += 1) {
21
+ const stripped = ctx.strippedLines[i];
22
+ if (!ANY_PATTERN.test(stripped)) continue;
23
+ const lineNum = i + 1;
24
+ if (ctx.isLineDisabled(lineNum, 'no-any-unknown')) continue;
25
+ violations.push(new V(
26
+ lineNum,
27
+ ctx.lines[i].trim(),
28
+ '`any` erases type information. Use a concrete type, an interface, or `unknown` with type guards.',
29
+ ));
30
+ }
31
+ return violations;
32
+ },
33
+ };
34
+
35
+ export default noAnyRule;
@@ -0,0 +1,34 @@
1
+ import type { EditRule, EditContext, Violation } from '../types';
2
+ import { Violation as V } from '../types';
3
+
4
+ const VARIABLE_DESTRUCTURE = /\b(?:const|let|var)\s*\{/;
5
+
6
+ const noDestructureRule: EditRule = {
7
+ name: 'no-destructure',
8
+ description: 'Disallow destructuring patterns. Assign the whole result and pass it around or access properties explicitly.',
9
+ scope: 'edit',
10
+ files: ['**/*.ts', '**/*.tsx'],
11
+ defaultOptions: { allowTopLevel: true },
12
+ fixHint: [
13
+ 'Instead of: const { x, y } = methodCall(); prefer const obj = methodCall(); then pass obj to other methods or use obj.x',
14
+ '// webpieces-disable no-destructure -- <reason>',
15
+ ],
16
+
17
+ check(ctx: EditContext): readonly Violation[] {
18
+ const violations: V[] = [];
19
+ for (let i = 0; i < ctx.strippedLines.length; i += 1) {
20
+ const stripped = ctx.strippedLines[i];
21
+ if (!VARIABLE_DESTRUCTURE.test(stripped)) continue;
22
+ const lineNum = i + 1;
23
+ if (ctx.isLineDisabled(lineNum, 'no-destructure')) continue;
24
+ violations.push(new V(
25
+ lineNum,
26
+ ctx.lines[i].trim(),
27
+ 'Destructuring pattern. Assign the whole result instead: const obj = methodCall(); then pass obj around or use obj.x',
28
+ ));
29
+ }
30
+ return violations;
31
+ },
32
+ };
33
+
34
+ export default noDestructureRule;
@@ -0,0 +1,67 @@
1
+ import type { EditRule, EditContext, Violation } from '../types';
2
+ import { Violation as V } from '../types';
3
+
4
+ const ARROW_PARAMS_RE = /\(([^()]*)\)\s*=>/g;
5
+ const FN_DECL_PARAMS_RE = /\bfunction\s*[\w$]*\s*\(([^()]*)\)/g;
6
+
7
+ function firstUntypedParam(paramsStr: string): string | null {
8
+ if (paramsStr.includes('{') || paramsStr.includes('[')) return null;
9
+ const parts = paramsStr.split(',').map((p) => p.trim()).filter((p) => p.length > 0);
10
+ for (const part of parts) {
11
+ if (part.startsWith('...')) continue;
12
+ if (part.includes(':')) continue;
13
+ if (part.includes('=')) continue;
14
+ if (part === 'this') continue;
15
+ if (/^[a-zA-Z_$][\w$]*$/.test(part)) return part;
16
+ }
17
+ return null;
18
+ }
19
+
20
+ function findOffender(line: string): string | null {
21
+ ARROW_PARAMS_RE.lastIndex = 0;
22
+ let m: RegExpExecArray | null = ARROW_PARAMS_RE.exec(line);
23
+ while (m !== null) {
24
+ const bad = firstUntypedParam(m[1]);
25
+ if (bad) return bad;
26
+ m = ARROW_PARAMS_RE.exec(line);
27
+ }
28
+ FN_DECL_PARAMS_RE.lastIndex = 0;
29
+ m = FN_DECL_PARAMS_RE.exec(line);
30
+ while (m !== null) {
31
+ const bad = firstUntypedParam(m[1]);
32
+ if (bad) return bad;
33
+ m = FN_DECL_PARAMS_RE.exec(line);
34
+ }
35
+ return null;
36
+ }
37
+
38
+ const noImplicitAnyRule: EditRule = {
39
+ name: 'no-implicit-any',
40
+ description: 'Disallow function parameters without explicit type annotations (implicit-any).',
41
+ scope: 'edit',
42
+ files: ['**/*.ts', '**/*.tsx'],
43
+ defaultOptions: {},
44
+ fixHint: [
45
+ 'Add explicit types: (x: string) => ... or function foo(x: number)',
46
+ '// webpieces-disable no-implicit-any -- <one-line reason>',
47
+ ],
48
+
49
+ check(ctx: EditContext): readonly Violation[] {
50
+ const violations: V[] = [];
51
+ for (let i = 0; i < ctx.strippedLines.length; i += 1) {
52
+ const stripped = ctx.strippedLines[i];
53
+ const lineNum = i + 1;
54
+ if (ctx.isLineDisabled(lineNum, 'no-implicit-any')) continue;
55
+ const offender = findOffender(stripped);
56
+ if (!offender) continue;
57
+ violations.push(new V(
58
+ lineNum,
59
+ ctx.lines[i].trim(),
60
+ `Parameter "${offender}" has no type annotation. Add an explicit type to avoid implicit-any.`,
61
+ ));
62
+ }
63
+ return violations;
64
+ },
65
+ };
66
+
67
+ export default noImplicitAnyRule;
@@ -0,0 +1,71 @@
1
+ import type { BashRule, BashContext, Violation } from '../types';
2
+ import { Violation as V } from '../types';
3
+
4
+ const FIX_HINT: readonly string[] = [
5
+ 'Shell substitutions trigger Claude Code "simple_expansion" permission prompts that interrupt the user.',
6
+ 'Instead:',
7
+ ' • Build payload files with Write, then: node script.js < /path/to/payload',
8
+ ' • Use Read, Grep, or Glob instead of piping shell output through $(...)',
9
+ ' • Write a small script file with Write and execute it: bash /path/to/script.sh',
10
+ ];
11
+
12
+ const noShellSubstitutionRule: BashRule = {
13
+ name: 'no-shell-substitution',
14
+ description: 'Reject Bash commands containing shell substitutions ($(...), backticks, $VAR).',
15
+ scope: 'bash',
16
+ files: [],
17
+ defaultOptions: {},
18
+ fixHint: FIX_HINT,
19
+
20
+ check(ctx: BashContext): readonly Violation[] {
21
+ const scanned = stripSingleQuoted(ctx.command);
22
+ const violations: Violation[] = [];
23
+
24
+ if (/\$\(/.test(scanned)) {
25
+ violations.push(new V(
26
+ 1,
27
+ truncate(ctx.command),
28
+ 'Command contains `$(...)` command substitution.',
29
+ ));
30
+ }
31
+ if (hasUnescapedBacktick(scanned)) {
32
+ violations.push(new V(
33
+ 1,
34
+ truncate(ctx.command),
35
+ 'Command contains backtick command substitution.',
36
+ ));
37
+ }
38
+ if (/\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(scanned) || hasBareVarExpansion(scanned)) {
39
+ violations.push(new V(
40
+ 1,
41
+ truncate(ctx.command),
42
+ 'Command contains `$VAR` or `${VAR}` variable expansion.',
43
+ ));
44
+ }
45
+ return violations;
46
+ },
47
+ };
48
+
49
+ function stripSingleQuoted(cmd: string): string {
50
+ return cmd.replace(/'[^']*'/g, "''");
51
+ }
52
+
53
+ function hasUnescapedBacktick(cmd: string): boolean {
54
+ for (let i = 0; i < cmd.length; i += 1) {
55
+ if (cmd[i] === '`' && (i === 0 || cmd[i - 1] !== '\\')) return true;
56
+ }
57
+ return false;
58
+ }
59
+
60
+ function hasBareVarExpansion(cmd: string): boolean {
61
+ const re = /(^|[^\\])\$([A-Za-z_][A-Za-z0-9_]*)/g;
62
+ return re.test(cmd);
63
+ }
64
+
65
+ function truncate(s: string): string {
66
+ const MAX = 120;
67
+ if (s.length <= MAX) return s;
68
+ return s.slice(0, MAX) + '…';
69
+ }
70
+
71
+ export default noShellSubstitutionRule;
@@ -0,0 +1,48 @@
1
+ import type { EditRule, EditContext, Violation } from '../types';
2
+ import { Violation as V } from '../types';
3
+ import { writeTemplateIfMissing } from '../instruct-ai-writer';
4
+
5
+ const TRY_PATTERN = /\btry\s*\{/;
6
+
7
+ // Both webpieces-disable and the existing ESLint directive suppress this rule
8
+ const DISABLE_PATTERN = /@webpieces\/no-unmanaged-exceptions|webpieces-disable\s+(?:[\w-]+,\s*)*no-unmanaged-exceptions/;
9
+
10
+ const noUnmanagedExceptionsRule: EditRule = {
11
+ name: 'no-unmanaged-exceptions',
12
+ description: 'try/catch is generally not allowed. Only allowed in chokepoints (filter, globalErrorHandler) or other rare locations.',
13
+ scope: 'edit',
14
+ files: ['**/*.ts', '**/*.tsx'],
15
+ defaultOptions: {},
16
+ fixHint: [
17
+ 'VERY IMPORTANT: READ .webpieces/instruct-ai/webpieces.exceptions.md to understand why and how to fix this!',
18
+ 'Exceptions should bubble to a chokepoint (filter in node.js, globalErrorHandler in Angular). Most code should NOT catch exceptions.',
19
+ '// webpieces-disable no-unmanaged-exceptions -- <reason>',
20
+ 'When try/catch IS used (after disabling), the catch block MUST use: catch (err: unknown) { const error = toError(err); ... } or //const error = toError(err); to explicitly ignore.',
21
+ ],
22
+
23
+ check(ctx: EditContext): readonly Violation[] {
24
+ const violations: V[] = [];
25
+ for (let i = 0; i < ctx.strippedLines.length; i += 1) {
26
+ const stripped = ctx.strippedLines[i];
27
+ if (!TRY_PATTERN.test(stripped)) continue;
28
+ const lineNum = i + 1;
29
+ if (ctx.isLineDisabled(lineNum, 'no-unmanaged-exceptions')) continue;
30
+ if (hasPrecedingDisable(ctx.lines, i)) continue;
31
+ violations.push(new V(
32
+ lineNum,
33
+ ctx.lines[i].trim(),
34
+ 'try/catch is generally not allowed. It is only allowed in chokepoints (filter, globalErrorHandler) or other rare locations.',
35
+ ));
36
+ }
37
+ if (violations.length > 0) writeTemplateIfMissing(ctx.workspaceRoot, 'webpieces.exceptions.md');
38
+ return violations;
39
+ },
40
+ };
41
+
42
+ function hasPrecedingDisable(lines: readonly string[], idx: number): boolean {
43
+ if (idx === 0) return false;
44
+ const prevLine = lines[idx - 1];
45
+ return DISABLE_PATTERN.test(prevLine);
46
+ }
47
+
48
+ export default noUnmanagedExceptionsRule;
@@ -0,0 +1,59 @@
1
+ import type { EditRule, EditContext, Violation } from '../types';
2
+ import { Violation as V } from '../types';
3
+
4
+ // Matches function/method signatures that don't have `: ReturnType` before the `{` body opener.
5
+ // Pattern: function name(<params>) { — missing `: Type` between `)` and `{`
6
+ const FUNC_DECL_MISSING = /\bfunction\s+\w+\s*(?:<[^>]*>)?\s*\([^)]*\)\s*\{/;
7
+
8
+ // Matches class method signatures: indented, optional async, name(<params>) {
9
+ const METHOD_MISSING = /^\s{2,}(?:async\s+)?\w+\s*(?:<[^>]*>)?\s*\([^)]*\)\s*\{/;
10
+
11
+ // Arrow function: const name = (async)? (<params>) => — missing `: Type` before `=>`
12
+ const ARROW_MISSING = /\bconst\s+\w+\s*=\s*(?:async\s+)?(?:<[^>]*>)?\s*\([^)]*\)\s*=>/;
13
+
14
+ // Lines that have ): ReturnType before { or => — these are COMPLIANT
15
+ const HAS_RETURN_TYPE = /\)\s*:\s*\S/;
16
+
17
+ // Skip constructors, getters, setters, and control flow keywords
18
+ const SKIP_PATTERN = /\b(?:constructor|get\s+\w+|set\s+\w+|if|else|while|for|switch|catch|return)\s*\(/;
19
+
20
+ const requireReturnTypeRule: EditRule = {
21
+ name: 'require-return-type',
22
+ description: 'Every function and method must declare its return type.',
23
+ scope: 'edit',
24
+ files: ['**/*.ts', '**/*.tsx'],
25
+ defaultOptions: {},
26
+ fixHint: [
27
+ 'Add return type: function foo(x: T): ReturnType { ... }',
28
+ '// webpieces-disable require-return-type -- <reason>',
29
+ ],
30
+
31
+ check(ctx: EditContext): readonly Violation[] {
32
+ const violations: V[] = [];
33
+ for (let i = 0; i < ctx.strippedLines.length; i += 1) {
34
+ const stripped = ctx.strippedLines[i];
35
+ if (!isMissingReturnType(stripped)) continue;
36
+ const lineNum = i + 1;
37
+ if (ctx.isLineDisabled(lineNum, 'require-return-type')) continue;
38
+ violations.push(new V(
39
+ lineNum,
40
+ ctx.lines[i].trim(),
41
+ 'Missing return type annotation.',
42
+ ));
43
+ }
44
+ return violations;
45
+ },
46
+ };
47
+
48
+ function isMissingReturnType(line: string): boolean {
49
+ if (SKIP_PATTERN.test(line)) return false;
50
+ const isFuncLike =
51
+ FUNC_DECL_MISSING.test(line) ||
52
+ METHOD_MISSING.test(line) ||
53
+ ARROW_MISSING.test(line);
54
+ if (!isFuncLike) return false;
55
+ if (HAS_RETURN_TYPE.test(line)) return false;
56
+ return true;
57
+ }
58
+
59
+ export default requireReturnTypeRule;