@7n/rules-ci-azure 1.3.0 → 1.4.1

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.4.1] - 2026-07-18
4
+
5
+ ### Fixed
6
+
7
+ - service_deploy_pipeline під реальну efes-форму: template-параметр покриває сервіс за префіксом glob-а (`run/nexus/**` ↔ modulePath `run/nexus`, було — хибний deny на всіх extends-пайплайнах); plan-гейт вимагається лише за наявності lint-джоб (утилітарні service-scoped pipelines типу gen:schema — поза каноном); команди читаються і з кроків `task: Bash@3` (`inputs.script`) — у rego і в автомігратора
8
+
9
+ ## [1.4.0] - 2026-07-18
10
+
11
+ ### Added
12
+
13
+ - Автоміграція легасі сервіс-pipeline-ів до канону: T0-фікс service_deploy_pipeline переписує `.azurepipelines/**` (job plan, легасі `lint --path` → per-domain `lint_<key>` по файлах піддерева, перешивка dependsOn і Skipped-толерантні condition) через yaml Document API зі збереженням коментарів
14
+
3
15
  ## [1.3.0] - 2026-07-18
4
16
 
5
17
  ### Added
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@7n/rules-ci-azure",
3
- "version": "1.3.0",
4
- "description": "\u041f\u043b\u0430\u0433\u0456\u043d @7n/rules: \u043a\u0430\u043d\u043e\u043d Azure Pipelines (azure-pipelines.yml) \u2014 \u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430, lint-\u0441\u0442\u0435\u043f, VS Code",
3
+ "version": "1.4.1",
4
+ "description": "Плагін @7n/rules: канон Azure Pipelines (azure-pipelines.yml) структура, lint-степ, VS Code",
5
5
  "keywords": [
6
6
  "azure-pipelines",
7
7
  "ci",
@@ -44,5 +44,8 @@
44
44
  "engines": {
45
45
  "bun": ">=1.3",
46
46
  "node": ">=24"
47
+ },
48
+ "dependencies": {
49
+ "yaml": "^2.9.0"
47
50
  }
48
51
  }
@@ -0,0 +1,31 @@
1
+ ---
2
+ type: JS Module
3
+ title: fix-service_deploy_pipeline.mjs
4
+ resource: plugins/ci-azure/rules/azure-pipelines/service_deploy_pipeline/fix-service_deploy_pipeline.mjs
5
+ docgen:
6
+ crc: 6d6df549
7
+ model: openai-codex/gpt-5.4-mini
8
+ tier: cloud-min
9
+ score: 100
10
+ issues: judge:inaccurate:0.99
11
+ judgeModel: openai-codex/gpt-5.4-mini
12
+ ---
13
+
14
+ ## Огляд
15
+
16
+ Файл існує для автоматичного переведення legacy `.azurepipelines/**.yml` у сервіс-канон ADR 260718-0835: він детерміновано переписує pipeline-и, де `trigger.paths.include` не відповідає формі `plan → lint_<domain> → deploy`, бо `lint --path` у `@7n/rules` 1.17 змінив семантику без major-бампа; `plan` додається через `bunx n-rules ci plan --path <svc> --azure`, а `lint_<key>` визначаються за файлами піддерева сервісу тими самими glob-ами, що й `ci plan`. Міграція зберігає коментарі та незачеплене форматування завдяки мутаціям через YAML Document API (`setIn`/`splice`). Також вона підшиває потрібні `dependsOn` і `condition` для нових lint-джоб і не перезаписує нетривіальний `condition`. Файл працює fail-safe: не кидає винятків назовні й за окремих помилок повертає порожнє значення замість exception.
17
+
18
+ ## Поведінка
19
+
20
+ - `migratePipelineFile` — Мігрує один `.azurepipelines/**.yml` із `trigger.paths.include` до сервіс-канону: за потреби додає `plan`, замінює legacy `lint`-джоби на per-domain `lint_<key>`, підшиває `dependsOn`/`condition` і `--no-fix`, зберігаючи коментарі та форматування; якщо файл поза скоупом, `null` або міграція не вдається, повертає `false` без винятку назовні.
21
+ - `patterns` — Описує rule-pattern, який знаходить порушені service pipeline-и та застосовує автоматичну міграцію до кожного знайденого файлу; помилки окремих файлів перехоплює, а результатом повертає список змінених файлів і повідомлення про кількість мігрованих pipeline-ів.
22
+
23
+ ## Публічний API
24
+
25
+ - migratePipelineFile — переносить один pipeline-файл у канонічний формат і сигналізує, чи були зміни.
26
+ - patterns — T0-патерн для fix-конвеєра: розпізнає порушені service pipeline-и й застосовує міграцію.
27
+
28
+ ## Гарантії поведінки
29
+
30
+ - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
31
+ - За певних помилок повертає порожнє значення (напр. `null`) замість винятку.
@@ -0,0 +1,9 @@
1
+ ---
2
+ type: Directory Index
3
+ title: plugins/ci-azure/rules/azure-pipelines/service_deploy_pipeline
4
+ resource: plugins/ci-azure/rules/azure-pipelines/service_deploy_pipeline/
5
+ ---
6
+
7
+ | Файл | Тип |
8
+ | ----------------------------------------------------------------- | --------- |
9
+ | [fix-service_deploy_pipeline.mjs](fix-service_deploy_pipeline.md) | JS Module |
@@ -0,0 +1,481 @@
1
+ /**
2
+ * T0-автоміграція легасі сервіс-pipeline-ів до сервіс-канону (ADR 260718-0835).
3
+ *
4
+ * Детермінований переписувач `.azurepipelines/**.yml` із `trigger.paths.include`,
5
+ * що НЕ відповідають формі plan → lint_<domain> → deploy (порушення rego-концерну
6
+ * service_deploy_pipeline). Семантика `lint --path` змінилась у `@7n/rules` 1.17
7
+ * (перетин із git-дельтою) без major-бампа — замість ручної міграції консюмерів
8
+ * (efes: 18 пайплайнів) фікс переводить pipeline одразу в новий канон:
9
+ *
10
+ * - додає job `plan` (prep + `bunx n-rules ci plan --path <svc> --azure`, name: plan);
11
+ * - легасі job із `n-rules lint --path <svc>` (без домену) замінюється на
12
+ * per-domain джоби lint_<key> — домени визначаються по ФАЙЛАХ піддерева сервісу
13
+ * тими самими glob-ами, що й `ci plan` (computeActiveDomains/domainKey);
14
+ * - domain-style lint-джоби добираються wiring-ом (dependsOn: plan, condition по
15
+ * outputs, `--no-fix`, fetchDepth: 0);
16
+ * - dependsOn інших джоб перешивається з легасі-імені на нові lint-джоби; джоби
17
+ * з прямими deps на умовні lint-джоби без власного condition отримують
18
+ * Skipped-толерантний канон (`not(canceled())` + `in(…, 'Succeeded', 'Skipped')`).
19
+ *
20
+ * Мутації — через `yaml` Document API (setIn/splice item-ів), щоб зберегти
21
+ * коментарі та форматування незачеплених частин. Наявний нетривіальний
22
+ * `condition` НЕ перезаписується (rego лишає deny → ручне рішення автора).
23
+ * Template-розкладка (`- template:`) не мігрується — фіксеру не видно
24
+ * розгорнутих джоб.
25
+ */
26
+ import { readFileSync, writeFileSync, existsSync, statSync } from 'node:fs'
27
+ import { join, resolve } from 'node:path'
28
+
29
+ import { parseDocument } from 'yaml'
30
+
31
+ import { loadEnabledLintRules, computeActiveDomains } from '@7n/rules/scripts/lib/lint-surface/run-detectors.mjs'
32
+ import { collectPathScopedFiles } from '@7n/rules/scripts/lib/lint-surface/path-scope.mjs'
33
+ import { domainKey } from '@7n/rules/scripts/lib/lint-surface/ci-plan.mjs'
34
+
35
+ const WS_RE = /\s+/u
36
+ const GLOB_SUFFIX_RE = /\/\*+$/u
37
+
38
+ /** Канонічні prep-кроки, якщо в pipeline немає власного зразка. */
39
+ const CANONICAL_PREP = [
40
+ { checkout: 'self', fetchDepth: 0 },
41
+ {
42
+ script: 'curl -fsSL https://bun.sh/install | bash\necho "##vso[task.prependpath]$HOME/.bun/bin"\n',
43
+ displayName: 'Install bun'
44
+ },
45
+ { script: 'bun install --frozen-lockfile', displayName: 'Install deps' }
46
+ ]
47
+
48
+ /**
49
+ * Текст команди кроку (`script:`, `bash:` або task-форма `inputs.script`),
50
+ * '' якщо крок не командний.
51
+ * @param {unknown} step крок джоби (plain JS)
52
+ * @returns {string} команда або ''
53
+ */
54
+ function stepCmd(step) {
55
+ if (!step || typeof step !== 'object') return ''
56
+ const s = /** @type {Record<string, unknown>} */ (step)
57
+ if (typeof s.script === 'string') return s.script
58
+ if (typeof s.bash === 'string') return s.bash
59
+ const inputs = s.inputs
60
+ if (
61
+ inputs &&
62
+ typeof inputs === 'object' &&
63
+ typeof (/** @type {Record<string, unknown>} */ (inputs).script) === 'string'
64
+ ) {
65
+ return /** @type {string} */ (/** @type {Record<string, unknown>} */ (inputs).script)
66
+ }
67
+ return ''
68
+ }
69
+
70
+ /**
71
+ * Шлях-суфікс до тексту команди всередині кроку (для `setIn`): `['script']`,
72
+ * `['bash']` або `['inputs', 'script']` (task-форма).
73
+ * @param {Record<string, unknown>} step крок джоби (plain JS)
74
+ * @returns {string[]} суфікс шляху
75
+ */
76
+ function stepCmdPath(step) {
77
+ if (typeof step.script === 'string') return ['script']
78
+ if (typeof step.bash === 'string') return ['bash']
79
+ return ['inputs', 'script']
80
+ }
81
+
82
+ /**
83
+ * Розбирає команду `n-rules lint …`/`n-rules ci plan …` токенами (без regex —
84
+ * стійко до багаторядкових script-ів і без бектрекінгу).
85
+ * @param {string} cmd повний текст команди кроку
86
+ * @param {string} marker підрядок-початок (`n-rules lint` | `n-rules ci plan`)
87
+ * @returns {{ domain: string|null, path: string|null }|null} розбір або null, якщо маркера немає
88
+ */
89
+ function parseNRulesCmd(cmd, marker) {
90
+ const at = cmd.indexOf(marker)
91
+ if (at === -1) return null
92
+ const tokens = cmd
93
+ .slice(at + marker.length)
94
+ .split(WS_RE)
95
+ .filter(Boolean)
96
+ let domain = null
97
+ let path = null
98
+ for (let i = 0; i < tokens.length; i++) {
99
+ const t = tokens[i]
100
+ if (t === '--path') {
101
+ path = tokens[i + 1] ?? null
102
+ i++
103
+ continue
104
+ }
105
+ if (!t.startsWith('-') && domain === null && path === null) domain = t
106
+ }
107
+ return { domain, path }
108
+ }
109
+
110
+ /**
111
+ * Prep-кроки для нових джоб: зразок із першої джоби, що робить
112
+ * `bun install` (кроки від початку до нього включно, checkout → fetchDepth: 0),
113
+ * інакше — канонічний блок.
114
+ * @param {Array<Record<string, unknown>>} jobs всі джоби pipeline (plain JS)
115
+ * @returns {Array<Record<string, unknown>>} prep-кроки (plain JS, глибока копія)
116
+ */
117
+ function derivePrepSteps(jobs) {
118
+ for (const job of jobs) {
119
+ const steps = Array.isArray(job.steps) ? job.steps : []
120
+ const idx = steps.findIndex(s => stepCmd(s).includes('bun install'))
121
+ if (idx === -1) continue
122
+ const prep = structuredClone(steps.slice(0, idx + 1))
123
+ const checkout = prep.find(s => s && typeof s === 'object' && 'checkout' in s)
124
+ if (checkout) checkout.fetchDepth = 0
125
+ else prep.unshift({ checkout: 'self', fetchDepth: 0 })
126
+ return prep
127
+ }
128
+ return structuredClone(CANONICAL_PREP)
129
+ }
130
+
131
+ /**
132
+ * Збирає всі jobs-послідовності документа: корінь `jobs` і `stages[i].jobs`.
133
+ * @param {import('yaml').Document} doc YAML-документ
134
+ * @returns {Array<{ path: Array<string|number>, items: unknown[] }>} шляхи та plain-JS елементи
135
+ */
136
+ function collectJobSeqs(doc) {
137
+ const out = []
138
+ const js = doc.toJS() ?? {}
139
+ if (Array.isArray(js.jobs)) out.push({ path: ['jobs'], items: js.jobs })
140
+ if (Array.isArray(js.stages)) {
141
+ for (const [i, stage] of js.stages.entries()) {
142
+ if (stage && Array.isArray(stage.jobs)) out.push({ path: ['stages', i, 'jobs'], items: stage.jobs })
143
+ }
144
+ }
145
+ return out
146
+ }
147
+
148
+ /**
149
+ * dependsOn джоби як масив імен.
150
+ * @param {Record<string, unknown>} job джоба (plain JS)
151
+ * @returns {string[]} імена залежностей
152
+ */
153
+ function dependsOf(job) {
154
+ const d = job.dependsOn
155
+ if (typeof d === 'string') return [d]
156
+ if (Array.isArray(d)) return d.filter(x => typeof x === 'string')
157
+ return []
158
+ }
159
+
160
+ /**
161
+ * Канонічний Skipped-толерантний condition для джоби з прямими deps.
162
+ * @param {string[]} deps імена прямих залежностей
163
+ * @returns {string} вираз condition
164
+ */
165
+ function skipTolerantCondition(deps) {
166
+ const parts = ['not(canceled())']
167
+ for (const d of deps) {
168
+ if (d === 'plan') parts.push("eq(dependencies.plan.result, 'Succeeded')")
169
+ else parts.push(`in(dependencies.${d}.result, 'Succeeded', 'Skipped')`)
170
+ }
171
+ return `and(${parts.join(', ')})`
172
+ }
173
+
174
+ /**
175
+ * Нова per-domain lint-джоба канону.
176
+ * @param {string} domain rule-id домену
177
+ * @param {string} servicePath каталог сервісу
178
+ * @param {Array<Record<string, unknown>>} prep prep-кроки
179
+ * @returns {Record<string, unknown>} джоба (plain JS)
180
+ */
181
+ function buildLintJob(domain, servicePath, prep) {
182
+ const key = domainKey(domain)
183
+ return {
184
+ job: `lint_${key}`,
185
+ dependsOn: 'plan',
186
+ condition: `eq(dependencies.plan.outputs['plan.${key}'], 'true')`,
187
+ steps: [
188
+ ...structuredClone(prep),
189
+ { script: `bunx n-rules lint ${domain} --path ${servicePath} --no-fix`, displayName: `Lint ${domain}` }
190
+ ]
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Релевантні домени сервісу: enabled-правила, чиї per-file glob-и матчать
196
+ * бодай один файл піддерева (та сама таблиця, що `ci plan` по не-дельті).
197
+ * @param {string} cwd корінь consumer-репо
198
+ * @param {string} servicePath каталог сервісу
199
+ * @returns {Promise<string[]>} відсортовані rule-id
200
+ */
201
+ async function relevantDomains(cwd, servicePath) {
202
+ const abs = resolve(cwd, servicePath)
203
+ if (!existsSync(abs) || !statSync(abs).isDirectory()) return []
204
+ const files = await collectPathScopedFiles(cwd, servicePath)
205
+ const { byRule, enabledSet } = await loadEnabledLintRules({ cwd })
206
+ const active = computeActiveDomains(byRule, enabledSet, files)
207
+ return active
208
+ .entries()
209
+ .filter(([, st]) => st.triggered)
210
+ .map(([id]) => id)
211
+ .toArray()
212
+ .toSorted((a, b) => a.localeCompare(b))
213
+ }
214
+
215
+ /**
216
+ * Розбір lint-команди джоби: `{ legacy, domain, path, stepIndex }` або null.
217
+ * @param {Array<Record<string, unknown>>} steps кроки джоби (plain JS)
218
+ * @returns {{ legacy: boolean, domain: string|null, path: string|null, stepIndex: number }|null} розбір
219
+ */
220
+ function findLintStep(steps) {
221
+ for (const [i, step] of steps.entries()) {
222
+ const cmd = stepCmd(step)
223
+ const parsed = parseNRulesCmd(cmd, 'n-rules lint')
224
+ if (!parsed || parsed.path === null) continue
225
+ return { legacy: parsed.domain === null, domain: parsed.domain, path: parsed.path, stepIndex: i }
226
+ }
227
+ return null
228
+ }
229
+
230
+ /**
231
+ * Крок 1: вставляє job `plan` на початок першої jobs-послідовності (якщо немає).
232
+ * @param {import('yaml').Document} doc документ (мутується)
233
+ * @param {Array<{ path: Array<string|number> }>} seqs jobs-послідовності
234
+ * @param {Array<Record<string, unknown>>} prep prep-кроки
235
+ * @param {string} servicePath каталог сервісу
236
+ * @returns {boolean} чи були зміни
237
+ */
238
+ function ensurePlanJob(doc, seqs, prep, servicePath) {
239
+ const planNode = doc.createNode({
240
+ job: 'plan',
241
+ steps: [...structuredClone(prep), { script: `bunx n-rules ci plan --path ${servicePath} --azure`, name: 'plan' }]
242
+ })
243
+ doc.getIn(seqs[0].path).items.unshift(planNode)
244
+ return true
245
+ }
246
+
247
+ /**
248
+ * Крок 2: замінює легасі lint-джоби на per-domain джоби у своїх послідовностях.
249
+ * @param {import('yaml').Document} doc документ (мутується)
250
+ * @param {Array<{ seq: { path: Array<string|number> }, job: Record<string, unknown> }>} legacy легасі-джоби
251
+ * @param {string[]} domains релевантні домени сервісу
252
+ * @param {string} servicePath каталог сервісу
253
+ * @param {Array<Record<string, unknown>>} prep prep-кроки
254
+ * @returns {{ renames: Map<string, string[]>, changed: boolean }} мапа перейменувань
255
+ */
256
+ function replaceLegacyJobs(doc, legacy, domains, servicePath, prep) {
257
+ /** @type {Map<string, string[]>} */
258
+ const renames = new Map()
259
+ let changed = false
260
+ for (const { seq, job } of legacy) {
261
+ const newJobs = domains.map(d => buildLintJob(d, servicePath, prep))
262
+ const seqNode = doc.getIn(seq.path)
263
+ const docIndex = seqNode.items.findIndex(item => item?.toJS?.(doc)?.job === job.job)
264
+ if (docIndex === -1) continue
265
+ seqNode.items.splice(docIndex, 1, ...newJobs.map(j => doc.createNode(j)))
266
+ renames.set(
267
+ job.job,
268
+ newJobs.map(j => /** @type {string} */ (j.job))
269
+ )
270
+ changed = true
271
+ }
272
+ return { renames, changed }
273
+ }
274
+
275
+ /**
276
+ * Крок 3 (одна джоба): добирає wiring domain-style lint-джоби —
277
+ * dependsOn: plan, condition по outputs, `--no-fix`, fetchDepth: 0.
278
+ * @param {import('yaml').Document} doc документ (мутується)
279
+ * @param {Array<string|number>} base шлях джоби в документі
280
+ * @param {Record<string, unknown>} j джоба (plain JS)
281
+ * @param {{ domain: string|null, stepIndex: number }} found розбір lint-кроку
282
+ * @returns {boolean} чи були зміни
283
+ */
284
+ function patchDomainLintJob(doc, base, j, found) {
285
+ let changed = false
286
+ const steps = Array.isArray(j.steps) ? j.steps : []
287
+ if (dependsOf(j).length === 0) {
288
+ doc.setIn([...base, 'dependsOn'], 'plan')
289
+ changed = true
290
+ }
291
+ if (typeof j.condition !== 'string' && found.domain) {
292
+ doc.setIn([...base, 'condition'], `eq(dependencies.plan.outputs['plan.${domainKey(found.domain)}'], 'true')`)
293
+ changed = true
294
+ }
295
+ const cmd = stepCmd(steps[found.stepIndex])
296
+ if (!cmd.includes('--no-fix')) {
297
+ doc.setIn([...base, 'steps', found.stepIndex, ...stepCmdPath(steps[found.stepIndex])], `${cmd.trimEnd()} --no-fix`)
298
+ changed = true
299
+ }
300
+ const checkoutIdx = steps.findIndex(s => s && typeof s === 'object' && 'checkout' in s)
301
+ if (checkoutIdx !== -1 && steps[checkoutIdx].fetchDepth !== 0) {
302
+ doc.setIn([...base, 'steps', checkoutIdx, 'fetchDepth'], 0)
303
+ changed = true
304
+ }
305
+ return changed
306
+ }
307
+
308
+ /**
309
+ * Крок 3 (обхід): знаходить усі domain-style lint-джоби, добирає wiring і
310
+ * доповнює множину імен lint-джоб.
311
+ * @param {import('yaml').Document} doc документ (мутується)
312
+ * @param {Array<{ path: Array<string|number> }>} seqs jobs-послідовності
313
+ * @param {Set<string>} lintJobNames множина lint-джоб (мутується)
314
+ * @returns {boolean} чи були зміни
315
+ */
316
+ function patchDomainLintJobs(doc, seqs, lintJobNames) {
317
+ let changed = false
318
+ for (const seq of seqs) {
319
+ const seqNode = doc.getIn(seq.path)
320
+ for (const [i, itemNode] of seqNode.items.entries()) {
321
+ const j = itemNode?.toJS?.(doc)
322
+ if (!j || typeof j.job !== 'string' || j.job === 'plan') continue
323
+ const found = findLintStep(Array.isArray(j.steps) ? j.steps : [])
324
+ if (!found || found.legacy) continue
325
+ lintJobNames.add(j.job)
326
+ if (patchDomainLintJob(doc, [...seq.path, i], j, found)) changed = true
327
+ }
328
+ }
329
+ return changed
330
+ }
331
+
332
+ /**
333
+ * Крок 4: перешиває dependsOn (легасі-ім'я → нові джоби) і ставить
334
+ * Skipped-толерантний condition джобам із прямими deps на умовні lint-джоби
335
+ * (лише коли власного condition немає — нетривіальний не перезаписуємо).
336
+ * @param {import('yaml').Document} doc документ (мутується)
337
+ * @param {Array<{ path: Array<string|number> }>} seqs jobs-послідовності
338
+ * @param {Map<string, string[]>} renames легасі-ім'я → нові lint-джоби
339
+ * @param {Set<string>} lintJobNames множина всіх lint-джоб
340
+ * @returns {boolean} чи були зміни
341
+ */
342
+ function rewireDependents(doc, seqs, renames, lintJobNames) {
343
+ let changed = false
344
+ for (const seq of seqs) {
345
+ const seqNode = doc.getIn(seq.path)
346
+ for (const [i, itemNode] of seqNode.items.entries()) {
347
+ const j = itemNode?.toJS?.(doc)
348
+ if (!j || typeof j.job !== 'string' || j.job === 'plan' || lintJobNames.has(j.job)) continue
349
+ if (rewireOneJob(doc, [...seq.path, i], j, renames, lintJobNames)) changed = true
350
+ }
351
+ }
352
+ return changed
353
+ }
354
+
355
+ /**
356
+ * Крок 4 (одна джоба): перешивка dependsOn і Skipped-толерантний condition.
357
+ * @param {import('yaml').Document} doc документ (мутується)
358
+ * @param {Array<string|number>} base шлях джоби в документі
359
+ * @param {Record<string, unknown>} j джоба (plain JS)
360
+ * @param {Map<string, string[]>} renames легасі-ім'я → нові lint-джоби
361
+ * @param {Set<string>} lintJobNames множина всіх lint-джоб
362
+ * @returns {boolean} чи були зміни
363
+ */
364
+ function rewireOneJob(doc, base, j, renames, lintJobNames) {
365
+ let deps = dependsOf(j)
366
+ if (deps.length === 0) return false
367
+ let changed = false
368
+ const expanded = deps.flatMap(d => renames.get(d) ?? [d])
369
+ if (JSON.stringify(expanded) !== JSON.stringify(deps)) {
370
+ doc.setIn([...base, 'dependsOn'], doc.createNode(expanded))
371
+ deps = expanded
372
+ changed = true
373
+ }
374
+ const touchesLint = deps.some(d => lintJobNames.has(d))
375
+ if (touchesLint && typeof j.condition !== 'string') {
376
+ const withPlan = deps.includes('plan') ? deps : ['plan', ...deps]
377
+ if (!deps.includes('plan')) doc.setIn([...base, 'dependsOn'], doc.createNode(withPlan))
378
+ doc.setIn([...base, 'condition'], skipTolerantCondition(withPlan))
379
+ changed = true
380
+ }
381
+ return changed
382
+ }
383
+
384
+ /**
385
+ * Аналіз pipeline перед міграцією: послідовності джоб, легасі lint-джоби,
386
+ * plan-джоба і сервісний каталог. `null` — файл поза скоупом міграції
387
+ * (без paths-тригера, template-розкладка, немає сервісного каталогу).
388
+ * @param {import('yaml').Document} doc розпарсений документ
389
+ * @returns {{ seqs: Array<{ path: Array<string|number>, items: unknown[] }>, allJobs: Array<Record<string, unknown>>, planJob: Record<string, unknown>|undefined, legacy: Array<{ seq: object, job: Record<string, unknown>, path: string|null }>, servicePath: string }|null} аналіз
390
+ */
391
+ function analyzePipeline(doc) {
392
+ const js = doc.toJS() ?? {}
393
+ const servicePaths = js?.trigger?.paths?.include
394
+ if (!Array.isArray(servicePaths) || servicePaths.length === 0) return null
395
+
396
+ const seqs = collectJobSeqs(doc)
397
+ const allJobs = seqs.flatMap(s => s.items).filter(j => j && typeof j === 'object' && typeof j.job === 'string')
398
+ if (allJobs.length === 0) return null // template-розкладка — не мігруємо
399
+
400
+ const planJob = allJobs.find(j => j.job === 'plan')
401
+ const legacy = seqs.flatMap(seq =>
402
+ seq.items
403
+ .filter(j => j && typeof j === 'object' && typeof j.job === 'string' && j.job !== 'plan')
404
+ .map(j => ({ seq, job: j, found: findLintStep(Array.isArray(j.steps) ? j.steps : []) }))
405
+ .filter(x => x.found?.legacy)
406
+ .map(x => ({ seq: x.seq, job: x.job, path: x.found.path }))
407
+ )
408
+
409
+ // Сервісний каталог: із plan-джоби → з легасі lint-джоби → перший paths.include
410
+ // (glob-суфікс /**, /* зрізається: `run/nexus/**` → `run/nexus`).
411
+ const planPath = (Array.isArray(planJob?.steps) ? planJob.steps : [])
412
+ .map(s => parseNRulesCmd(stepCmd(s), 'n-rules ci plan')?.path)
413
+ .find(Boolean)
414
+ const firstTriggerPath = servicePaths
415
+ .filter(p => typeof p === 'string')
416
+ .map(p => p.replace(GLOB_SUFFIX_RE, ''))
417
+ .find(p => p !== '' && !p.includes('*'))
418
+ const servicePath = planPath ?? legacy[0]?.path ?? firstTriggerPath
419
+ if (typeof servicePath !== 'string') return null
420
+
421
+ return { seqs, allJobs, planJob, legacy, servicePath }
422
+ }
423
+
424
+ /**
425
+ * Мігрує один pipeline-файл до канону. Повертає true, якщо файл змінено.
426
+ * @param {string} absPath абсолютний шлях pipeline-файлу
427
+ * @param {string} cwd корінь consumer-репо
428
+ * @returns {Promise<boolean>} чи були зміни
429
+ */
430
+ export async function migratePipelineFile(absPath, cwd) {
431
+ const prevText = readFileSync(absPath, 'utf8')
432
+ let doc
433
+ try {
434
+ doc = parseDocument(prevText)
435
+ } catch {
436
+ return false
437
+ }
438
+ const analyzed = analyzePipeline(doc)
439
+ if (!analyzed) return false
440
+ const { seqs, allJobs, planJob, legacy, servicePath } = analyzed
441
+
442
+ const prep = derivePrepSteps(allJobs)
443
+ const domains = await relevantDomains(cwd, servicePath)
444
+ let changed = false
445
+
446
+ if (!planJob) changed = ensurePlanJob(doc, seqs, prep, servicePath) || changed
447
+ const { renames, changed: legacyChanged } = replaceLegacyJobs(doc, legacy, domains, servicePath, prep)
448
+ changed = legacyChanged || changed
449
+
450
+ const lintJobNames = new Set(renames.values().toArray().flat())
451
+ changed = patchDomainLintJobs(doc, seqs, lintJobNames) || changed
452
+ changed = rewireDependents(doc, seqs, renames, lintJobNames) || changed
453
+
454
+ if (!changed) return false
455
+ writeFileSync(absPath, doc.toString())
456
+ return true
457
+ }
458
+
459
+ export const patterns = [
460
+ {
461
+ id: 'azure-service-pipeline-canon-migrate',
462
+ test: violations => violations.length > 0,
463
+ async apply(violations, ctx) {
464
+ const files = [...new Set(violations.map(v => v.file).filter(Boolean))]
465
+ const touched = []
466
+ for (const rel of files) {
467
+ const abs = join(ctx.cwd, rel)
468
+ if (!existsSync(abs)) continue
469
+ try {
470
+ if (await migratePipelineFile(abs, ctx.cwd)) touched.push(abs)
471
+ } catch {
472
+ // міграція конкретного файлу не вдалася — лишаємо deny детектору (fail-open до ручного фіксу)
473
+ }
474
+ }
475
+ return {
476
+ touchedFiles: touched,
477
+ message: touched.length > 0 ? `мігровано до сервіс-канону: ${touched.length} pipeline(ів)` : null
478
+ }
479
+ }
480
+ }
481
+ ]
@@ -14,6 +14,8 @@ Rego-пакет: `azure_pipelines.service_deploy_pipeline`
14
14
 
15
15
  **Template-розкладка** (efes-стиль `- template:` з параметром-каталогом): законна, rego тоді перевіряє лише видиме у файлі — що параметр template-посилання дорівнює одному з `paths.include`; повнота графа всередині шаблону — відповідальність автора за цим каноном (rego не бачить розгорнутий YAML).
16
16
 
17
+ **Автоміграція (T0-фікс, `n-rules lint` у fix-режимі):** легасі сервіс-pipeline (inline-джоби, `n-rules lint --path <svc>` без домену) детерміновано переписується до канону — додається job `plan`, легасі lint-джоба замінюється на per-domain `lint_<key>` (домени — по файлах піддерева сервісу, ті самі glob-и, що в `ci plan`), `dependsOn` залежних джоб перешивається, джоби з прямими deps на умовні lint-джоби без власного `condition` отримують Skipped-толерантний канон. Нетривіальний наявний `condition` НЕ перезаписується; template-розкладка не мігрується.
18
+
17
19
  Що НЕ перевіряється: добір доменів під вміст сервісу, зміст test/deploy-кроків, uv-prep, шаблони в `templates/**`.
18
20
 
19
21
  Канон-snippet (документаційний приклад): [deploy-service-pipeline.yml.snippet.yml](./template/deploy-service-pipeline.yml.snippet.yml)
@@ -45,10 +45,14 @@ template_refs contains t if {
45
45
  is_string(object.get(t, "template", 0))
46
46
  }
47
47
 
48
+ # Параметр-каталог покриває сервіс: точний збіг АБО glob paths.include
49
+ # починається з нього (`run/nexus/**` ↔ modulePath `run/nexus`).
48
50
  template_covers_service if {
49
51
  some t in template_refs
50
52
  some v in object.get(t, "parameters", {})
51
- v in service_paths
53
+ is_string(v)
54
+ some p in service_paths
55
+ startswith(p, v)
52
56
  }
53
57
 
54
58
  plan_jobs contains j if {
@@ -74,6 +78,17 @@ lint_cmds contains cmd if {
74
78
 
75
79
  check_jobs contains cmd.job if some cmd in lint_cmds
76
80
 
81
+ # Чи є в pipeline lint-гейт узагалі (domain-style або легасі `lint --path` без
82
+ # домену) — утилітарні service-scoped pipelines (gen:schema тощо) без lint-джоб
83
+ # каноном plan-гейта не зобовʼязані.
84
+ has_lint_jobs if count(lint_cmds) > 0
85
+
86
+ has_lint_jobs if {
87
+ some j in all_jobs
88
+ some r in job_cmds(j)
89
+ regex.match(`n-rules lint\s+--path\s`, r)
90
+ }
91
+
77
92
  # Граф dependsOn і транзитивна досяжність (ланцюг deploy → build → перевірки).
78
93
  needs_graph[name] := depends_of(j) if {
79
94
  some j in all_jobs
@@ -102,6 +117,7 @@ job_by_name[name] := j if {
102
117
  deny contains msg if {
103
118
  is_service_pipeline
104
119
  count(all_jobs) > 0
120
+ has_lint_jobs
105
121
  count(plan_jobs) == 0
106
122
  not template_covers_service
107
123
  msg := "service-pipeline: немає job `plan` з кроком `bunx n-rules ci plan --path <svc> --azure` (azure-pipelines.mdc: сервіс-канон)"
@@ -134,7 +150,7 @@ deny contains msg if {
134
150
  some r in plan_runs
135
151
  m := regex.find_all_string_submatch_n(`n-rules ci plan\s+--path\s+(\S+)`, r, 1)
136
152
  count(m) > 0
137
- not m[0][1] in service_paths
153
+ not path_covers_service(m[0][1])
138
154
  msg := sprintf("service-pipeline: `ci plan --path %s` ∉ trigger.paths.include — план рахується не для того каталогу (azure-pipelines.mdc)", [m[0][1]])
139
155
  }
140
156
 
@@ -166,7 +182,7 @@ deny contains msg if {
166
182
  deny contains msg if {
167
183
  is_service_pipeline
168
184
  some cmd in lint_cmds
169
- not cmd.path in service_paths
185
+ not path_covers_service(cmd.path)
170
186
  msg := sprintf("%s: `lint %s --path %s` ∉ trigger.paths.include — лінтиться інший каталог (azure-pipelines.mdc)", [cmd.job, cmd.domain, cmd.path])
171
187
  }
172
188
 
@@ -241,7 +257,14 @@ depends_of(job) := [n] if {
241
257
 
242
258
  depends_of(job) := object.get(job, "dependsOn", []) if is_array(object.get(job, "dependsOn", []))
243
259
 
244
- # Команда кроку: `script:` або `bash:`.
260
+ # Каталог `--path` покривається trigger.paths.include: точний збіг або glob із
261
+ # суфіксом (`run/nexus/**` покриває `run/nexus`).
262
+ path_covers_service(p) if {
263
+ some sp in service_paths
264
+ startswith(sp, p)
265
+ }
266
+
267
+ # Команда кроку: `script:`, `bash:` або task-форма (`task: Bash@3` → `inputs.script`).
245
268
  step_cmd(step) := s if {
246
269
  s := object.get(step, "script", "")
247
270
  s != ""
@@ -253,9 +276,17 @@ step_cmd(step) := s if {
253
276
  s != ""
254
277
  }
255
278
 
279
+ step_cmd(step) := s if {
280
+ object.get(step, "script", "") == ""
281
+ object.get(step, "bash", "") == ""
282
+ s := object.get(object.get(step, "inputs", {}), "script", "")
283
+ s != ""
284
+ }
285
+
256
286
  step_cmd(step) := "" if {
257
287
  object.get(step, "script", "") == ""
258
288
  object.get(step, "bash", "") == ""
289
+ object.get(object.get(step, "inputs", {}), "script", "") == ""
259
290
  }
260
291
 
261
292
  job_cmds(job) := [c |