يضم Elide مُصرّفًا، ومحلِّلًا لتبعيات Maven، ومُجمِّعًا للحاويات، ومشغّلًا للاختبارات. لكنه لا يضم protoc ولا shellcheck ولا ما سيحتاجه مشروعك لاحقًا. إضافة البناء هي طريقتك لتعريف البناء بأداة كهذه: وحدة JavaScript أو TypeScript يقيّمها Elide أثناء ضبط البناء.
تصرّح الإضافة بمهام. وتصبح كل مهمة منها مهمة حقيقية في المخطط: يمكنك تسميتها هدفًا للبناء، وجعل مهمة أخرى تعتمد عليها، ووضعها في مجموعة، ورؤيتها في elide build --inspect.
التصريح بإضافة
أدرجها في ملف البيان، نسبةً إلى جذر المشروع:
amends "elide:project.pkl"
name = "demo"
extensions {
"tools/protoc.ts"
}
يُصرَّح بالإضافات ولا تُكتشف أبدًا، لذا فإن تشغيل إحداها دائمًا شيء طلبه مشروعك. والمسار غير الموجود يُفشل البناء بدلًا من ألا يُسجَّل شيء بصمت.
يُحلّ المسار نسبةً إلى جذر المشروع، ويجب أن يبقى داخل شجرة المصادر: جذر المشروع، أو جذر مساحة العمل إذا كان المشروع عضوًا فيها، كي يتمكن الأعضاء من مشاركة ملف يقع فوق جذورهم (../tools/protoc.ts). ويُرفض المسار المطلق خارج الشجرة، والمسار الذي يخرج منها، والرابط الرمزي الذي يشير إلى خارجها، ويُبلَّغ عن كل منها كأي تصريح خاطئ آخر.
يسمّي التصريح أحد شيئين، ويحدد Elide أيهما من امتداد الملف وحده: وحدة ES، أي .ts أو .mts أو .js أو .mjs أو .jsx أو .tsx، أو أرشيف حزمة .elx (راجع تحزيم إضافة). لا يُقبل .cjs: فالعقد تصدير افتراضي لا يستطيع سكربت CommonJS التصريح به، ويُقرأ ملف .js دائمًا وحدةً أيًّا كان ما يقوله أقرب package.json. وأي شيء آخر يُفشل البناء بدلًا من تخمينه.
ولا يلزم أن يكون المُدخل مسارًا أصلًا: إذ يمكنه تسمية حزمة تُنزَّل من عنوان URL أو من npm أو من Maven. راجع جلب إضافة.
كتابة إضافة
export default ({ task }: ElideBuild) => {
task(
"protoc",
{
description: "Generate protobuf sources",
group: "compile",
inputs: ["proto/**/*.proto"],
outputs: ["src/main/generated"],
},
(ctx) => {
const result = ctx.exec("protoc", [
`--kotlin_out=${ctx.layout.projectDir}/src/main/generated`,
"proto/api.proto",
]);
if (!result.ok) ctx.fail(result.stderr);
},
);
};
ثم:
elide build --inspect # protoc Generate protobuf sources
elide build protoc # run just that task
elide build compile # run it with everything else in its group
تُنشر الأنواع في @elide-dev/types باسم ElideBuild و ElideBuildTaskContext و ElideBuildTaskOptions.
يعمل التصدير الافتراضي لوحدتك أثناء ضبط البناء، ويمكن أن يكون async. ويعمل إجراء كل مهمة عند تشغيل المهمة، ويمكن أن يكون async أيضًا؛ وينتظر البناء في الحالتين.
تحزيم إضافة
الإضافة التي تتجاوز ملفًا واحدًا تُوزَّع أرشيفًا: ملف zip فيه ملف بيان باسم extension.json في جذره، ويحمل الامتداد .elx. والاسم ليس زخرفة: فامتداد الملف في التصريح هو ما يخبر Elide بأن أمامه حزمة، و .elx هو الامتداد الوحيد الذي يفعل ذلك، لذا يُحمَّل tools/protoc.elx ولا تُحمَّل البايتات نفسها باسم tools/protoc.zip.
protoc-tasks/
├── extension.json
├── tasks.ts
└── lib/
└── flags.ts
{
"name": "protoc-tasks",
"version": "1.0.0",
"description": "Generates sources with protoc",
"license": "Apache-2.0",
"repository": "https://github.com/example/protoc-tasks",
"homepage": "https://example.dev/protoc-tasks",
"authors": ["Example Inc."],
"type": "source",
"entrypoint": "tasks.ts"
}
يتطلب ملف بيان الأرشيف name و type. وقيمة type هي source، النوع الوحيد الموجود، ويجب أن تسمّي حزمة source أيضًا entrypoint، وهو مسار نسبةً إلى جذر الأرشيف؛ ويُرفض ما يشير إلى خارج الأرشيف. وكل ما عدا ذلك بيانات وصفية يعيدها Elide إليك، وتُتجاهل المفاتيح التي لا يعرفها، لذا يمكن للحزمة أن تحمل مفاتيح خاصة بها.
بناء الحزمة
صرّح بالحزمة مُخرَجًا، وسيُنتجها elide build:
amends "elide:project.pkl"
import "elide:Extensions.pkl" as Extensions
name = "protoc-tasks"
version = "1.0.0"
description = "Generates sources with protoc"
artifacts {
["tasks"] = new Extensions.ExtensionArchive {
entrypoint = "tasks.ts"
srcs { "lib/**/*.ts" }
license = "Apache-2.0"
repository = "https://github.com/example/protoc-tasks"
authors { "Example Inc." }
}
}
يسجّل ذلك مهمة باسم package-extension-tasks يشغّلها elide build العادي، وتكتب .dev/artifacts/tasks.elx. ويُكتب extension.json أعلاه نيابةً عنك: تأخذ name و version و description قيم المشروع افتراضيًا، وتكون قيمة type دائمًا source.
entrypoint هي الوحدة التي يصرّح تصديرها الافتراضي بمهامك. و srcs هي الوحدات التي تستوردها، مسارات أو أنماط glob نسبةً إلى جذر المشروع؛ وتُحزَّم نقطة الدخول نفسها دائمًا. تحتفظ الملفات بمساراتها في مشروعك، لذا تُحلّ الاستيرادات النسبية داخل الأرشيف تمامًا كما في نسختك المحلية. ويجب أن يقع كل ملف محزَّم داخل المشروع بعد حلّ روابطه: فالرابط الرمزي الذي يشير إلى خارج المشروع يُفشل البناء بدلًا من توزيع ما يشير إليه.
ويمكنك بالطبع بناء ملف zip بنفسك، من المجلد الذي يحوي ملف البيان، كي يقع extension.json في الجذر:
cd protoc-tasks && zip -r ../tools/protoc.elx .
حزمة مصدرية
يعني "type": "source" أن الأرشيف يحمل شيفرتك المصدرية، وأن امتداد ملف نقطة الدخول يحدد طريقة تحميلها، تمامًا كما في ملف تصرّح به مباشرة. تُقيَّم نقطة الدخول .ts أو .tsx أو .mts أو .js أو .jsx أو .mjs وحدةً ضيفة، ولأن Elide يفك الأرشيف قبل تقييمه، تُحلّ الاستيرادات النسبية داخل الحزمة (import { flags } from "./lib/flags.ts").
لا يُجمَّع شيء ولا يُصرَّف في الطريق: فالحزمة توزّع المصادر التي كتبتها، وتكتب الإضافة نفسها سواء انتقلت ملفًا واحدًا أو أرشيفًا: التصدير الافتراضي نفسه، و task(...) نفسها، و ctx نفسه.
التصريح بحزمة
مثل إضافة محلية، أي مسار مع إعدادات إن كانت تقبلها، أو شيئًا يُجلب:
extensions {
"tools/shellcheck.elx"
new {
from = "tools/protoc.elx"
config { greeting = "hei" }
}
}
يفك Elide الأرشيف ضمن مجلد البناء، في .dev/extensions/packages/<archive>-<hash>/contents/، ويفكه من جديد كلما تغيّر الأرشيف. ويُرفض أي مُدخل قد يُكتب خارج ذلك المجلد بدلًا من فكّه.
ما يعرضه --inspect
يطبع elide build --inspect قسم Extensions: يغطي كل ما صرّح به ملف البيان. ولكل إضافة يعرض التصريح الذي كتبته، واسم الحزمة وإصدارها، ووصفها، ونوع الإضافة، أي JavaScript/TypeScript module أو packaged extension، وترخيصها، ومستودعها، وأسماء المهام التي صرّحت بها.
Extensions:
tools/protoc.elx protoc-tasks 1.0.0
Generates sources with protoc
packaged extension · Apache-2.0 · https://github.com/example/protoc-tasks · Example Inc.
tasks: protoc-generate, protoc-clean
توزيع الأرشيف متروك لك: أرفقه بإصدار، أو قدّمه من عنوان URL، أو انشره حزمة npm بما تستخدمه بالفعل. وأيًّا كانت طريقة انتقاله، يستهلكه المشروع بالطريقة نفسها؛ راجع جلب إضافة.
جلب إضافة
لا يلزم أن تقيم الإضافة في مشروعك. فالتصريح الذي يبدأ بأحد ثلاثة مخططات محجوزة يسمّي شيئًا يُنزَّل:
extensions {
"url:https://downloads.example.com/protoc-tasks-1.4.0.elx"
"npm:@acme/protoc-tasks@1.4.0"
"maven:com.acme:protoc-tasks:elx:1.4.0"
new {
from = "npm:@acme/codegen@2.0.1"
config { proto = "src/proto" }
}
}
لا يُحجز إلا url: و npm: و maven:. وكل ما عداها مسار، لذا يبقى C:\tools\ext.ts يعني ما يقوله، ولا يمكن لمخطط يضيفه Elide لاحقًا أن يغيّر طريقة قراءة ملف بيان يُبنى بالفعل.
بعد التنزيل لا يختلف شيء. يُحمَّل .elx المجلوب حزمةً و .ts المجلوب وحدةً ضيفة: فالحاسم هو ما حُلّ إليه، لا طريقة وصوله. ويجب أن يكون url: عنوان URL مطلقًا بـ https؛ ويُرفض http غير المشفّر إلا إلى مضيف loopback، وهو موجود كي تتمكن من الاختبار على شيء محلي. تُتبع عمليات إعادة التوجيه حتى خمس قفزات، وتخضع كل قفزة للقاعدة نفسها، لذا لا يمكن لإعادة توجيه أن تنقل جلبًا من loopback إلى جهاز آخر دون تشفير.
الإصدارات مثبّتة
يسمّي الإصدار البعيد إصدارًا واحدًا بالضبط. يرفض مخطط ملف البيان npm:tasks و maven:com.acme:tasks لأنهما لا يسمّيان أي إصدار، ويرفض npm:tasks@^1.2 و npm:tasks@latest و maven:com.acme:tasks:1.2.3-SNAPSHOT لأنها تسمّي أكثر من إصدار.
وهذا من شأن الذاكرة المؤقتة. فالإضافة المجلوبة تُخزَّن تحت ما صرّحت به، لذا فإن الإصدار المتحرك إما أن يكلّف طلبًا إلى السجل في كل بناء، ما يجعل --offline مستحيلًا، وإما أن يتجمد بصمت عند ما رآه أول بناء.
مكان تخزينها مؤقتًا
تُحفظ المُخرَجات المجلوبة في .dev/dependencies/extensions/remote/، بجانب مستودع Maven المحلول، وتشاركها مساحة العمل كلها بالطريقة نفسها. ولأن التصريح مثبّت، لا يتقادم المخزَّن أبدًا: لا يصل إلى الشبكة إلا البناء الأول، ويعمل elide build --offline من بعده. والتصريح الذي لم يُجلب ولا يمكن جلبه يقول ذلك:
Build extension 'npm:@acme/protoc-tasks@1.4.0' has not been downloaded, and --offline forbids fetching it
Run the build once without --offline to populate the cache.
يُتحقق من أي ملخّص ينشره المصدر: dist.integrity أو dist.shasum في npm، وملف .sha1 المرافق في Maven. والمستودع الذي يقدّم مُخرَجًا دون ملخّص مقبول؛ أما الذي يقدّم الاثنين ويناقض نفسه فيُفشل البناء.
تثبيت البايتات
يحدد تثبيت الإصدار أي إصدار يعمل؛ ويحدد تثبيت integrity أي بايتات. اكتبه بصيغة Subresource Integrity، أي sha256-<base64> أو sha512-<base64>، بجانب المصدر الذي ينتمي إليه:
extensions {
new {
from = "url:https://downloads.example.com/protoc-tasks-1.4.0.elx"
integrity = "sha512-Ln7jRoUMYMK7TBZ0bNyPmrHDCgAsQ3bIt5nZeUuzpuGqBbw1zYWTPdA2tRbClC2CoRP3UExpHnCw+5kZLgP0lQ=="
}
}
لا يُوثق بـ url: إلا عبر TLS ومضيفه، لذا فالتثبيت هو ما يجعله قابلًا لإعادة الإنتاج. يُتحقق منه قبل تخزين التنزيل مؤقتًا، ومرة أخرى عند كل إصابة في الذاكرة المؤقتة، وتعمل حزمة npm من شجرة مستخرَجة من البايتات المتحقَّق منها نفسها، لذا لا يستطيع الخادم ولا الذاكرة المؤقتة المشتركة لمساحة العمل تغيير ما يعمل من وراء ظهرك. ويُعاد تنزيل الملف المخزَّن الذي لم يعد يطابق التثبيت ويُستبدل؛ ومع --offline يفشل البناء بدلًا من ذلك مسمّيًا الملف المخزَّن. ويُضاف التثبيت إلى ما ينشره السجل بدلًا من أن يحل محله: إذ يظل ملخّص npm أو Maven قيد التحقق.
ولا يقبله إلا المصدر المجلوب. ويُرفض تثبيت مسار داخل مشروعك، فبايتاته هي أصلًا ما تحويه شجرتك.
التنزيل محدود في الحجم والوقت: تفشل الاستجابة التي تتجاوز 64 MiB، وكذلك المضيف الذي يستجيب ثم يتوقف، بدلًا من تعليق عملية الضبط.
حزم npm
يُخزَّن ملف tarball مؤقتًا مرة واحدة لمساحة العمل، ويُستخرج إلى مجلد البناء في مشروعك، ضمن .dev/extensions/npm/<name>/<version>/contents/، ويُحمَّل من نقطة دخوله: تلك التي يسمّيها extension.json إن كانت الحزمة تتضمنه، وإلا فـ main في package.json، وإلا فـ index.js. والحزمة التي تحمل extension.json حزمة بالمعنى الكامل، وتُقرأ تمامًا كما يُقرأ .elx: فإن كانت غير صالحة، أو تسمّي type لا يعرفه هذا الإصدار من Elide، أفشلت البناء بدلًا من الرجوع إلى main.
يمر الحلّ بثلاث خطوات بالترتيب: dependencies.npm.registry إذا ضبطه ملف البيان، ثم $ELIDE_PKGST_NPM_ENDPOINT، وهو المتغير نفسه الذي يقرؤه محلِّل npm الخاص بـ Elide، وإن غاب الاثنان فالخادم الوسيط npm.pkg.st التابع لـ Elide، أو السجل العام registry.npmjs.org عند تمرير --use-pkgst=false.
dependencies {
npm {
registry = "https://npm.internal.example.com"
}
}
مُخرَجات Maven
تُجلب الإحداثية من المستودعات التي تصرّح بها بالفعل، أي dependencies.maven.repositories، إضافة إلى الخادم الوسيط الخاص بـ Elide و Maven Central ما لم يكن enableDefaultRepositories معطّلًا، باستخدام بيانات الاعتماد التي تحملها تلك المستودعات. لا تُرسل بيانات الاعتماد إلا إلى مخطط المستودع نفسه ومضيفه ومنفذه؛ وتُتبع إعادة التوجيه إلى أي مكان آخر من دونها. ويقع [:packaging[:classifier]] المعتاد بين المُخرَج والإصدار، والإحداثية التي لا تسمّي تحزيمًا تأخذ elx: يجلب maven:com.acme:tasks:1.4.0 الملف tasks-1.4.0.elx، تمامًا مثل maven:com.acme:tasks:elx:1.4.0. ويُرفض التحزيم الذي لا يستطيع Elide تحميله (maven:com.acme:tasks:zip:1.4.0) كما يُرفض أي تصريح لا يمكن تحميله.
تُجرَّب المستودعات بالترتيب. ويُتجاوز المستودع الذي يستجيب بخطأ، أو باستجابة كبيرة جدًا أو متوقفة، أو بإعادة توجيه مرفوضة، إلى التالي؛ أما ملف .sha1 المنشور الذي لا يطابق المُخرَج فيُفشل البناء.
يُجلب مُخرَج واحد، ولا يُحلّ أي مخطط تبعيات، تمامًا كما في أرشيف تصرّح به بمسار. والإضافة التي تحتاج إلى مكتبات تضمّنها داخل حزمتها.
ضبط إضافة
يُصرَّح بالإضافة التي تقبل إعدادات في كتلة بدلًا من مسار مجرد، وتحمل الإعدادات معها. لا يفسّرها Elide:
extensions {
"tools/shellcheck.ts"
new {
from = "tools/protoc.ts"
config {
version = "25.1"
plugins = new Listing { "grpc" }
}
}
}
تصل تلك الكتلة إلى تلك الإضافة، وإليها وحدها، باسم config، أثناء تصريحها بالمهام وأثناء تشغيلها:
export default ({ task, config }: ElideBuild) => {
task("protoc", { description: `protoc ${config.version}` }, (ctx) => {
const { version, plugins } = ctx.config;
});
};
الإضافة المصرَّح بها بمسار مجرد تقرأ config فارغًا، لذا يكفي التحقق من أن الإعداد undefined لتطبيق قيمة افتراضية.
الإضافة التي تريد إعدادات مُنمَّطة وموثَّقة توزّع وحدة Pkl خاصة بها، ينسخها مشروعك ويستوردها ويملؤها. يستورد ملف البيان الملفات المحلية، ومكتبة Pkl القياسية، والمخطط الخاص بـ Elide، ولا يستورد أبدًا package:// أو أي URI شبكي آخر، لذا تقيم الوحدة في مشروعك:
import "tools/Protoc.pkl"
extensions {
new {
from = "tools/protoc.ts"
config = new Protoc.Settings { version = "25.1" }
}
}
تتمايز المُدخلات بالمسار أيًّا كانت طريقة كتابتها، لذا لا يمكن التصريح بالإضافة نفسها، أو ضبطها، مرتين.
اكتب القائمة داخل الكتلة بصيغة Listing (أو List("grpc")). أما العناصر المضمَّنة، مثل plugins { "grpc" }، فستصل إلى إضافتك مفهرسة بالموضع لا قائمةً، لذا يرفضها ملف البيان ويخبرك بالصيغة الصحيحة.
الإعدادات بيانات. يمكن أن تكون القيمة سلسلة نصية، أو قيمة منطقية، أو رقمًا، أو Duration، أو DataSize (تصل إلى إضافتك عددًا من البايتات)، أو Regex، أو كتلة أو Listing أو List أو Set أو Map أو Pair منها. أما Bytes أو الصنف أو الاسم البديل للنوع أو الدالة فخطأ في ملف البيان يذكر موضعه، مثل config.flags.blob.
خيارات المهام
| الخيار | المعنى |
|---|---|
description | يعرضه elide build --inspect. |
dependsOn | أسماء المهام التي يجب أن تكتمل أولًا؛ ويمكن إعطاء اسم واحد دون مصفوفة. |
before | المهام التي يجب أن تسبقها هذه المهمة: اسم مهمة، أو deps/compile/test لكل مهمة في تلك المجموعة. |
group | deps أو compile أو test للانضمام إلى مجموعة مدمجة؛ وأي اسم آخر ينشئ مجموعة خاصة بك. |
default | ما إذا كان elide build المجرد يشغّل هذه المهمة. القيمة الافتراضية true. |
inputs | مسارات أو أنماط glob نسبةً إلى المشروع، يحدد محتواها ما إذا كانت المهمة ستُعاد. |
outputs | مسارات أو أنماط glob نسبةً إلى المشروع تكتبها المهمة. |
cache | ما إذا كان يمكن لذاكرة البناء المؤقتة أرشفة مخرجات هذه المهمة. القيمة الافتراضية true. |
options | خيارات سطر الأوامر التي تقبلها هذه المهمة (انظر أدناه). |
inputs و outputs عقد واحد: صرّح بكليهما فيتخطى Elide المهمة إذا لم يتغير شيء (راجع مهام البناء)؛ وصرّح بأحدهما أو بلا شيء فتعمل في كل بناء. وتُحسب إضافتك مُدخلًا أيضًا، أي الملف نفسه وكل وحدة يستوردها بمسار نسبي، لذا فإن تعديل نقطة الدخول أو أحد مساعديها يعيد تشغيل المهام التي تصرّح بها.
والمهمة التي صرّحت بكليهما تُخزَّن مؤقتًا أيضًا مثل مهام Elide نفسه: تُؤرشف مخرجاتها عند نجاحها، وتُستعاد في أي بناء لاحق تحمل مُدخلاته البصمة نفسها، بما في ذلك نسخة محلية جديدة. اضبط cache: false للإبقاء على فحص التحديث وتخطي الأرشيف، وهو الخيار الصحيح عندما تعتمد مخرجاتك على شيء لا تسمّيه inputs.
تُحسب بصمة التصريح من وضع كل ملف يسمّيه ومحتواه، ويقرأ إجراؤك الملفات نفسها والبصمات نفسها عبر ctx.inputs و ctx.outputs؛ راجع الملفات التي يسمّيها التصريح.
الخيارات التي تقبلها مهمتك
ينبغي للمهمة التي تقرأ خيارًا أن تصرّح بذلك. تُطبع الخيارات المصرَّح بها عبر elide build --inspect و elide build <task> --help، ويُتحقق منها قبل تشغيل أي شيء، لذا يُبلَّغ عن الخطأ الإملائي بدلًا من إسقاطه بصمت:
task(
"publish",
{
options: [
{ name: "channel", description: "Channel to publish to", arity: "required" },
{ name: "tag", description: "Extra tag to push", arity: "required", repeatable: true },
{ name: "sign", description: "Sign the release", negatable: true, default: "true" },
],
},
(ctx) => {
ctx.option("channel"); // first value, or the declared default
ctx.values("tag"); // every value, in command-line order
ctx.flag("sign"); // `--no-sign` reads false; absent reads the default
},
);
| الحقل | المعنى |
|---|---|
name | الخيار، دون شرطات وبصيغة kebab-case: "dry-run" هو --dry-run. |
description | يعرضه --inspect و --help. |
arity | "none" خيار دون قيمة، و "required" يتطلب --flag=value، و "optional" يقبل الاثنين. القيمة الافتراضية "none". |
repeatable | ما إذا كان يمكن تمرير الخيار أكثر من مرة. القيمة الافتراضية false. |
negatable | ما إذا كان --no-<name> مقبولًا، ويُقرأ "false". القيمة الافتراضية false. |
default | القيمة التي تعود إليها القراءة عند عدم تمرير الخيار. |
keyed | ما إذا كانت القيمة الممرَّرة للخيار تحدد ما تنتجه المهمة. القيمة الافتراضية true. |
يطبع --inspect و --help التصريح كما يجب كتابته، لذا تُقرأ الخيارات الثلاثة أعلاه --channel=<value> و --tag=<value> (repeatable) و --sign (or --no-sign) [default: true].
الخيار الذي لا يصرّح به شيء يُفشل البناء، مع أقرب اسم مصرَّح به إن وُجد؛ وكذلك القيمة الممنوحة لخيار دون قيمة، والخيار المطلوب المتروك دون قيمة، وتكرار خيار يقبل قيمة واحدة. أما خيارات Elide نفسه، مثل --verbose و --project و --offline وغيرها، فمقبولة دائمًا.
تقرأ ctx.option و ctx.flag و ctx.values عبر التصريح، وهذا ما يجعل --no-sign والقيمة الافتراضية قابلين للقراءة أصلًا: فلا يقول سطر الأوامر أيًّا منهما بنفسه. والاسم الذي لم تصرّح به مهمتك يُقرأ من سطر الأوامر كما حُلِّل.
الخيار المصرَّح به جزء مما يحدد إعادة تشغيل مهمتك: إذ تُسجَّل القيم التي حُلّ إليها بجانب inputs، لذا لا يُعدّ البناء الذي يعمل مع --channel=stable محدَّثًا أبدًا، ولا يتلقى ما خزّنه --channel=canary، مقارنةً ببناء عمل مع القيمة الأخرى. اضبط keyed: false على الخيار الذي يغيّر مقدار ما تطبعه المهمة لا ما تكتبه، وإلا فإن كل بناء يكتبه بشكل مختلف سيخطئ الذاكرة المؤقتة دون داعٍ. والخيار الذي يقرؤه إجراؤك دون التصريح به لا يحدد شيئًا على الإطلاق.
التشغيل قبل مهمة أخرى
يسمّي dependsOn ما يجب تشغيله أولًا. ويسمّي before ما يجب أن ينتظر، بما في ذلك مهام لم تصرّح بها، وهذا ما تحتاجه مهمة توليد الشيفرة:
task(
"generate-sources",
{ before: "compile", default: false, outputs: ["src/main/generated"] },
(ctx) => { ... },
);
تمثّل compile و deps و test كل مهمة في تلك المجموعة، وهذا ما يجب أن تعنيه عبارة «قبل التصريف»: فالمفاتيح الحقيقية لكل لغة ولكل مجموعة مصادر (compile-kotlin-main، compile-java-test). وأي اسم آخر هو اسم مهمة واحدة، والاسم الذي لم يسجّله شيء يُفشل البناء بدلًا من ألا يعمل شيء بصمت. وفي مساحة العمل، تختار الإضافة التي يصرّح بها عضو مهام ذلك العضو فقط، بينما تختار الإضافة التي يصرّح بها ملف البيان الجذري المجموعة عبر كل الأعضاء.
ويجرّ رابط كهذا مهمتك أيضًا إلى أي بناء يشغّل المهمة التي تسبقها: إذ يشغّل elide build compile في المثال أعلاه generate-sources أولًا، رغم أنها اختارت الخروج من default.
البناء التزايدي
تُبلَّغ المهمة الجارية بما وجده البناء متقادمًا، ويمكنها الاحتفاظ بقيم لتشغيلها التالي:
task("codegen", { inputs: ["proto", "schema"], outputs: ["gen"] }, (ctx) => {
for (const input of ctx.dirty.inputs) ctx.log(`changed: ${input}`);
if (ctx.dirty.outputs.length) ctx.log("an output is missing or was edited");
if (ctx.dirty.extension) ctx.log("this extension or one of its imports changed");
ctx.log(`last built version ${ctx.state.get("version") ?? "(never)"}`);
ctx.state.set("version", ctx.manifest.version);
});
لا تسمّي ctx.dirty إلا التصريحات التي تغيّرت؛ وفي التشغيل الأول يُدرج كل ما صرّحت به. وتحفظ ctx.state السلاسل النصية والأرقام والقيم المنطقية و null والمصفوفات والكائنات البسيطة، مفهرسة بالاسم، وتُرجع get القيمة null لمفتاح لم يُخزَّن فيه شيء. ليس للدالة والرمز و undefined صيغة تخزين، والقيمة التي تحتوي نفسها تُرفض بدلًا من تتبّعها. والتشغيل الفاشل يتخلص من حالة المهمة كلها، بما في ذلك ما سجّلته التشغيلات السابقة.
ما تتذكره المهمة مذكّرة لا مُدخل: لا يحدد أبدًا ما إذا كانت المهمة ستُعاد، ولا يغيّر أبدًا مفتاح ذاكرتها المؤقتة.
الملفات التي يسمّيها التصريح
ctx.inputs و ctx.outputs هما التصريحات نفسها، محلولةً ومختومةً: المسح نفسه والملخّص نفسه اللذان استخدمهما البناء ليقرر ما إذا كانت مهمتك محدَّثة:
task("codegen", { inputs: ["proto"], outputs: ["gen"] }, (ctx) => {
for (const file of ctx.inputs.files()) {
ctx.log(`${file.path} ${file.size}b ${file.stamp}`);
}
const before = ctx.state.get("proto") ?? {};
const after = Object.fromEntries(ctx.inputs.files().map((f) => [f.path, f.stamp]));
for (const path of Object.keys(after)) {
if (after[path] !== before[path]) ctx.exec("protoc", ["--kotlin_out=gen", path]);
}
ctx.state.set("proto", after);
ctx.log(`produced ${ctx.outputs.stamp()}`);
});
تُدرج files() كل ملف عادي تسمّيه التصريحات، نسبةً إلى المشروع ومفصولًا بـ /، مرتبًا ودون تكرار. ويحمل كل مُدخل path و absolute و size و modified و stamp، وهو SHA-256 لوضع ذلك الملف ومحتواه. و stamp() هو الملخّص للمجموعة كلها.
تقبل كلتاهما أنماطًا خاصة بها لا يلزم أن تكون مما صرّحت به: إذ تضيّق ctx.inputs.files("proto/api/**/*.proto") القائمة، و ctx.inputs.stamp("proto") هي بالضبط البصمة التي سجّلها البناء لذلك التصريح. وتُحلّ كلتاهما عند استدعائهما، لذا فإن قراءة ctx.outputs بعد كتابة مخرجاتك تصف ما أنتجته للتو.
تعتمد الأختام على المحتوى: فالتعديل الذي لا يغيّر حجم الملف ووقت تعديله يغيّرها مع ذلك، ويعيد تشغيل مهمتك. وتسمّي ctx.dirty التصريح كاملًا؛ أما الختم لكل ملف المحفوظ في ctx.state، كما في المثال، فهو طريقة المهمة لمعرفة أي ملفاتها تغيّر.
تصريحات يجب أن يحسبها البناء
بعض ما يحدد ما إذا كانت المهمة محدَّثة لا يمكن كتابته في التصريح: شجرة مولَّدة، أو classpath حلّته مهمة أخرى، أو إصدار الأداة التي توشك على تشغيلها. امنح المهمة خطّاف prepare وصرّح به هناك:
task(
"package",
{
outputs: ["dist"],
prepare: (ctx) => {
ctx.inputs.add("gen/**/*.kt");
ctx.inputs.value("classpath", ctx.state.of("resolve")?.get("classpath"));
ctx.outputs.add(`dist/${ctx.manifest.version}.tgz`);
},
},
(ctx) => {
for (const changed of ctx.dirty.values) ctx.log(`${changed} changed`);
},
);
ما يضيفه الخطّاف تصريح كأي تصريح آخر: يُختم النمط، وتسمّيه ctx.dirty.inputs، ويدخل في مفتاح الذاكرة المؤقتة للمهمة تمامًا كما يدخل النمط المصرَّح به. وتصرّح ctx.inputs.value(name, value) بمُدخل ليس ملفًا، أي أي شيء يستطيع البناء حسابه ولا يستطيع فحصه بـ stat، وتغييره يعيد تشغيل المهمة ويغيّر مفتاح أرشيفها؛ وتسمّي ctx.dirty.values ما تغيّر منها. والمهمة التي يُستكمل تصريحها بهذه الطريقة تُتتبَّع وتُخزَّن مؤقتًا وتُتخطى بوصفها محدَّثة كأي مهمة أخرى.
يعمل الخطّاف في كل بناء تشارك فيه مهمتك، بما في ذلك تلك التي يتبيّن فيها أنه لا عمل لها، لذا اقصره على اشتقاق التصريحات: فليس لديه exec ولا tools ولا dirty، فتلك هي الإجابة التي يساعد على حسابها، و ctx.state للقراءة فقط هناك. وإرجاع false يُفشل المهمة قبل تشغيلها.
يمكن لإجرائك إضافة مخرجات لا يعرفها إلا عند كتابتها، باستخدام ctx.outputs.add(...) نفسها؛ فتُختم وتُؤرشف مع البقية عند عودته. ولا يمكنه إضافة مُدخلات: فبحلول ذلك الوقت يكون البناء قد قرر تشغيله، لذا فإن ctx.inputs.add المتأخرة خطأ لا تصريح لا يفعل شيئًا بصمت.
تمرير قيمة إلى مهمة أخرى
تُقيَّم وحدة إضافتك مرة واحدة لكل بناء وتتشاركها كل المهام التي تصرّح بها، لذا فإن متغير الوحدة الذي تعيّنه مهمة غالبًا ما يبقى للمهمة التالية، لكن لا شيء يضمن ذلك، والتقييم الجديد يبدأ من لا شيء. عامل نطاق الوحدة ذاكرةً مؤقتة، لا قناةً. وما تحفظه المهمة في ctx.state هو الطريقة المدعومة لتمرير قيمة:
task("stage", (ctx) => {
ctx.state.set("staged", `${ctx.layout.buildDir}/staged`);
});
task("publish", { dependsOn: "stage" }, (ctx) => {
const stage = ctx.state.of("stage");
if (stage === null) return ctx.skip("nothing has been staged");
ctx.exec("rsync", [String(stage.get("staged")), "example:/srv"]);
});
تُرجع ctx.state.of(name) عرضًا لما لدى تلك المهمة، أي get و has و keys، و null إذا لم تنشر شيئًا على الإطلاق. والمهمة التي تخطّاها البناء لكونها محدَّثة، أو استعادها من الذاكرة المؤقتة، تجيب بما احتفظت به في آخر تشغيل لها: فكونها محدَّثة تقرير عما أنتجته، وما نشرته جزء من ذلك. وتعني null أنه لم يكن هناك ما يُقرأ: فالاسم ليس مهمة صرّحت بها إضافة، أو لم تكن في هذا البناء، أو فشلت، أو لم تعمل بعد؛ بينما العرض الذي لا مفاتيح فيه مهمة لم تحتفظ بشيء. اجعل مهمتك تعتمد على المهمة التي تقرؤها؛ فمن دون رابط تصبح «لم تعمل بعد» حالة تسابق.
تنتقل القيمة بالبنية التي كانت عليها، لا نصًا، وتنطبق على ما يمكن نقله القواعد نفسها التي تنطبق على ctx.state. وقراءة اسمك تُرجع ما تحتفظ به الآن.
ما تتلقاه مهمتك
يحمل الكائن الممرَّر إلى إجرائك الأشياء نفسها التي يقرؤها البناء:
project: الاسم والإصدار والوصف ومجلد الجذر ومساحة العمل التي ينتمي إليها.manifest: ملفelide.pklالمحلول، بيانات JavaScript عادية. وهو بالضبط ما يكتبهelide project export --format=json، لذا تعمل manifest.dependencies.maven.packages.map(...) كما تتوقع.config: الإعدادات التي صرّح بها ملف البيان لهذه الإضافة (انظر أعلاه)، وتكون فارغة إن لم يصرّح بشيء.layout:projectDirوbuildDirوartifactsوstagingوما شابهها، مسارات مطلقة.option(name)/flag(name)/values(name): الخيارات التي شُغّل بها هذا الاستدعاء، مقروءة عبر تصريحك (انظر أعلاه)؛ وoptionsهو التحليل الخام لكل ما لم تصرّح به.task: تصريحك أنت، كما سجّله البناء، بما في ذلك الخيارات التي يصرّح بها.dirty/state: ما تغيّر منذ آخر تشغيل لمهمتك، وما احتفظت به، وما سجّلته المهام الأخرى في هذا البناء (انظر أعلاه).
الإبلاغ عن النتيجة
| في إجرائك | ما يسجّله البناء |
|---|---|
العودة، أو إرجاع true | نجاح |
ctx.fail(reason) | فشل، مع ذلك السبب |
إرجاع false | فشل، دون سبب |
ctx.skip(reason) | تخطٍّ، مع ذلك السبب |
| رمي استثناء أو رفض | فشل، مع رسالته |
ctx.exit(code, reason) | فشل مع الخروج بـ code |
تكتب ctx.log و ctx.warn و ctx.error أسطرًا منسوبة إلى مهمتك. وهي رسائل بناء، لذا تذهب إلى الخطأ القياسي مع كل رسائل البناء الأخرى؛ ولا يحمل الإخراج القياسي إلا ما يكتبه برنامج شغّلته.
الخروج بحالة محددة
تُفشل ctx.exit(code, …message) المهمة وتطلب من البناء الخروج بـ code. أما 0 فيعني النجاح، وهذا ما يجعلها قرار النجاح أو الفشل كله لمهمة تغلّف برنامجًا:
ctx.exit(ctx.exec("shellcheck", ["scripts/build.sh"]).exitCode);
استخدمها عندما تحمل الحالة معنى قد تمحوه القيمة المنطقية. فسقّاطة الصحة التي تخرج بـ 2 لأن المحلّل انهار، و 1 لأنه وجد مخالفة جديدة، لا تفيد إلا إذا استطاع المستدعي التمييز بينهما؛ وكذلك التشخيص الذي يخرج بـ 2 لـ «غير مدعوم على هذا المضيف» و 1 لـ «وُجد شيء».
لا يصل الرمز إلى العملية إلا إذا كانت مهمتك هي التي أوقفت البناء؛ فإن فشلت مهمة أخرى أولًا فهي الغالبة. والرمز الواقع خارج 0-255 يُلفّ كما تبلّغ عنه الصدفة، ولا يُلفّ إلى صفر أبدًا.
تشغيل أدوات خارجية
const result = ctx.exec("shellcheck", ["scripts/build.sh"], {
cwd: "tools", // relative paths resolve against the project root
env: { CI: "1" }, // added on top of the inherited environment
quiet: false, // set true to keep the program's output out of the build log
timeout: 30_000, // milliseconds; the child is killed and the task fails
});
result.ok; // exited zero
result.exitCode; // 0, 3, …
result.stdout; // captured up to 1 MiB, even when not quiet; the rest is streamed only
result.stderr; // under the same cap
تنتظر exec حتى يخرج البرنامج، وتبث مخرجاته إلى عرض البناء فور وصولها، منسوبة إلى مهمتك. لا تتدخل أي صدفة: تصل الوسائط إلى البرنامج كما كتبتها تمامًا، لذا لا يمكن إعادة تفسير أي شيء في اسم ملف أو خيار بوصفه صياغة صدفة. ويُبحث عن اسم الأمر المجرد في PATH الذي ستعمل به العملية الفرعية، أي الذي يضبطه env إن ضبطه، ويُحلّ الاسم الذي يحتوي فاصلًا نسبةً إلى cwd. وإلغاء البناء ينهي البرنامج وكل ما شغّله. ويحدّ timeout البرنامج الذي سمّيته؛ وبعد خروجه تُقرأ المخرجات التي لا تزال تصل لثانية إضافية ثم تُسقط، لذا لا تؤخر عمليةُ خلفيةٍ تركها ممسكة بمخرجاته مهمتَك، ولا يُنتظر تلك العملية ولا تُنهى.
وكل ما يملكه JavaScript الضيف متاح أيضًا، مثل node:fs و node:child_process و fetch، لذا استخدم exec عندما تريد نسبة المخرجات إلى مهمتك، و node:child_process عندما تحتاج إلى شيء لا تغطيه. وتأتي مع ذلك قاعدة واحدة: العملية الفرعية التي تمنحها طرفية البناء نفسها (stdio: "inherit") تكتب حيث يرسم عرض التقدم، لذا سلّمها الطرفية عبر ctx.foreground. أما العملية الفرعية التي تمرّر مخرجاتها وتطبعها بنفسك فلا تحتاج إلى شيء: إذ تمر مخرجات console الخاصة بك عبر العرض كأي مخرجات أخرى.
منح برنامج الطرفية
const code = await ctx.foreground(
() =>
new Promise((resolve) => {
const child = spawn(bin, args, { stdio: "inherit" });
child.on("close", resolve);
}),
);
يتوقف عرض التقدم عن الرسم طوال مدة عمل الدالة بالضبط، ويعود أيًّا كانت طريقة انتهائها، لذا لا تمحو مخرجات البرنامج وصفوف العرض بعضها بعضًا أبدًا. وتحتفظ الدالة async بالطرفية حتى يستقر وعدها، وتُرجع await ctx.foreground(...) ما أرجعته الدالة وتعيد رمي ما رمته.
غلّف التسليم، لا المهمة كلها: فكل ما تفعله مهمتك قبل تشغيل العملية، مثل حلّ المسارات وقراءة الملفات واستدعاء ctx.tools، يجب أن يظل يعرض التقدم. ولا تستخدمها إلا عندما يكتب شيء إلى واصفات البناء نفسها، أي عملية فرعية بـ stdio موروث أو أي شيء تفاعلي مثل REPL أو مصحّح أخطاء. فالمخرجات المكتوبة بـ ctx.log أو ctx.exec أو ctx.tools أو console الخاصة بمهمتك مرتّبة أصلًا مع العرض.
استخدام سلسلة الأدوات المدمجة
يحمل Elide بالفعل مُصرّف Kotlin و javac و jar و Native Image و ktfmt و google-java-format. والمهمة التي تحتاج إلى أحدها لا يلزمها تشغيل Elide آخر للوصول إليه:
// Before: a new process per compile, diagnostics as text on a stream.
const compiled = ctx.exec("elide", ["kotlinc", "-d", ".dev/classes", "-cp", cp, "src/main/kotlin"]);
if (!compiled.ok) return ctx.fail(compiled.stderr);
ctx.exec("elide", ["jar", "--create", "--file", ".dev/lib/app.jar", "-C", ".dev/classes", "."]);
// After: the same tools, in-process, with structured results.
const result = ctx.tools.kotlinc({
sources: ["src/main/kotlin"], // files, directories or globs, project-relative
out: ".dev/classes", // a directory, or a path ending in `.jar`
classpath: ctx.tools.classpath(), // the resolved compile classpath, in order
friendPaths: [".dev/classes-core"], // whose `internal` declarations this compile may see
args: ["-Xjvm-default=all"], // any further kotlinc flags, verbatim
});
if (!result.ok) {
const first = result.diagnostics.find((d) => d.severity === "error");
return ctx.fail(`${first?.file}:${first?.line}: ${first?.message}`);
}
ctx.tools.jar({
out: ".dev/lib/app.jar",
entries: [{ from: ".dev/classes" }, { from: "LICENSE", into: "META-INF" }],
mainClass: "com.acme.MainKt",
manifest: { "Implementation-Version": ctx.project.version ?? "dev" },
});
ينتظر كل استدعاء حتى تعود الأداة، ويُرجع { ok, exitCode, tool, diagnostics }. تُعرض تشخيصات المُصرّف وأداة التنسيق في مخرجات البناء تمامًا كما يعرضها التصريف المدمج، وتعود في diagnostics بصيغة { severity, message, file, line, column }؛ أما jar و nativeImage فتنتجان نصًا يُبث إلى سجل البناء منسوبًا إلى مهمتك (مرّر quiet: true لإخفائه)، وتتركان diagnostics فارغة. المسارات نسبية إلى المشروع ما لم تكن مطلقة. يمكنك كتابة المخرجات في أي مكان، لكن المخرجات الواقعة ضمن مجلد المشروع وحدها يمكن تخزينها مؤقتًا واستعادتها، لذا أبقها هناك عندما تصرّح بها المهمة.
kotlinc({ sources, out, classpath?, friendPaths?, args?, incremental?, plugins? }): مُصرّف Kotlin المدمج. تُطبَّق إضافات المُصرّف التي يصرّح بها ملف البيان أو يفعّلها (kotlin.features،kotlin.plugins،mavenDependencies().kotlinPlugins) كما في التصريف المدمج، وتنضم بيئات التشغيل التي توفرها تلك الميزات (بيئة Metro مثلًا) إلى classpath معها: فالإضافة دون بيئة تشغيلها تُفشل المُصرّف بدلًا من ألا تفعل شيئًا. ويشغّلplugins: falseالمُصرّف المجرد، وهو ما تحتاجه المصادر التي تقتصر على التعليقات التوضيحية. ولا يُضاف أي-jvm-targetما لم تمرّره فيargs.javac({ sources, out, classpath?, processorPath?, args? }): مُصرّف Java المدمج؛ ويفعّلprocessorPathمعالجة التعليقات التوضيحية.jar({ out, entries, exclude?, date?, manifest?, mainClass?, quiet? }): كل مُدخل مجلد (محتواه)، أو ملف، أو أرشيف آخر تُؤخذ مُدخلاته (archive: true)، ويقع في جذر الأرشيف أو تحتinto. يُكتبManifest-VersionوCreated-Byدائمًا؛ ويصبحmainClassهوMain-Class.nativeImage({ args, cwd?, quiet? }): برنامج تشغيل Native Image المدمج؛ تُربط المسارات النسبية فيargsبـcwd، وقيمته الافتراضية جذر المشروع. ويعمل الباني بوصفه JVM فرعية مثبّتة على JDK الخاص بـ Elide.format({ tool, files, check?, args? }): أدوات التنسيق المدمجة:toolهي"ktfmt"لـ Kotlin أو"javaformat"لـ google-java-format. وfilesملفات أو مجلدات أو أنماط glob، مقصورة على ما تقبله أداة التنسيق (.kt/.ktsأو.java)؛ وإن لم يطابق شيء ينجح الاستدعاء ولا يفعل شيئًا. تُعاد كتابة الملفات في مكانها، أو معcheck: trueيُبلَّغ عنها فقط، ويفشل الاستدعاء إذا كان أي ملف سيتغير. وفي الحالتين تُدرجchangedالمسارات المطلقة للملفات التي لم تكن منسّقة، ويصل كل منها تشخيصًا: تحذيرًا معcheck، ومعلومة عند إعادة الكتابة، وخطأً للملف الذي تعذّرت قراءته أو تحليله. وتمرّرargsخيارات إضافية إلى أداة التنسيق، مثل["--google-style"].classpath({ sourceSet?, usage? }): classpath التبعيات المحلول، مسارات مطلقة بالترتيب. وusageهي"compile"(الافتراضي؛ ما تراه مهام التصريف)، أو"runtime"، أو"test"(classpath التشغيل لمجموعة الاختبار)، أو"processor"، أو"modules". وتُرجع ما حلّه هذا البناء، لذا ينبغي للمهمة التي تقرؤها أن تستخدمdependsOn: "maven-dependencies".
ctx.tools.version هو إصدار أشكال الخيارات والنتائج هذه، وهو 1 اليوم. ولا يتغير إلا مع تغيير غير متوافق؛ فالخيار الاختياري الجديد أو عضو النتيجة الجديد لا يغيّره. والإضافة التي يجب أن تعمل مع أكثر من إصدار من Elide تتفرّع بحسبه.
لا تعمل المُصرّفات داخل العملية نفسها إلا عبر سلسلة الأدوات المدمجة. وملف البيان الذي يختار kotlin.toolchain مخصّصًا أو مُدارًا، أو jvm.java.compiler.mode الخارجي، يتلقى من ctx.tools.kotlinc / ctx.tools.javac خطأً يسمّي المهمة والأداة؛ أزل الاختيار، أو شغّل سلسلة الأدوات تلك برنامجًا عبر ctx.exec. وينطبق الأمر نفسه على توزيعة بُنيت دون سلسلة أدوات JVM.
إعادة تحزيم أرشيف
تقرأ jar أرشيفًا موجودًا مصدرًا، لذا فإن تقليص تبعية وختمها بشكل قابل لإعادة الإنتاج استدعاء واحد داخل العملية بدلًا من سلسلة unzip / rm -rf / jar:
ctx.tools.jar({
out: ".dev/lib/compiler-slim.jar",
entries: [{ from: "libs/compiler.jar", archive: true }, { from: ".dev/patched" }],
exclude: ["com/intellij/**", "META-INF/*.SF"],
date: "2024-01-01T00:00:00Z",
});
لا يُستنتج archive: true أبدًا من امتداد الملف: فمن دونه يُضاف ملف .jar الذي يسمّيه from ملفًا واحدًا. ويُسقط META-INF/MANIFEST.MF الخاص بالأرشيف المصدر، إذ تحمل النتيجة ملف البيان الذي يصفه manifest و mainClass لا ملف المصدر أبدًا، ويعيد into تأصيل كل ما عدا ذلك. والعضو الذي سيقع مساره بعد into خارج الأرشيف (../x، أو /x مطلق) يُفشل المهمة بدلًا من كتابته، وكذلك into الذي يخرج منه.
تُطابق أنماط exclude المسار الذي سيأخذه المُدخل داخل الأرشيف، بعد أن يعيد into تأصيله. وهي مسارات لا أسماء حزم، لذا فشجرة الحزمة هي "kotlin/**" ونوع الملف هو "META-INF/*.SF". والنمط في الخيارات ينطبق على كل مُدخل أيًّا كان مصدره؛ والنمط على مُدخل ينطبق على ذلك المُدخل وحده، إضافة إلى الأنماط العامة.
يختم date كل مُدخل، بما فيه ملف البيان، بتلك اللحظة، وتكتب الأداة ملفات الجذر بترتيب مرتّب، لذا فإن إعادة تحزيم مُدخلات غير متغيرة تعطي نتيجة متطابقة بايتًا ببايت، وهو ما يحتاجه ختم AOT أو ختم التوزيعة. ومن دونه لا تكون أوقات المُدخلات قابلة لإعادة الإنتاج: فالمُدخل المُمرَّر دون تغيير يحتفظ بأوقات تعديل ملفاته، بينما يحمل عضو الأرشيف، والمُدخل ذو into، والمُدخل الذي مسّه استبعاد أو تصادم، وقت الاستدعاء.
تُحزَّم المُدخلات بالترتيب الذي تُدرجها به، ويمكن لاثنين منها المطالبة بمسار الأرشيف نفسه: فيفوز الأخير، ويحذّر البناء مسمّيًا المسار والمصدر الذي أُخذ منه. لا يُدمج شيء: فمُخرَج Jar المصرَّح به في ملف البيان يوحّد ملفات META-INF/services من جذوره، أما ctx.tools.jar فلا، لذا فإن المهمة التي تحتاج إلى ذلك التوحيد تكتب الملف المدموج بنفسها وتُدرجه أخيرًا.