지피지기 백전백퇴

Linter를 만드세요 - 클로드 코드 사용 후기


Abstract

여기 다음 문제가 있다.

어떤 코드가 있는데, 구현이 너무 구려서 깔끔하게 고치고 싶다. unsafe.Pointer로 되어있는 타입도 아는대로 조금 바꾸고, pointer arithmetic으로 되어있는 필드 접근도 가능하면 struct를 이용하는 방식으로 바꾸고, 겸사겸사 변수 이름도 조금 바꾸고, struct 필드 타입도 보이는 대로 적용하고 등등…

이러한 문제를 나이브한 느낌으로 LLM에게 맡기면 조금 되는듯 하다가 대환장파티가 벌어진다. 특히나 코드에 유닛 테스트가 전혀 없어서 결국 최종 컴파일을 하고 기능이 돌아가나 확인하는 것만이 유일한 테스트 방법인데, 사람이 직접 실행을 하고 몇몇 기능을 동작시켜서 원하는 대로 동작하나 확인하는 아주 비싼 과정이 필요한 것이다. 삽질의 기록

그래서 해당 기능을 linter rule을 통해 구현하는 것은 어떨까?

Linter tool

결론적으로 말해서, golangci-lint에 내가 원하는 lint 룰을 몇개 추가해서 시도해보기로 했다.

GL_DEBUG="linter" /srv/golangci-lint/golangci-lint run \
--enable noemptycomments,nestedcast,unsafeadd2,unsafeadd \
--disable unused,govet,ineffassign . $@

무엇을 쓰던, 주어진 코드에 대해 Edit suggestion을 뱉어줄 수 있는 녀석이면 된다. 아마 LSP로도 비슷한 것을 구현할 수는 있을텐데, golangci-lint를 이용하면 조금 더 간단한 인터페이스로 구현이 가능해서 이걸로 시도해봤다.

The prompts

I'd like to add linter implemented in testlint/ to golangci-lint.
Here is the reference: https://golangci-lint.run/docs/contributing/new-linters/
Apply to golangci-lint codebase.

---

Check previous commit for how to add sample linter.

Now I need to add another linter which finds the usage of `unsafe.Add`, and check following

1. First argument can be arbitrary pointer type casted to unsafe.Pointer
2. Second argument should be a constant integer.

Create a new linter to be named as 'unsafeadd' which can be used similar as other linter tools.

ultrathink

---

Now I got unsafeadd linter. Now I need to check it can lookup offset to field
translation for Struct576 structs.

The linter should look into `Struct576` type definition. If the second argument
is a constant 24, then overall unsafeAdd(unsafe.Pointer(a1), 24) can be
replaced to unsafe.Pointer(&a1.field_6)

For beginning, only check offset 24 for now, but there can be other
offsets can be added later. Introduce some map to store such mappings.

ultrathink

---

Now I'd like to expand support not just unsafe.Add(a, b), but also uintptr(a + b)

Now the code should take a look when there is a binary expression within uintptr(...),
see if first operand is actually cast of *Struct576, and second operand is a constant.

It should suggest the same replacement as unsafe.Add, but now wrapped with uintptr(...).

ultrathink

---

The new function checkUintptrBinaryExpr checks uintptr(unsafe.Pointer(ptr)) + offset, but
I also need to check uintptr(int32(uintptr(unsafe.Pointer(ptr))) + offset) as well.

ultrathink

이런 식으로 프롬프트를 조금씩 늘려나가면서 claude code에 vibe 모드로 대충 때려넣어서 원하는 기능을 구현하긴 했다.

Need improvements

되는듯… 하다가도 잘 안되는 부분이 많았다. unsafe.Pointer cast가 중복되는 경우에 잘 이해를 하지 못한다던가, 아예 괄호가 하나 더 들어가기만 해도 원래 변수명이 뭐였는지 판단을 못한다던가 등등…

고치면서 보니 정말 주어진 프롬프트에 대해서만 적용한 코드가 많았다. 이를테면:

// extractSupportedStructPointer extracts the supported struct pointer from potentially nested casts
// Handles patterns like:
// - unsafe.Pointer(StructPtr)
// - int32(uintptr(unsafe.Pointer(StructPtr)))
// - uintptr(unsafe.Pointer(StructPtr))
// Returns the pointer expression, struct type name, and whether it was found
func extractSupportedStructPointer(expr ast.Expr, typesInfo *types.Info) (ast.Expr, string, bool) {
	switch v := expr.(type) {
	case *ast.CallExpr:
		// Check if it's unsafe.Pointer(StructPtr)
		if isUnsafePointerType(v.Fun, typesInfo) && len(v.Args) == 1 {
			if structType := isSupportedStructPointer(v.Args[0], typesInfo); structType != "" {
				return v.Args[0], structType, true
			}
		}

		// Check if it's uintptr(unsafe.Pointer(StructPtr))
		if isUintptrCall(v, typesInfo) && len(v.Args) == 1 {
			// The argument should be unsafe.Pointer(StructPtr)
			if unsafePtrCall, ok := v.Args[0].(*ast.CallExpr); ok {
				if isUnsafePointerType(unsafePtrCall.Fun, typesInfo) && len(unsafePtrCall.Args) == 1 {
					if structType := isSupportedStructPointer(unsafePtrCall.Args[0], typesInfo); structType != "" {
						return unsafePtrCall.Args[0], structType, true
					}
				}
			}
		}

		// Check if it's int32(uintptr(unsafe.Pointer(StructPtr)))
		if isInt32Call(v, typesInfo) && len(v.Args) == 1 {
			// The argument should be uintptr(unsafe.Pointer(StructPtr))
			if uintptrCall, ok := v.Args[0].(*ast.CallExpr); ok {
				if isUintptrCall(uintptrCall, typesInfo) && len(uintptrCall.Args) == 1 {
					// The argument to uintptr should be unsafe.Pointer(StructPtr)
					if unsafePtrCall, ok := uintptrCall.Args[0].(*ast.CallExpr); ok {
						if isUnsafePointerType(unsafePtrCall.Fun, typesInfo) && len(unsafePtrCall.Args) == 1 {
							if structType := isSupportedStructPointer(unsafePtrCall.Args[0], typesInfo); structType != "" {
								return unsafePtrCall.Args[0], structType, true
							}
						}
					}
				}
			}
		}
	}

	return nil, "", false
}

즉, 내가 하라고 한 3가지 형태에 대해서만 있는 그대로 대응하고, 나머지는 나몰라라 한것이다.

Done well

어쨌든 코드 하나라도 틀릴 걱정 없이 적용할 수 있으니 좋은일 아닌가? ./chk —fix를 마구 실행해도 된다.

내가 처음부터 golangci-lint 코드를 이해하면서 새 linter를 짜려고 했으면 훨씬 오래 걸렸을 걸?

해봐서 되는걸 알았으니 새로 linter를 짤 수도 있고, 번거로움을 감수하고 LLM이 작성한 linter를 고쳐 쓸 수도 있을 것이다.

결론

linter를 이용한 접근은 리뷰를 좀 대충 해도 된다. 어차피 lint 결과물을 보고 뭔가 이상하게 lint가 적용되면 해당 linter를 버리는 게 훨씬 빠르다. 뭔가 이런 식의 접근을 좀 더 일반화해 보고 싶은데…