flutter_quality_ratchet 0.1.0
flutter_quality_ratchet: ^0.1.0 copied to clipboard
A quality ratchet for Dart and Flutter projects: freeze today's lint debt in a baseline that may only shrink, and fail CI on every new violation.
Flutter Quality Ratchet
Freeze today's code-quality debt in a baseline that can only go down, and fail CI on every new violation.
English summary. Flutter Quality Ratchet is a small Dart CLI (
quality_ratchet) that lets any Dart or Flutter project adopt strict rules today without fixing the whole codebase first. You describe rules inquality_ratchet.yaml(file size, function size, forbidden imports between layers, forbidden code patterns such assetState(orColor(0x, and hardcoded UI strings);initcounts the current violations per file and freezes them inquality_ratchet_baseline.json. From then oncheckfails on any new violation or any file that grew, and also when a count went down until you lock the progress in withupdate-baseline, which is shrink-only. Moving debt into a new file (a split) is allowed while a rule's total holds. With--base <ref>a raise guard compares the baseline with the one at the git merge-base, so a hand-edited baseline is visible in review; an urgent exception carries areasonand shows up as a warning. Comments and string literals are stripped before matching (interpolations stay code). Output is human text, GitHub annotations or JSON; exit codes are 0 clean, 1 violations, 2 usage/config error. Ships with a composite GitHub Action:uses: youssef336/flutter_quality_ratchet@v1.
عندك مشروع Flutter فيه مئات الـ print والألوان المكتوبة بإيدك والفايلات اللي عدّت الألف سطر،
وعايز تبدأ تشدّد القواعد من النهارده، من غير ما توقف الشغل شهر عشان تنضف كل حاجة؟
ده بالظبط اللي الأداة دي بتعمله.
🪜 الفكرة: «الـ ratchet» #
الـ ratchet هو الترس اللي بيلف في اتجاه واحد بس. هنا الاتجاه ده هو لتحت:
- 🧊 الدين القديم بيتجمّد: أول مرة بتشغّل
init، الأداة بتعدّ المخالفات في كل فايل وبتكتبها فيquality_ratchet_baseline.json. ده «المسموح بيه» النهارده، ومحدش هيزعّقلك عليه. - ⬇️ العدد يقدر ينزل بس: لو صلّحت حاجة قديمة، العدد بينزل. الأداة بتطلب منك تثبّت التحسين
(
update-baseline) عشان محدش يرجع يستخدم المساحة اللي فضيت. - 🚫 الكود الجديد لازم يبقى نضيف: أي مخالفة جديدة، أو فايل قديم زادت مخالفاته، الـ check بيفشل.
- 🛡 الـ baseline مش بيتعلّى:
update-baselineعمره ما بيزوّد رقم. ولو حد علّاه بإيده في PR، الـ raise guard بيقارن بالـ baseline اللي في الـ merge-base وبيقفش ده.
النتيجة: الكود بيتحسّن مع كل PR، من غير big-bang refactor.
📦 التثبيت #
عالمي (global):
dart pub global activate --source git https://github.com/youssef336/flutter_quality_ratchet
quality_ratchet --help
أو كـ dev_dependency جوه المشروع (كل الفريق ياخد نفس النسخة):
# pubspec.yaml
dev_dependencies:
flutter_quality_ratchet:
git:
url: https://github.com/youssef336/flutter_quality_ratchet
ref: v0.1.0
dart run flutter_quality_ratchet check
🚀 أول مرة #
cd my_flutter_app
quality_ratchet init # بيكتب quality_ratchet.yaml وبيجمّد الدين الحالي
quality_ratchet check # لازم يطلع 0
git add quality_ratchet.yaml quality_ratchet_baseline.json
init بيبص على pubspec.yaml: لو المشروع Flutter بيضيف قواعد الـ UI (النصوص المكتوبة في
Text(...) والألوان)، ولو Dart بس بيكتفي بالقواعد العامة. الملف كله متعلّق عليه، عدّل فيه براحتك.
⌨️ الأوامر #
| الأمر | بيعمل إيه |
|---|---|
init [--force] |
بيكتب quality_ratchet.yaml بقيم معقولة، وبيعمل baseline بالأعداد الحالية |
check [--base <ref>] [--rule id] [--format text|github|json] |
الفحص نفسه. بيفشل لو فيه جديد، أو زيادة، أو baseline قديم (stale)، أو baseline اتعلّى |
update-baseline [--base <ref>] [--rule id] |
بينزّل الـ baseline للأعداد الحالية. بينزّل بس، عمره ما بيعلّي |
seed-rule <id> |
لما تضيف قاعدة جديدة: بيكتب الـ section بتاعتها بس، ومش بيلمس الباقي |
explain [--rule id] |
بيعرض كل قاعدة، إعداداتها، والـ baseline قدام العدد الحالي، وأكتر الفايلات |
-C <dir> بيشغّلها على مشروع في فولدر تاني، و --no-color بيقفل الألوان.
الـ exit codes: 0 نضيف · 1 فيه مخالفات · 2 غلط في الأمر أو الإعدادات أو الـ baseline مش موجود.
شكل الـ output:
quality_ratchet check · 158 file(s) scanned
Errors (1)
x lib/features/home/presentation/views/widgets/cart_item.dart:127 [no_print] `print(`: Use a logger instead of print.
Rule Unit Baseline -> Now Status
file_size lines 0 -> 0 holds
long_functions lines 539 -> 539 holds
no_print count 5 -> 6 1 error(s)
no_core_to_features count 26 -> 26 holds
hardcoded_ui_strings count 10 -> 10 holds
no_hardcoded_colors count 69 -> 69 holds
FAILED: 1 error(s).
Fix the reported lines. The baseline only covers old debt: new and changed code must be clean.
⚙️ ملف الإعدادات quality_ratchet.yaml #
include: # الافتراضي: lib/**/*.dart
- lib/**/*.dart
exclude: # الافتراضي: الكود المتولّد
- "*.g.dart"
- "*.freezed.dart"
- generated/
- lib/gen/
baseline: quality_ratchet_baseline.json # الافتراضي
rules:
- id: file_size # اسم من اختيارك، وهو المفتاح في الـ baseline
type: max_file_lines
message: Split this file. # بيظهر مع كل مخالفة: قول للناس يعملوا إيه
# files: [lib/**] # (اختياري) تحدّد القاعدة على فايلات معينة
# exempt: [lib/legacy/**] # (اختياري) فايلات مستثناة
fail_above: 500
الـ globs: * (من غير /)، **/ (أي عدد فولدرات)، {a,b}، [abc]. الـ glob اللي
مفيهوش / بيطابق في أي عمق (*.g.dart)، واللي آخره / معناه «كل اللي جوه الفولدر ده» (generated/).
📏 max_file_lines: حجم الفايل #
- id: file_size
type: max_file_lines
fail_above: 500 # أكتر من كده = مخالفة، والـ baseline بيحفظ عدد السطور
warn_above: 400 # (اختياري) تحذير بس لأي فايل أكبر من كده
new_file_warn_above: 300 # (اختياري) تحذير للفايلات الجديدة، بيشتغل مع --base بس
message: Split this file into smaller files.
الفايل الكبير القديم يقدر يصغر بس، عمره ما يكبر. «السطور» هي اللي الـ editor بيرقّمها
(زي wc -l لأي فايل آخره سطر فاضي).
🧩 max_function_lines: طول الـ function #
- id: long_functions
type: max_function_lines
max: 80
message: Extract smaller functions or widgets.
بيقيس الـ functions والـ methods والـ constructors والـ getters اللي ليها اسم وجسمها { }
(من سطر الاسم لحد القوس اللي بيقفل). الـ baseline بيحفظ عدد السطور، فالـ build الطويلة
تقدر تقصر بس.
🧱 forbidden_imports: حدود الطبقات #
- id: no_core_to_features
type: forbidden_imports
from: lib/core/**
forbidden:
- lib/features/**
- package:get/** # ينفع تمنع باكدج خارجية كمان
exempt:
- lib/core/routing/** # الـ router لازم يشوف كل الصفحات
message: lib/core must not import lib/features.
بيفهم الـ imports النسبية (../features/x.dart) و package:<اسم مشروعك>/... (بيقرا الاسم من
pubspec.yaml)، والـ exports، والـ conditional imports. الـ glob بيتطابق على المسار بعد الـ
resolve وعلى الـ URI نفسه، فـ dart:io و package:get/** شغالين.
🔍 forbidden_patterns: أنماط ممنوعة #
- id: no_set_state
type: forbidden_patterns
patterns:
- '\bsetState\('
message: Use your state management instead of setState.
- id: no_hardcoded_colors
type: forbidden_patterns
patterns:
- '\bColor\(0x'
- '\bColor\.from(ARGB|RGBO)\('
exempt: [lib/**/theme/**]
message: Use a color from the theme.
- id: design_system_buttons
type: forbidden_patterns
patterns:
- '\b(ElevatedButton|TextButton|OutlinedButton)\('
files: [lib/features/**]
message: Use the shared AppButton.
الـ regex بيشتغل على الكود بس: التعليقات ونصوص الـ strings بتتمسح الأول، فـ
// setState( أو 'setState(' مش هيتحسبوا. بس اللي جوه ${...} كود، فبيتحسب.
اكتب الـ regex بين '...' في الـ YAML عشان الـ \ يفضل زي ما هو.
💬 hardcoded_strings: نصوص مكتوبة في الـ UI #
- id: hardcoded_ui_strings
type: hardcoded_strings
widgets: [Text, SelectableText, AutoSizeText] # أول argument positional
named_args: [hintText, labelText, title] # named arguments
ignore:
- '^https?://'
message: Move this text to your translation files.
بيمسك الـ string literal اللي متباصي على طول: Text('Welcome') و hintText: 'Email'.
ومش بيمسك Text('key'.tr()) ولا Text(label) ولا Text('a' + b)، ولا النصوص اللي مفيهاش
ولا حرف ('-'، '42 %'، '$count'). الحروف العربي بتتحسب حروف عادي.
🧮 قواعد الـ ratchet بالتفصيل #
| الحالة | النتيجة |
|---|---|
| العدد في فايل = الـ baseline | ✅ تمام |
| العدد زاد في فايل موجود في الـ baseline | ❌ error |
| فايل مش في الـ baseline وفيه مخالفات | ❌ error لكل مخالفة (إلا لو «نقل»، تحت) |
| العدد نزل | ❌ error «stale»: شغّل update-baseline وثبّت التحسين |
| نقل (move): مخالفات في فايل جديد، ومجموع القاعدة ما زادش | ℹ️ مسموح (split أو rename مش دين جديد) |
| نقل لفايل كان موجود في الـ merge-base | ❌ ده مقايضة: صلّحت هنا وزوّدت هناك |
--base: entry أعلى من الـ merge-base |
❌ error، أو ⚠️ warning لو معاه reason |
--base: entry جديد ومجموع القاعدة زاد |
❌ error (أو ⚠️ مع reason) |
ليه الـ stale بيفشل؟ عشان لو ما ثبّتّش التحسين، أول PR بعدك يقدر يرجّع نفس العدد من غير ما حد يحس.
🤖 GitHub Action #
# .github/workflows/quality.yml
name: quality
on:
pull_request:
jobs:
ratchet:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # الـ raise guard محتاج الـ merge-base
- uses: youssef336/flutter_quality_ratchet@v1
# with:
# working-directory: app # لو المشروع في فولدر جوه الـ repo
# base-ref: origin/main # الافتراضي: origin/${{ github.base_ref }}
# version: v0.1.0 # الافتراضي: نفس نسخة الـ action
الـ action بيسطّب Dart، وبيفعّل الأداة من نفس الـ ref بتاع الـ action، وبيشغّل
check --format github --base <base-ref>. المخالفات بتظهر annotations على سطور الـ PR،
وجدول الـ rules بيظهر في الـ job summary. في الـ push (من غير base) بيشتغل من غير --base.
❓ أسئلة #
لازم أعمل استثناء مستعجل (hotfix) ومش وقت تنضيف. أعمل إيه؟
علّي الـ entry بإيدك في نفس الـ PR، واكتب السبب:
"no_print": {
"lib/features/payment/checkout.dart": {"count": 3, "reason": "hotfix #214, cleanup in #215"}
}
الـ check هيعدّي، بس الـ raise guard هيطلّع warning فيه السبب على الـ PR، فالـ reviewer
شايفه وبيوافق عليه عن قصد. من غير reason الرفع ده error. وأول ما العدد ينزل تاني،
update-baseline بيشيل الـ reason لوحده.
قسمت فايل كبير لتلات فايلات، والـ check بيقول new path؟
لو مجموع القاعدة ما زادش والفايلات جديدة، ده «نقل» ومسموح (notice). شغّل
update-baseline عشان يسجّل المسارات الجديدة ويشيل القديم.
ضفت قاعدة جديدة للإعدادات، والـ check بيطلع exit 2؟
عادي: القاعدة ملهاش section في الـ baseline. شغّل quality_ratchet seed-rule <id> مرة واحدة،
وده بيجمّد الدين الحالي للقاعدة دي بس. ولو شلت قاعدة، update-baseline بيشيل الـ section بتاعتها.
ممكن أشغّلها من غير --base؟
أيوه، كل حاجة شغالة. اللي بيقف: الـ raise guard، وتحذير الفايلات الجديدة، والتأكد إن «النقل»
لفايل جديد فعلًا. في الـ CI استخدم --base دايمًا.
monorepo؟
حط quality_ratchet.yaml في فولدر المشروع، وشغّل quality_ratchet -C app check أو
working-directory: app في الـ action. الـ git commands بتشتغل نسبةً للفولدر ده.
⚖️ الحدود #
- مش parser كامل لـ Dart: lexer صغير بيفرّق الكود عن التعليقات والـ strings. ده كفاية
للقواعد دي، بس
max_function_linesheuristic: مش بيقيس الـ closures ولا=>، ومش بيقيس constructor عنده initializer list قبل الـ{. hardcoded_stringsبيمسك الـ literal المتباصي مباشرة بس، مش اللي في متغير الأول.- الـ regex في
forbidden_patternsبيشوف الكود بس؛ مينفعش تدوّر بيه جوه نص string. - الـ raise guard بيحرس الـ baseline، مش ملف الإعدادات: لو حد خفّف
fail_aboveأو شال قاعدة، ده بيبان في الـ diff بتاعquality_ratchet.yamlفي الـ review.
🛠 Tech stack #
- Dart 3.9+, no Flutter dependency (it reads source files, so it works on any Dart or Flutter repo).
package:argsfor the CLI,package:yamlfor the config. Everything else (lexer, globs, import resolution, the ratchet) is plain Dart with unit tests.git(only with--base):merge-base,showandls-tree.
🏗 Project structure #
| File | Role |
|---|---|
bin/quality_ratchet.dart |
thin entry point (also bin/flutter_quality_ratchet.dart for dart run) |
lib/src/cli/cli.dart |
argument parsing, file and git IO, writing the baseline |
lib/src/cli/project.dart |
file collection, pubspec reading, git wrapper |
lib/src/cli/init_template.dart |
the commented sample config |
lib/src/config.dart |
quality_ratchet.yaml parsing and validation |
lib/src/engine.dart |
the whole run as a pure function (runRatchet) |
lib/src/source/dart_scanner.dart |
comment/string stripping, literal spans, line counting |
lib/src/glob.dart |
glob matching |
lib/src/rules/*.dart |
one file per rule type |
lib/src/ratchet/baseline.dart |
baseline model, parsing, stable encoding |
lib/src/ratchet/ratchet.dart |
compare, shrink-only update, move rule, raise guard |
lib/src/report/reporters.dart |
text, GitHub annotations, JSON, job summary |
action.yml |
composite GitHub Action |
🧪 Development #
dart pub get
dart format --output=none --set-exit-if-changed .
dart analyze --fatal-infos
dart test
📄 License #
MIT, see LICENSE.