@jacob-z/oxlint-gate 1.0.0 → 1.1.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.
Files changed (2) hide show
  1. package/package.json +3 -3
  2. package/src/index.ts +291 -81
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@jacob-z/oxlint-gate",
3
- "version": "1.0.0",
4
3
  "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",
4
+ "version": "1.1.0",
5
+ "description": "Real-time lint quality gate for OMP — auto-detects ESLint ≥ 9 or falls back to oxlint",
7
6
  "author": { "name": "Jacob" },
7
+ "license": "MIT",
8
8
  "repository": { "type": "git", "url": "https://github.com/JacobZyy/jacob-omp-collections", "directory": "packages/oxlint-gate" },
9
9
  "omp": {
10
10
  "extensions": ["./src/index.ts"]
package/src/index.ts CHANGED
@@ -1,17 +1,18 @@
1
1
  /**
2
- * oxlint-gate: Real-time type assertion gate for OMP.
2
+ * lint-gate: Real-time lint quality gate for OMP.
3
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.
4
+ * Intercepts Edit/Write tool calls and checks the target file after save.
5
+ * Strategy is auto-detected per project:
6
+ * - ESLint ≥ 9 (e.g. antfu config) project's own eslint --fix
7
+ * - Otherwise → global oxlint as fallback
7
8
  *
8
- * Configuration: reads rules from `~/.config/oxlint/oxlintrc.json`
9
- * Logs: writes to `~/.omp/logs/oxlint-gate.log`
9
+ * Cache: `.omp/lint-strategy.json` in project root.
10
+ * Logs: `~/.omp/logs/oxlint-gate.log`
10
11
  */
11
12
 
12
- import type { ExtensionAPI, ExtensionFactory } from './omp-types'
13
+ import type { ExtensionAPI, ExtensionFactory, ToolResultEventResult } from './omp-types'
13
14
  import { spawnSync } from 'node:child_process'
14
- import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'
15
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
15
16
  import { homedir } from 'node:os'
16
17
  import { isAbsolute, join, relative, resolve } from 'node:path'
17
18
  import process from 'node:process'
@@ -28,7 +29,7 @@ const WRITE_TOOLS = new Set(['edit', 'write'])
28
29
  /** Max auto-fix attempts per file per turn. */
29
30
  const MAX_FIX_ATTEMPTS = 3
30
31
 
31
- /** Max lines of oxlint output to keep. */
32
+ /** Max lines of lint output to keep. */
32
33
  const MAX_OUTPUT_LINES = 20
33
34
 
34
35
  // ── Types ──────────────────────────────────────────────────────────────────
@@ -37,6 +38,14 @@ interface OxlintConfig {
37
38
  ignorePatterns?: string[]
38
39
  }
39
40
 
41
+ type LintStrategy = 'eslint' | 'oxlint'
42
+
43
+ interface LintStrategyCache {
44
+ strategy: LintStrategy
45
+ eslintVersion?: string
46
+ sniffedAt: string
47
+ }
48
+
40
49
  // ── Local Logger ───────────────────────────────────────────────────────────
41
50
 
42
51
  function ensureLogDir(): void {
@@ -58,9 +67,6 @@ function writeLog(level: 'INFO' | 'WARN' | 'DEBUG', msg: string): void {
58
67
 
59
68
  // ── Helpers ────────────────────────────────────────────────────────────────
60
69
 
61
- /**
62
- * Expand ~ to home directory
63
- */
64
70
  function expandTilde(p: string): string {
65
71
  if (p === '~' || p.startsWith('~/')) {
66
72
  return join(HOME, p.slice(1))
@@ -121,7 +127,6 @@ function matchesIgnorePattern(filePath: string, patterns: string[]): boolean {
121
127
  const rel = relative(process.cwd(), filePath)
122
128
  const candidates = [filePath, rel, `./${rel}`]
123
129
 
124
- // Simple glob matching without Bun.Glob (for Node.js compatibility)
125
130
  for (const pattern of patterns) {
126
131
  const regex = globToRegex(pattern)
127
132
  if (regex) {
@@ -136,13 +141,12 @@ function matchesIgnorePattern(filePath: string, patterns: string[]): boolean {
136
141
 
137
142
  function globToRegex(glob: string): RegExp | null {
138
143
  try {
139
- // Convert glob to regex
140
144
  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
145
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
146
+ .replace(/\*\*/g, '{{DOUBLE_STAR}}')
147
+ .replace(/\*/g, '[^/]*')
148
+ .replace(/\?/g, '[^/]')
149
+ .replace(/\{\{DOUBLE_STAR\}\}/g, '.*')
146
150
 
147
151
  return new RegExp(`^${regexStr}$`)
148
152
  }
@@ -151,22 +155,29 @@ function globToRegex(glob: string): RegExp | null {
151
155
  }
152
156
  }
153
157
 
158
+ function truncateOutput(output: string, maxLines: number = MAX_OUTPUT_LINES): string {
159
+ const lines = output.split('\n')
160
+ if (lines.length <= maxLines)
161
+ return output
162
+ const head = lines.slice(0, 10).join('\n')
163
+ const summary = lines.slice(-5).join('\n')
164
+ return `${head}\n\n... (${lines.length - 15} lines truncated) ...\n\n${summary}`
165
+ }
166
+
167
+ // ── Oxlint runner ─────────────────────────────────────────────────────
168
+
154
169
  function runOxlint(filePath: string, cfgPath: string): { passed: boolean, output: string } {
155
170
  const result = spawnSync('oxlint', ['-c', cfgPath, filePath], {
156
171
  encoding: 'utf8',
157
172
  stdio: ['ignore', 'pipe', 'pipe'],
158
- timeout: 5000, // 5 second timeout
173
+ timeout: 5000,
159
174
  })
160
175
 
161
- if (result.error) {
162
- // oxlint not found or spawn error — treat as pass (fail-open)
176
+ if (result.error)
163
177
  return { passed: true, output: `oxlint error: ${result.error.message}` }
164
- }
165
178
 
166
179
  const exitCode = result.status ?? -1
167
180
  const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim()
168
-
169
- // exit 0 = pass, exit 1 = violations found, other = tool error (fail-open)
170
181
  return { passed: exitCode !== 1, output }
171
182
  }
172
183
 
@@ -177,35 +188,272 @@ function runOxlintFix(filePath: string, cfgPath: string): { fixed: boolean, rema
177
188
  timeout: 10000,
178
189
  })
179
190
 
180
- if (result.error) {
191
+ if (result.error)
181
192
  return { fixed: false, remaining: -1, output: `oxlint error: ${result.error.message}` }
182
- }
183
193
 
184
194
  const exitCode = result.status ?? -1
185
195
  const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim()
186
-
187
- // exit 0 = all fixed, exit 1 = remaining violations
188
196
  return { fixed: exitCode === 0, remaining: exitCode === 1 ? 1 : 0, output }
189
197
  }
190
198
 
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}`
199
+ // ── ESLint runner ─────────────────────────────────────────────────────
200
+
201
+ function runEslintCheck(filePath: string, cwd: string): { passed: boolean, output: string } {
202
+ const result = spawnSync('npx', ['eslint', '--no-warn-ignored', filePath], {
203
+ encoding: 'utf8',
204
+ stdio: ['ignore', 'pipe', 'pipe'],
205
+ timeout: 10000,
206
+ cwd,
207
+ })
208
+
209
+ if (result.error)
210
+ return { passed: true, output: `eslint error: ${result.error.message}` }
211
+
212
+ const exitCode = result.status ?? -1
213
+ const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim()
214
+ return { passed: exitCode !== 1, output }
215
+ }
216
+
217
+ function runEslintFix(filePath: string, cwd: string): { fixed: boolean, output: string } {
218
+ // Try project's lint:fix script first, fall back to direct npx eslint --fix
219
+ const result = spawnSync('npx', ['eslint', '--fix', filePath], {
220
+ encoding: 'utf8',
221
+ stdio: ['ignore', 'pipe', 'pipe'],
222
+ timeout: 15000,
223
+ cwd,
224
+ })
225
+
226
+ if (result.error)
227
+ return { fixed: false, output: `eslint error: ${result.error.message}` }
228
+
229
+ const exitCode = result.status ?? -1
230
+ const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim()
231
+ // eslint: 0 = clean, 1 = lint errors remain, 2 = fatal
232
+ return { fixed: exitCode === 0, output }
233
+ }
234
+
235
+ // ── Strategy sniffing ─────────────────────────────────────────────────
236
+
237
+ /** In-memory strategy cache, keyed by project cwd. */
238
+ const strategyCache = new Map<string, LintStrategyCache>()
239
+
240
+ function getCachePath(cwd: string): string {
241
+ return join(cwd, '.omp', 'lint-strategy.json')
242
+ }
243
+
244
+ /**
245
+ * Read eslint version from package.json (dependencies + devDependencies).
246
+ * Returns the version string or undefined.
247
+ */
248
+ function sniffEslintVersion(cwd: string): string | undefined {
249
+ try {
250
+ const pkgPath = join(cwd, 'package.json')
251
+ if (!existsSync(pkgPath))
252
+ return undefined
253
+
254
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>
255
+ const deps = {
256
+ ...(pkg.devDependencies as Record<string, string> | undefined),
257
+ ...(pkg.dependencies as Record<string, string> | undefined),
258
+ }
259
+ const raw = deps['eslint']
260
+ if (!raw)
261
+ return undefined
262
+
263
+ // Strip workspace/caret/tilde prefixes: "^9.1.0" → "9.1.0"
264
+ const clean = raw.replace(/^[^0-9]*/, '')
265
+ return clean || undefined
266
+ }
267
+ catch {
268
+ return undefined
269
+ }
270
+ }
271
+
272
+ function isEslintGte(version: string, major: number): boolean {
273
+ const parts = version.split('.')
274
+ const v = Number.parseInt(parts[0] ?? '0', 10)
275
+ return !Number.isNaN(v) && v >= major
276
+ }
277
+
278
+ /**
279
+ * Sniff lint strategy for a project and persist to .omp/lint-strategy.json.
280
+ */
281
+ function sniffStrategy(cwd: string): LintStrategyCache {
282
+ const eslintVersion = sniffEslintVersion(cwd)
283
+ const strategy: LintStrategy = eslintVersion && isEslintGte(eslintVersion, 9) ? 'eslint' : 'oxlint'
284
+
285
+ const cache: LintStrategyCache = {
286
+ strategy,
287
+ eslintVersion,
288
+ sniffedAt: new Date().toISOString(),
289
+ }
290
+
291
+ // Persist
292
+ try {
293
+ const cacheDir = join(cwd, '.omp')
294
+ if (!existsSync(cacheDir))
295
+ mkdirSync(cacheDir, { recursive: true })
296
+ writeFileSync(getCachePath(cwd), JSON.stringify(cache, null, 2))
297
+ }
298
+ catch {
299
+ // Non-critical; we'll re-sniff next session
300
+ }
301
+
302
+ writeLog('INFO', `sniffed strategy for ${cwd}: ${strategy}${eslintVersion ? ` (eslint@${eslintVersion})` : ''}`)
303
+ return cache
198
304
  }
199
305
 
306
+ /**
307
+ * Load strategy from cache or sniff fresh.
308
+ */
309
+ function loadStrategy(cwd: string): LintStrategyCache {
310
+ const cached = strategyCache.get(cwd)
311
+ if (cached)
312
+ return cached
313
+
314
+ // Try reading persisted cache
315
+ const cachePath = getCachePath(cwd)
316
+ if (existsSync(cachePath)) {
317
+ try {
318
+ const data = JSON.parse(readFileSync(cachePath, 'utf8')) as LintStrategyCache
319
+ if (data.strategy === 'eslint' || data.strategy === 'oxlint') {
320
+ strategyCache.set(cwd, data)
321
+ return data
322
+ }
323
+ }
324
+ catch {
325
+ // Corrupt cache; re-sniff
326
+ }
327
+ }
328
+
329
+ const result = sniffStrategy(cwd)
330
+ strategyCache.set(cwd, result)
331
+ return result
332
+ }
333
+
334
+ // ── Extension state ───────────────────────────────────────────────────
335
+
200
336
  const pendingPaths = new Map<string, { toolName: string, timestamp: number }>()
201
337
  const fixCounters = new Map<string, number>()
202
338
 
339
+ // ── Strategy handlers ─────────────────────────────────────────────────
340
+
341
+ function handleEslintStrategy(
342
+ pi: ExtensionAPI,
343
+ filePath: string,
344
+ cwd: string,
345
+ fixCount: number,
346
+ log: ExtensionAPI['logger'],
347
+ ): undefined | ToolResultEventResult {
348
+ // 1. Check first
349
+ const { passed } = runEslintCheck(filePath, cwd)
350
+ if (passed) {
351
+ log.info(`[lint-gate] eslint passed: ${filePath}`)
352
+ writeLog('INFO', `eslint passed: ${filePath}`)
353
+ fixCounters.delete(filePath)
354
+ return undefined
355
+ }
356
+
357
+ log.warn(`[lint-gate] eslint violations in ${filePath}, attempting auto-fix`)
358
+ writeLog('WARN', `eslint violations in ${filePath}, attempting auto-fix`)
359
+
360
+ // 2. Auto-fix
361
+ fixCounters.set(filePath, fixCount + 1)
362
+ const { fixed, output } = runEslintFix(filePath, cwd)
363
+
364
+ if (fixed) {
365
+ log.info(`[lint-gate] eslint auto-fixed: ${filePath}`)
366
+ writeLog('INFO', `eslint auto-fixed: ${filePath}`)
367
+ return {
368
+ content: [{ type: 'text', text: `✅ [lint-gate] eslint auto-fixed ${filePath}` }],
369
+ }
370
+ }
371
+
372
+ // 3. Remaining issues
373
+ const remaining = truncateOutput(output)
374
+ log.warn(`[lint-gate] eslint partial fix in ${filePath}`)
375
+ writeLog('WARN', `eslint partial fix in ${filePath}`)
376
+
377
+ pi.sendMessage(
378
+ {
379
+ customType: 'lint-gate',
380
+ content: `⚠️ [lint-gate] ${filePath} has remaining eslint issues after auto-fix:\n\n${remaining}`,
381
+ display: true,
382
+ attribution: 'agent',
383
+ },
384
+ { triggerTurn: false },
385
+ )
386
+ return undefined
387
+ }
388
+
389
+ function handleOxlintStrategy(
390
+ pi: ExtensionAPI,
391
+ filePath: string,
392
+ fixCount: number,
393
+ log: ExtensionAPI['logger'],
394
+ ): undefined | ToolResultEventResult {
395
+ if (!existsSync(OXLINT_CFG))
396
+ return undefined
397
+
398
+ const ignorePatterns = loadIgnorePatterns(OXLINT_CFG)
399
+ if (matchesIgnorePattern(filePath, ignorePatterns))
400
+ return undefined
401
+
402
+ // 1. Check for violations
403
+ const { passed } = runOxlint(filePath, OXLINT_CFG)
404
+ if (passed) {
405
+ log.info(`[lint-gate] oxlint passed: ${filePath}`)
406
+ writeLog('INFO', `oxlint passed: ${filePath}`)
407
+ fixCounters.delete(filePath)
408
+ return undefined
409
+ }
410
+
411
+ log.warn(`[lint-gate] oxlint violations in ${filePath}, attempting auto-fix`)
412
+ writeLog('WARN', `oxlint violations in ${filePath}, attempting auto-fix`)
413
+
414
+ // 2. Try auto-fix
415
+ fixCounters.set(filePath, fixCount + 1)
416
+ const fixResult = runOxlintFix(filePath, OXLINT_CFG)
417
+
418
+ if (fixResult.fixed) {
419
+ log.info(`[lint-gate] oxlint auto-fixed: ${filePath}`)
420
+ writeLog('INFO', `oxlint auto-fixed: ${filePath}`)
421
+ return {
422
+ content: [{ type: 'text', text: `✅ [lint-gate] auto-fixed lint issues in ${filePath}` }],
423
+ }
424
+ }
425
+
426
+ // 3. Some violations remain — report to LLM
427
+ const remaining = truncateOutput(fixResult.output)
428
+ log.warn(`[lint-gate] oxlint partial fix in ${filePath}, remaining issues`)
429
+ writeLog('WARN', `oxlint partial fix in ${filePath}`)
430
+
431
+ pi.sendMessage(
432
+ {
433
+ customType: 'lint-gate',
434
+ content: `⚠️ [lint-gate] ${filePath} has remaining lint issues after auto-fix:\n\n${remaining}`,
435
+ display: true,
436
+ attribution: 'agent',
437
+ },
438
+ { triggerTurn: false },
439
+ )
440
+ return undefined
441
+ }
442
+
443
+ // ── Extension factory ─────────────────────────────────────────────────
444
+
203
445
  const oxlintGate: ExtensionFactory = (pi: ExtensionAPI): void => {
204
446
  const log = pi.logger
205
447
 
206
- log.info('[oxlint-gate] extension loaded (auto-fix mode)')
448
+ log.info('[lint-gate] extension loaded (auto-fix mode)')
207
449
  writeLog('INFO', 'extension loaded (auto-fix mode)')
208
450
 
451
+ // ── session_start: pre-warm strategy cache ──────────────────────
452
+ pi.on('session_start', async (_event, ctx) => {
453
+ const strategy = loadStrategy(ctx.cwd)
454
+ log.info(`[lint-gate] project strategy: ${strategy.strategy}${strategy.eslintVersion ? ` (eslint@${strategy.eslintVersion})` : ''}`)
455
+ })
456
+
209
457
  // ── tool_call: record file path, don't block ────────────────────────
210
458
  pi.on('tool_call', async (event, ctx) => {
211
459
  if (!WRITE_TOOLS.has(event.toolName))
@@ -248,58 +496,20 @@ const oxlintGate: ExtensionFactory = (pi: ExtensionAPI): void => {
248
496
  return
249
497
  if (!isExistingFile(filePath))
250
498
  return
251
- if (!existsSync(OXLINT_CFG))
252
- return
253
499
 
254
- const ignorePatterns = loadIgnorePatterns(OXLINT_CFG)
255
- if (matchesIgnorePattern(filePath, ignorePatterns))
256
- return
500
+ // Ensure strategy is loaded (may sniff on first call)
501
+ const { strategy } = loadStrategy(ctx.cwd)
257
502
 
258
503
  const fixCount = fixCounters.get(filePath) ?? 0
259
504
  if (fixCount >= MAX_FIX_ATTEMPTS) {
260
- log.debug(`[oxlint-gate] max fix attempts (${MAX_FIX_ATTEMPTS}) reached for ${filePath}`)
505
+ log.debug(`[lint-gate] max fix attempts (${MAX_FIX_ATTEMPTS}) reached for ${filePath}`)
261
506
  return
262
507
  }
263
508
 
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
509
+ if (strategy === 'eslint') {
510
+ return handleEslintStrategy(pi, filePath, ctx.cwd, fixCount, log)
271
511
  }
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
512
+ return handleOxlintStrategy(pi, filePath, fixCount, log)
303
513
  })
304
514
 
305
515
  // ── turn_end: clear pending paths only (keep fixCounters to prevent loops) ──