محرّك النسب
إعادة تشغيل الـ commits، والبصمات، وحالة النسب ودرجة الثقة
محرّك النسب يقرر من أدخل كل مخالفة. موجود في src/attribution/، وهو جوهر ما يميّز CleanLens عن الأدوات المبنية على blame.
الفكرة في فقرة واحدة
لكل commit محلل، تحلل CleanLens كل ملف تغيّر قبل الـ commit (نسخة الأب) وبعده، وتعطي كل مخالفة بصمة، ثم تقارن المجموعتين. البصمات الجديدة أدخلها الـ commit؛ والبصمات التي اختفت أصلحها؛ والمشتركة كانت موجودة سابقاً. بعد انتهاء المرور، يُبحث عن كل مخالفة في الكود الحالي ببصمتها لمعرفة الـ commit (والمطوّر) الذي أدخلها.
الخطوة 1 — المرور على الـ commits (commitAttribution.ts)
attributeCommits(root, config, opts):
- يسرد الـ commits التي سيُعاد تشغيلها (أحدث N غير دمجية، من الأقدم للأحدث؛ انظر 04 — طبقة Git).
- يعالج حتى 12 commit بالتوازي (
COMMIT_CONCURRENCY). معظم العمل هو انتظارgit، لذلك يساعد التوازي كثيراً. - لكل commit يستخدم النتيجة المخزنة إن وُجدت؛ وإلا يحسب مساهمة الـ commit (الخطوة 2) ويخزنها.
- يطبّق المساهمات من الأقدم للأحدث بشكل صارم، حتى يعكس سجل الأصول دائماً آخر حالة.
- إذا أُلغي التشغيل، يتوقف عند أول مساهمة ناقصة ويعلّم النتيجة
aborted.
الخطوة 2 — commit واحد (computeCommit)
parent (or empty tree) ──git diff──▶ changed files + added/removed line ranges
│ filter
▼
analyzable, not excluded, not binary, not whitespace-only
│ git cat-file --batch
▼
before blob after blob
│ │
analyzeRevision() analyzeRevision()
│ │
fingerprints (B) fingerprints (A)
\ /
compare the two setsتُتخطى الملفات إذا كانت ثنائية، أو ليس لها محلل، أو تطابق قائمة الاستثناءات، أو كان تعديلها مسافات فقط (لا يوجد مدى أسطر مع -w)، أو كانت ترويستها تقول إنها مولَّدة (عندما تكون excludeGenerated مفعّلة).
لكل ملف متبقٍّ:
- الأسطر المحللة += عدد الأسطر في مدى
addedللـ commit. هذا هو "الكود الذي كتبه المطوّر" الذي تُقسم عليه الدرجات. - تُحلَّل نسختا ما قبل وما بعد. تُخزَّن النتائج حسب معرّف الـ blob طوال التشغيل، فالنسخة التي يتشاركها commitان متتاليان تُحلَّل مرة واحدة فقط.
- ثم تُقارن المجموعتان:
| البصمة موجودة في… | التصنيف | الأثر |
|---|---|---|
| ما بعد فقط | introduced (أُدخلت) | تُحسب على الكاتب (إذا كانت الثقة high/medium) |
| ما قبل وما بعد | existing (موجودة سابقاً) | تُعدّ، ولا تُحسب على أحد أبداً |
| ما قبل فقط | fixed (أُصلحت) | تُحسب لصالح الكاتب (fixedWeighted) |
إذا تعذّر تحليل نسخة ما بعد (خطأ صياغة أو blob مفقود)، تُعدّ كل مخالفات نسخة ما قبل غير منسوبة (unattributed) لذلك الـ commit.
الخطوة 3 — ثقة النسب
كل مخالفة أُدخلت تأخذ درجة ثقة من classifyConfidence():
| الثقة | متى | تدخل في الدرجة؟ |
|---|---|---|
| high | الملف جديد (مضاف، أو منسوخ، أو commit جذري)، أو سطر بداية المخالفة داخل مدى متغيّر، أو امتداد أسطرها يتقاطع مع مدى متغيّر | ✅ |
| medium | ليست على أسطر متغيرة، لكنها تنتمي لرمز مسمّى (دالة/class) والـ commit غيّر أسطراً في الملف | ✅ |
| low | لا دليل على أن التعديل سبّبها (مثلاً تعذّرت قراءة نسخة الأب لملف موجود) | ❌ تُعرض فقط |
فقط high وmedium تُحسبان (SCORING_CONFIDENCES في scoringConfig.ts).
لماذا هذا مهم، مع أمثلة:
- يضيف أحمد 60 سطراً إلى دالة موجودة ← صارت تخالف
maxFunctionLines. امتداد الدالة يتقاطع مع الأسطر التي أضافها أحمد ← high، تُحسب على أحمد. - تضيف ليلى
ifواحدة في عمق دالة، فتتجاوز الدالة حد التعقيد. النتيجة تبدأ عند ترويسة الدالة (لم تتغير)، لكن امتدادها يتقاطع مع سطر ليلى ← high، تُحسب على ليلى. - يعيد خالد تنسيق ملف. مع
-wلم يتغير شيء ← يُتخطى الملف. لا أحد يرث أي شيء. - تعيد رنا تسمية
utils.jsإلىhelpers.jsدون تعديله. يكشف-Mإعادة التسمية، لكن البصمات تتضمن المسار، فتبدو كل مخالفة جديدة تحت المسار الجديد. إعادة التسمية لم تغيّر أي سطر (لا يوجد مدىadded)، فتأخذ كل مخالفة ثقة low ولا تُحسب على رنا. آثار جانبية في التنفيذ الحالي: بصمات المسار القديم تُعدّfixedفي إجماليات رنا المعروضة (عدد المُصلحة وصافي الأثر؛ وليس الدرجة)، وفي التقرير تظهر هذه المخالفاتintroduced/lowمع commit رنا. ويفقد الكاتب الأصلي ارتباطه بها.
الخطوة 4 — البصمات (fingerprint.ts)
البصمة تتعرف على "نفس المخالفة" في نسختين دون استخدام أرقام الأسطر (التي تتغير كلما عدّل أحد شيئاً فوقها):
fingerprint = FNV-1a( ruleId + normalizedPath + symbolName + normalizedContext )- normalizedPath: شرطات
/، بدون./في البداية. - symbolName: الدالة/الـ class المحيطة، إن كانت معروفة.
- normalizedContext: أسطر الكود من
startLineإلىmin(endLine, startLine + 2)(3 أسطر كحد أقصى)، بعد حذف تعليقات//و#وضم المسافات.
النتائج:
- إدراج أسطر فوق المخالفة ← نفس البصمة (تبقى
existing). - تعديل المسافات أو التعليقات في تلك الأسطر ← نفس البصمة.
- تعديل الكود في أول أسطر المخالفة ← بصمة جديدة: القديمة
fixedوالجديدةintroducedبنفس الـ commit. يُحسب على الكاتب وله؛ والأثر الصافي محايد تقريباً. - إعادة تسمية الدالة المحيطة ← بصمة جديدة (تغيّر الرمز).
الخطوة 5 — الدمج مع الكود الحالي (attributeReport.ts)
بعد المرور، تأخذ كل مخالفة من فحص HEAD حالتها النهائية:
| الشرط | الحالة | الثقة | developerId |
|---|---|---|---|
| المسار يطابق قائمة الاستثناءات | excluded | — | لا يوجد |
| تعذّرت قراءة الملف من القرص | unattributed | low | لا يوجد |
آخر أصل للبصمة هو introduced | introduced | ثقة الأصل | الكاتب إذا كانت high/medium |
| غير ذلك (أقدم من النافذة، أو لم تظهر في المرور) | existing | low | لا يوجد |
الحقول introducedBy وintroducedCommit وintroducedAt تُملأ كلما كان الأصل معروفاً، حتى مع الثقة المنخفضة، لتستطيع رؤية الـ commit.
الخطوة 6 — الإجماليات لكل مطوّر
لكل مطوّر (يُطابَق بالبريد بأحرف صغيرة ← معرّف المطوّر)، يجمع المحرّك عبر الـ commits الخاصة به:
| الحقل | المعنى |
|---|---|
analyzedLines | الأسطر التي أضافها/غيّرها في الملفات القابلة للتحليل |
introducedWeighted[category] | مجموع أوزان المخالفات التي أدخلها بثقة high/medium، لكل فئة |
fixedWeighted | مجموع أوزان المخالفات التي أزالتها الـ commits الخاصة به |
counts.introduced | كل ما أُدخل (بأي ثقة) |
counts.introducedScored | ما أُدخل بثقة high/medium |
counts.fixed وcounts.existing وcounts.unattributed | حسب تصنيف كل commit |
لاحظ أن هذه أعداد تاريخية: تشمل مخالفات أُدخلت ثم أُصلحت لاحقاً. لذلك قد يكون عدد "المخالفات الجديدة" لمطوّر أكبر من عدد مخالفاته الباقية في الكود.
هذه الإجماليات هي مدخلات الدرجة: انظر 08 — التقييم.
الضمانات والحالات الخاصة
| الحالة | السلوك |
|---|---|
| commit جذري (بلا أب) | يُقارن مع الشجرة الفارغة؛ كل مخالفة introduced بثقة high |
| commits الدمج | تُتخطى (--no-merges) |
| تغيير في المسافات فقط | يُتجاهل |
| إعادة تسمية | تُكشف بـ -M؛ من أعاد التسمية لا يُحاسب (ثقة low)، لكن إعادة التسمية تقطع الارتباط بالكاتب الأصلي وتضيف رصيد "fixed" لمن أعاد التسمية (للعرض فقط) |
| نسخ | يُكشف بـ -C؛ ويُعامل كملف جديد، فيُحاسب الناسخ (ثقة high) على المخالفات في النسخة |
| ملف محذوف | مخالفاته تُعدّ fixed لمن حذفه |
| كاتب غير موجود في قائمة المطورين | يُحسب في إجماليات المشروع، ولا يُحسب لأي مطوّر |
| مخالفة أقدم من النافذة | existing؛ لا تُحسب على أحد أبداً |
| تشغيل ملغى | المساهمات بعد الفجوة تُتجاهل |
الاختبارات التي تثبّت هذا السلوك
test/integration.test.ts ينشئ مستودعات Git مؤقتة حقيقية ويتحقق من أن: المخالفة الموجودة سابقاً لا تُحسب على من عدّل الملف لاحقاً؛ والمخالفة على أسطر معدّلة تُحسب؛ والمخالفة المحذوفة تُسجَّل كمُصلحة؛ وإعادة تنسيق المسافات لا تنقل شيئاً؛ والملفات المولَّدة والمستثناة تُتجاهل؛ والـ commit الجذري آمن؛ والمساهم الصغير النظيف لا يحصل على 100 ولا يدخل الترتيب.