@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,96 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
import { stripTsNoise } from './strip-ts-noise';
|
|
5
|
+
import { createIsLineDisabled } from './disable-directives';
|
|
6
|
+
import {
|
|
7
|
+
ToolKind, NormalizedToolInput, NormalizedEdit,
|
|
8
|
+
EditContext, FileContext, BashContext,
|
|
9
|
+
} from './types';
|
|
10
|
+
|
|
11
|
+
export class BuiltContexts {
|
|
12
|
+
readonly fileContext: FileContext;
|
|
13
|
+
readonly editContexts: readonly EditContext[];
|
|
14
|
+
|
|
15
|
+
constructor(fileContext: FileContext, editContexts: readonly EditContext[]) {
|
|
16
|
+
this.fileContext = fileContext;
|
|
17
|
+
this.editContexts = editContexts;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildContexts(
|
|
22
|
+
toolKind: ToolKind,
|
|
23
|
+
input: NormalizedToolInput,
|
|
24
|
+
workspaceRoot: string,
|
|
25
|
+
): BuiltContexts {
|
|
26
|
+
const filePath = input.filePath;
|
|
27
|
+
const relativePath = path.relative(workspaceRoot, filePath);
|
|
28
|
+
const edits = input.edits;
|
|
29
|
+
|
|
30
|
+
const currentFileLines = readCurrentFileLines(filePath);
|
|
31
|
+
let linesAdded = 0;
|
|
32
|
+
let linesRemoved = 0;
|
|
33
|
+
for (const e of edits) {
|
|
34
|
+
linesAdded += countLines(e.newString);
|
|
35
|
+
linesRemoved += countLines(e.oldString);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const projectedFileLines =
|
|
39
|
+
toolKind === 'Write'
|
|
40
|
+
? countLines(edits.length > 0 ? edits[0].newString : '')
|
|
41
|
+
: currentFileLines + linesAdded - linesRemoved;
|
|
42
|
+
|
|
43
|
+
const fileContext = new FileContext(
|
|
44
|
+
toolKind,
|
|
45
|
+
filePath,
|
|
46
|
+
relativePath,
|
|
47
|
+
workspaceRoot,
|
|
48
|
+
currentFileLines,
|
|
49
|
+
linesAdded,
|
|
50
|
+
linesRemoved,
|
|
51
|
+
projectedFileLines,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const editContexts = edits.map((e, idx) => {
|
|
55
|
+
const addedContent = e.newString;
|
|
56
|
+
const stripped = stripTsNoise(addedContent);
|
|
57
|
+
const isLineDisabled = createIsLineDisabled(addedContent);
|
|
58
|
+
return new EditContext(
|
|
59
|
+
toolKind,
|
|
60
|
+
idx,
|
|
61
|
+
edits.length,
|
|
62
|
+
filePath,
|
|
63
|
+
relativePath,
|
|
64
|
+
workspaceRoot,
|
|
65
|
+
addedContent,
|
|
66
|
+
stripped,
|
|
67
|
+
addedContent.split('\n'),
|
|
68
|
+
stripped.split('\n'),
|
|
69
|
+
e.oldString,
|
|
70
|
+
isLineDisabled,
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return new BuiltContexts(fileContext, editContexts);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function buildBashContext(command: string, workspaceRoot: string): BashContext {
|
|
78
|
+
return new BashContext(command, workspaceRoot);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function readCurrentFileLines(filePath: string): number {
|
|
82
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
83
|
+
try {
|
|
84
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
85
|
+
return countLines(content);
|
|
86
|
+
} catch (err: unknown) {
|
|
87
|
+
// eslint-disable-next-line @webpieces/catch-error-pattern -- file-not-found is expected for new Write targets
|
|
88
|
+
void err;
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function countLines(s: string): number {
|
|
94
|
+
if (!s) return 0;
|
|
95
|
+
return s.split('\n').length;
|
|
96
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// webpieces-disable no-any-unknown -- rule options are opaque at framework level
|
|
2
|
+
export const defaultRules: Record<string, Record<string, unknown>> = {
|
|
3
|
+
'no-any-unknown': { enabled: true },
|
|
4
|
+
'max-file-lines': { enabled: true, limit: 900 },
|
|
5
|
+
'file-location': {
|
|
6
|
+
enabled: true,
|
|
7
|
+
allowedRootFiles: ['jest.setup.ts'],
|
|
8
|
+
excludePaths: [
|
|
9
|
+
'node_modules', 'dist', '.nx', '.git',
|
|
10
|
+
'architecture', 'tmp', 'scripts',
|
|
11
|
+
],
|
|
12
|
+
},
|
|
13
|
+
'no-destructure': { enabled: true, allowTopLevel: true },
|
|
14
|
+
'require-return-type': { enabled: true },
|
|
15
|
+
'no-unmanaged-exceptions': { enabled: true },
|
|
16
|
+
'catch-error-pattern': { enabled: true },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const defaultRulesDir: readonly string[] = [];
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { IsLineDisabled } from './types';
|
|
2
|
+
|
|
3
|
+
const DIRECTIVE_RE =
|
|
4
|
+
/\/\/\s*(?:ai-hook-disable|webpieces-disable)(?:-(next|file|all))?(?:\s+([\w\-*,\s]+?))?(?:\s*--\s*(.*))?\s*$/;
|
|
5
|
+
|
|
6
|
+
export class DirectiveIndex {
|
|
7
|
+
private readonly lineDisables: Map<number, Set<string>>;
|
|
8
|
+
private readonly fileDisables: Set<string>;
|
|
9
|
+
|
|
10
|
+
constructor(lineDisables: Map<number, Set<string>>, fileDisables: Set<string>) {
|
|
11
|
+
this.lineDisables = lineDisables;
|
|
12
|
+
this.fileDisables = fileDisables;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
isLineDisabled(lineNum: number, ruleName: string): boolean {
|
|
16
|
+
if (this.fileDisables.has('*') || this.fileDisables.has(ruleName)) return true;
|
|
17
|
+
const set = this.lineDisables.get(lineNum);
|
|
18
|
+
if (!set) return false;
|
|
19
|
+
return set.has('*') || set.has(ruleName);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseRuleList(raw: string | undefined): readonly string[] {
|
|
24
|
+
if (!raw || raw.trim() === '' || raw.trim() === '*') return ['*'];
|
|
25
|
+
return raw.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function nextTargetLine(lines: readonly string[], lineIdx: number): number | null {
|
|
29
|
+
for (let j = lineIdx + 1; j < lines.length; j += 1) {
|
|
30
|
+
const trimmed = lines[j].trim();
|
|
31
|
+
if (trimmed === '') continue;
|
|
32
|
+
if (/^\/\/\s*(?:ai-hook-disable|webpieces-disable)/.test(trimmed)) continue;
|
|
33
|
+
return j + 1;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function addDisable(map: Map<number, Set<string>>, lineNum: number, rules: readonly string[]): void {
|
|
39
|
+
if (!map.has(lineNum)) map.set(lineNum, new Set<string>());
|
|
40
|
+
const set = map.get(lineNum)!;
|
|
41
|
+
for (const r of rules) set.add(r);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function resolveTarget(
|
|
45
|
+
line: string, lines: readonly string[], i: number,
|
|
46
|
+
map: Map<number, Set<string>>, rules: readonly string[],
|
|
47
|
+
): void {
|
|
48
|
+
const beforeComment = line.slice(0, line.indexOf('//')).trim();
|
|
49
|
+
if (beforeComment !== '') {
|
|
50
|
+
addDisable(map, i + 1, rules);
|
|
51
|
+
} else {
|
|
52
|
+
const target = nextTargetLine(lines, i);
|
|
53
|
+
if (target !== null) addDisable(map, target, rules);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseDirectives(source: string): DirectiveIndex {
|
|
58
|
+
const lines = source.split('\n');
|
|
59
|
+
const lineDisables = new Map<number, Set<string>>();
|
|
60
|
+
const fileDisables = new Set<string>();
|
|
61
|
+
|
|
62
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
63
|
+
const line = lines[i];
|
|
64
|
+
const match = line.match(DIRECTIVE_RE);
|
|
65
|
+
if (!match) continue;
|
|
66
|
+
const variant = match[1] || null;
|
|
67
|
+
const rules = parseRuleList(match[2] || '');
|
|
68
|
+
|
|
69
|
+
if (variant === 'file') {
|
|
70
|
+
if (i < 20) { for (const r of rules) fileDisables.add(r); }
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (variant === 'next') {
|
|
74
|
+
const target = nextTargetLine(lines, i);
|
|
75
|
+
if (target !== null) addDisable(lineDisables, target, rules);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (variant === 'all') {
|
|
79
|
+
resolveTarget(line, lines, i, lineDisables, ['*']);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
resolveTarget(line, lines, i, lineDisables, rules);
|
|
83
|
+
}
|
|
84
|
+
return new DirectiveIndex(lineDisables, fileDisables);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createIsLineDisabled(source: string): IsLineDisabled {
|
|
88
|
+
const index = parseDirectives(source);
|
|
89
|
+
return (lineNum: number, ruleName: string): boolean => index.isLineDisabled(lineNum, ruleName);
|
|
90
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
const INSTRUCT_DIR = '.webpieces/instruct-ai';
|
|
5
|
+
|
|
6
|
+
export function writeTemplateIfMissing(workspaceRoot: string, templateName: string): void {
|
|
7
|
+
const dir = path.join(workspaceRoot, INSTRUCT_DIR);
|
|
8
|
+
const filePath = path.join(dir, templateName);
|
|
9
|
+
if (fs.existsSync(filePath)) return;
|
|
10
|
+
|
|
11
|
+
const templatePath = path.join(__dirname, '..', '..', 'templates', templateName);
|
|
12
|
+
const content = fs.readFileSync(templatePath, 'utf-8');
|
|
13
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
14
|
+
fs.writeFileSync(filePath, content);
|
|
15
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
import type { Rule, ResolvedConfig } from './types';
|
|
5
|
+
import { toError } from './to-error';
|
|
6
|
+
|
|
7
|
+
const REQUIRED_FIELDS: readonly string[] = ['name', 'description', 'scope', 'files', 'check'];
|
|
8
|
+
const VALID_SCOPES = new Set(['edit', 'file', 'bash']);
|
|
9
|
+
|
|
10
|
+
export function loadRules(config: ResolvedConfig, workspaceRoot: string): readonly Rule[] {
|
|
11
|
+
const builtIns = loadBuiltInRules();
|
|
12
|
+
const custom = loadCustomRules(config.rulesDir, workspaceRoot);
|
|
13
|
+
const all = [...builtIns, ...custom];
|
|
14
|
+
return all.filter((rule) => validateRule(rule));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function loadBuiltInRules(): Rule[] {
|
|
18
|
+
const registry: readonly string[] = require('./rules/index').builtInRuleNames;
|
|
19
|
+
const modules: Rule[] = [];
|
|
20
|
+
for (const name of registry) {
|
|
21
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
22
|
+
try {
|
|
23
|
+
const mod = require(`./rules/${name}`);
|
|
24
|
+
modules.push(mod.default || mod);
|
|
25
|
+
} catch (err: unknown) {
|
|
26
|
+
const error = toError(err);
|
|
27
|
+
process.stderr.write(`[ai-hooks] failed to load built-in rule ${name}: ${error.message}\n`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return modules;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function loadCustomRules(rulesDirs: readonly string[], workspaceRoot: string): Rule[] {
|
|
34
|
+
const modules: Rule[] = [];
|
|
35
|
+
for (const dir of rulesDirs) {
|
|
36
|
+
const absDir = path.isAbsolute(dir) ? dir : path.join(workspaceRoot, dir);
|
|
37
|
+
if (!fs.existsSync(absDir)) {
|
|
38
|
+
process.stderr.write(`[ai-hooks] rulesDir not found: ${absDir}\n`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
let entries: string[];
|
|
42
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
43
|
+
try {
|
|
44
|
+
entries = fs.readdirSync(absDir).filter((e) => e.endsWith('.js'));
|
|
45
|
+
} catch (err: unknown) {
|
|
46
|
+
const error = toError(err);
|
|
47
|
+
process.stderr.write(`[ai-hooks] cannot read rulesDir ${absDir}: ${error.message}\n`);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
const full = path.join(absDir, entry);
|
|
52
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
53
|
+
try {
|
|
54
|
+
const mod = require(full);
|
|
55
|
+
modules.push(mod.default || mod);
|
|
56
|
+
} catch (err: unknown) {
|
|
57
|
+
const error = toError(err);
|
|
58
|
+
process.stderr.write(`[ai-hooks] failed to load custom rule ${full}: ${error.message}\n`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return modules;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// webpieces-disable no-any-unknown -- validates untrusted require() output at system boundary
|
|
66
|
+
function validateRule(rule: unknown): rule is Rule {
|
|
67
|
+
if (!rule || typeof rule !== 'object') {
|
|
68
|
+
process.stderr.write('[ai-hooks] rule is not an object, skipping\n');
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
// webpieces-disable no-any-unknown -- narrowing from unknown at system boundary
|
|
72
|
+
const obj = rule as Record<string, unknown>;
|
|
73
|
+
for (const field of REQUIRED_FIELDS) {
|
|
74
|
+
if (obj[field] === undefined) {
|
|
75
|
+
const name = typeof obj['name'] === 'string' ? obj['name'] : '<unnamed>';
|
|
76
|
+
process.stderr.write(`[ai-hooks] rule "${name}" missing required field: ${field}\n`);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!VALID_SCOPES.has(obj['scope'] as string)) {
|
|
81
|
+
process.stderr.write(`[ai-hooks] rule "${obj['name']}" has invalid scope: ${String(obj['scope'])}\n`);
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
if (!Array.isArray(obj['files'])) {
|
|
85
|
+
process.stderr.write(`[ai-hooks] rule "${obj['name']}" files must be an array\n`);
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
if (typeof obj['check'] !== 'function') {
|
|
89
|
+
process.stderr.write(`[ai-hooks] rule "${obj['name']}" check must be a function\n`);
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function globMatches(pattern: string, filePath: string): boolean {
|
|
96
|
+
const regex = globToRegex(pattern);
|
|
97
|
+
return regex.test(filePath);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function globToRegex(pattern: string): RegExp {
|
|
101
|
+
let re = '';
|
|
102
|
+
let i = 0;
|
|
103
|
+
while (i < pattern.length) {
|
|
104
|
+
const ch = pattern[i];
|
|
105
|
+
if (ch === '*') {
|
|
106
|
+
if (pattern[i + 1] === '*') {
|
|
107
|
+
re += '.*';
|
|
108
|
+
i += 2;
|
|
109
|
+
if (pattern[i] === '/') i += 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
re += '[^/]*';
|
|
113
|
+
i += 1;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (ch === '?') {
|
|
117
|
+
re += '[^/]';
|
|
118
|
+
i += 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if ('.+^$(){}|[]\\'.includes(ch)) {
|
|
122
|
+
re += '\\' + ch;
|
|
123
|
+
i += 1;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
re += ch;
|
|
127
|
+
i += 1;
|
|
128
|
+
}
|
|
129
|
+
return new RegExp('^' + re + '$');
|
|
130
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
import type { ToolKind, NormalizedToolInput, BlockedResult } from './types';
|
|
5
|
+
|
|
6
|
+
const HOOKS_DIR = '.webpieces/hooks';
|
|
7
|
+
const LOG_FILE = 'hook-rejection.log';
|
|
8
|
+
const LOG_FILE_PREV = 'hook-rejection.1.log';
|
|
9
|
+
const MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded
|
|
10
|
+
const MAX_AGE_DAYS = 7;
|
|
11
|
+
|
|
12
|
+
const RULE_NAME_RE = /^\[([^\]]+)\] \(/gm;
|
|
13
|
+
|
|
14
|
+
export function logRejection(
|
|
15
|
+
toolKind: ToolKind,
|
|
16
|
+
input: NormalizedToolInput,
|
|
17
|
+
result: BlockedResult,
|
|
18
|
+
cwd: string,
|
|
19
|
+
): void {
|
|
20
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
21
|
+
try {
|
|
22
|
+
const now = new Date();
|
|
23
|
+
const timestamp = now.toISOString();
|
|
24
|
+
const epochMs = String(now.getTime());
|
|
25
|
+
const dateStr = timestamp.slice(0, 10);
|
|
26
|
+
|
|
27
|
+
const hooksDir = path.join(cwd, HOOKS_DIR);
|
|
28
|
+
const dayDir = path.join(hooksDir, dateStr);
|
|
29
|
+
fs.mkdirSync(dayDir, { recursive: true });
|
|
30
|
+
|
|
31
|
+
const relativePath = computeRelativePath(input.filePath, cwd);
|
|
32
|
+
const ruleNames = extractRuleNames(result.report);
|
|
33
|
+
const detailFileName = `writeInfo-${epochMs}.md`;
|
|
34
|
+
const detailRelPath = `${dateStr}/${detailFileName}`;
|
|
35
|
+
|
|
36
|
+
const detail = buildDetailContent(timestamp, toolKind, relativePath, ruleNames, result.report, input);
|
|
37
|
+
fs.writeFileSync(path.join(dayDir, detailFileName), detail);
|
|
38
|
+
|
|
39
|
+
const logPath = path.join(hooksDir, LOG_FILE);
|
|
40
|
+
rotateLogFile(logPath, path.join(hooksDir, LOG_FILE_PREV));
|
|
41
|
+
|
|
42
|
+
const logLine = `[${timestamp}]\t${toolKind}\t${relativePath}\t[${ruleNames.join(',')}]\t${detailRelPath}\n`;
|
|
43
|
+
fs.appendFileSync(logPath, logLine);
|
|
44
|
+
|
|
45
|
+
rotateOldDays(hooksDir, MAX_AGE_DAYS);
|
|
46
|
+
} catch (err: unknown) {
|
|
47
|
+
//const error = toError(err);
|
|
48
|
+
void err;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function computeRelativePath(filePath: string, cwd: string): string {
|
|
53
|
+
if (filePath.startsWith(cwd)) {
|
|
54
|
+
const rel = filePath.slice(cwd.length);
|
|
55
|
+
if (rel.startsWith('/')) return rel.slice(1);
|
|
56
|
+
return rel;
|
|
57
|
+
}
|
|
58
|
+
return filePath;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function extractRuleNames(report: string): string[] {
|
|
62
|
+
const names: string[] = [];
|
|
63
|
+
let match = RULE_NAME_RE.exec(report);
|
|
64
|
+
while (match !== null) {
|
|
65
|
+
names.push(match[1]);
|
|
66
|
+
match = RULE_NAME_RE.exec(report);
|
|
67
|
+
}
|
|
68
|
+
RULE_NAME_RE.lastIndex = 0;
|
|
69
|
+
return names;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildDetailContent(
|
|
73
|
+
timestamp: string,
|
|
74
|
+
toolKind: ToolKind,
|
|
75
|
+
relativePath: string,
|
|
76
|
+
ruleNames: string[],
|
|
77
|
+
report: string,
|
|
78
|
+
input: NormalizedToolInput,
|
|
79
|
+
): string {
|
|
80
|
+
const lines: string[] = [];
|
|
81
|
+
lines.push('# Hook Rejection Detail');
|
|
82
|
+
lines.push('');
|
|
83
|
+
lines.push(`- **Timestamp:** ${timestamp}`);
|
|
84
|
+
lines.push(`- **Tool:** ${toolKind}`);
|
|
85
|
+
lines.push(`- **File:** ${relativePath}`);
|
|
86
|
+
lines.push(`- **Rules violated:** ${ruleNames.join(', ')}`);
|
|
87
|
+
lines.push('');
|
|
88
|
+
lines.push('## Report');
|
|
89
|
+
lines.push('');
|
|
90
|
+
lines.push('```');
|
|
91
|
+
lines.push(report.trimEnd());
|
|
92
|
+
lines.push('```');
|
|
93
|
+
lines.push('');
|
|
94
|
+
lines.push('## Content Being Written');
|
|
95
|
+
lines.push('');
|
|
96
|
+
|
|
97
|
+
if (toolKind === 'Write') {
|
|
98
|
+
const content = input.edits.length > 0 ? input.edits[0].newString : '';
|
|
99
|
+
lines.push('```typescript');
|
|
100
|
+
lines.push(content.trimEnd());
|
|
101
|
+
lines.push('```');
|
|
102
|
+
} else {
|
|
103
|
+
for (let i = 0; i < input.edits.length; i += 1) {
|
|
104
|
+
const edit = input.edits[i];
|
|
105
|
+
lines.push(`### Edit ${String(i + 1)} of ${String(input.edits.length)}`);
|
|
106
|
+
lines.push('');
|
|
107
|
+
lines.push('**old_string:**');
|
|
108
|
+
lines.push('```typescript');
|
|
109
|
+
lines.push(edit.oldString.trimEnd());
|
|
110
|
+
lines.push('```');
|
|
111
|
+
lines.push('');
|
|
112
|
+
lines.push('**new_string:**');
|
|
113
|
+
lines.push('```typescript');
|
|
114
|
+
lines.push(edit.newString.trimEnd());
|
|
115
|
+
lines.push('```');
|
|
116
|
+
lines.push('');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return lines.join('\n') + '\n';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function rotateLogFile(logPath: string, prevPath: string): void {
|
|
124
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
125
|
+
try {
|
|
126
|
+
const stat = fs.statSync(logPath);
|
|
127
|
+
if (stat.size > MAX_LOG_BYTES) {
|
|
128
|
+
if (fs.existsSync(prevPath)) fs.unlinkSync(prevPath);
|
|
129
|
+
fs.renameSync(logPath, prevPath);
|
|
130
|
+
}
|
|
131
|
+
} catch (err: unknown) {
|
|
132
|
+
//const error = toError(err);
|
|
133
|
+
void err;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function rotateOldDays(hooksDir: string, maxAgeDays: number): void {
|
|
138
|
+
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
|
|
139
|
+
let entries: string[];
|
|
140
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
141
|
+
try {
|
|
142
|
+
entries = fs.readdirSync(hooksDir);
|
|
143
|
+
} catch (err: unknown) {
|
|
144
|
+
//const error = toError(err);
|
|
145
|
+
void err;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(entry)) continue;
|
|
151
|
+
const dirDate = new Date(entry + 'T00:00:00Z');
|
|
152
|
+
if (isNaN(dirDate.getTime())) continue;
|
|
153
|
+
if (dirDate.getTime() < cutoff) {
|
|
154
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
155
|
+
try {
|
|
156
|
+
fs.rmSync(path.join(hooksDir, entry), { recursive: true, force: true });
|
|
157
|
+
} catch (err: unknown) {
|
|
158
|
+
//const error = toError(err);
|
|
159
|
+
void err;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { RuleGroup, Violation } from './types';
|
|
2
|
+
|
|
3
|
+
export function formatReport(relativePath: string, ruleGroups: readonly RuleGroup[]): string {
|
|
4
|
+
const lines: string[] = [];
|
|
5
|
+
lines.push(`\u274c webpieces ai-hooks blocked this write: ${relativePath}`);
|
|
6
|
+
lines.push('');
|
|
7
|
+
|
|
8
|
+
for (const group of ruleGroups) {
|
|
9
|
+
const count = group.violations.length;
|
|
10
|
+
const label = count === 1 ? '1 violation' : `${count} violations`;
|
|
11
|
+
lines.push(`[${group.ruleName}] (${label})`);
|
|
12
|
+
for (const v of group.violations) {
|
|
13
|
+
const editPrefix = formatEditPrefix(v);
|
|
14
|
+
lines.push(` ${editPrefix}L${String(v.line)}: ${v.snippet}`);
|
|
15
|
+
lines.push(` \u2192 ${v.message}`);
|
|
16
|
+
}
|
|
17
|
+
if (group.fixHint.length > 0) {
|
|
18
|
+
for (let i = 0; i < group.fixHint.length; i += 1) {
|
|
19
|
+
lines.push(` Fix Option ${String(i + 1)}: ${group.fixHint[i]}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
lines.push('');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
lines.push('This is a pre-write check. Fix and retry the Write/Edit.');
|
|
26
|
+
lines.push('');
|
|
27
|
+
return lines.join('\n');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatEditPrefix(v: Violation): string {
|
|
31
|
+
if (v.editIndex !== undefined && v.editCount !== undefined && v.editCount > 1) {
|
|
32
|
+
return `edit ${String(v.editIndex + 1)}/${String(v.editCount)} `;
|
|
33
|
+
}
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { EditRule, EditContext, Violation } from '../types';
|
|
2
|
+
import { Violation as V } from '../types';
|
|
3
|
+
import { writeTemplateIfMissing } from '../instruct-ai-writer';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Matches a catch clause opening: } catch (paramName: typeAnnotation) {
|
|
7
|
+
* Captures: group 1 = param name, group 2 = type annotation (if present)
|
|
8
|
+
*/
|
|
9
|
+
const CATCH_PATTERN = /\bcatch\s*\(\s*(\w+)(?:\s*:\s*(\w+))?\s*\)/;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Matches the required toError first statement (with or without comment-out).
|
|
13
|
+
* Group 1 = variable name, group 2 = param passed to toError
|
|
14
|
+
*/
|
|
15
|
+
const TO_ERROR_PATTERN = /^\s*(?:\/\/\s*)?const\s+(\w+)\s*=\s*toError\(\s*(\w+)\s*\)\s*;?\s*$/;
|
|
16
|
+
|
|
17
|
+
const catchErrorPatternRule: EditRule = {
|
|
18
|
+
name: 'catch-error-pattern',
|
|
19
|
+
description: 'Catch blocks must use: catch (err: unknown) { const error = toError(err); }',
|
|
20
|
+
scope: 'edit',
|
|
21
|
+
files: ['**/*.ts', '**/*.tsx'],
|
|
22
|
+
defaultOptions: {},
|
|
23
|
+
fixHint: [
|
|
24
|
+
'VERY IMPORTANT: READ .webpieces/instruct-ai/webpieces.exceptions.md to understand why and how to fix this!',
|
|
25
|
+
'catch (err: unknown) { const error = toError(err); ... }',
|
|
26
|
+
'Or to explicitly ignore: catch (err: unknown) { //const error = toError(err); }',
|
|
27
|
+
'For nested catches: catch (err2: unknown) { const error2 = toError(err2); }',
|
|
28
|
+
'// webpieces-disable catch-error-pattern -- <reason>',
|
|
29
|
+
],
|
|
30
|
+
|
|
31
|
+
check(ctx: EditContext): readonly Violation[] {
|
|
32
|
+
const violations: V[] = [];
|
|
33
|
+
const lines = ctx.strippedLines;
|
|
34
|
+
|
|
35
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
36
|
+
const stripped = lines[i];
|
|
37
|
+
const catchMatch = CATCH_PATTERN.exec(stripped);
|
|
38
|
+
if (!catchMatch) continue;
|
|
39
|
+
|
|
40
|
+
const lineNum = i + 1;
|
|
41
|
+
if (ctx.isLineDisabled(lineNum, 'catch-error-pattern')) continue;
|
|
42
|
+
|
|
43
|
+
const actualParam = catchMatch[1];
|
|
44
|
+
const typeAnnotation = catchMatch[2];
|
|
45
|
+
|
|
46
|
+
// Determine expected names from suffix on the actual param (err, err2, err3...)
|
|
47
|
+
const suffixMatch = actualParam.match(/^err(\d*)$/);
|
|
48
|
+
const suffix = suffixMatch ? suffixMatch[1] : '';
|
|
49
|
+
const expectedParam = 'err' + suffix;
|
|
50
|
+
const expectedVar = 'error' + suffix;
|
|
51
|
+
|
|
52
|
+
// Check parameter name
|
|
53
|
+
if (actualParam !== expectedParam) {
|
|
54
|
+
violations.push(new V(
|
|
55
|
+
lineNum,
|
|
56
|
+
ctx.lines[i].trim(),
|
|
57
|
+
`Catch parameter must be named "${expectedParam}" (or "err2", "err3" for nested catches), got "${actualParam}"`,
|
|
58
|
+
));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check type annotation is unknown
|
|
62
|
+
if (typeAnnotation !== 'unknown') {
|
|
63
|
+
const msg = typeAnnotation
|
|
64
|
+
? `Catch parameter must be typed as "unknown": catch (${expectedParam}: unknown), got "${typeAnnotation}"`
|
|
65
|
+
: `Catch parameter must be typed as "unknown": catch (${expectedParam}: unknown)`;
|
|
66
|
+
violations.push(new V(lineNum, ctx.lines[i].trim(), msg));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Find next non-blank line after the catch opening to check for toError
|
|
70
|
+
const toErrorResult = findToErrorStatement(lines, i + 1);
|
|
71
|
+
if (toErrorResult === 'not-found') {
|
|
72
|
+
violations.push(new V(
|
|
73
|
+
lineNum,
|
|
74
|
+
ctx.lines[i].trim(),
|
|
75
|
+
`Catch block must call toError(${actualParam}) as first statement: const ${expectedVar} = toError(${actualParam}); or //const ${expectedVar} = toError(${actualParam});`,
|
|
76
|
+
));
|
|
77
|
+
} else if (toErrorResult !== 'end-of-content') {
|
|
78
|
+
// Validate variable name and param match
|
|
79
|
+
if (toErrorResult.varName !== expectedVar) {
|
|
80
|
+
const toErrorLineNum = toErrorResult.lineIndex + 1;
|
|
81
|
+
violations.push(new V(
|
|
82
|
+
toErrorLineNum,
|
|
83
|
+
ctx.lines[toErrorResult.lineIndex].trim(),
|
|
84
|
+
`Error variable must be named "${expectedVar}", got "${toErrorResult.varName}"`,
|
|
85
|
+
));
|
|
86
|
+
}
|
|
87
|
+
if (toErrorResult.paramName !== actualParam) {
|
|
88
|
+
const toErrorLineNum = toErrorResult.lineIndex + 1;
|
|
89
|
+
violations.push(new V(
|
|
90
|
+
toErrorLineNum,
|
|
91
|
+
ctx.lines[toErrorResult.lineIndex].trim(),
|
|
92
|
+
`toError() must be called with "${actualParam}", got "${toErrorResult.paramName}"`,
|
|
93
|
+
));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (violations.length > 0) writeTemplateIfMissing(ctx.workspaceRoot, 'webpieces.exceptions.md');
|
|
98
|
+
return violations;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
interface ToErrorMatch {
|
|
103
|
+
varName: string;
|
|
104
|
+
paramName: string;
|
|
105
|
+
lineIndex: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function findToErrorStatement(lines: readonly string[], startIndex: number): ToErrorMatch | 'not-found' | 'end-of-content' {
|
|
109
|
+
for (let j = startIndex; j < lines.length; j += 1) {
|
|
110
|
+
const line = lines[j].trim();
|
|
111
|
+
if (line === '' || line === '{') continue;
|
|
112
|
+
|
|
113
|
+
const match = TO_ERROR_PATTERN.exec(line);
|
|
114
|
+
if (match) {
|
|
115
|
+
return { varName: match[1], paramName: match[2], lineIndex: j };
|
|
116
|
+
}
|
|
117
|
+
// First non-blank line is not a toError call
|
|
118
|
+
return 'not-found';
|
|
119
|
+
}
|
|
120
|
+
// Ran off the end of the edit content — can't validate further
|
|
121
|
+
return 'end-of-content';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export default catchErrorPatternRule;
|