@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,205 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
|
|
3
|
+
import { buildContexts, buildBashContext } from './build-context';
|
|
4
|
+
import { loadConfig } from './load-config';
|
|
5
|
+
import { loadRules, globMatches } from './load-rules';
|
|
6
|
+
import { toError } from './to-error';
|
|
7
|
+
import { formatReport } from './report';
|
|
8
|
+
import {
|
|
9
|
+
ToolKind, NormalizedToolInput, BlockedResult,
|
|
10
|
+
Rule, EditRule, FileRule, BashRule, Violation, RuleGroup,
|
|
11
|
+
EditContext, FileContext, BashContext,
|
|
12
|
+
ResolvedConfig, ResolvedRuleConfig, RuleOptions,
|
|
13
|
+
} from './types';
|
|
14
|
+
|
|
15
|
+
export function run(
|
|
16
|
+
toolKind: ToolKind,
|
|
17
|
+
input: NormalizedToolInput,
|
|
18
|
+
cwd: string,
|
|
19
|
+
): BlockedResult | null {
|
|
20
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
21
|
+
try {
|
|
22
|
+
return runInternal(toolKind, input, cwd);
|
|
23
|
+
} catch (err: unknown) {
|
|
24
|
+
const error = toError(err);
|
|
25
|
+
console.error(`[ai-hooks] runner crashed (failing open): ${error.message}`);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function runInternal(
|
|
31
|
+
toolKind: ToolKind,
|
|
32
|
+
input: NormalizedToolInput,
|
|
33
|
+
cwd: string,
|
|
34
|
+
): BlockedResult | null {
|
|
35
|
+
const config = loadConfig(cwd);
|
|
36
|
+
if (!config.configPath) return null;
|
|
37
|
+
|
|
38
|
+
const workspaceRoot = path.dirname(config.configPath);
|
|
39
|
+
const rules = loadRules(config, workspaceRoot);
|
|
40
|
+
if (rules.length === 0) return null;
|
|
41
|
+
|
|
42
|
+
const contexts = buildContexts(toolKind, input, workspaceRoot);
|
|
43
|
+
const relativePath = path.relative(workspaceRoot, input.filePath);
|
|
44
|
+
|
|
45
|
+
const editGroups = runEditRules(rules, contexts.editContexts, config);
|
|
46
|
+
const fileGroups = runFileRules(rules, contexts.fileContext, config);
|
|
47
|
+
const allGroups = [...editGroups, ...fileGroups];
|
|
48
|
+
|
|
49
|
+
if (allGroups.length === 0) return null;
|
|
50
|
+
|
|
51
|
+
const report = formatReport(relativePath, allGroups);
|
|
52
|
+
return new BlockedResult(report);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function runBash(command: string, cwd: string): BlockedResult | null {
|
|
56
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
57
|
+
try {
|
|
58
|
+
return runBashInternal(command, cwd);
|
|
59
|
+
} catch (err: unknown) {
|
|
60
|
+
const error = toError(err);
|
|
61
|
+
console.error(`[ai-hooks] bash runner crashed (failing open): ${error.message}`);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function runBashInternal(command: string, cwd: string): BlockedResult | null {
|
|
67
|
+
const config = loadConfig(cwd);
|
|
68
|
+
if (!config.configPath) return null;
|
|
69
|
+
|
|
70
|
+
const workspaceRoot = path.dirname(config.configPath);
|
|
71
|
+
const rules = loadRules(config, workspaceRoot);
|
|
72
|
+
if (rules.length === 0) return null;
|
|
73
|
+
|
|
74
|
+
const ctx = buildBashContext(command, workspaceRoot);
|
|
75
|
+
const groups = runBashRules(rules, ctx, config);
|
|
76
|
+
if (groups.length === 0) return null;
|
|
77
|
+
|
|
78
|
+
const report = formatReport('<bash>', groups);
|
|
79
|
+
return new BlockedResult(report);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function safeCheckBash(rule: BashRule, ctx: BashContext): readonly Violation[] {
|
|
83
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
84
|
+
try {
|
|
85
|
+
return rule.check(ctx);
|
|
86
|
+
} catch (err: unknown) {
|
|
87
|
+
const error = toError(err);
|
|
88
|
+
process.stderr.write(`[ai-hooks] rule ${rule.name} crashed: ${error.message}\n`);
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function runBashRules(
|
|
94
|
+
rules: readonly Rule[],
|
|
95
|
+
bashContext: BashContext,
|
|
96
|
+
config: ResolvedConfig,
|
|
97
|
+
): readonly RuleGroup[] {
|
|
98
|
+
const groups: RuleGroup[] = [];
|
|
99
|
+
for (const rule of rules) {
|
|
100
|
+
if (rule.scope !== 'bash') continue;
|
|
101
|
+
const ruleConfig = config.rules.get(rule.name);
|
|
102
|
+
if (!ruleConfig || ruleConfig.enabled === false) continue;
|
|
103
|
+
bashContext.options = mergeOptions(rule.defaultOptions, ruleConfig);
|
|
104
|
+
const vs = safeCheckBash(rule as BashRule, bashContext);
|
|
105
|
+
if (vs.length > 0) {
|
|
106
|
+
groups.push(new RuleGroup(
|
|
107
|
+
rule.name, rule.description, [...rule.fixHint], [...vs],
|
|
108
|
+
));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return groups;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function ruleMatchesFile(rule: Rule, relativePath: string): boolean {
|
|
115
|
+
for (const pattern of rule.files) {
|
|
116
|
+
if (globMatches(pattern, relativePath)) return true;
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function mergeOptions(defaultOptions: RuleOptions, ruleConfig: ResolvedRuleConfig): RuleOptions {
|
|
122
|
+
// webpieces-disable no-any-unknown -- building an options bag from opaque RuleOptions
|
|
123
|
+
const out: Record<string, unknown> = {};
|
|
124
|
+
for (const key of Object.keys(defaultOptions)) out[key] = defaultOptions[key];
|
|
125
|
+
for (const key of Object.keys(ruleConfig.options)) {
|
|
126
|
+
if (key === 'enabled') continue;
|
|
127
|
+
out[key] = ruleConfig.options[key];
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function safeCheckEdit(rule: EditRule, ctx: EditContext): readonly Violation[] {
|
|
133
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
134
|
+
try {
|
|
135
|
+
return rule.check(ctx);
|
|
136
|
+
} catch (err: unknown) {
|
|
137
|
+
const error = toError(err);
|
|
138
|
+
process.stderr.write(`[ai-hooks] rule ${rule.name} crashed: ${error.message}\n`);
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function safeCheckFile(rule: FileRule, ctx: FileContext): readonly Violation[] {
|
|
144
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
145
|
+
try {
|
|
146
|
+
return rule.check(ctx);
|
|
147
|
+
} catch (err: unknown) {
|
|
148
|
+
const error = toError(err);
|
|
149
|
+
process.stderr.write(`[ai-hooks] rule ${rule.name} crashed: ${error.message}\n`);
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function runEditRules(
|
|
155
|
+
rules: readonly Rule[],
|
|
156
|
+
editContexts: readonly EditContext[],
|
|
157
|
+
config: ResolvedConfig,
|
|
158
|
+
): readonly RuleGroup[] {
|
|
159
|
+
const groups: RuleGroup[] = [];
|
|
160
|
+
for (const rule of rules) {
|
|
161
|
+
if (rule.scope !== 'edit') continue;
|
|
162
|
+
const ruleConfig = config.rules.get(rule.name);
|
|
163
|
+
if (!ruleConfig || ruleConfig.enabled === false) continue;
|
|
164
|
+
const allViolations: Violation[] = [];
|
|
165
|
+
for (const ctx of editContexts) {
|
|
166
|
+
if (!ruleMatchesFile(rule, ctx.relativePath)) continue;
|
|
167
|
+
ctx.options = mergeOptions(rule.defaultOptions, ruleConfig);
|
|
168
|
+
const vs = safeCheckEdit(rule as EditRule, ctx);
|
|
169
|
+
for (const v of vs) {
|
|
170
|
+
const copy = new Violation(v.line, v.snippet, v.message);
|
|
171
|
+
copy.editIndex = ctx.editIndex;
|
|
172
|
+
copy.editCount = ctx.editCount;
|
|
173
|
+
allViolations.push(copy);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (allViolations.length > 0) {
|
|
177
|
+
groups.push(new RuleGroup(
|
|
178
|
+
rule.name, rule.description, [...rule.fixHint], allViolations,
|
|
179
|
+
));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return groups;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function runFileRules(
|
|
186
|
+
rules: readonly Rule[],
|
|
187
|
+
fileContext: FileContext,
|
|
188
|
+
config: ResolvedConfig,
|
|
189
|
+
): readonly RuleGroup[] {
|
|
190
|
+
const groups: RuleGroup[] = [];
|
|
191
|
+
for (const rule of rules) {
|
|
192
|
+
if (rule.scope !== 'file') continue;
|
|
193
|
+
const ruleConfig = config.rules.get(rule.name);
|
|
194
|
+
if (!ruleConfig || ruleConfig.enabled === false) continue;
|
|
195
|
+
if (!ruleMatchesFile(rule, fileContext.relativePath)) continue;
|
|
196
|
+
fileContext.options = mergeOptions(rule.defaultOptions, ruleConfig);
|
|
197
|
+
const vs = safeCheckFile(rule as FileRule, fileContext);
|
|
198
|
+
if (vs.length > 0) {
|
|
199
|
+
groups.push(new RuleGroup(
|
|
200
|
+
rule.name, rule.description, [...rule.fixHint], [...vs],
|
|
201
|
+
));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return groups;
|
|
205
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
interface StackFrame {
|
|
2
|
+
readonly state: string;
|
|
3
|
+
readonly braceDepth: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
class StripState {
|
|
7
|
+
readonly source: string;
|
|
8
|
+
readonly len: number;
|
|
9
|
+
readonly out: string[];
|
|
10
|
+
readonly stack: StackFrame[];
|
|
11
|
+
state: string;
|
|
12
|
+
braceDepth: number;
|
|
13
|
+
i: number;
|
|
14
|
+
|
|
15
|
+
constructor(source: string) {
|
|
16
|
+
this.source = source;
|
|
17
|
+
this.len = source.length;
|
|
18
|
+
this.out = new Array<string>(source.length);
|
|
19
|
+
this.stack = [];
|
|
20
|
+
this.state = 'code';
|
|
21
|
+
this.braceDepth = 0;
|
|
22
|
+
this.i = 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
ch(): string { return this.source[this.i]; }
|
|
26
|
+
next(): string { return this.i + 1 < this.len ? this.source[this.i + 1] : ''; }
|
|
27
|
+
emit(idx: number, ch: string): void { this.out[idx] = ch; }
|
|
28
|
+
blank(idx: number, ch: string): void { this.out[idx] = ch === '\n' ? '\n' : ' '; }
|
|
29
|
+
|
|
30
|
+
pushState(newState: string): void {
|
|
31
|
+
this.stack.push({ state: this.state, braceDepth: this.braceDepth });
|
|
32
|
+
this.state = newState;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
popState(): void {
|
|
36
|
+
const frame = this.stack.pop()!;
|
|
37
|
+
this.state = frame.state;
|
|
38
|
+
this.braceDepth = frame.braceDepth;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function handleCodeOrInterp(s: StripState): void {
|
|
43
|
+
const ch = s.ch();
|
|
44
|
+
const next = s.next();
|
|
45
|
+
|
|
46
|
+
if (s.state === 'templateInterp') {
|
|
47
|
+
if (ch === '{') { s.braceDepth += 1; s.emit(s.i, ch); s.i += 1; return; }
|
|
48
|
+
if (ch === '}') {
|
|
49
|
+
if (s.braceDepth === 0) { s.emit(s.i, ch); s.i += 1; s.popState(); return; }
|
|
50
|
+
s.braceDepth -= 1; s.emit(s.i, ch); s.i += 1; return;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (ch === '/' && next === '/') { s.emit(s.i, '/'); s.emit(s.i + 1, '/'); s.i += 2; s.pushState('lineComment'); return; }
|
|
54
|
+
if (ch === '/' && next === '*') { s.emit(s.i, '/'); s.emit(s.i + 1, '*'); s.i += 2; s.pushState('blockComment'); return; }
|
|
55
|
+
if (ch === '"') { s.emit(s.i, '"'); s.i += 1; s.pushState('dquote'); return; }
|
|
56
|
+
if (ch === "'") { s.emit(s.i, "'"); s.i += 1; s.pushState('squote'); return; }
|
|
57
|
+
if (ch === '`') { s.emit(s.i, '`'); s.i += 1; s.pushState('template'); return; }
|
|
58
|
+
s.emit(s.i, ch); s.i += 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function handleLineComment(s: StripState): void {
|
|
62
|
+
if (s.ch() === '\n') { s.emit(s.i, '\n'); s.i += 1; s.popState(); return; }
|
|
63
|
+
s.blank(s.i, s.ch()); s.i += 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function handleBlockComment(s: StripState): void {
|
|
67
|
+
if (s.ch() === '*' && s.next() === '/') { s.emit(s.i, '*'); s.emit(s.i + 1, '/'); s.i += 2; s.popState(); return; }
|
|
68
|
+
s.blank(s.i, s.ch()); s.i += 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function handleStringLiteral(s: StripState, quoteChar: string): void {
|
|
72
|
+
const ch = s.ch();
|
|
73
|
+
if (ch === '\\' && s.i + 1 < s.len) { s.blank(s.i, ch); s.blank(s.i + 1, s.source[s.i + 1]); s.i += 2; return; }
|
|
74
|
+
if (ch === quoteChar) { s.emit(s.i, quoteChar); s.i += 1; s.popState(); return; }
|
|
75
|
+
s.blank(s.i, ch); s.i += 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function handleTemplate(s: StripState): void {
|
|
79
|
+
const ch = s.ch();
|
|
80
|
+
if (ch === '\\' && s.i + 1 < s.len) { s.blank(s.i, ch); s.blank(s.i + 1, s.source[s.i + 1]); s.i += 2; return; }
|
|
81
|
+
if (ch === '`') { s.emit(s.i, '`'); s.i += 1; s.popState(); return; }
|
|
82
|
+
if (ch === '$' && s.next() === '{') {
|
|
83
|
+
s.emit(s.i, '$'); s.emit(s.i + 1, '{'); s.i += 2;
|
|
84
|
+
s.stack.push({ state: 'template', braceDepth: 0 });
|
|
85
|
+
s.state = 'templateInterp'; s.braceDepth = 0;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
s.blank(s.i, ch); s.i += 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function stripTsNoise(source: string): string {
|
|
92
|
+
const s = new StripState(source);
|
|
93
|
+
while (s.i < s.len) {
|
|
94
|
+
if (s.state === 'code' || s.state === 'templateInterp') { handleCodeOrInterp(s); }
|
|
95
|
+
else if (s.state === 'lineComment') { handleLineComment(s); }
|
|
96
|
+
else if (s.state === 'blockComment') { handleBlockComment(s); }
|
|
97
|
+
else if (s.state === 'dquote') { handleStringLiteral(s, '"'); }
|
|
98
|
+
else if (s.state === 'squote') { handleStringLiteral(s, "'"); }
|
|
99
|
+
else if (s.state === 'template') { handleTemplate(s); }
|
|
100
|
+
else { s.emit(s.i, s.ch()); s.i += 1; }
|
|
101
|
+
}
|
|
102
|
+
return s.out.join('');
|
|
103
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// ai-hook-disable-file require-return-type -- toError is a utility copied from dev-config; function return type is on line 6
|
|
2
|
+
/**
|
|
3
|
+
* Lightweight duplicate of @webpieces/core-util toError.
|
|
4
|
+
* ai-hooks is a standalone package and cannot depend on core-util or dev-config.
|
|
5
|
+
*/
|
|
6
|
+
// webpieces-disable no-any-unknown -- toError intentionally accepts unknown to safely convert any thrown value to Error
|
|
7
|
+
export function toError(err: unknown): Error {
|
|
8
|
+
if (err instanceof Error) {
|
|
9
|
+
return err;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (err && typeof err === 'object') {
|
|
13
|
+
if ('message' in err) {
|
|
14
|
+
const error = new Error(String(err.message));
|
|
15
|
+
if ('stack' in err && typeof err.stack === 'string') {
|
|
16
|
+
error.stack = err.stack;
|
|
17
|
+
}
|
|
18
|
+
if ('name' in err && typeof err.name === 'string') {
|
|
19
|
+
error.name = err.name;
|
|
20
|
+
}
|
|
21
|
+
return error;
|
|
22
|
+
}
|
|
23
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
24
|
+
try {
|
|
25
|
+
return new Error(`Non-Error object thrown: ${JSON.stringify(err)}`);
|
|
26
|
+
} catch (err: unknown) {
|
|
27
|
+
//const error = toError(err);
|
|
28
|
+
void err;
|
|
29
|
+
return new Error('Non-Error object thrown (unable to stringify)');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const message = err == null ? 'Null or undefined thrown' : String(err);
|
|
34
|
+
return new Error(message);
|
|
35
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// ResolvedConfig / ResolvedRuleConfig / RuleOptions now live in @webpieces/rules-config
|
|
2
|
+
// so ai-hooks and the Nx validate-code executor share one loader and one config file.
|
|
3
|
+
import { RuleOptions } from '@webpieces/rules-config';
|
|
4
|
+
export { ResolvedConfig, ResolvedRuleConfig, RuleOptions } from '@webpieces/rules-config';
|
|
5
|
+
|
|
6
|
+
export type ToolKind = 'Write' | 'Edit' | 'MultiEdit';
|
|
7
|
+
export type RuleScope = 'edit' | 'file' | 'bash';
|
|
8
|
+
export type IsLineDisabled = (lineNum: number, ruleName: string) => boolean;
|
|
9
|
+
|
|
10
|
+
export class Violation {
|
|
11
|
+
readonly line: number;
|
|
12
|
+
readonly snippet: string;
|
|
13
|
+
readonly message: string;
|
|
14
|
+
editIndex: number | undefined;
|
|
15
|
+
editCount: number | undefined;
|
|
16
|
+
|
|
17
|
+
constructor(line: number, snippet: string, message: string) {
|
|
18
|
+
this.line = line;
|
|
19
|
+
this.snippet = snippet;
|
|
20
|
+
this.message = message;
|
|
21
|
+
this.editIndex = undefined;
|
|
22
|
+
this.editCount = undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class NormalizedEdit {
|
|
27
|
+
readonly oldString: string;
|
|
28
|
+
readonly newString: string;
|
|
29
|
+
|
|
30
|
+
constructor(oldString: string, newString: string) {
|
|
31
|
+
this.oldString = oldString;
|
|
32
|
+
this.newString = newString;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class NormalizedToolInput {
|
|
37
|
+
readonly filePath: string;
|
|
38
|
+
readonly edits: readonly NormalizedEdit[];
|
|
39
|
+
|
|
40
|
+
constructor(filePath: string, edits: readonly NormalizedEdit[]) {
|
|
41
|
+
this.filePath = filePath;
|
|
42
|
+
this.edits = edits;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class NormalizedBashInput {
|
|
47
|
+
readonly command: string;
|
|
48
|
+
|
|
49
|
+
constructor(command: string) {
|
|
50
|
+
this.command = command;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class EditContext {
|
|
55
|
+
readonly tool: ToolKind;
|
|
56
|
+
readonly editIndex: number;
|
|
57
|
+
readonly editCount: number;
|
|
58
|
+
readonly filePath: string;
|
|
59
|
+
readonly relativePath: string;
|
|
60
|
+
readonly workspaceRoot: string;
|
|
61
|
+
readonly addedContent: string;
|
|
62
|
+
readonly strippedContent: string;
|
|
63
|
+
readonly lines: readonly string[];
|
|
64
|
+
readonly strippedLines: readonly string[];
|
|
65
|
+
readonly removedContent: string;
|
|
66
|
+
readonly isLineDisabled: IsLineDisabled;
|
|
67
|
+
options: RuleOptions;
|
|
68
|
+
|
|
69
|
+
constructor(
|
|
70
|
+
tool: ToolKind,
|
|
71
|
+
editIndex: number,
|
|
72
|
+
editCount: number,
|
|
73
|
+
filePath: string,
|
|
74
|
+
relativePath: string,
|
|
75
|
+
workspaceRoot: string,
|
|
76
|
+
addedContent: string,
|
|
77
|
+
strippedContent: string,
|
|
78
|
+
lines: readonly string[],
|
|
79
|
+
strippedLines: readonly string[],
|
|
80
|
+
removedContent: string,
|
|
81
|
+
isLineDisabled: IsLineDisabled,
|
|
82
|
+
) {
|
|
83
|
+
this.tool = tool;
|
|
84
|
+
this.editIndex = editIndex;
|
|
85
|
+
this.editCount = editCount;
|
|
86
|
+
this.filePath = filePath;
|
|
87
|
+
this.relativePath = relativePath;
|
|
88
|
+
this.workspaceRoot = workspaceRoot;
|
|
89
|
+
this.addedContent = addedContent;
|
|
90
|
+
this.strippedContent = strippedContent;
|
|
91
|
+
this.lines = lines;
|
|
92
|
+
this.strippedLines = strippedLines;
|
|
93
|
+
this.removedContent = removedContent;
|
|
94
|
+
this.isLineDisabled = isLineDisabled;
|
|
95
|
+
this.options = {};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class BashContext {
|
|
100
|
+
readonly tool: 'Bash';
|
|
101
|
+
readonly command: string;
|
|
102
|
+
readonly workspaceRoot: string;
|
|
103
|
+
options: RuleOptions;
|
|
104
|
+
|
|
105
|
+
constructor(command: string, workspaceRoot: string) {
|
|
106
|
+
this.tool = 'Bash';
|
|
107
|
+
this.command = command;
|
|
108
|
+
this.workspaceRoot = workspaceRoot;
|
|
109
|
+
this.options = {};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class FileContext {
|
|
114
|
+
readonly tool: ToolKind;
|
|
115
|
+
readonly filePath: string;
|
|
116
|
+
readonly relativePath: string;
|
|
117
|
+
readonly workspaceRoot: string;
|
|
118
|
+
readonly currentFileLines: number;
|
|
119
|
+
readonly linesAdded: number;
|
|
120
|
+
readonly linesRemoved: number;
|
|
121
|
+
readonly projectedFileLines: number;
|
|
122
|
+
options: RuleOptions;
|
|
123
|
+
|
|
124
|
+
constructor(
|
|
125
|
+
tool: ToolKind,
|
|
126
|
+
filePath: string,
|
|
127
|
+
relativePath: string,
|
|
128
|
+
workspaceRoot: string,
|
|
129
|
+
currentFileLines: number,
|
|
130
|
+
linesAdded: number,
|
|
131
|
+
linesRemoved: number,
|
|
132
|
+
projectedFileLines: number,
|
|
133
|
+
) {
|
|
134
|
+
this.tool = tool;
|
|
135
|
+
this.filePath = filePath;
|
|
136
|
+
this.relativePath = relativePath;
|
|
137
|
+
this.workspaceRoot = workspaceRoot;
|
|
138
|
+
this.currentFileLines = currentFileLines;
|
|
139
|
+
this.linesAdded = linesAdded;
|
|
140
|
+
this.linesRemoved = linesRemoved;
|
|
141
|
+
this.projectedFileLines = projectedFileLines;
|
|
142
|
+
this.options = {};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface RuleBase {
|
|
147
|
+
readonly name: string;
|
|
148
|
+
readonly description: string;
|
|
149
|
+
readonly files: readonly string[];
|
|
150
|
+
readonly defaultOptions: RuleOptions;
|
|
151
|
+
readonly fixHint: readonly string[];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface EditRule extends RuleBase {
|
|
155
|
+
readonly scope: 'edit';
|
|
156
|
+
check(ctx: EditContext): readonly Violation[];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface FileRule extends RuleBase {
|
|
160
|
+
readonly scope: 'file';
|
|
161
|
+
check(ctx: FileContext): readonly Violation[];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface BashRule extends RuleBase {
|
|
165
|
+
readonly scope: 'bash';
|
|
166
|
+
check(ctx: BashContext): readonly Violation[];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export type Rule = EditRule | FileRule | BashRule;
|
|
170
|
+
|
|
171
|
+
export class RuleGroup {
|
|
172
|
+
readonly ruleName: string;
|
|
173
|
+
readonly ruleDescription: string;
|
|
174
|
+
readonly fixHint: readonly string[];
|
|
175
|
+
readonly violations: readonly Violation[];
|
|
176
|
+
|
|
177
|
+
constructor(
|
|
178
|
+
ruleName: string,
|
|
179
|
+
ruleDescription: string,
|
|
180
|
+
fixHint: readonly string[],
|
|
181
|
+
violations: readonly Violation[],
|
|
182
|
+
) {
|
|
183
|
+
this.ruleName = ruleName;
|
|
184
|
+
this.ruleDescription = ruleDescription;
|
|
185
|
+
this.fixHint = fixHint;
|
|
186
|
+
this.violations = violations;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export class BlockedResult {
|
|
191
|
+
readonly report: string;
|
|
192
|
+
|
|
193
|
+
constructor(report: string) {
|
|
194
|
+
this.report = report;
|
|
195
|
+
}
|
|
196
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Pluggable write-time validation framework for AI coding agents
|
|
2
|
+
export {
|
|
3
|
+
ToolKind, RuleScope, RuleOptions, IsLineDisabled,
|
|
4
|
+
Violation, NormalizedEdit, NormalizedToolInput,
|
|
5
|
+
EditContext, FileContext,
|
|
6
|
+
Rule, EditRule, FileRule,
|
|
7
|
+
RuleGroup, BlockedResult,
|
|
8
|
+
ResolvedConfig, ResolvedRuleConfig,
|
|
9
|
+
} from './core/types';
|
|
10
|
+
|
|
11
|
+
export { run } from './core/runner';
|
|
12
|
+
export { stripTsNoise } from './core/strip-ts-noise';
|
|
13
|
+
export { parseDirectives, DirectiveIndex, createIsLineDisabled } from './core/disable-directives';
|
|
14
|
+
export { formatReport } from './core/report';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@webpieces/ai-hook-rules/default",
|
|
3
|
+
"rules": {
|
|
4
|
+
"no-any": { "enabled": true },
|
|
5
|
+
"max-file-lines": { "enabled": true, "limit": 900 },
|
|
6
|
+
"file-location": {
|
|
7
|
+
"enabled": true,
|
|
8
|
+
"allowedRootFiles": ["jest.setup.ts"],
|
|
9
|
+
"excludePaths": ["scripts", "tmp", "architecture"]
|
|
10
|
+
},
|
|
11
|
+
"no-destructure": { "enabled": true },
|
|
12
|
+
"require-return-type": { "enabled": true },
|
|
13
|
+
"no-unmanaged-exceptions": { "enabled": true }
|
|
14
|
+
},
|
|
15
|
+
"rulesDir": []
|
|
16
|
+
}
|