@7n/rules 1.83.3 → 1.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.85.0] - 2026-08-05
4
+
5
+ ### Added
6
+
7
+ - Контракт плагінів `n-rules:plugin` — мінор `3.1.0`: новий host-імпорт `exec-tool`
8
+ (`run-tool` плюс `cwd`, накладені змінні середовища й двобічний scratch-обмін
9
+ файлами) і слот `scratch-dir@1` на чинному `host-context`. Зміна additive:
10
+ плагін, зібраний під v3.0, лінкується й працює без повторної збірки.
11
+
12
+ Резолв тулів у `manifest.tools` навчився схем (рішення В спеки v3.1):
13
+ `path:<name>` резолвиться по PATH замість github-релізу; відсутність схеми —
14
+ як було (`pinned:`).
15
+
16
+ Перший концерн на новій поверхні — `bun/licensee`: детекція ліцензій
17
+ npm-залежностей переїхала з JS у wasm-компонент `lang-js/wasm-concerns`
18
+ (T0-фікс лишається JS). Спавнений тул виконується з правами хоста, поза
19
+ wasm-пісочницею — межа довіри плагіна це його пін, не пісочниця.
20
+
21
+ ### Fixed
22
+
23
+ - регенерує `.cursor/rules/n-rust.mdc` (дрейф із каноном після #392) і піднімає `KNOWN_PLUGIN_RANGES['@7n/rules-lang-rust']` до `^0.16` слідом за релізом плагіна
24
+
25
+ ## [1.84.0] - 2026-08-05
26
+
27
+ ### Changed
28
+
29
+ - `k8s/manifests` — перший зріз порту в `rules-core` плюс полагоджений дефект канону. Портовано два з чотирьох шарів `lint()`: rego-контур (`runAllK8sRego` — усі девʼять namespace-ів, класифікатор fix-підказок, гілка `--data`/`templateData` у `conftest`) і чотири самодостатні cross-file перевірки (`assertNoForbiddenK8sDevPaths`, `validateSvcYamlAndSvcHlPairs`, `validateKustomizationPathRefsExistOnDisk`, `validateKustomizationIncludesSvcHlWithSvc`, `validateConfigMapNameMatchesDeployment`). Сам концерн у `NATIVE_CONCERNS` **не** заведено — він неподільний для диспатчу і зайде туди разом із рештою шарів, тож поведінка лінту незмінна.
30
+
31
+ **Зміна поведінки:** звірка `metadata.name` ConfigMap із Deployment (`k8s/base/configmap.yaml`) більше не спирається на «перший `kind: Deployment` за порядком `readdir`». Обхід файлів каталогу став відсортованим (детермінований на будь-якій файловій системі — раніше macOS і Linux-раннер CI могли давати різний вислід), і серед **усіх** Deployment каталогу беруться лише ті, що посилаються рівно на один ConfigMap. Наслідок — fail-closed: каталог, де перший Deployment ConfigMap не вживає, раніше мовчки випадав із перевірки, тепер перевіряється. Репозиторії з одним Deployment на каталог змін не побачать.
32
+
3
33
  ## [1.83.3] - 2026-08-05
4
34
 
5
35
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.83.3",
3
+ "version": "1.85.0",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -83,9 +83,9 @@
83
83
  "zod": "^4.4.3"
84
84
  },
85
85
  "optionalDependencies": {
86
- "@7n/rules-darwin-arm64": "1.83.1",
87
- "@7n/rules-linux-x64": "1.83.1",
88
- "@7n/rules-win32-x64": "1.83.1",
86
+ "@7n/rules-darwin-arm64": "1.84.0",
87
+ "@7n/rules-linux-x64": "1.84.0",
88
+ "@7n/rules-win32-x64": "1.84.0",
89
89
  "@earendil-works/pi-ai": "0.80.10",
90
90
  "@earendil-works/pi-coding-agent": "0.80.10"
91
91
  },
@@ -635,7 +635,7 @@ async function validateOneKustomizationPathRefsExist(root, kustAbs, rootNorm, fa
635
635
  * @param {(msg: string) => void} fail callback для повідомлень про помилки
636
636
  * @returns {Promise<void>} результат
637
637
  */
638
- async function validateKustomizationPathRefsExistOnDisk(root, yamlFilesAbs, fail) {
638
+ export async function validateKustomizationPathRefsExistOnDisk(root, yamlFilesAbs, fail) {
639
639
  const rootNorm = resolve(root)
640
640
  const kustFiles = yamlFilesAbs.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')
641
641
  for (const kustAbs of kustFiles) {
@@ -724,7 +724,7 @@ async function validateOneKustomizationSvcHlWithSvc(root, kustAbs, fail) {
724
724
  * @param {(msg: string) => void} fail callback помилки
725
725
  * @returns {Promise<void>} результат
726
726
  */
727
- async function validateKustomizationIncludesSvcHlWithSvc(root, yamlFiles, fail) {
727
+ export async function validateKustomizationIncludesSvcHlWithSvc(root, yamlFiles, fail) {
728
728
  const kustFiles = yamlFiles.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')
729
729
  for (const kustAbs of kustFiles) {
730
730
  await validateOneKustomizationSvcHlWithSvc(root, kustAbs, fail)
@@ -2372,35 +2372,27 @@ async function tryReaddir(dirPath) {
2372
2372
  }
2373
2373
 
2374
2374
  /**
2375
- * Читає YAML-файл і шукає перший документ із заданим `kind`.
2376
- * @param {string} filePath абсолютний шлях до YAML-файлу
2377
- * @param {string} kind очікуваний `kind`
2378
- * @returns {Promise<Record<string, unknown> | null>} знайдений об'єкт або null
2379
- */
2380
- async function readFirstDocByKindFromFile(filePath, kind) {
2381
- const raw = await tryReadFileUtf8(filePath)
2382
- if (raw === undefined) return null
2383
- const docs = tryParseAllYamlDocs(raw)
2384
- if (docs === undefined) return null
2385
- return findFirstDocByKind(docs, kind)
2386
- }
2387
-
2388
- /**
2389
- * Знаходить перший документ **Deployment** серед YAML-файлів каталогу (для перевірки імені ConfigMap, js-run.mdc).
2375
+ * Усі документи **Deployment** каталогу, у **відсортованому** порядку імен файлів.
2376
+ * Сортування обов'язкове: без нього результат залежав би від порядку `readdir`,
2377
+ * тобто від файлової системи (APFS впорядковує, ext4 — hash-порядок).
2390
2378
  * @param {string} dirPath абсолютний шлях до каталогу
2391
- * @returns {Promise<Record<string, unknown> | null>} об'єкт Deployment або null
2379
+ * @returns {Promise<Record<string, unknown>[]>} документи Deployment у детермінованому порядку
2392
2380
  */
2393
- export async function findDeploymentDocInDir(dirPath) {
2394
- const entries = await tryReaddir(dirPath)
2381
+ async function collectDeploymentDocsInDir(dirPath) {
2382
+ const dirEntries = await tryReaddir(dirPath)
2383
+ const entries = dirEntries.filter(e => K8S_YAML_EXT_RE.test(e)).toSorted()
2384
+ /**
2385
+ @type {Record<string, unknown>[]}
2386
+ */
2387
+ const out = []
2395
2388
  for (const entry of entries) {
2396
- if (!K8S_YAML_EXT_RE.test(entry)) {
2397
- continue
2398
- }
2399
-
2400
- const found = await readFirstDocByKindFromFile(join(dirPath, entry), 'Deployment')
2401
- if (found !== null) return found
2389
+ const raw = await tryReadFileUtf8(join(dirPath, entry))
2390
+ if (raw === undefined) continue
2391
+ const docs = tryParseAllYamlDocs(raw)
2392
+ if (docs === undefined) continue
2393
+ out.push(...collectDocsByKind(docs, 'Deployment'))
2402
2394
  }
2403
- return null
2395
+ return out
2404
2396
  }
2405
2397
 
2406
2398
  /**
@@ -3064,7 +3056,7 @@ async function validateOneSvcYamlHlPair(root, absSet, svcAbs, fail) {
3064
3056
  * @param {(msg: string) => void} fail callback помилки
3065
3057
  * @returns {Promise<void>} результат
3066
3058
  */
3067
- async function validateSvcYamlAndSvcHlPairs(root, yamlFiles, fail) {
3059
+ export async function validateSvcYamlAndSvcHlPairs(root, yamlFiles, fail) {
3068
3060
  const absSet = new Set(yamlFiles)
3069
3061
  failIfSvcHlWithoutSiblingSvc(root, yamlFiles, absSet, fail)
3070
3062
  const svcFiles = yamlFiles.filter(p => basename(p).toLowerCase() === 'svc.yaml')
@@ -3374,7 +3366,7 @@ async function checkK8sYamlFile(abs, root, fail, pass) {
3374
3366
  * @param {(msg: string) => void} fail callback для реєстрації порушення
3375
3367
  * @returns {void} результат
3376
3368
  */
3377
- function assertNoForbiddenK8sDevPaths(yamlFiles, root, fail) {
3369
+ export function assertNoForbiddenK8sDevPaths(yamlFiles, root, fail) {
3378
3370
  for (const abs of yamlFiles) {
3379
3371
  const rel = relative(root, abs).replaceAll('\\', '/')
3380
3372
  if (isForbiddenK8sDevPath(rel)) {
@@ -3409,6 +3401,31 @@ function extractFirstConfigMapName(raw) {
3409
3401
  return manifestMetadataName(cm)
3410
3402
  }
3411
3403
 
3404
+ /**
3405
+ * Deployment каталогу, з яким має звірятись `metadata.name` ConfigMap.
3406
+ *
3407
+ * Раніше тут стояв `findDeploymentDocInDir` — «перший `kind: Deployment` за порядком
3408
+ * `readdir`». Це давало два дефекти: недетермінізм (порядок ФС) і false negative —
3409
+ * якщо першим траплявся Deployment без ConfigMap-рефа (або з двома), перевірка мовчки
3410
+ * закривалась, хоча поруч стояв Deployment рівно з одним рефом. Тепер обхід
3411
+ * детермінований (відсортований), і серед **усіх** Deployment каталогу беруться лише
3412
+ * ті, що посилаються рівно на один ConfigMap.
3413
+ * @param {string} dirPath каталог із `configmap.yaml`
3414
+ * @param {string} cmName `metadata.name` ConfigMap
3415
+ * @returns {Promise<{ matched: boolean, deployName: string } | null>} збіг, кандидат для повідомлення або null
3416
+ */
3417
+ async function configMapOwnerDeployment(dirPath, cmName) {
3418
+ let firstCandidate = null
3419
+ for (const deployment of await collectDeploymentDocsInDir(dirPath)) {
3420
+ if (collectDeploymentConfigMapRefs(deployment).size !== 1) continue
3421
+ const deployName = manifestMetadataName(deployment)
3422
+ if (deployName === null) continue
3423
+ if (deployName === cmName) return { matched: true, deployName }
3424
+ firstCandidate ??= deployName
3425
+ }
3426
+ return firstCandidate === null ? null : { matched: false, deployName: firstCandidate }
3427
+ }
3428
+
3412
3429
  /**
3413
3430
  * Перевіряє один файл `configmap.yaml`: якщо поруч є Deployment з рівно одним ConfigMap-рефом,
3414
3431
  * `metadata.name` ConfigMap має збігатися з `metadata.name` Deployment.
@@ -3422,16 +3439,13 @@ async function validateSingleConfigMapNameMatch(cmAbs, rel, fail, passFn) {
3422
3439
  if (raw === undefined) return
3423
3440
  const cmName = extractFirstConfigMapName(raw)
3424
3441
  if (cmName === null) return
3425
- const deployment = await findDeploymentDocInDir(dirname(cmAbs))
3426
- if (deployment === null) return
3427
- const deployName = manifestMetadataName(deployment)
3428
- const cmRefs = collectDeploymentConfigMapRefs(deployment)
3429
- if (cmRefs.size !== 1 || typeof deployName !== 'string') return
3430
- if (cmName === deployName) {
3442
+ const owner = await configMapOwnerDeployment(dirname(cmAbs), cmName)
3443
+ if (owner === null) return
3444
+ if (owner.matched) {
3431
3445
  passFn(`${rel}: metadata.name '${cmName}' збігається з Deployment (k8s.mdc)`)
3432
3446
  } else {
3433
3447
  fail(
3434
- `${rel}: metadata.name '${cmName}' має збігатися з назвою Deployment '${deployName}' — Deployment посилається рівно на один ConfigMap (k8s.mdc)`
3448
+ `${rel}: metadata.name '${cmName}' має збігатися з назвою Deployment '${owner.deployName}' — Deployment посилається рівно на один ConfigMap (k8s.mdc)`
3435
3449
  )
3436
3450
  }
3437
3451
  }
@@ -3444,7 +3458,7 @@ async function validateSingleConfigMapNameMatch(cmAbs, rel, fail, passFn) {
3444
3458
  * @param {(msg: string) => void} fail callback при помилці
3445
3459
  * @param {(msg: string) => void} passFn callback при успіху
3446
3460
  */
3447
- async function validateConfigMapNameMatchesDeployment(root, yamlFilesAbs, fail, passFn) {
3461
+ export async function validateConfigMapNameMatchesDeployment(root, yamlFilesAbs, fail, passFn) {
3448
3462
  const cmFiles = yamlFilesAbs.filter(abs => {
3449
3463
  const rel = relative(root, abs).replaceAll('\\', '/')
3450
3464
  return CONFIGMAP_BASE_PATH_RE.test(`/${rel}`) || rel === 'k8s/base/configmap.yaml'
@@ -6421,7 +6435,7 @@ function k8sRegoFixHint(ns, file, message) {
6421
6435
  * @param {(msg: string, hint?: unknown) => void} fail callback реєстрації порушення.
6422
6436
  * @returns {Promise<void>} результат
6423
6437
  */
6424
- async function runAllK8sRego(root, yamlFiles, fail) {
6438
+ export async function runAllK8sRego(root, yamlFiles, fail) {
6425
6439
  const relOf = abs => relative(root, abs).replaceAll('\\', '/') || abs
6426
6440
 
6427
6441
  const allYaml = yamlFiles
@@ -208,7 +208,7 @@
208
208
  },
209
209
  {
210
210
  "name": "n-rules plugin manifest",
211
- "description": "plugin.toml — маніфест wasm-плагіна n-rules:plugin@3.0.0 (crates/<guest>/plugin.toml); офіційної JSON Schema поки нема (schemars-схема rules_contract::manifest::Manifest ще не публікується як окремий файл), тому permissive any.json",
211
+ "description": "plugin.toml — маніфест wasm-плагіна n-rules:plugin@3.1.0 (crates/<guest>/plugin.toml); офіційної JSON Schema поки нема (schemars-схема rules_contract::manifest::Manifest ще не публікується як окремий файл), тому permissive any.json",
212
212
  "url": "any.json",
213
213
  "fileMatch": [
214
214
  "plugin.toml",
@@ -7,7 +7,9 @@
7
7
  * (значення — НЕ голий рядок шляху, доккомент [`buildWasmConcernMap`] нижче).
8
8
  *
9
9
  * **Run-tool контур (задача N1, рішення Д спеки)**: `manifest.tools` —
10
- * задекларовані зовнішні tool-залежності плагіна (напр. `"shellcheck@^0.9"`).
10
+ * задекларовані зовнішні tool-залежності плагіна (напр. `"shellcheck@^0.9"`,
11
+ * `"path:bun"` — схеми резолву, рішення В спеки
12
+ * `docs/specs/2026-08-01-plugin-contract-v31-surfaces.md`, [`parseToolRef`]).
11
13
  * Для кожного запису резолвер кличе ensure-tool контур (`ensureToolAsync`,
12
14
  * `../ensure-tool.mjs`, injectable через `opts.ensureToolFn`) — будує мапу
13
15
  * «ім'я тула (без semver-суфікса декларації) → абсолютний шлях», яку
@@ -114,6 +116,7 @@ import { fileURLToPath } from 'node:url'
114
116
 
115
117
  import { ensureToolAsync } from '../ensure-tool.mjs'
116
118
  import { loadNative } from '../native.mjs'
119
+ import { resolveCmd } from '../../utils/resolve-cmd.mjs'
117
120
 
118
121
  /**
119
122
  * Абсолютний шлях до `npm/wasm-plugins/` — тека, куди `build-wasm-plugins.mjs`
@@ -431,16 +434,39 @@ async function resolveEntryPath(entry, ctx) {
431
434
  }
432
435
 
433
436
  /**
434
- * Ім'я тула без semver-суфікса декларації (`"shellcheck@^0.9"` `"shellcheck"`)
435
- * — той самий парсинг, що host-бік `ToolResolver::run`
436
- * (`crates/rules-plugin-host/src/tool_resolver.rs`, доккомент модуля
437
- * пояснює версійну політику вона НЕ тут, ensure-tool ставить канонічну
438
- * закріплену версію).
437
+ * Схеми резолву тула в рядку `manifest.tools` (рішення В спеки
438
+ * `docs/specs/2026-08-01-plugin-contract-v31-surfaces.md`). Дзеркало
439
+ * Rust-боку (`rules_contract::validators::tool::ToolScheme`) і саме
440
+ * ДЗЕРКАЛО, а не незалежний список: розбіжність означала б, що маніфест,
441
+ * прийнятий хостом, мовчки не резолвиться оркестрацією.
442
+ */
443
+ const TOOL_SCHEMES = {
444
+ /** Github-реліз із закріпленою версією (`TOOLS` + `tool-pins.json`) — дефолт за відсутності схеми. */
445
+ PINNED: 'pinned',
446
+ /** Резолв по `PATH` (`bun`, `bunx` — ensure-tool не вміє і не має їх завантажувати). */
447
+ PATH: 'path'
448
+ }
449
+
450
+ /**
451
+ * Розбирає запис `manifest.tools` у `{ scheme, name }`: відрізає схему
452
+ * (якщо є), потім semver-суфікс декларації (`"path:bun@^1.2"` →
453
+ * `{ scheme: 'path', name: 'bun' }`). Порт
454
+ * `rules_contract::validators::tool::parse_tool_ref` — дзеркальний тест
455
+ * конвенції: `tests/wasm-plugins.test.mjs`.
456
+ *
457
+ * Невідома схема НЕ інтерпретується як частина імені: [`ensureDeclaredTools`]
458
+ * пропускає такий запис із warn, бо резолвити його однаково нема чим (та
459
+ * сама поведінка, що host-валідатор `validate_tool_ref`).
439
460
  * @param {string} declared запис із `manifest.tools`
440
- * @returns {string} ім'я тула
461
+ * @returns {{ scheme: string | null, name: string }} схема (`null` — невідома) й ім'я тула
441
462
  */
442
- function toolName(declared) {
443
- return declared.split('@', 1)[0]
463
+ function parseToolRef(declared) {
464
+ const separator = declared.indexOf(':')
465
+ if (separator === -1) return { scheme: TOOL_SCHEMES.PINNED, name: declared.split('@', 1)[0] }
466
+ const prefix = declared.slice(0, separator)
467
+ const rest = declared.slice(separator + 1)
468
+ if (prefix !== TOOL_SCHEMES.PINNED && prefix !== TOOL_SCHEMES.PATH) return { scheme: null, name: declared }
469
+ return { scheme: prefix, name: rest.split('@', 1)[0] }
444
470
  }
445
471
 
446
472
  /**
@@ -456,14 +482,26 @@ function toolName(declared) {
456
482
  * @param {string} pluginName ім'я плагіна (лише для diagnostics-повідомлень)
457
483
  * @param {string[]} declaredTools `manifest.tools` — рядки виду `"shellcheck@^0.9"`
458
484
  * @param {(toolId: string) => Promise<string>} ensureToolFn ін'єкція `ensureToolAsync` (тести підміняють)
485
+ * @param {(cmd: string) => string | null} resolveCmdFn ін'єкція `resolveCmd` для схеми `path:` (тести підміняють — інакше результат залежав би від PATH машини)
459
486
  * @returns {Promise<Record<string, string>>} ім'я тула (без semver-суфікса) → абсолютний шлях
460
487
  */
461
- async function ensureDeclaredTools(pluginName, declaredTools, ensureToolFn) {
488
+ async function ensureDeclaredTools(pluginName, declaredTools, ensureToolFn, resolveCmdFn) {
462
489
  /** @type {Record<string, string>} */
463
490
  const toolPaths = {}
464
491
  for (const declared of declaredTools) {
465
- const name = toolName(declared)
492
+ const { scheme, name } = parseToolRef(declared)
466
493
  try {
494
+ if (scheme === null)
495
+ throw new Error(`невідома схема резолву (відомі "${TOOL_SCHEMES.PINNED}:", "${TOOL_SCHEMES.PATH}:")`)
496
+ if (scheme === TOOL_SCHEMES.PATH) {
497
+ // `path:` НЕ йде в ensure-tool контур узагалі: той уміє лише
498
+ // github-релізи, а `bun`/`bunx` встановлює користувач. Відсутність —
499
+ // не помилка резолву плагіна, а звичайний skip одного тула.
500
+ const fromPath = resolveCmdFn(name)
501
+ if (!fromPath) throw new Error('не знайдено в PATH')
502
+ toolPaths[name] = fromPath
503
+ continue
504
+ }
467
505
  toolPaths[name] = await ensureToolFn(name)
468
506
  } catch (error) {
469
507
  console.warn(
@@ -484,7 +522,7 @@ async function ensureDeclaredTools(pluginName, declaredTools, ensureToolFn) {
484
522
  * запис у мапу для кожного `manifest.concerns`. Усі кроки — skip-not-crash
485
523
  * (доккомент модуля).
486
524
  * @param {string} cwd абсолютний корінь consumer-репо
487
- * @param {{fetchFn: typeof fetch, cacheDir: string, env: Record<string, string | undefined>, ensureToolFn: (toolId: string) => Promise<string>, nativeFn: typeof loadNative, builtinPinsDir: string}} ctx ін'єктовані залежності
525
+ * @param {{fetchFn: typeof fetch, cacheDir: string, env: Record<string, string | undefined>, ensureToolFn: (toolId: string) => Promise<string>, resolveCmdFn: (cmd: string) => string | null, nativeFn: typeof loadNative, builtinPinsDir: string}} ctx ін'єктовані залежності
488
526
  * @returns {Promise<Map<string, WasmConcernMapEntry>>} ключ концерну → `{ wasmPath, toolPaths }`
489
527
  */
490
528
  async function buildWasmConcernMap(cwd, ctx) {
@@ -500,7 +538,7 @@ async function buildWasmConcernMap(cwd, ctx) {
500
538
  console.warn(`⚠️ wasm-плагін "${entry.name}" пропущено: не вдалось завантажити (${error.message})`)
501
539
  continue
502
540
  }
503
- const toolPaths = await ensureDeclaredTools(entry.name, manifest.tools ?? [], ctx.ensureToolFn)
541
+ const toolPaths = await ensureDeclaredTools(entry.name, manifest.tools ?? [], ctx.ensureToolFn, ctx.resolveCmdFn)
504
542
  // `manifest.concerns` — масив структурованих контрибуцій `{ key, scope, glob }`
505
543
  // (задача N2, передумова full-scope мосту, доккомент `wit/world.wit`
506
544
  // `record concern-contribution`), не голі рядки — мапа концернів індексується
@@ -521,9 +559,10 @@ async function buildWasmConcernMap(cwd, ctx) {
521
559
  * неминуче асинхронні; єдиний виклик-сайт (`detect.mjs`) вже `async`,
522
560
  * контракт виклику не ламається.
523
561
  * @param {string} cwd абсолютний корінь consumer-репо (звідки читається `.n-rules.json`)
524
- * @param {{fetchFn?: typeof fetch, cacheDir?: string, env?: Record<string, string | undefined>, ensureToolFn?: (toolId: string) => Promise<string>, nativeFn?: typeof loadNative, builtinPinsDir?: string}} [opts] ін'єкції для тестів:
562
+ * @param {{fetchFn?: typeof fetch, cacheDir?: string, env?: Record<string, string | undefined>, ensureToolFn?: (toolId: string) => Promise<string>, resolveCmdFn?: (cmd: string) => string | null, nativeFn?: typeof loadNative, builtinPinsDir?: string}} [opts] ін'єкції для тестів:
525
563
  * `fetchFn` (дефолт — глобальний `fetch`), `cacheDir` (дефолт — `resolvePluginCacheDir`), `env` (дефолт — `process.env`),
526
- * `ensureToolFn` (дефолт — `ensureToolAsync`), `nativeFn` (дефолт — `loadNative`, wiring-тести підміняють фейковим addon-ом),
564
+ * `ensureToolFn` (дефолт — `ensureToolAsync`), `resolveCmdFn` (дефолт — `resolveCmd`; тести підміняють, бо інакше
565
+ * `path:`-схема давала б різний `toolPaths` залежно від PATH машини), `nativeFn` (дефолт — `loadNative`, wiring-тести підміняють фейковим addon-ом),
527
566
  * `builtinPinsDir` (дефолт — [`WASM_PLUGINS_DIR`], реальна `npm/wasm-plugins/`; тести ізолюють неіснуючим каталогом,
528
567
  * щоб локальна wasm-збірка в робочому дереві не підмішувала builtin-контрибуції в контрольовані сценарії)
529
568
  * @returns {Promise<Map<string, WasmConcernMapEntry>>} ключ концерну (`ruleId/concernId`) → `{ wasmPath, toolPaths }`
@@ -536,6 +575,7 @@ export function resolveWasmConcernMap(cwd, opts = {}) {
536
575
  cacheDir: opts.cacheDir ?? resolvePluginCacheDir(env),
537
576
  env,
538
577
  ensureToolFn: opts.ensureToolFn ?? ensureToolAsync,
578
+ resolveCmdFn: opts.resolveCmdFn ?? resolveCmd,
539
579
  nativeFn: opts.nativeFn ?? loadNative,
540
580
  builtinPinsDir: opts.builtinPinsDir ?? WASM_PLUGINS_DIR
541
581
  }
@@ -283,7 +283,7 @@ export const KNOWN_PLUGIN_RANGES = Object.freeze({
283
283
  '@7n/rules-ci-azure': '^2',
284
284
  '@7n/rules-lang-js': '^0.26',
285
285
  '@7n/rules-lang-python': '^0.12',
286
- '@7n/rules-lang-rust': '^0.15',
286
+ '@7n/rules-lang-rust': '^0.16',
287
287
  '@7n/rules-lang-php': '^0.3'
288
288
  })
289
289
 
@@ -441,6 +441,49 @@ per-file: пиши `detect_whole_batch(files: &[SourceFile]) -> Vec<Diagnostic>`
441
441
  фейковий бінарник (shell-скрипт у tempdir) у `ToolResolver::new(map)`
442
442
  (зразок — `run_tool_reaches_resolved_fake_tool_binary`
443
443
  `crates/rules-plugin-host/tests/contract_test_kit.rs`).
444
+ - **`exec-tool`** (зріз 5 контракту v3.1, спека
445
+ `docs/specs/2026-08-01-plugin-contract-v31-surfaces.md`, рішення А/Б/В/З) —
446
+ `run-tool` ПЛЮС виконавчий контекст. Бери його, якщо тулу потрібне бодай
447
+ щось із трьох:
448
+ - **`cwd`** (posix-relative від кореня репо; `none` — сам корінь). Без
449
+ нього процес успадковує cwd ХОСТ-процесу, який збігається з коренем репо
450
+ випадково. Обгортка, що читає `node_modules`/конфіги відносно себе, без
451
+ цього поля перевіряє не той каталог;
452
+ - **`env`** — пари НАКЛАДАЮТЬСЯ поверх успадкованого середовища, не
453
+ заміщують його;
454
+ - **scratch-обмін**: `scratch-in` хост матеріалізує у тимчасовому каталозі
455
+ ПЕРЕД спавном (вшиті в компонент конфіги/політики), `scratch-out` —
456
+ глоби файлів, які хост зчитує ПІСЛЯ виходу процесу (звіти тула).
457
+ Абсолютний шлях каталогу гість бере зі слоту `scratch-dir@1`
458
+ (`host-context`) і САМ кладе його в `args` — placeholder-підстановки в
459
+ аргументах контракт свідомо не має. Каталог живе РІВНО один
460
+ `detect`/`fix`-виклик, тож шлях не можна кешувати між викликами;
461
+ відсутній `scratch-out`-файл — не помилка, а «звіту немає».
462
+
463
+ Помилки — та сама форма, що в `run-tool`: `status: none` + людиночитний
464
+ `stderr`, ніколи не паніка. `exec-tool` виконує ВЖЕ зарезолвлений тул і
465
+ нічого не добуває — провізіонінг живе в окремій команді `tools ensure`.
466
+
467
+ **Схеми `manifest.tools`** (рішення В): `pinned:` (дефолт за відсутності
468
+ схеми — github-реліз із закріпленою версією) і `path:` (резолв по `PATH`:
469
+ `path:bun`, `path:bunx` — те, що ensure-tool завантажити не може й не має).
470
+
471
+ > **Trust boundary — прочитай ДО того, як напишеш перший `exec-tool`.**
472
+ > Спавнений тул виконується **з правами хоста, ПОЗА wasm-пісочницею**, і
473
+ > **МОЖЕ писати в репо**. Ані `fs_read`, ані `network` до нього не
474
+ > застосовуються: пісочниця обмежує wasm-код гостя, а не процес, який гість
475
+ > попросив запустити. Хост звужує лише ЯКИЙ бінарник (задекларований),
476
+ > ЗВІДКИ він стартує (`cwd` — безпечний repo-relative) і ЩО потрапляє у
477
+ > scratch (без `..`, з лімітами). Тобто **межа довіри плагіна — його пін
478
+ > (`url` + `sha256`), а не пісочниця**: ревʼю піна = ревʼю прав.
479
+ > Enforcement не додається свідомо (потрібен був би власний sandbox-шар
480
+ > навколо спавна на трьох ОС).
481
+
482
+ Живий зразок — `bun/licensee` (`crates/plugin-lang-js/src/lib.rs`, секція
483
+ «Зріз 5»); golden-тести з фейковим `bun` —
484
+ `crates/rules-plugin-host/tests/plugin_lang_js.rs`; повний контекст разом
485
+ (cwd + env + scratch) — `test/guest-exec-tool`
486
+ у `crates/test-plugin-guest`.
444
487
  - **`ci_artifacts`** (слот `ci.artifact@1`): типізовані записи
445
488
  `n-rules:slots` (`crates/rules-contract/src/slots/ci_artifact.rs`) —
446
489
  семантичні перевірки (safe-path, id-regex) живуть у