@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.
- package/LICENSE +373 -0
- package/README.md +43 -0
- package/bin/setup-ai-hooks.sh +137 -0
- package/openclaw.plugin.json +15 -0
- package/package.json +37 -0
- package/src/adapters/claude-code-hook.ts +117 -0
- package/src/adapters/openclaw-plugin.ts +88 -0
- package/src/core/__tests__/disable-directives.test.ts +114 -0
- package/src/core/__tests__/rules/file-location.test.ts +90 -0
- package/src/core/__tests__/rules/max-file-lines.test.ts +53 -0
- package/src/core/__tests__/rules/no-any.test.ts +68 -0
- package/src/core/__tests__/rules/no-destructure.test.ts +50 -0
- package/src/core/__tests__/rules/no-shell-substitution.test.ts +118 -0
- package/src/core/__tests__/rules/no-unmanaged-exceptions.test.ts +54 -0
- package/src/core/__tests__/rules/require-return-type.test.ts +79 -0
- package/src/core/__tests__/runner.test.ts +288 -0
- package/src/core/__tests__/strip-ts-noise.test.ts +109 -0
- package/src/core/build-context.ts +96 -0
- package/src/core/configs/default.ts +19 -0
- package/src/core/disable-directives.ts +90 -0
- package/src/core/instruct-ai-writer.ts +15 -0
- package/src/core/load-config.ts +3 -0
- package/src/core/load-rules.ts +130 -0
- package/src/core/rejection-log.ts +163 -0
- package/src/core/report.ts +35 -0
- package/src/core/rules/catch-error-pattern.ts +124 -0
- package/src/core/rules/file-location.ts +87 -0
- package/src/core/rules/index.ts +11 -0
- package/src/core/rules/max-file-lines.ts +137 -0
- package/src/core/rules/no-any-unknown.ts +35 -0
- package/src/core/rules/no-destructure.ts +34 -0
- package/src/core/rules/no-implicit-any.ts +67 -0
- package/src/core/rules/no-shell-substitution.ts +71 -0
- package/src/core/rules/no-unmanaged-exceptions.ts +48 -0
- package/src/core/rules/require-return-type.ts +59 -0
- package/src/core/runner.ts +205 -0
- package/src/core/strip-ts-noise.ts +103 -0
- package/src/core/to-error.ts +35 -0
- package/src/core/types.ts +196 -0
- package/src/index.ts +14 -0
- package/templates/claude-settings-hook.json +15 -0
- package/templates/webpieces.ai-hooks.seed.json +16 -0
- 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;
|