@jacob-z/oxlint-gate 1.0.0
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/README.md +127 -0
- package/package.json +29 -0
- package/src/index.test.ts +39 -0
- package/src/index.ts +311 -0
- package/src/omp-types.ts +129 -0
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# oxlint-gate
|
|
2
|
+
|
|
3
|
+
Real-time type assertion gate for [oh-my-pi](https://github.com/JacobZyy/oh-my-pi).
|
|
4
|
+
|
|
5
|
+
Intercepts Edit/Write tool calls and checks the target file with oxlint before allowing the edit to proceed. Blocks if type laziness assertions (e.g., `as any`, `as unknown as X`) are detected.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Real-time blocking**: Checks files before they're saved, not after
|
|
10
|
+
- **oxlint integration**: Uses the same rules as your CLI workflow
|
|
11
|
+
- **Configurable**: Reads ignore patterns from `~/.config/oxlint/oxlintrc.json`
|
|
12
|
+
- **Fail-open**: If oxlint is not installed or crashes, edits are allowed (won't block your workflow)
|
|
13
|
+
- **Local logs**: Writes detailed logs to `~/.omp/logs/oxlint-gate.log` for debugging
|
|
14
|
+
|
|
15
|
+
## Prerequisites
|
|
16
|
+
|
|
17
|
+
1. **oxlint** installed globally:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install -g oxlint
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
2. **oxlint config** at `~/.config/oxlint/oxlintrc.json`:
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"rules": {
|
|
27
|
+
"typescript/no-explicit-any": "error",
|
|
28
|
+
"typescript/no-unnecessary-type-assertion": "error"
|
|
29
|
+
},
|
|
30
|
+
"ignorePatterns": ["*.test.ts", "*.config.ts"]
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
### Via OMP marketplace
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
omp plugin install @jacob-z/oxlint-gate
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Manual installation
|
|
43
|
+
|
|
44
|
+
1. Clone this repository
|
|
45
|
+
2. Link the package:
|
|
46
|
+
```bash
|
|
47
|
+
cd packages/oxlint-gate
|
|
48
|
+
bun link
|
|
49
|
+
```
|
|
50
|
+
3. Add to your OMP config (`~/.omp/agent/config.yml`):
|
|
51
|
+
```yaml
|
|
52
|
+
extensions:
|
|
53
|
+
- /path/to/jacob-z/packages/oxlint-gate/src/index.ts
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## How it works
|
|
57
|
+
|
|
58
|
+
1. When you use Edit/Write tools in OMP, the extension intercepts the tool call
|
|
59
|
+
2. It extracts the target file path from the tool input
|
|
60
|
+
3. If the file is a TypeScript/Vue file, it runs oxlint with your config
|
|
61
|
+
4. If type assertion violations are found, the edit is blocked with a detailed error message
|
|
62
|
+
5. The error message includes the violations and suggests how to fix them
|
|
63
|
+
|
|
64
|
+
## Configuration
|
|
65
|
+
|
|
66
|
+
The extension reads from `~/.config/oxlint/oxlintrc.json`:
|
|
67
|
+
|
|
68
|
+
- `rules`: oxlint rules to check
|
|
69
|
+
- `ignorePatterns`: glob patterns for files to skip (e.g., `*.test.ts`, `*.config.ts`)
|
|
70
|
+
|
|
71
|
+
## Logs
|
|
72
|
+
|
|
73
|
+
Logs are written to `~/.omp/logs/oxlint-gate.log`.
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# View logs in real-time
|
|
77
|
+
tail -f ~/.omp/logs/oxlint-gate.log
|
|
78
|
+
|
|
79
|
+
# Search for blocked edits
|
|
80
|
+
grep "BLOCKED" ~/.omp/logs/oxlint-gate.log
|
|
81
|
+
|
|
82
|
+
# View today's checks
|
|
83
|
+
grep "$(date +%Y-%m-%d)" ~/.omp/logs/oxlint-gate.log
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Log format:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
[2026-05-30T10:15:30.123Z] [INFO] extension loaded
|
|
90
|
+
[2026-05-30T10:15:35.456Z] [INFO] checking: /path/to/file.ts
|
|
91
|
+
[2026-05-30T10:15:35.789Z] [WARN] BLOCKED: /path/to/file.ts
|
|
92
|
+
Found 2 errors.
|
|
93
|
+
...
|
|
94
|
+
[2026-05-30T10:16:00.123Z] [INFO] passed: /path/to/other.ts
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Error message format
|
|
98
|
+
|
|
99
|
+
When violations are found, you'll see:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
❌ [oxlint-gate] 检测到类型偷懒断言 — Found 2 errors.
|
|
103
|
+
|
|
104
|
+
/path/to/file.ts
|
|
105
|
+
10:5 error Unexpected any, use a specific type @typescript-eslint/no-explicit-any
|
|
106
|
+
15:10 error Unnecessary type assertion @typescript-eslint/no-unnecessary-type-assertion
|
|
107
|
+
|
|
108
|
+
按 ts-type-discipline 协议处理:
|
|
109
|
+
1) 优先用泛型 / 条件类型 / 类型守卫消除断言,禁止 as any / as unknown as X
|
|
110
|
+
2) 类型体操无效 → 追溯并修复底层类型声明(接口/DTO/类型定义)
|
|
111
|
+
3) 若是后端接口少返回字段 → 用 AskUserQuestion 与用户确认方案
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Differences from Claude Code hook
|
|
115
|
+
|
|
116
|
+
This is an OMP extension ported from the Claude Code hook in [jacob-skills-collection](https://github.com/JacobZyy/jacob-skills-collection).
|
|
117
|
+
|
|
118
|
+
| Feature | Claude Code hook | OMP extension |
|
|
119
|
+
| ----------- | ---------------------- | --------------------------- |
|
|
120
|
+
| Timing | End of session | Real-time (before save) |
|
|
121
|
+
| Blocking | Blocks session | Blocks tool call |
|
|
122
|
+
| Transcript | Reads JSONL transcript | Intercepts tool_call events |
|
|
123
|
+
| Performance | Batch check at end | Single file check per edit |
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jacob-z/oxlint-gate",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Real-time type assertion gate using oxlint — blocks `as any` and other type laziness before files are saved",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": { "name": "Jacob" },
|
|
8
|
+
"repository": { "type": "git", "url": "https://github.com/JacobZyy/jacob-omp-collections", "directory": "packages/oxlint-gate" },
|
|
9
|
+
"omp": {
|
|
10
|
+
"extensions": ["./src/index.ts"]
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./src/index.ts"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"main": "./src/index.ts",
|
|
19
|
+
"files": ["src"],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc --build",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"test:watch": "vitest",
|
|
24
|
+
"typecheck": "tsc --noEmit"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^25.9.1"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { extractFilePath } from './index'
|
|
3
|
+
|
|
4
|
+
describe('extractFilePath', () => {
|
|
5
|
+
it('should extract path from direct path field', () => {
|
|
6
|
+
const input = { path: '/foo/bar.ts' }
|
|
7
|
+
expect(extractFilePath(input)).toBe('/foo/bar.ts')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('should extract path from hashline input', () => {
|
|
11
|
+
const input = { input: '¶/foo/bar.ts#abc123\nreplace 1..1:\n+new line' }
|
|
12
|
+
expect(extractFilePath(input)).toBe('/foo/bar.ts')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('should extract path from apply-patch input', () => {
|
|
16
|
+
const input = { input: '*** Add File: /foo/bar.ts\n+content' }
|
|
17
|
+
expect(extractFilePath(input)).toBe('/foo/bar.ts')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('should return undefined for missing path', () => {
|
|
21
|
+
const input = { content: 'some content' }
|
|
22
|
+
expect(extractFilePath(input)).toBeUndefined()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('should return undefined for empty path', () => {
|
|
26
|
+
const input = { path: '' }
|
|
27
|
+
expect(extractFilePath(input)).toBeUndefined()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should handle Update File in apply-patch', () => {
|
|
31
|
+
const input = { input: '*** Update File: /foo/bar.ts\n-old\n+new' }
|
|
32
|
+
expect(extractFilePath(input)).toBe('/foo/bar.ts')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('should handle Delete File in apply-patch', () => {
|
|
36
|
+
const input = { input: '*** Delete File: /foo/bar.ts' }
|
|
37
|
+
expect(extractFilePath(input)).toBe('/foo/bar.ts')
|
|
38
|
+
})
|
|
39
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* oxlint-gate: Real-time type assertion gate for OMP.
|
|
3
|
+
*
|
|
4
|
+
* Intercepts Edit/Write tool calls and checks the target file with oxlint
|
|
5
|
+
* before allowing the edit to proceed. Blocks if type laziness assertions
|
|
6
|
+
* (e.g., `as any`, `as unknown as X`) are detected.
|
|
7
|
+
*
|
|
8
|
+
* Configuration: reads rules from `~/.config/oxlint/oxlintrc.json`
|
|
9
|
+
* Logs: writes to `~/.omp/logs/oxlint-gate.log`
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI, ExtensionFactory } from './omp-types'
|
|
13
|
+
import { spawnSync } from 'node:child_process'
|
|
14
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
import { isAbsolute, join, relative, resolve } from 'node:path'
|
|
17
|
+
import process from 'node:process'
|
|
18
|
+
|
|
19
|
+
const TS_EXTENSIONS = /\.(?:ts|tsx|mts|cts|vue)$/
|
|
20
|
+
const OXLINT_CFG = join(homedir(), '.config', 'oxlint', 'oxlintrc.json')
|
|
21
|
+
const LOG_DIR = join(homedir(), '.omp', 'logs')
|
|
22
|
+
const LOG_FILE = join(LOG_DIR, 'oxlint-gate.log')
|
|
23
|
+
const HOME = homedir()
|
|
24
|
+
|
|
25
|
+
// Tools that modify files
|
|
26
|
+
const WRITE_TOOLS = new Set(['edit', 'write'])
|
|
27
|
+
|
|
28
|
+
/** Max auto-fix attempts per file per turn. */
|
|
29
|
+
const MAX_FIX_ATTEMPTS = 3
|
|
30
|
+
|
|
31
|
+
/** Max lines of oxlint output to keep. */
|
|
32
|
+
const MAX_OUTPUT_LINES = 20
|
|
33
|
+
|
|
34
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
interface OxlintConfig {
|
|
37
|
+
ignorePatterns?: string[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Local Logger ───────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
function ensureLogDir(): void {
|
|
43
|
+
if (!existsSync(LOG_DIR)) {
|
|
44
|
+
mkdirSync(LOG_DIR, { recursive: true })
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeLog(level: 'INFO' | 'WARN' | 'DEBUG', msg: string): void {
|
|
49
|
+
try {
|
|
50
|
+
ensureLogDir()
|
|
51
|
+
const ts = new Date().toISOString()
|
|
52
|
+
appendFileSync(LOG_FILE, `[${ts}] [${level}] ${msg}\n`)
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Silently ignore log write failures
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Expand ~ to home directory
|
|
63
|
+
*/
|
|
64
|
+
function expandTilde(p: string): string {
|
|
65
|
+
if (p === '~' || p.startsWith('~/')) {
|
|
66
|
+
return join(HOME, p.slice(1))
|
|
67
|
+
}
|
|
68
|
+
return p
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function extractFilePath(input: Record<string, unknown>): string | undefined {
|
|
72
|
+
// Direct `path` field (replace/patch modes of edit, and write tool)
|
|
73
|
+
const directPath = input.path
|
|
74
|
+
if (typeof directPath === 'string' && directPath)
|
|
75
|
+
return directPath
|
|
76
|
+
|
|
77
|
+
// Hashline / apply-patch modes: `input` is a raw string containing the path
|
|
78
|
+
const rawInput = input.input
|
|
79
|
+
if (typeof rawInput !== 'string' || !rawInput)
|
|
80
|
+
return undefined
|
|
81
|
+
|
|
82
|
+
// Hashline: ¶path#hash or §path#hash or @path#hash
|
|
83
|
+
const hashlineMatch = /^[¶§@]([^\s#]+)/m.exec(rawInput)
|
|
84
|
+
if (hashlineMatch?.[1])
|
|
85
|
+
return hashlineMatch[1]
|
|
86
|
+
|
|
87
|
+
// Apply-patch: *** Add/Update/Delete File: path
|
|
88
|
+
const applyPatchMatch = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)/m.exec(rawInput)
|
|
89
|
+
if (applyPatchMatch?.[1])
|
|
90
|
+
return applyPatchMatch[1].trim()
|
|
91
|
+
|
|
92
|
+
return undefined
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isExistingFile(p: string): boolean {
|
|
96
|
+
try {
|
|
97
|
+
return statSync(p).isFile()
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function loadIgnorePatterns(cfgPath: string): string[] {
|
|
105
|
+
try {
|
|
106
|
+
const raw = readFileSync(cfgPath, 'utf8')
|
|
107
|
+
const cfg = JSON.parse(raw) as OxlintConfig
|
|
108
|
+
if (!Array.isArray(cfg.ignorePatterns))
|
|
109
|
+
return []
|
|
110
|
+
return cfg.ignorePatterns.filter((p): p is string => typeof p === 'string')
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return []
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function matchesIgnorePattern(filePath: string, patterns: string[]): boolean {
|
|
118
|
+
if (patterns.length === 0)
|
|
119
|
+
return false
|
|
120
|
+
|
|
121
|
+
const rel = relative(process.cwd(), filePath)
|
|
122
|
+
const candidates = [filePath, rel, `./${rel}`]
|
|
123
|
+
|
|
124
|
+
// Simple glob matching without Bun.Glob (for Node.js compatibility)
|
|
125
|
+
for (const pattern of patterns) {
|
|
126
|
+
const regex = globToRegex(pattern)
|
|
127
|
+
if (regex) {
|
|
128
|
+
for (const c of candidates) {
|
|
129
|
+
if (regex.test(c))
|
|
130
|
+
return true
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function globToRegex(glob: string): RegExp | null {
|
|
138
|
+
try {
|
|
139
|
+
// Convert glob to regex
|
|
140
|
+
const regexStr = glob
|
|
141
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars except * and ?
|
|
142
|
+
.replace(/\*\*/g, '{{DOUBLE_STAR}}') // Temporarily replace **
|
|
143
|
+
.replace(/\*/g, '[^/]*') // * matches anything except /
|
|
144
|
+
.replace(/\?/g, '[^/]') // ? matches single char except /
|
|
145
|
+
.replace(/\{\{DOUBLE_STAR\}\}/g, '.*') // ** matches everything
|
|
146
|
+
|
|
147
|
+
return new RegExp(`^${regexStr}$`)
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function runOxlint(filePath: string, cfgPath: string): { passed: boolean, output: string } {
|
|
155
|
+
const result = spawnSync('oxlint', ['-c', cfgPath, filePath], {
|
|
156
|
+
encoding: 'utf8',
|
|
157
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
158
|
+
timeout: 5000, // 5 second timeout
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
if (result.error) {
|
|
162
|
+
// oxlint not found or spawn error — treat as pass (fail-open)
|
|
163
|
+
return { passed: true, output: `oxlint error: ${result.error.message}` }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const exitCode = result.status ?? -1
|
|
167
|
+
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim()
|
|
168
|
+
|
|
169
|
+
// exit 0 = pass, exit 1 = violations found, other = tool error (fail-open)
|
|
170
|
+
return { passed: exitCode !== 1, output }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function runOxlintFix(filePath: string, cfgPath: string): { fixed: boolean, remaining: number, output: string } {
|
|
174
|
+
const result = spawnSync('oxlint', ['--fix', '-c', cfgPath, filePath], {
|
|
175
|
+
encoding: 'utf8',
|
|
176
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
177
|
+
timeout: 10000,
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
if (result.error) {
|
|
181
|
+
return { fixed: false, remaining: -1, output: `oxlint error: ${result.error.message}` }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const exitCode = result.status ?? -1
|
|
185
|
+
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim()
|
|
186
|
+
|
|
187
|
+
// exit 0 = all fixed, exit 1 = remaining violations
|
|
188
|
+
return { fixed: exitCode === 0, remaining: exitCode === 1 ? 1 : 0, output }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function truncateOutput(output: string, maxLines: number = MAX_OUTPUT_LINES): string {
|
|
192
|
+
const lines = output.split('\n')
|
|
193
|
+
if (lines.length <= maxLines)
|
|
194
|
+
return output
|
|
195
|
+
const head = lines.slice(0, 10).join('\n')
|
|
196
|
+
const summary = lines.slice(-5).join('\n')
|
|
197
|
+
return `${head}\n\n... (${lines.length - 15} lines truncated) ...\n\n${summary}`
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const pendingPaths = new Map<string, { toolName: string, timestamp: number }>()
|
|
201
|
+
const fixCounters = new Map<string, number>()
|
|
202
|
+
|
|
203
|
+
const oxlintGate: ExtensionFactory = (pi: ExtensionAPI): void => {
|
|
204
|
+
const log = pi.logger
|
|
205
|
+
|
|
206
|
+
log.info('[oxlint-gate] extension loaded (auto-fix mode)')
|
|
207
|
+
writeLog('INFO', 'extension loaded (auto-fix mode)')
|
|
208
|
+
|
|
209
|
+
// ── tool_call: record file path, don't block ────────────────────────
|
|
210
|
+
pi.on('tool_call', async (event, ctx) => {
|
|
211
|
+
if (!WRITE_TOOLS.has(event.toolName))
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
const extractedPath = extractFilePath(event.input as Record<string, unknown>)
|
|
215
|
+
if (!extractedPath)
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
const expandedPath = expandTilde(extractedPath)
|
|
219
|
+
const filePath = isAbsolute(expandedPath) ? expandedPath : resolve(ctx.cwd, expandedPath)
|
|
220
|
+
|
|
221
|
+
if (!TS_EXTENSIONS.test(filePath))
|
|
222
|
+
return
|
|
223
|
+
if (!isExistingFile(filePath))
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
pendingPaths.set(filePath, { toolName: event.toolName, timestamp: Date.now() })
|
|
227
|
+
return undefined
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
// ── tool_result: check & auto-fix ───────────────────────────────────
|
|
231
|
+
pi.on('tool_result', async (event, ctx) => {
|
|
232
|
+
if (!WRITE_TOOLS.has(event.toolName))
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
const extractedPath = extractFilePath(event.input as Record<string, unknown>)
|
|
236
|
+
if (!extractedPath)
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
const expandedPath = expandTilde(extractedPath)
|
|
240
|
+
const filePath = isAbsolute(expandedPath) ? expandedPath : resolve(ctx.cwd, expandedPath)
|
|
241
|
+
|
|
242
|
+
const pending = pendingPaths.get(filePath)
|
|
243
|
+
pendingPaths.delete(filePath)
|
|
244
|
+
if (!pending)
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
if (!TS_EXTENSIONS.test(filePath))
|
|
248
|
+
return
|
|
249
|
+
if (!isExistingFile(filePath))
|
|
250
|
+
return
|
|
251
|
+
if (!existsSync(OXLINT_CFG))
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
const ignorePatterns = loadIgnorePatterns(OXLINT_CFG)
|
|
255
|
+
if (matchesIgnorePattern(filePath, ignorePatterns))
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
const fixCount = fixCounters.get(filePath) ?? 0
|
|
259
|
+
if (fixCount >= MAX_FIX_ATTEMPTS) {
|
|
260
|
+
log.debug(`[oxlint-gate] max fix attempts (${MAX_FIX_ATTEMPTS}) reached for ${filePath}`)
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 1. Check for violations
|
|
265
|
+
const { passed } = runOxlint(filePath, OXLINT_CFG)
|
|
266
|
+
if (passed) {
|
|
267
|
+
log.info(`[oxlint-gate] passed: ${filePath}`)
|
|
268
|
+
writeLog('INFO', `passed: ${filePath}`)
|
|
269
|
+
fixCounters.delete(filePath) // reset counter on clean pass
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
log.warn(`[oxlint-gate] violations in ${filePath}, attempting auto-fix`)
|
|
274
|
+
writeLog('WARN', `violations in ${filePath}, attempting auto-fix`)
|
|
275
|
+
|
|
276
|
+
// 2. Try auto-fix
|
|
277
|
+
const fixResult = runOxlintFix(filePath, OXLINT_CFG)
|
|
278
|
+
fixCounters.set(filePath, fixCount + 1)
|
|
279
|
+
|
|
280
|
+
if (fixResult.fixed) {
|
|
281
|
+
log.info(`[oxlint-gate] auto-fixed: ${filePath}`)
|
|
282
|
+
writeLog('INFO', `auto-fixed: ${filePath}`)
|
|
283
|
+
return {
|
|
284
|
+
content: [{ type: 'text', text: `✅ [oxlint-gate] auto-fixed lint issues in ${filePath}` }],
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 3. Some violations remain — report to LLM
|
|
289
|
+
const remaining = truncateOutput(fixResult.output)
|
|
290
|
+
log.warn(`[oxlint-gate] partial fix in ${filePath}, remaining issues`)
|
|
291
|
+
writeLog('WARN', `partial fix in ${filePath}`)
|
|
292
|
+
|
|
293
|
+
pi.sendMessage(
|
|
294
|
+
{
|
|
295
|
+
customType: 'oxlint-gate',
|
|
296
|
+
content: `⚠️ [oxlint-gate] ${filePath} has remaining lint issues after auto-fix:\n\n${remaining}`,
|
|
297
|
+
display: true,
|
|
298
|
+
attribution: 'agent',
|
|
299
|
+
},
|
|
300
|
+
{ triggerTurn: false },
|
|
301
|
+
)
|
|
302
|
+
return undefined
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
// ── turn_end: clear pending paths only (keep fixCounters to prevent loops) ──
|
|
306
|
+
pi.on('turn_end', async () => {
|
|
307
|
+
pendingPaths.clear()
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export default oxlintGate
|
package/src/omp-types.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OMP Extension type bridge.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports the subset of extension types needed by this plugin.
|
|
5
|
+
* At runtime these resolve via OMP's package exports.
|
|
6
|
+
* For type-checking outside OMP, types are inlined below as a fallback.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ── OMP runtime types (resolved when loaded inside OMP) ────────────────
|
|
10
|
+
// The extension factory receives ExtensionAPI; we only need the event shapes
|
|
11
|
+
// and the handler signature for type-safe `pi.on()` calls.
|
|
12
|
+
|
|
13
|
+
/** Fired on initial session load */
|
|
14
|
+
export interface SessionStartEvent {
|
|
15
|
+
type: 'session_start'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Fired before a tool executes. Discriminated union by toolName.
|
|
20
|
+
* For the `edit` tool, input is Record<string, unknown> because
|
|
21
|
+
* the edit tool accepts 4 different schema modes (replace, patch,
|
|
22
|
+
* hashline, apply-patch).
|
|
23
|
+
*/
|
|
24
|
+
export interface EditToolCallEvent {
|
|
25
|
+
type: 'tool_call'
|
|
26
|
+
toolName: 'edit'
|
|
27
|
+
toolCallId: string
|
|
28
|
+
input: Record<string, unknown>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface WriteToolCallEvent {
|
|
32
|
+
type: 'tool_call'
|
|
33
|
+
toolName: 'write'
|
|
34
|
+
toolCallId: string
|
|
35
|
+
input: { path: string, content: string }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ReadToolCallEvent {
|
|
39
|
+
type: 'tool_call'
|
|
40
|
+
toolName: 'read'
|
|
41
|
+
toolCallId: string
|
|
42
|
+
input: { path: string }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface BashToolCallEvent {
|
|
46
|
+
type: 'tool_call'
|
|
47
|
+
toolName: 'bash'
|
|
48
|
+
toolCallId: string
|
|
49
|
+
input: { command: string, env?: Record<string, string>, timeout?: number, cwd?: string }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SearchToolCallEvent {
|
|
53
|
+
type: 'tool_call'
|
|
54
|
+
toolName: 'search'
|
|
55
|
+
toolCallId: string
|
|
56
|
+
input: { pattern: string, paths: string | string[], i?: boolean, gitignore?: boolean }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface FindToolCallEvent {
|
|
60
|
+
type: 'tool_call'
|
|
61
|
+
toolName: 'find'
|
|
62
|
+
toolCallId: string
|
|
63
|
+
input: { paths: string[], hidden?: boolean, gitignore?: boolean }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface CustomToolCallEvent {
|
|
67
|
+
type: 'tool_call'
|
|
68
|
+
toolName: string
|
|
69
|
+
toolCallId: string
|
|
70
|
+
input: Record<string, unknown>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type ToolCallEvent
|
|
74
|
+
= | BashToolCallEvent
|
|
75
|
+
| ReadToolCallEvent
|
|
76
|
+
| EditToolCallEvent
|
|
77
|
+
| WriteToolCallEvent
|
|
78
|
+
| SearchToolCallEvent
|
|
79
|
+
| FindToolCallEvent
|
|
80
|
+
| CustomToolCallEvent
|
|
81
|
+
|
|
82
|
+
/** Fired after a tool executes. */
|
|
83
|
+
export interface ToolResultEvent {
|
|
84
|
+
type: 'tool_result'
|
|
85
|
+
toolName: string
|
|
86
|
+
toolCallId: string
|
|
87
|
+
input: Record<string, unknown>
|
|
88
|
+
content: ({ type: 'text', text: string } | { type: 'image', data: string, mimeType: string })[]
|
|
89
|
+
isError: boolean
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Context passed to extension event handlers.
|
|
94
|
+
* We only need `cwd` from it.
|
|
95
|
+
*/
|
|
96
|
+
export interface ExtensionContext {
|
|
97
|
+
cwd: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Handler function type */
|
|
101
|
+
export type ExtensionHandler<E, R = undefined> = (
|
|
102
|
+
event: E,
|
|
103
|
+
ctx: ExtensionContext,
|
|
104
|
+
) => Promise<R | void> | R | void
|
|
105
|
+
|
|
106
|
+
/** Extension factory function type */
|
|
107
|
+
export type ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* ExtensionAPI — the `pi` object passed to the factory.
|
|
111
|
+
* We only declare the subset we use (on, logger).
|
|
112
|
+
*/
|
|
113
|
+
export interface ExtensionAPI {
|
|
114
|
+
on: ((event: 'session_start', handler: ExtensionHandler<SessionStartEvent>) => void) & ((event: 'tool_call', handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult | undefined>) => void) & ((event: 'tool_result', handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult | undefined>) => void) & ((event: 'turn_end', handler: ExtensionHandler<{ type: 'turn_end' }>) => void)
|
|
115
|
+
logger: { debug: (msg: string) => void, info: (msg: string) => void, warn: (msg: string) => void, error: (msg: string) => void }
|
|
116
|
+
sendMessage: (message: { customType: string, content: string, display: boolean, attribution: string }, options?: { triggerTurn?: boolean }) => void
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Return type for tool_call handlers (can block) */
|
|
120
|
+
export interface ToolCallEventResult {
|
|
121
|
+
block?: boolean
|
|
122
|
+
reason?: string
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Return type for tool_result handlers (can modify result) */
|
|
126
|
+
export interface ToolResultEventResult {
|
|
127
|
+
content?: ({ type: 'text', text: string } | { type: 'image', data: string, mimeType: string })[]
|
|
128
|
+
isError?: boolean
|
|
129
|
+
}
|