문법만 보면 틀리는 린트: pubspec까지 읽는 Flutter 커스텀 린트

다음 Flutter 코드를 보고 린트가 경고해야 할까요?
Navigator.pushNamed(context, '/details');
“요즘은 go_router 쓰니까 경고해야죠!”라고 바로 답하면 절반만 맞습니다.
그 프로젝트가 정말 go_router를 쓰는지 아직 모르기 때문입니다.
기본 Navigator named route를 정상적으로 사용하는 앱이라면 이 코드는 잘못이 아닙니다.
문법만 보면 두 프로젝트의 코드는 똑같습니다.
의미는 pubspec.yaml에 따라 달라집니다.
이런 규칙을 pubspec-aware lint라고 부를 수 있습니다. AST만 검사하는 것이 아니라 소비 프로젝트가 어떤 패키지를 의존하는지 확인한 뒤 규칙을 활성화합니다.
custom_linters 저장소의 avoid_navigator_named_routes_with_go_router 규칙이 정확히 이 패턴을 사용합니다.
go_router 있음 + Navigator.pushNamed() -> 경고
go_router 없음 + Navigator.pushNamed() -> 통과
조건 하나가 추가됐을 뿐인데 테스트 방법까지 달라집니다.
가짜 pubspec을 넣지 않으면 린트가 조용히 비활성화되고, 테스트는 실패가 아니라 “경고 없음”으로 끝납니다.
아무 일도 안 일어나서 더 무서운 종류죠. (조용한 테스트는 가끔 아주 시끄러운 거짓말을 합니다.)
먼저 커스텀 린트의 기본 뼈대
custom_lint_builder의 규칙은 크게 네 부분으로 구성됩니다.
- 플러그인 진입점이 규칙 목록을 등록합니다.
- 규칙 클래스가
DartLintRule을 상속합니다. LintCode가 규칙 이름과 메시지를 정의합니다.run()에서 AST visitor를 등록하고 진단을 보고합니다.
기본 형태는 이렇습니다.
class MyLintRule extends DartLintRule {
const MyLintRule() : super(code: _code);
static const _code = LintCode(
name: 'my_lint_rule',
problemMessage: 'Problem description',
);
@override
void run(
CustomLintResolver resolver,
DiagnosticReporter reporter,
CustomLintContext context,
) {
context.registry.addMethodInvocation((node) {
if (/* violation */) {
reporter.atNode(node, code);
}
});
}
}
규칙 파일만 만든다고 실행되지는 않습니다.
플러그인의 getLintRules() 목록에도 등록해야 합니다.
@override
List<LintRule> getLintRules(CustomLintConfigs configs) => <LintRule>[
const AvoidNavigatorNamedRoutesWithGoRouter(),
];
파일 생성, 규칙 구현, 플러그인 등록, 테스트, README 문서화까지가 규칙 하나의 완성 단위입니다. 클래스만 만들고 등록을 빼먹으면 세상에서 가장 조용한 린트가 탄생합니다.
context.pubspec으로 의존성 확인하기
핵심 코드는 놀랄 만큼 짧습니다.
final hasGoRouter =
context.pubspec.dependencies.containsKey('go_router') ||
context.pubspec.devDependencies.containsKey('go_router');
if (!hasGoRouter) return;
go_router가 일반 의존성이나 개발 의존성에 있을 때만 AST visitor를 등록합니다.
이 순서에는 성능상 장점도 있습니다. 해당 패키지가 없는 프로젝트에서는 모든 메서드 호출 노드를 방문한 뒤 결과를 버리는 게 아니라, 시작할 때 바로 반환합니다.
그다음 실제 호출을 검사합니다.
context.registry.addMethodInvocation((node) {
if (!_namedNavigatorMethods.contains(node.methodName.name)) {
return;
}
final target = node.target;
final isNavigatorStaticCall =
target is Identifier && target.name == 'Navigator';
final isNavigatorStateCall =
target?.staticType?.element?.name == 'NavigatorState';
if (isNavigatorStaticCall || isNavigatorStateCall) {
reporter.atNode(node, _code);
}
});
메서드 이름만 비교하지 않는 점이 중요합니다.
어떤 객체든 pushNamed()라는 메서드를 가질 수 있습니다.
그래서 두 종류의 타깃을 확인합니다.
Navigator.pushNamed()같은 정적 호출NavigatorState인스턴스의 named-route 호출
그리고 pushNamed, pushReplacementNamed, popAndPushNamed 등 규칙이 금지하는 메서드 집합 안에 있을 때만 진단을 냅니다.
문법과 타입 정보, 프로젝트 의존성이 함께 있어야 “이 프로젝트에서 이 호출은 피해야 한다”는 결론이 완성됩니다.
왜 단순 문자열 검색으로는 부족할까요?
다음 문자열을 찾는 스크립트를 만들 수도 있습니다.
Navigator.pushNamed
하지만 문자열 검색은 여러 맥락을 구분하지 못합니다.
- 주석이나 문서 속 예제일 수 있습니다.
- 동일한 이름의 다른 심볼일 수 있습니다.
NavigatorState인스턴스 호출을 놓칠 수 있습니다.- 프로젝트가
go_router를 사용하지 않을 수 있습니다.
린트는 “문자열이 보였다”가 아니라 “분석된 Dart 프로그램에서 특정 호출이 발생했다”를 다룹니다. 그래서 analyzer가 해석한 AST와 정적 타입을 사용합니다.
정규식은 빠릅니다. 대신 자신감도 빠릅니다. 틀릴 때 아주 자신 있게 틀립니다.
테스트가 소스 코드만으로 끝나지 않는 이유
일반 AST 규칙은 위반 코드와 정상 코드만 준비하면 됩니다.
pubspec-aware 규칙은 프로젝트 컨텍스트도 필요합니다.
다음 테스트는 go_router가 있는 상황을 만듭니다.
final errors = await analyzeLintRule(
const AvoidNavigatorNamedRoutesWithGoRouter(),
'''
import 'package:flutter/widgets.dart';
void navigate(BuildContext context) {
Navigator.pushNamed(context, '/details');
}
''',
pubspec: Pubspec.parse('''
name: test_project
dependencies:
go_router: any
'''),
);
expect(
errors,
everyElement('avoid_navigator_named_routes_with_go_router'),
);
반대 테스트에서는 pubspec을 전달하지 않습니다.
final errors = await analyzeLintRule(
const AvoidNavigatorNamedRoutesWithGoRouter(),
'''
import 'package:flutter/widgets.dart';
void navigate(BuildContext context) {
Navigator.pushNamed(context, '/details');
}
''',
);
expect(errors, isEmpty);
소스는 거의 같습니다. 의존성 컨텍스트만 다릅니다.
이 두 테스트를 함께 둬야 규칙의 진짜 계약을 검증할 수 있습니다. 경고하는 능력만큼 경고하지 말아야 할 때 조용한 능력도 중요합니다.
실제 analyzer를 사용하는 임시 디렉터리 하네스
이 저장소의 테스트 하네스는 AST를 손으로 만들어 넣지 않습니다. 임시 디렉터리에 진짜 Dart 파일을 쓰고 analyzer로 해석합니다.
흐름은 다섯 단계입니다.
test/아래에 임시 디렉터리를 만듭니다.- 테스트 소스를
main.dart로 씁니다. AnalysisContextCollection으로 파일을 resolve합니다.rule.testRun()을 실행합니다.finally에서 임시 디렉터리를 삭제합니다.
축약하면 이런 모양입니다.
Future<List<String>> analyzeLintRule(
DartLintRule rule,
String source, {
Pubspec? pubspec,
}) async {
final directory = Directory('test').createTempSync('lint_test_');
try {
final file = File('${directory.path}/main.dart')
..writeAsStringSync(source);
final result = await _resolveFile(file.absolute.path);
final diagnostics = await rule.testRun(result, pubspec: pubspec);
return diagnostics
.map((diagnostic) => diagnostic.diagnosticCode.name)
.toList();
} finally {
directory.deleteSync(recursive: true);
}
}
실제 analyzer를 쓰면 production 규칙이 만나는 element model과 해석 결과를 테스트에서도 만납니다. mock이 너무 친절해서 테스트만 통과하는 일을 줄일 수 있습니다.
단점은 순수 함수 테스트보다 무겁다는 것입니다. 하지만 커스텀 린트의 핵심 위험은 analyzer와의 통합 경계에 있습니다. 그 경계를 빼고 테스트하면 빠르지만 중요한 것을 놓칩니다.
pubspec 조건이 잘 맞는 규칙
모든 린트를 의존성 조건부로 만들 필요는 없습니다.
다음처럼 특정 생태계를 선택했을 때만 의미가 생기는 규칙에 잘 맞습니다.
go_router를 쓰는 프로젝트에서 Navigator named route 금지- 특정 상태관리 패키지와 충돌하는 패턴 금지
- 특정 직렬화 패키지를 쓸 때 생성 규칙 강제
- 특정 테스트 프레임워크가 있을 때 matcher 관례 적용
- 플랫폼 플러그인이 있을 때 필수 설정 확인
반대로 언어 자체의 안전 규칙이나 프로젝트 의존성과 무관한 스타일 규칙은 AST만 보는 편이 단순합니다.
조건이 늘어날수록 린트가 언제 켜지는지 문서화도 중요해집니다. 사용자는 같은 코드가 저장소마다 다르게 경고되는 이유를 알아야 합니다.
새 규칙을 만들 때 체크할 것
pubspec-aware lint를 추가한다면 다음 순서가 안전합니다.
- 규칙이 특정 의존성이 있을 때만 참인지 먼저 확인합니다.
context.pubspec에서 일반·개발 의존성을 어디까지 인정할지 정합니다.- 의존성이 없으면 visitor 등록 전에 반환합니다.
- 문자열이 아니라 AST 종류와 정적 타입을 확인합니다.
- 의존성이 있는 양성 테스트를 작성합니다.
- 같은 소스에 의존성만 뺀 음성 테스트를 작성합니다.
- 실제 analyzer로 resolve한 결과에서 규칙을 실행합니다.
- 플러그인 등록과 README 문서화를 잊지 않습니다.
특히 5번과 6번을 한 쌍으로 두세요. 하나는 규칙이 일하는지 확인하고, 다른 하나는 규칙이 남의 프로젝트에 출장 가지 않는지 확인합니다.
정리
좋은 린트는 문법 오류만 찾지 않습니다. 프로젝트가 선택한 도구와 아키텍처 안에서 의미 없는 조합을 찾아냅니다.
Navigator.pushNamed()는 혼자서는 죄가 없습니다.
go_router를 쓰는 프로젝트에서 두 라우팅 모델을 섞을 때 문제가 됩니다.
그래서 이 규칙은 세 가지 증거를 함께 봅니다.
- AST의 메서드 호출
- 타깃의 정적 타입
pubspec.yaml의 의존성
그리고 테스트도 소스와 가짜 pubspec을 함께 제공합니다.
문법만 보면 오탐이 되고, 의존성만 보면 실제 호출을 모릅니다. 둘을 합쳐야 비로소 프로젝트 문맥을 아는 린트가 됩니다.
린터도 눈치가 있어야 합니다. 다만 사람 눈치와 달리 테스트로 고정할 수 있어서 훨씬 다루기 쉽습니다. 꺄하하.
다음 글도 받아보세요.
글을 끝까지 읽으셨다면, 다음 글은 받은편지함이나 RSS 리더에서 만나보세요.