diff --git a/crates/rules-contract/wit/world.wit b/crates/rules-contract/wit/world.wit index e135e8b1c..888239e90 100644 --- a/crates/rules-contract/wit/world.wit +++ b/crates/rules-contract/wit/world.wit @@ -88,27 +88,39 @@ world plugin { /// (`crates/rules-core/src/diagnostics.rs`): `reason`/`message` обов'язкові, /// `file` опційний posix-relative шлях, `severity` з дефолтом `error`. /// - /// # `file: none` для `concern-scope::per-file` — ВІДКРИТЕ питання, не - /// заборона (задача fix/napi-empty-fix-batch) + /// # `file: none` для `concern-scope::per-file` — ДОЗВОЛЕНО (рішення (б)) /// - /// Схема НЕ вимагає `file` для `per-file`-концерну: агрегована + /// Схема НЕ вимагає `file` для `per-file`-концерну, і це свідомо: агрегована /// діагностика (одна на весь прогін зовнішнього тула, що сам не /// атрибутує вивід по файлах — приклад: `python/ruff`, /// `crates/plugin-lang-python/src/lib.rs::detect_ruff`) — легітимний - /// `detect()`-результат, ЦЯ record цього не забороняє. Але контракт - /// СВІДОМО НЕ визначає, як host має будувати `fix-request.files` - /// (нижче), коли `fix`-запит несе такі діагностики: ні мовчазний - /// порожній `files` (гість не відрізнить «файлів немає» від «хост їх не - /// передав» — вада #513, `crates/rules-napi/src/lib.rs::run_wasm_concern_fix`, - /// доккомент `ambiguous_empty_fix_batch_err`), ні full-scope glob-обхід - /// (безпечний лише для `scope: full` — для `per-file` він розширив би - /// `fix` за межі дельти) тут не встановлені як дефолт. Сьогоднішній - /// host — fail-loud на цьому стані (типізована помилка, не здогадка). - /// Наступний, хто чіпає це поле: або (а) заборонити `file: none` для - /// `per-file`-концернів явно тут, у схемі/доккоменті, або (б) додати - /// цій record-і чи `fix-request` спосіб нести «per-file-делта без - /// per-diagnostic атрибуції» (напр. окреме поле файлів на рівні - /// batch-у) — не вгадуй, обирай і документуй тут. + /// `detect()`-результат. `file: none` тут означає рівно «детектор не + /// знає, який файл», і це чесніше за вигадану атрибуцію. + /// + /// Питання, яке #517 лишив відкритим — заборонити такий стан для + /// `per-file` (варіант (а)) чи дати спосіб нести дельту без + /// per-diagnostic атрибуції (варіант (б)) — вирішене на користь (б). + /// Причина: дельта — властивість ЗАПИТУ, а не діагностики. Варіант (а) + /// змусив би кожен агрегований детектор парсити вивід тула лише заради + /// host-механіки, якій відповідність «діагностика → файл» не потрібна: + /// фіксер такого концерну хоче СПИСОК ТАРГЕТІВ (`ruff check --fix` + /// ), а не мапу. + /// + /// Розширювати цю схему для (б) не довелось: `fix-request.files` — + /// окреме поле, не похідне від `diagnostics`, — існує з `3.0.0`. + /// Бракувало лише проводки на host-боці, і вона зроблена там: + /// `run_wasm_concern_fix` приймає `delta_files` (той самий список, що + /// йде в `run_wasm_concern` на детекті) і заповнює ним `files`, коли + /// жодна діагностика не назвала файл. Жодного ABI-руху, жодного version + /// bump. Host і далі fail-loud, коли дельти немає взагалі + /// (`ambiguous_empty_fix_batch_err`) — мовчазний порожній `files` був би + /// регресом до #513: гість не відрізнить «файлів немає» від «хост їх не + /// передав». + /// + /// Межа рішення (і єдине, заради чого варто повертатись до (а)): fix + /// ПІДМНОЖИНИ діагностик агрегованого концерну лишається неможливим — + /// для нього потрібна саме атрибуція. Сьогодні такого споживача немає + /// (`applyT0` передає всі violations концерну одним масивом). /// /// `data` — JSON-рядок (серіалізований `serde_json::Value::Object`), не /// структурний тип: WIT record/variant не підтримує довільний @@ -161,11 +173,13 @@ world plugin { /// Вхід `fix`: ті самі файли й концерн, що й `detect`, плюс діагностики, /// для яких запитується fix (підмножина результату `detect`). /// - /// Host сьогодні виводить `files` із `diagnostics[].file` (не з - /// незалежного delta-списку `detect`-виклику) — коли жодна діагностика - /// не несе `file`, дивись відкрите питання біля `record diagnostic` - /// (`file: none` для `per-file`): цей запис нічого не гарантує щодо - /// того, звідки host бере `files` у такому разі. + /// Звідки host бере `files`: спершу з `diagnostics[].file` (найвужчий + /// batch — рівно ті файли, на які вказують діагностики); коли жодна + /// діагностика не несе `file` — з full-scope glob-обходу для + /// `scope: full` концернів, інакше з явної дельти запиту, яку передає + /// оркестрація (рішення (б), доккомент біля `record diagnostic`). Це + /// host-політика, не гарантія схеми: гість НЕ має покладатись на те, + /// що `files` — саме файли його діагностик. record fix-request { concern-id: string, files: list, diff --git a/crates/rules-napi/src/lib.rs b/crates/rules-napi/src/lib.rs index 4d03a450d..dbd45a5b5 100644 --- a/crates/rules-napi/src/lib.rs +++ b/crates/rules-napi/src/lib.rs @@ -613,8 +613,19 @@ fn build_full_scope_files(cwd: &Path, glob_patterns: &[String]) -> Vec Vec>, + delta_files: Option>, ) -> Result { use rules_contract::diagnostic::Diagnostic; use rules_contract::fix::FixRequest; @@ -813,17 +848,33 @@ pub fn run_wasm_concern_fix( build_full_scope_files(&cwd_path, &c.glob) } _ if diagnostics.is_empty() => Vec::new(), - _ => { - let scope_label = contribution - .as_ref() - .map(|c| format!("{:?}", c.scope)) - .unwrap_or_else(|| "не заявлений у describe().concerns".to_string()); - return Err(ambiguous_empty_fix_batch_err( - &key, - &scope_label, - diagnostics.len(), - )); - } + // Явна дельта викликача (`delta_files`) — рішення (б) + // відкритого питання, яке лишив по собі fix/napi-empty-fix-batch + // (доккомент `wit/world.wit` біля `record diagnostic`). + // Агрегована діагностика `per-file`-концерну (одна на весь + // прогін зовнішнього тула, `python/ruff`) НЕ несе `file` — і + // не мусить: дельта є властивістю ЗАПИТУ, а не діагностики. + // Хост-оркестрація її вже знає (`item.files`, той самий + // список, який сусіднім викликом іде в `detect`), тож замість + // вимагати per-diagnostic атрибуцію вона просто проводить + // дельту сюди. Порожній `delta_files` НЕ приймається за + // відповідь (`!delta.is_empty()`): «дельта є, але порожня» + // при непорожніх diagnostics — той самий нерозрізненний стан, + // що й відсутня дельта, тож він і далі падає голосно нижче. + _ => match delta_files.as_deref() { + Some(delta) if !delta.is_empty() => read_source_files(&cwd_path, delta.to_vec()), + _ => { + let scope_label = contribution + .as_ref() + .map(|c| format!("{:?}", c.scope)) + .unwrap_or_else(|| "не заявлений у describe().concerns".to_string()); + return Err(ambiguous_empty_fix_batch_err( + &key, + &scope_label, + diagnostics.len(), + )); + } + }, } } else { read_source_files(&cwd_path, target_files.clone()) @@ -1077,6 +1128,7 @@ mod tests { cwd.path().to_string_lossy().to_string(), violations, None, + None, ); let err = result.expect_err( @@ -1119,6 +1171,7 @@ mod tests { dir.path().to_string_lossy().to_string(), violations, None, + None, ); let plan = @@ -1161,6 +1214,7 @@ mod tests { dir.path().to_string_lossy().to_string(), violations, None, + None, ); let plan = result.expect("file-scoped фіксер з diagnostic.file не мав зламатись фіксом"); @@ -1185,9 +1239,130 @@ mod tests { cwd.path().to_string_lossy().to_string(), serde_json::json!([]), None, + None, ); let plan = result.expect("порожні diagnostics — немає що фіксити, не помилка"); assert_eq!(plan["edits"].as_array().expect("edits — масив").len(), 0); } + + // --- run_wasm_concern_fix: явна дельта запиту (`delta_files`) --- + // + // Рішення (б) відкритого питання #517 (доккомент + // [`ambiguous_empty_fix_batch_err`], секція «Контракт більше не мовчить»). + + /// Червоно-зелений якір рішення (б): `test/guest-fix-rewrite` — `scope: + /// per-file` у `describe()`, violation БЕЗ `file` (агрегована + /// діагностика, як `python/ruff`). До цієї зміни виклик падав + /// [`ambiguous_empty_fix_batch_err`]; тепер `delta_files` дає хосту + /// список, з якого будується `FixRequest::files`. + /// + /// Перевіряється саме ДОСТАВКА файлів гостю, а не «виклик не впав»: + /// guest-фікстура переписує `BROKEN`→`FIXED` лише в тих файлах, які + /// РЕАЛЬНО прийшли в `FixRequest::files` (їх вміст вона бачить тільки + /// звідти — власного fs-доступу в неї для цього концерну немає). Порожній + /// `edits` тут означав би, що дельта до гостя не дійшла. + #[test] + fn run_wasm_concern_fix_uses_delta_files_when_no_diagnostic_carries_file() { + let wasm_path = require_guest_fixture(); + let dir = tempfile::tempdir().expect("tmp dir"); + std::fs::write(dir.path().join("target.txt"), "BROKEN content").expect("target file"); + let violations = serde_json::json!([ + { + "reason": "guest-echo", + "message": "агрегована діагностика без file", + "severity": "warn" + } + ]); + + let result = run_wasm_concern_fix( + wasm_path.to_string_lossy().to_string(), + "test/guest-fix-rewrite".to_string(), + dir.path().to_string_lossy().to_string(), + violations, + None, + Some(vec!["target.txt".to_string()]), + ); + + let plan = result.expect("явна дельта запиту має зняти двозначність, а не падати"); + let edits = plan["edits"].as_array().expect("edits — масив"); + assert_eq!( + edits.len(), + 1, + "дельта мала дійти до гостя як FixRequest::files: {plan:?}" + ); + assert_eq!(edits[0]["path"], "target.txt"); + assert_eq!(edits[0]["content"], "FIXED content"); + } + + /// Дельта НЕ звужує batch, коли діагностики самі несуть `file`: + /// пріоритет лишається за `diagnostic.file` (найвужчий batch — рівно ті + /// файли, на які вказують violations). Тут `delta_files` називає ДВА + /// файли, violation — один; фіксер має чинити тільки названий + /// діагностикою, інакше fix розповзся б по всій дельті. + #[test] + fn run_wasm_concern_fix_diagnostic_file_wins_over_delta_files() { + let wasm_path = require_guest_fixture(); + let dir = tempfile::tempdir().expect("tmp dir"); + std::fs::write(dir.path().join("target.txt"), "BROKEN content").expect("target file"); + std::fs::write(dir.path().join("other.txt"), "BROKEN content").expect("other file"); + let violations = serde_json::json!([ + { + "reason": "guest-echo", + "message": "file-scoped violation", + "file": "target.txt", + "severity": "warn" + } + ]); + + let result = run_wasm_concern_fix( + wasm_path.to_string_lossy().to_string(), + "test/guest-fix-rewrite".to_string(), + dir.path().to_string_lossy().to_string(), + violations, + None, + Some(vec!["target.txt".to_string(), "other.txt".to_string()]), + ); + + let plan = result.expect("file-scoped шлях не мав зламатись через передану дельту"); + let edits = plan["edits"].as_array().expect("edits — масив"); + assert_eq!( + edits.len(), + 1, + "fix мав лишитись у межах diagnostic.file, не розповзтись по дельті: {plan:?}" + ); + assert_eq!(edits[0]["path"], "target.txt"); + } + + /// ПОРОЖНЯ дельта не приймається за відповідь: «дельта є, але порожня» + /// при непорожніх діагностиках — той самий нерозрізненний стан, що й + /// відсутня дельта (гість не відрізнить «файлів немає» від «хост їх не + /// передав»), тож fail-loud лишається. + #[test] + fn run_wasm_concern_fix_empty_delta_files_still_errors_loudly() { + let wasm_path = require_guest_fixture(); + let cwd = tempfile::tempdir().expect("tmp dir"); + let violations = serde_json::json!([ + { + "reason": "guest-echo", + "message": "агрегована діагностика без file", + "severity": "warn" + } + ]); + + let result = run_wasm_concern_fix( + wasm_path.to_string_lossy().to_string(), + "test/guest-echo".to_string(), + cwd.path().to_string_lossy().to_string(), + violations, + None, + Some(Vec::new()), + ); + + let err = result.expect_err("порожня дельта — та сама двозначність, має падати"); + assert!( + err.to_string().contains("delta_files"), + "повідомлення має підказати штатний шлях: {err}" + ); + } } diff --git a/docs/plans/2026-08-05-open-questions-register.md b/docs/plans/2026-08-05-open-questions-register.md index 8bcba11d1..b70256dca 100644 --- a/docs/plans/2026-08-05-open-questions-register.md +++ b/docs/plans/2026-08-05-open-questions-register.md @@ -5911,6 +5911,71 @@ detect-шлях цього концерну (жодного `exec_tool`, на в 55/55 по вужчому підмножині пʼяти policy-тек; тут повний каталог), `.rego` не торкнуто. +### 2.53. `file: none` для `per-file` — відкрите питання §2.52 ЗАКРИТО варіантом (б): дельту несе запит, а не діагностика + +**Задача:** §2.52 (PR #517) зробив неоднозначний порожній fix-батч гучною +помилкою і лишив по собі розвилку, записану прямо у WIT-доккоменті: або +**(а)** заборонити `file: none` для `per-file`-концернів, або **(б)** дати +спосіб нести per-file-дельту без per-diagnostic атрибуції. Обрано (б). + +**Чому не (а).** `file: none` для агрегованого детектора — чесне твердження +«я не знаю, який файл». `python/ruff` +(`crates/plugin-lang-python/src/lib.rs::detect_ruff`) повертає ОДНУ +діагностику на весь прогін `ruff check`, бо тула не атрибутує свій вивід по +файлах. Варіант (а) змусив би кожен такий детектор парсити вивід і +синтезувати N діагностик — заради host-механіки, якій відповідність +«діагностика → файл» НЕ потрібна: фіксер такого концерну хоче список +таргетів (`ruff check --fix `), а не мапу. Тобто (а) — це робота +на кожен агрегований концерн, щоб задовольнити обмеження, яке саме по собі +не потрібне. + +**Що виявилось при розборі.** Корінь був не в тому, що «контракт мовчить», а +в асиметрії двох napi-сигнатур: + +``` +run_wasm_concern (wasm_path, key, cwd, files: Option>, tool_paths) +run_wasm_concern_fix(wasm_path, key, cwd, violations, tool_paths) +``` + +`detect` ПРИЙМАЄ дельту явним параметром, `fix` — реконструює її з +`diagnostics[].file`. Інформація в системі була (JS тримає `item.files` і +сусіднім викликом віддає її в `detect`) — її просто не проведено до +fix-шляху. Прогалина проводки, не знання. + +**Зміна.** WIT розширювати НЕ довелось: `fix-request.files` — окреме поле, не +похідне від `diagnostics`, — існує з `3.0.0`. Додано лише проводку: +`run_wasm_concern_fix` приймає шостий, опційний `delta_files`, а +`run-fix.mjs` передає туди `item.files` (через `loadT0Patterns` → +`wasmFixPattern`, вшито в замикання з тієї ж причини, що й `cwd`: +`T0Pattern.test(violations)` не приймає `ctx`, а план будується вже в +`test()`). Жодного ABI-руху, жодного version bump. + +Порядок джерел `FixRequest::files` після зміни: `diagnostics[].file` +(найвужчий батч) → full-scope glob для `scope: full` → явна дельта запиту → +**помилка**. Помилка §2.52 лишається як backstop на проводку, а не як +основна поведінка: мовчазний `Vec::new()` тут був би регресом до #513. +Порожня дельта (`Some(vec![])`) НЕ приймається за відповідь — це той самий +нерозрізненний стан, що й відсутня. + +**Коригує §2.51 (PR #516, на момент запису не змержений):** «Перешкода 1 — +продакшн-міст не доносить до гостя жодного файлу» цією зміною ЗНЯТА. Порт +`python/ruff` лишається відкладеним, але вже тільки через Перешкоду 2 +(exec-tool у fix-контурі) — при мержі обох PR текст §2.51 варто прочитати з +цією поправкою. + +**Межа рішення (єдине, заради чого варто повертатись до (а)):** fix +ПІДМНОЖИНИ діагностик агрегованого концерну лишається неможливим — для +нього потрібна саме атрибуція. Сьогодні такого споживача немає: `applyT0` +передає всі violations концерну одним масивом. + +**Тести:** `cargo test -p rules-napi` 10 → 13 (+3: дельта доносить файли до +гостя; `diagnostic.file` має пріоритет над дельтою; порожня дельта і далі +падає). Новий `npm/scripts/lib/lint-surface/tests/fix-delta-files.test.mjs` +(2) закриває саме host-проводку — без нього обидві половини були б зелені +поодинці, а фіча не працювала б. Обидва якорі перевірено дією: нейтралізація +гілки `delta_files` валить рівно rust-тест, зняття аргументу в +`wasmFixPattern` — рівно JS-тест. + --- ## Як користуватись diff --git a/npm/.changes/260828-1120.md b/npm/.changes/260828-1120.md new file mode 100644 index 000000000..6f43622e5 --- /dev/null +++ b/npm/.changes/260828-1120.md @@ -0,0 +1,40 @@ +--- +bump: patch +section: Added +--- +`runWasmConcernFix` (`crates/rules-napi/src/lib.rs`) приймає шостий, +опційний аргумент `delta_files` — явний список файлів запиту (той самий, що +йде в `runWasmConcern` на детекті). Він знімає стан, який #517 зробив +гучною помилкою: `per-file`-концерн із АГРЕГОВАНИМИ діагностиками (одна на +весь прогін зовнішнього тула, який не атрибутує вивід по файлах — живий +приклад `python/ruff`) не давав хосту з чого побудувати `FixRequest::files`, +бо жодна діагностика не несла `file`. + +Це рішення (б) розвилки, яку #517 лишив у WIT-доккоменті: не забороняти +`file: none` для `per-file` (варіант (а)), а визнати, що дельта — властивість +ЗАПИТУ, а не діагностики. Варіант (а) змусив би кожен агрегований детектор +парсити вивід тула й синтезувати per-file діагностики заради host-механіки, +якій відповідність «діагностика → файл» не потрібна: фіксер такого концерну +хоче список таргетів (`ruff check --fix `), а не мапу. + +Розширювати контракт не довелось — `fix-request.files` існує з `3.0.0` як +окреме поле, не похідне від `diagnostics`; бракувало лише проводки +host → napi. Жодного ABI-руху й version bump: guest-и не чіпані, `.wasm`-пін +не змінюється. + +Порядок джерел `FixRequest::files`: `diagnostics[].file` (найвужчий батч, +пріоритет незмінний) → full-scope glob-обхід для `scope: full` → явна дельта +запиту → типізована помилка. Помилка #517 лишається backstop-ом на проводку +(мовчазний порожній батч тут був би регресом до #513); порожня дельта +(`Some([])`) за відповідь НЕ приймається — це той самий нерозрізненний стан, +що й відсутня. + +JS-бік (`npm/scripts/lib/lint-surface/run-fix.mjs`): `loadT0Patterns` бере +пʼятим аргументом дельту й вшиває її у wasm-патерн — з тієї ж причини, що й +`cwd`: `T0Pattern.test(violations)` не приймає `ctx`, а план будується вже в +`test()` і кешується під identity `violations`. Викликачі, які дельту не +передають, поведінки не міняють (`undefined` лишає хосту full-scope гілку). + +Побічний наслідок: «Перешкода 1» з розбору порту `python/ruff` (PR #516) +цією зміною знята — порт лишається відкладеним тільки через межу exec-tool +у fix-контурі. diff --git a/npm/scripts/lib/lint-surface/run-fix.mjs b/npm/scripts/lib/lint-surface/run-fix.mjs index 5403442fb..c5d805878 100644 --- a/npm/scripts/lib/lint-surface/run-fix.mjs +++ b/npm/scripts/lib/lint-surface/run-fix.mjs @@ -201,13 +201,21 @@ const wasmFixPlanCache = new WeakMap() * @param {string} cwd Абсолютний корінь consumer-репо. * @param {import('./wasm-plugins.mjs').WasmConcernMapEntry} entry Резолвлений запис wasm-мапи (`wasmPath`, `toolPaths`). * @param {LintViolation[]} violations Порушення concern-а (той самий вхід у test() і apply()). + * @param {string[]|undefined} deltaFiles Дельта запиту (`item.files`) або `undefined` для full-scope плану. * @returns {{ edits: Array<{ type: string, path: string, content?: string }> }} План (порожні `edits` = фіксити нічого). */ -function computeWasmFixPlan(key, cwd, entry, violations) { +function computeWasmFixPlan(key, cwd, entry, violations, deltaFiles) { if (wasmFixPlanCache.has(violations)) return wasmFixPlanCache.get(violations) let plan try { - plan = loadNative().runWasmConcernFix(entry.wasmPath, key, cwd, violations, entry.toolPaths) + plan = loadNative().runWasmConcernFix( + entry.wasmPath, + key, + cwd, + violations, + entry.toolPaths, + deltaFiles + ) } catch (error) { console.error( `❌ wasm fix ${key}: runWasmConcernFix відмовився будувати план — ${error.message}. ` + @@ -223,16 +231,30 @@ function computeWasmFixPlan(key, cwd, entry, violations) { * Синтезує T0Pattern-обгортку над wasm fix-планом — рівно та сама форма й * rollback-контракт (`ctx.recordWrite?.(abs)` ПЕРЕД кожною мутацією), що * {@link nativeFixPattern}; відрізняється лише джерело плану: `export fix` - * wasm-плагіна через napi замість `NATIVE_FIXES`-реєстру. Валідність плану + * wasm-плагіна через napi замість `NATIVE_FIXES`-реєстру. + * + * `deltaFiles` вшито у замикання з ТІЄЇ САМОЇ причини, що й `cwd` у + * {@link nativeFixPattern}: `T0Pattern.test(violations)` не приймає `ctx` + * (типізація `types.mjs`), а план будується вже в `test()` і кешується під + * identity `violations` — діставати дельту з `ctx` у `apply()` було б пізно. + * Дельта потрібна host-у рівно для агрегованих (file-less) діагностик + * `per-file`-концерну: без неї napi не має з чого побудувати + * `FixRequest::files` і свідомо падає (доккомент + * `ambiguous_empty_fix_batch_err`, `crates/rules-napi/src/lib.rs`). + * `undefined` (full-scope концерн — оркестрація не має списку) лишається + * валідним: там host будує batch сам, glob-обходом. + * + * Валідність плану * (safe repo-relative шляхи, ліміти розміру) гарантує host ДО повернення * (`LoadedPlugin::fix` → `rules-contract::validators::fix`) — обгортці * лишається тільки застосувати. * @param {string} key `ruleId/concernId` wasm-концерну. * @param {string} cwd Абсолютний корінь consumer-репо. * @param {import('./wasm-plugins.mjs').WasmConcernMapEntry} entry Резолвлений запис wasm-мапи. + * @param {string[]} [deltaFiles] Дельта запиту (`item.files`); `undefined` — full-scope концерн. * @returns {T0Pattern} Синтетичний патерн. */ -function wasmFixPattern(key, cwd, entry) { +function wasmFixPattern(key, cwd, entry, deltaFiles) { return { id: `wasm-fix:${key}`, // Гість-пріоритет (T0Pattern.guestFix, types.mjs): якщо `apply()` дасть непорожній @@ -240,9 +262,9 @@ function wasmFixPattern(key, cwd, entry) { // вада double-apply (реальний wasm-фікс + `fix-.mjs` поспіль на тому самому // файлі, напр. `js/doc_comments`). guestFix: true, - test: violations => computeWasmFixPlan(key, cwd, entry, violations).edits.length > 0, + test: violations => computeWasmFixPlan(key, cwd, entry, violations, deltaFiles).edits.length > 0, apply: async (violations, ctx) => { - const plan = computeWasmFixPlan(key, cwd, entry, violations) + const plan = computeWasmFixPlan(key, cwd, entry, violations, deltaFiles) const touchedFiles = [] for (const edit of plan?.edits ?? []) { const abs = join(cwd, edit.path) @@ -298,15 +320,16 @@ function wasmFixPattern(key, cwd, entry) { * @param {string} concernName Ім'я concern-а для формування назви fix-файлу. * @param {string} ruleId Id правила (для `ruleId/concernId`-ключа native-fix реєстру). * @param {string} cwd Абсолютний корінь consumer-репо (вшивається у синтетичний патерн). + * @param {string[]} [deltaFiles] Дельта запиту (`item.files`) — вшивається у wasm-патерн, див. {@link wasmFixPattern}. * @returns {Promise} Масив T0-патернів або порожній, якщо немає ані native/wasm, ані fix-файлу. */ -export async function loadT0Patterns(concernDir, concernName, ruleId, cwd) { +export async function loadT0Patterns(concernDir, concernName, ruleId, cwd, deltaFiles) { const nativeKey = `${ruleId}/${concernName}` if (getNativeFixKeys().has(nativeKey)) return [nativeFixPattern(nativeKey, cwd)] const patterns = [] const wasmConcernMap = await resolveWasmConcernMap(cwd) const wasmEntry = wasmConcernMap.get(nativeKey) - if (wasmEntry) patterns.push(wasmFixPattern(nativeKey, cwd, wasmEntry)) + if (wasmEntry) patterns.push(wasmFixPattern(nativeKey, cwd, wasmEntry, deltaFiles)) const fixPath = join(concernDir, `fix-${concernName}.mjs`) if (existsSync(fixPath)) { try { @@ -944,7 +967,8 @@ async function fixConcernCore(item, initialViolations, deps, chain, chainExtra, const lintCtx = { cwd, ruleId, concernId: concernName, concernDir, files: item.files, verbose } // ── T0 (детермінований, permanent) ── - const patterns = deps.t0Override ?? (await loadT0Patterns(concernDir, concernName, ruleId, cwd)) + const patterns = + deps.t0Override ?? (await loadT0Patterns(concernDir, concernName, ruleId, cwd, lintCtx.files)) const t0 = await runT0Phase(item, initialViolations, patterns, lintCtx, cwd, log, progress, verbose) noteT0Phase(t0, chainExtra, touchedAbs) if (t0.closed) return true @@ -1140,7 +1164,13 @@ export async function runFixPipeline(opts) { item, deps.t0For ? (deps.t0For(item.entry) ?? []) - : await loadT0Patterns(item.entry.concern.dir, item.entry.concern.name, item.entry.ruleId, cwd) + : await loadT0Patterns( + item.entry.concern.dir, + item.entry.concern.name, + item.entry.ruleId, + cwd, + item.files + ) ]) ) ) diff --git a/npm/scripts/lib/lint-surface/tests/fix-delta-files.test.mjs b/npm/scripts/lib/lint-surface/tests/fix-delta-files.test.mjs new file mode 100644 index 000000000..f2427f9bb --- /dev/null +++ b/npm/scripts/lib/lint-surface/tests/fix-delta-files.test.mjs @@ -0,0 +1,75 @@ +/** + * Проводка дельти запиту (`item.files`) у fix-контур wasm-плагінів — + * рішення (б) відкритого питання, яке лишив PR #517 (доккомент + * `crates/rules-contract/wit/world.wit` біля `record diagnostic`, + * `crates/rules-napi/src/lib.rs::ambiguous_empty_fix_batch_err`). + * + * Rust-бік (`cargo test -p rules-napi`) доводить, що napi ЧЕСНО будує + * `FixRequest::files` із `delta_files`. Цей файл закриває інший розрив — + * host-проводку: чи доходить `item.files` від оркестрації до napi-виклику. + * Без нього обидві половини були б зелені поодинці, а фіча не працювала б. + * + * Мокнуто рівно дві межі: `resolveWasmConcernMap` (щоб не залежати від + * наявності локальної wasm-збірки — `builtin-pins.json` gitignored, у + * чистому дереві мапа порожня) і `loadNative` (щоб ЗАПИСАТИ аргументи + * виклику, а не виконати його). Сам `loadT0Patterns`/`wasmFixPattern` — + * РЕАЛЬНІ: перевіряється продакшн-фабрика, не власна фікстура. + */ +import { describe, expect, test, vi } from 'vitest' + +/** Ключ, свідомо відсутній серед реальних native-fix і wasm-контрибуцій. */ +const CONCERN_KEY = 'scratch/delta_probe' + +/** Записує аргументи `runWasmConcernFix` замість реального napi-виклику. */ +const runWasmConcernFixMock = vi.fn(() => ({ edits: [] })) + +vi.mock('../../native.mjs', () => ({ + loadNative: () => ({ + listNativeFixes: () => [], + runWasmConcernFix: runWasmConcernFixMock, + }), +})) + +vi.mock('../wasm-plugins.mjs', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + resolveWasmConcernMap: async () => + new Map([[CONCERN_KEY, { wasmPath: '/fake/plugin.wasm', toolPaths: {} }]]), + } +}) + +const { loadT0Patterns } = await import('../run-fix.mjs') + +/** + * Викликає `test()` продакшн-патерна й повертає аргументи, з якими він + * пішов у napi. + * @param {string[]|undefined} deltaFiles Дельта, передана в `loadT0Patterns`. + * @returns {Promise} Аргументи одного `runWasmConcernFix`. + */ +async function callArgsFor(deltaFiles) { + runWasmConcernFixMock.mockClear() + const [ruleId, concernId] = CONCERN_KEY.split('/') + const patterns = await loadT0Patterns('/nonexistent-dir', concernId, ruleId, '/repo', deltaFiles) + // Порожній масив зробив би перевірку нижче беззмістовною — падаємо тут. + expect(patterns.length).toBe(1) + patterns[0].test([{ reason: 'agg', message: 'без file' }]) + expect(runWasmConcernFixMock).toHaveBeenCalledTimes(1) + return runWasmConcernFixMock.mock.calls[0] +} + +describe('loadT0Patterns — дельта запиту доходить до runWasmConcernFix', () => { + test('передана дельта стає шостим аргументом napi-виклику', async () => { + const args = await callArgsFor(['src/a.py', 'src/b.py']) + expect(args[5]).toEqual(['src/a.py', 'src/b.py']) + }) + + test('full-scope концерн (дельти немає) передає undefined, а не порожній масив', async () => { + // Порожній масив тут був би ГІРШИМ за undefined: napi трактує + // `Some(vec![])` як ту саму двозначність, що й відсутню дельту, і падає + // (`ambiguous_empty_fix_batch_err`), тоді як `undefined` лишає йому + // full-scope glob-обхід. + const args = await callArgsFor(undefined) + expect(args[5]).toBeUndefined() + }) +})