@7n/rules-ci-github 1.5.0 → 1.6.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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.6.0] - 2026-07-18
4
+
5
+ ### Added
6
+
7
+ - GA-автоміграція: T0-фікс service_deploy_workflow переписує deploy-*.yml до канону — job plan з outputs-мапінгом, легасі `lint --path` → per-domain lint-джоби (needs+if по outputs), перешивка needs, Skipped-толерантний if термінальних джоб; yaml Document API зберігає коментарі
8
+
3
9
  ## [1.5.0] - 2026-07-18
4
10
 
5
11
  ### Added
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@7n/rules-ci-github",
3
- "version": "1.5.0",
4
- "description": "\u041f\u043b\u0430\u0433\u0456\u043d @7n/rules: \u043a\u0430\u043d\u043e\u043d GitHub Actions (.github/workflows) \u2014 \u043e\u0431\u043e\u0432'\u044f\u0437\u043a\u043e\u0432\u0456 workflow, zizmor, VS Code",
3
+ "version": "1.6.0",
4
+ "description": "Плагін @7n/rules: канон GitHub Actions (.github/workflows) обов'язкові workflow, zizmor, VS Code",
5
5
  "keywords": [
6
6
  "github-actions",
7
7
  "ci",
@@ -45,5 +45,8 @@
45
45
  "engines": {
46
46
  "bun": ">=1.3",
47
47
  "node": ">=24"
48
+ },
49
+ "dependencies": {
50
+ "yaml": "^2.9.0"
48
51
  }
49
52
  }
@@ -0,0 +1,31 @@
1
+ ---
2
+ type: JS Module
3
+ title: fix-service_deploy_workflow.mjs
4
+ resource: plugins/ci-github/rules/ga/service_deploy_workflow/fix-service_deploy_workflow.mjs
5
+ docgen:
6
+ crc: 9582591b
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
+ Файл виконує T0-автоміграцію `.github/workflows/deploy-*.yml` до сервісного канону для `service_deploy_workflow`, як у `fix-service_deploy_pipeline` для `ci-azure`: детерміновано переписує лише workflow, що не відповідають формі `plan → lint-<domain> → deploy`. Додає job `plan` із `fetch-depth: 0`, prep і `bunx n-rules ci plan --path <svc> --github`, створює `id: plan` та outputs-мапінг доменів і `any`, щоб `needs.plan.outputs.*` були доступні в runtime. Legacy job із `n-rules lint --path <svc>` без домену замінює на per-domain jobs `lint-<domain>` за тими самими glob-ами, що й `ci plan` через `computeActiveDomains/domainKey`; domain-style lint-джоби отримують wiring із `needs: plan`, умовами по outputs, `--no-fix`, `fetch-depth: 0` і prep. `needs` інших jobs перешивається з legacy-імені, а jobs із прямими `needs` на умовні lint-джоби без власного `if` отримують Skipped-толерантний канон `!cancelled` + `!contains`. Мутації виконуються через YAML Document API, тому `jobs` у GA лишається map, а коментарі та форматування незачеплених частин зберігаються; наявний нетривіальний `if` не перезаписується. Публічні функції: `migrateWorkflowFile`, `patterns`. Fail-safe: помилки не прокидаються назовні, а за окремих збоїв повертається порожнє значення.
17
+
18
+ ## Поведінка
19
+
20
+ - `migrateWorkflowFile` — мігрує один GitHub Actions deploy-workflow до сервісного канону: додає `plan`, розкладає legacy lint на per-domain jobs, перешиває `needs` і лишає файл без змін, якщо міграція не потрібна або не вдалася.
21
+ - `patterns` — описує T0-патерн fix-конвеєра: запускає міграцію лише для workflow з порушенням і повертає перелік змінених файлів та коротке повідомлення без винесення помилок назовні.
22
+
23
+ ## Публічний API
24
+
25
+ - migrateWorkflowFile — переводить один deploy-workflow у канонічний формат і повідомляє, чи були зміни
26
+ - patterns — задає шаблони для розпізнавання та обробки workflow-файлів
27
+
28
+ ## Гарантії поведінки
29
+
30
+ - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
31
+ - За певних помилок повертає порожнє значення (напр. `null`) замість винятку.
@@ -0,0 +1,9 @@
1
+ ---
2
+ type: Directory Index
3
+ title: plugins/ci-github/rules/ga/service_deploy_workflow
4
+ resource: plugins/ci-github/rules/ga/service_deploy_workflow/
5
+ ---
6
+
7
+ | Файл | Тип |
8
+ | ----------------------------------------------------------------- | --------- |
9
+ | [fix-service_deploy_workflow.mjs](fix-service_deploy_workflow.md) | JS Module |
@@ -0,0 +1,404 @@
1
+ /**
2
+ * T0-автоміграція GA deploy-workflow до сервіс-канону (ADR 260718-0835) —
3
+ * дзеркало fix-service_deploy_pipeline (ci-azure) для GitHub Actions.
4
+ *
5
+ * Детермінований переписувач `.github/workflows/deploy-*.yml`, що НЕ
6
+ * відповідають формі plan → lint-<domain> → deploy (порушення rego-концерну
7
+ * service_deploy_workflow):
8
+ *
9
+ * - додає job `plan` (checkout fetch-depth: 0 + prep + `bunx n-rules ci plan
10
+ * --path <svc> --github` з `id: plan`) і **outputs-мапінг** доменів + `any`
11
+ * (без нього гейти `needs.plan.outputs.*` порожні в runtime);
12
+ * - легасі job із `n-rules lint --path <svc>` (без домену) замінюється на
13
+ * per-domain джоби lint-<domain> — домени по файлах піддерева сервісу, ті
14
+ * самі glob-и, що `ci plan` (computeActiveDomains/domainKey);
15
+ * - domain-style lint-джоби добираються wiring-ом (needs: plan, if по outputs,
16
+ * `--no-fix`, fetch-depth: 0, prep);
17
+ * - `needs` інших джоб перешивається з легасі-імені; джоби з прямими needs на
18
+ * умовні lint-джоби без власного `if` отримують Skipped-толерантний канон
19
+ * (`!cancelled()` + `!contains(needs.*.result, 'failure')`).
20
+ *
21
+ * Мутації — через `yaml` Document API (jobs у GA — мапа, не послідовність):
22
+ * коментарі та форматування незачеплених частин зберігаються. Наявний
23
+ * нетривіальний `if` не перезаписується (deny лишається — ручне рішення).
24
+ */
25
+ import { readFileSync, writeFileSync, existsSync, statSync } from 'node:fs'
26
+ import { join, resolve } from 'node:path'
27
+
28
+ import { parseDocument } from 'yaml'
29
+
30
+ import { loadEnabledLintRules, computeActiveDomains } from '@7n/rules/scripts/lib/lint-surface/run-detectors.mjs'
31
+ import { collectPathScopedFiles } from '@7n/rules/scripts/lib/lint-surface/path-scope.mjs'
32
+ import { domainKey } from '@7n/rules/scripts/lib/lint-surface/ci-plan.mjs'
33
+
34
+ const WS_RE = /\s+/u
35
+ const GLOB_SUFFIX_RE = /\/\*+$/u
36
+
37
+ const CANONICAL_DEPLOY_IF = `\${{ !cancelled() && needs.plan.result == 'success' && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}`
38
+
39
+ /** Канонічні prep-кроки нової джоби (checkout повної глибини + bun-залежності). */
40
+ const CANONICAL_PREP = [
41
+ { uses: 'actions/checkout@v6', with: { 'persist-credentials': false, 'fetch-depth': 0 } },
42
+ { uses: './.github/actions/setup-bun-deps' }
43
+ ]
44
+
45
+ /**
46
+ * Розбирає команду `n-rules …` токенами (стійко до багаторядкових run-ів).
47
+ * @param {string} cmd текст run-кроку
48
+ * @param {string} marker підрядок-початок (`n-rules lint` | `n-rules ci plan`)
49
+ * @returns {{ domain: string|null, path: string|null }|null} розбір або null
50
+ */
51
+ function parseNRulesCmd(cmd, marker) {
52
+ const at = cmd.indexOf(marker)
53
+ if (at === -1) return null
54
+ const tokens = cmd
55
+ .slice(at + marker.length)
56
+ .split(WS_RE)
57
+ .filter(Boolean)
58
+ let domain = null
59
+ let path = null
60
+ for (let i = 0; i < tokens.length; i++) {
61
+ const t = tokens[i]
62
+ if (t === '--path') {
63
+ path = tokens[i + 1] ?? null
64
+ i++
65
+ continue
66
+ }
67
+ if (!t.startsWith('-') && domain === null && path === null) domain = t
68
+ }
69
+ return { domain, path }
70
+ }
71
+
72
+ /**
73
+ * Текст run-кроку ('' якщо крок не run).
74
+ * @param {unknown} step крок джоби (plain JS)
75
+ * @returns {string} команда або ''
76
+ */
77
+ function stepRun(step) {
78
+ if (!step || typeof step !== 'object') return ''
79
+ const r = /** @type {Record<string, unknown>} */ (step).run
80
+ return typeof r === 'string' ? r : ''
81
+ }
82
+
83
+ /**
84
+ * needs джоби як масив імен.
85
+ * @param {Record<string, unknown>} job джоба (plain JS)
86
+ * @returns {string[]} імена залежностей
87
+ */
88
+ function needsOf(job) {
89
+ const n = job.needs
90
+ if (typeof n === 'string') return [n]
91
+ if (Array.isArray(n)) return n.filter(x => typeof x === 'string')
92
+ return []
93
+ }
94
+
95
+ /**
96
+ * Розбір lint-кроку джоби.
97
+ * @param {Array<Record<string, unknown>>} steps кроки джоби (plain JS)
98
+ * @returns {{ legacy: boolean, domain: string|null, path: string|null, stepIndex: number }|null} розбір
99
+ */
100
+ function findLintStep(steps) {
101
+ for (const [i, step] of steps.entries()) {
102
+ const parsed = parseNRulesCmd(stepRun(step), 'n-rules lint')
103
+ if (!parsed || parsed.path === null) continue
104
+ return { legacy: parsed.domain === null, domain: parsed.domain, path: parsed.path, stepIndex: i }
105
+ }
106
+ return null
107
+ }
108
+
109
+ /**
110
+ * Релевантні домени сервісу (ті самі glob-и, що `ci plan` по не-дельті).
111
+ * @param {string} cwd корінь consumer-репо
112
+ * @param {string} servicePath каталог сервісу
113
+ * @returns {Promise<string[]>} відсортовані rule-id
114
+ */
115
+ async function relevantDomains(cwd, servicePath) {
116
+ const abs = resolve(cwd, servicePath)
117
+ if (!existsSync(abs) || !statSync(abs).isDirectory()) return []
118
+ const files = await collectPathScopedFiles(cwd, servicePath)
119
+ const { byRule, enabledSet } = await loadEnabledLintRules({ cwd })
120
+ const active = computeActiveDomains(byRule, enabledSet, files)
121
+ return active
122
+ .entries()
123
+ .filter(([, st]) => st.triggered)
124
+ .map(([id]) => id)
125
+ .toArray()
126
+ .toSorted((a, b) => a.localeCompare(b))
127
+ }
128
+
129
+ /**
130
+ * Prep-кроки для нових джоб: зразок із наявної джоби з `setup-bun-deps`
131
+ * (кроки до нього включно, checkout → fetch-depth: 0), інакше — канонічні.
132
+ * @param {Array<Record<string, unknown>>} jobs джоби (plain JS)
133
+ * @returns {Array<Record<string, unknown>>} prep-кроки (глибока копія)
134
+ */
135
+ function derivePrepSteps(jobs) {
136
+ for (const job of jobs) {
137
+ const steps = Array.isArray(job.steps) ? job.steps : []
138
+ const idx = steps.findIndex(s => s && typeof s === 'object' && s.uses === './.github/actions/setup-bun-deps')
139
+ if (idx === -1) continue
140
+ const prep = structuredClone(steps.slice(0, idx + 1))
141
+ const checkout = prep.find(s => typeof s.uses === 'string' && s.uses.startsWith('actions/checkout@'))
142
+ if (checkout) {
143
+ checkout.with = { ...checkout.with, 'fetch-depth': 0 }
144
+ } else {
145
+ prep.unshift(structuredClone(CANONICAL_PREP[0]))
146
+ }
147
+ return prep
148
+ }
149
+ return structuredClone(CANONICAL_PREP)
150
+ }
151
+
152
+ /**
153
+ * Джоби документа як [name, job] (plain JS); GA jobs — мапа.
154
+ * @param {import('yaml').Document} doc YAML-документ
155
+ * @returns {Array<[string, Record<string, unknown>]>} пари імʼя→джоба
156
+ */
157
+ function jobEntries(doc) {
158
+ const js = doc.toJS() ?? {}
159
+ const jobs = js.jobs
160
+ if (!jobs || typeof jobs !== 'object' || Array.isArray(jobs)) return []
161
+ return Object.entries(jobs).filter(([, j]) => j && typeof j === 'object')
162
+ }
163
+
164
+ /**
165
+ * Сервісний каталог workflow: із plan-джоби → з легасі lint-джоби →
166
+ * з `on.push.paths` (glob-суфікс зрізається).
167
+ * @param {import('yaml').Document} doc YAML-документ
168
+ * @param {Array<[string, Record<string, unknown>]>} entries пари імʼя→джоба
169
+ * @returns {string|null} каталог сервісу або null
170
+ */
171
+ function resolveServicePath(doc, entries) {
172
+ for (const [, job] of entries) {
173
+ for (const step of Array.isArray(job.steps) ? job.steps : []) {
174
+ const p = parseNRulesCmd(stepRun(step), 'n-rules ci plan')?.path
175
+ if (p) return p
176
+ }
177
+ }
178
+ for (const [, job] of entries) {
179
+ const found = findLintStep(Array.isArray(job.steps) ? job.steps : [])
180
+ if (found?.path) return found.path
181
+ }
182
+ const js = doc.toJS() ?? {}
183
+ const on = js.on ?? js.true ?? {}
184
+ const paths = on?.push?.paths
185
+ if (!Array.isArray(paths)) return null
186
+ return (
187
+ paths
188
+ .filter(p => typeof p === 'string')
189
+ .map(p => p.replace(GLOB_SUFFIX_RE, ''))
190
+ .find(p => p !== '' && !p.includes('*')) ?? null
191
+ )
192
+ }
193
+
194
+ /**
195
+ * Крок 1: вставляє job `plan` (з outputs-мапінгом доменів + any) першим у jobs.
196
+ * @param {import('yaml').Document} doc документ (мутується)
197
+ * @param {string[]} domains релевантні домени
198
+ * @param {Array<Record<string, unknown>>} prep prep-кроки
199
+ * @param {string} servicePath каталог сервісу
200
+ * @returns {void}
201
+ */
202
+ function insertPlanJob(doc, domains, prep, servicePath) {
203
+ /** @type {Record<string, string>} */
204
+ const outputs = {}
205
+ for (const d of domains) outputs[domainKey(d)] = `\${{ steps.plan.outputs.${domainKey(d)} }}`
206
+ outputs.any = `\${{ steps.plan.outputs.any }}`
207
+ const planJob = {
208
+ 'runs-on': 'ubuntu-latest',
209
+ permissions: { contents: 'read' },
210
+ outputs,
211
+ steps: [...structuredClone(prep), { id: 'plan', run: `bunx n-rules ci plan --path ${servicePath} --github` }]
212
+ }
213
+ const jobsMap = doc.getIn(['jobs'])
214
+ jobsMap.items.unshift(doc.createNode({ plan: planJob }).items[0])
215
+ }
216
+
217
+ /**
218
+ * Нова per-domain lint-джоба канону (пара [імʼя, джоба]).
219
+ * @param {string} domain rule-id домену
220
+ * @param {string} servicePath каталог сервісу
221
+ * @param {Array<Record<string, unknown>>} prep prep-кроки
222
+ * @returns {[string, Record<string, unknown>]} пара для jobs-мапи
223
+ */
224
+ function buildLintJob(domain, servicePath, prep) {
225
+ const key = domainKey(domain)
226
+ return [
227
+ `lint-${key}`,
228
+ {
229
+ needs: 'plan',
230
+ if: `needs.plan.outputs.${key} == 'true'`,
231
+ 'runs-on': 'ubuntu-latest',
232
+ permissions: { contents: 'read' },
233
+ steps: [...structuredClone(prep), { run: `bunx n-rules lint ${domain} --path ${servicePath} --no-fix` }]
234
+ }
235
+ ]
236
+ }
237
+
238
+ /**
239
+ * Крок 2: замінює легасі lint-джоби на per-domain джоби (in-place у мапі jobs).
240
+ * @param {import('yaml').Document} doc документ (мутується)
241
+ * @param {string[]} legacyNames імена легасі-джоб
242
+ * @param {string[]} domains релевантні домени
243
+ * @param {string} servicePath каталог сервісу
244
+ * @param {Array<Record<string, unknown>>} prep prep-кроки
245
+ * @returns {Map<string, string[]>} легасі-імʼя → нові імена
246
+ */
247
+ function replaceLegacyJobs(doc, legacyNames, domains, servicePath, prep) {
248
+ /** @type {Map<string, string[]>} */
249
+ const renames = new Map()
250
+ const jobsMap = doc.getIn(['jobs'])
251
+ for (const legacyName of legacyNames) {
252
+ const idx = jobsMap.items.findIndex(pair => pair?.key?.toString?.() === legacyName)
253
+ if (idx === -1) continue
254
+ const newPairs = domains.map(d => buildLintJob(d, servicePath, prep))
255
+ const nodes = newPairs.map(([name, job]) => doc.createNode({ [name]: job }).items[0])
256
+ jobsMap.items.splice(idx, 1, ...nodes)
257
+ renames.set(
258
+ legacyName,
259
+ newPairs.map(([name]) => name)
260
+ )
261
+ }
262
+ return renames
263
+ }
264
+
265
+ /**
266
+ * Крок 3 (одна джоба): wiring domain-style lint-джоби (needs/if/--no-fix/fetch-depth/prep).
267
+ * @param {import('yaml').Document} doc документ (мутується)
268
+ * @param {string} name імʼя джоби
269
+ * @param {Record<string, unknown>} j джоба (plain JS)
270
+ * @param {{ domain: string|null, stepIndex: number }} found розбір lint-кроку
271
+ * @returns {boolean} чи були зміни
272
+ */
273
+ function patchDomainLintJob(doc, name, j, found) {
274
+ let changed = false
275
+ const base = ['jobs', name]
276
+ const steps = Array.isArray(j.steps) ? j.steps : []
277
+ if (needsOf(j).length === 0) {
278
+ doc.setIn([...base, 'needs'], 'plan')
279
+ changed = true
280
+ }
281
+ if (typeof j.if !== 'string' && found.domain) {
282
+ doc.setIn([...base, 'if'], `needs.plan.outputs.${domainKey(found.domain)} == 'true'`)
283
+ changed = true
284
+ }
285
+ const cmd = stepRun(steps[found.stepIndex])
286
+ if (!cmd.includes('--no-fix')) {
287
+ doc.setIn([...base, 'steps', found.stepIndex, 'run'], `${cmd.trimEnd()} --no-fix`)
288
+ changed = true
289
+ }
290
+ const checkoutIdx = steps.findIndex(s => typeof s?.uses === 'string' && s.uses.startsWith('actions/checkout@'))
291
+ if (checkoutIdx !== -1 && steps[checkoutIdx]?.with?.['fetch-depth'] !== 0) {
292
+ doc.setIn([...base, 'steps', checkoutIdx, 'with', 'fetch-depth'], 0)
293
+ changed = true
294
+ }
295
+ return changed
296
+ }
297
+
298
+ /**
299
+ * Крок 4 (одна джоба): перешивка needs (легасі → нові) + Skipped-толерантний if.
300
+ * @param {import('yaml').Document} doc документ (мутується)
301
+ * @param {string} name імʼя джоби
302
+ * @param {Record<string, unknown>} j джоба (plain JS)
303
+ * @param {Map<string, string[]>} renames легасі-імʼя → нові lint-джоби
304
+ * @param {Set<string>} lintJobNames множина всіх lint-джоб
305
+ * @returns {boolean} чи були зміни
306
+ */
307
+ function rewireOneJob(doc, name, j, renames, lintJobNames) {
308
+ let deps = needsOf(j)
309
+ if (deps.length === 0) return false
310
+ let changed = false
311
+ const base = ['jobs', name]
312
+ const expanded = deps.flatMap(d => renames.get(d) ?? [d])
313
+ if (JSON.stringify(expanded) !== JSON.stringify(deps)) {
314
+ doc.setIn([...base, 'needs'], doc.createNode(expanded))
315
+ deps = expanded
316
+ changed = true
317
+ }
318
+ const touchesLint = deps.some(d => lintJobNames.has(d))
319
+ if (touchesLint && typeof j.if !== 'string') {
320
+ const withPlan = deps.includes('plan') ? deps : ['plan', ...deps]
321
+ if (!deps.includes('plan')) doc.setIn([...base, 'needs'], doc.createNode(withPlan))
322
+ doc.setIn([...base, 'if'], CANONICAL_DEPLOY_IF)
323
+ changed = true
324
+ }
325
+ return changed
326
+ }
327
+
328
+ /**
329
+ * Мігрує один deploy-workflow до канону. Повертає true, якщо файл змінено.
330
+ * @param {string} absPath абсолютний шлях workflow-файлу
331
+ * @param {string} cwd корінь consumer-репо
332
+ * @returns {Promise<boolean>} чи були зміни
333
+ */
334
+ export async function migrateWorkflowFile(absPath, cwd) {
335
+ const prevText = readFileSync(absPath, 'utf8')
336
+ let doc
337
+ try {
338
+ doc = parseDocument(prevText)
339
+ } catch {
340
+ return false
341
+ }
342
+ const entries = jobEntries(doc)
343
+ if (entries.length === 0) return false
344
+ const servicePath = resolveServicePath(doc, entries)
345
+ if (servicePath === null) return false
346
+
347
+ const hasPlan = entries.some(([name]) => name === 'plan')
348
+ const legacyNames = entries
349
+ .filter(([name, j]) => name !== 'plan' && findLintStep(Array.isArray(j.steps) ? j.steps : [])?.legacy)
350
+ .map(([name]) => name)
351
+
352
+ const jobsPlain = entries.map(([, j]) => j)
353
+ const prep = derivePrepSteps(jobsPlain)
354
+ const domains = await relevantDomains(cwd, servicePath)
355
+ let changed = false
356
+
357
+ if (!hasPlan) {
358
+ insertPlanJob(doc, domains, prep, servicePath)
359
+ changed = true
360
+ }
361
+ const renames = replaceLegacyJobs(doc, legacyNames, domains, servicePath, prep)
362
+ if (renames.size > 0) changed = true
363
+
364
+ const lintJobNames = new Set(renames.values().toArray().flat())
365
+ for (const [name, j] of jobEntries(doc)) {
366
+ if (name === 'plan') continue
367
+ const found = findLintStep(Array.isArray(j.steps) ? j.steps : [])
368
+ if (!found || found.legacy) continue
369
+ lintJobNames.add(name)
370
+ if (patchDomainLintJob(doc, name, j, found)) changed = true
371
+ }
372
+ for (const [name, j] of jobEntries(doc)) {
373
+ if (name === 'plan' || lintJobNames.has(name)) continue
374
+ if (rewireOneJob(doc, name, j, renames, lintJobNames)) changed = true
375
+ }
376
+
377
+ if (!changed) return false
378
+ writeFileSync(absPath, doc.toString())
379
+ return true
380
+ }
381
+
382
+ export const patterns = [
383
+ {
384
+ id: 'ga-service-workflow-canon-migrate',
385
+ test: violations => violations.length > 0,
386
+ async apply(violations, ctx) {
387
+ const files = [...new Set(violations.map(v => v.file).filter(Boolean))]
388
+ const touched = []
389
+ for (const rel of files) {
390
+ const abs = join(ctx.cwd, rel)
391
+ if (!existsSync(abs)) continue
392
+ try {
393
+ if (await migrateWorkflowFile(abs, ctx.cwd)) touched.push(abs)
394
+ } catch {
395
+ // міграція конкретного файлу не вдалася — лишаємо deny детектору (fail-open до ручного фіксу)
396
+ }
397
+ }
398
+ return {
399
+ touchedFiles: touched,
400
+ message: touched.length > 0 ? `мігровано до сервіс-канону: ${touched.length} workflow(ів)` : null
401
+ }
402
+ }
403
+ }
404
+ ]
@@ -18,4 +18,6 @@ Rego-пакет: `ga.service_deploy_workflow`
18
18
 
19
19
  Repo-wide перевірки без path-підтримки (knip, jscpd, dep-policy) сюди НЕ входять — вони в окремому `lint-repo.yml` (концерн `lint_repo_yml`) і деплой не гейтять.
20
20
 
21
+ **Автоміграція (T0-фікс, `n-rules lint` у fix-режимі):** легасі deploy-workflow (job із `n-rules lint --path <svc>` без домену, без plan-джоби) детерміновано переписується до канону — додається `plan` з outputs-мапінгом доменів + `any`, легасі lint-джоба замінюється на per-domain `lint-<domain>` (домени по файлах піддерева, ті самі glob-и, що `ci plan`), `needs` залежних джоб перешивається, джоби з прямими needs на умовні lint-джоби без власного `if` отримують Skipped-толерантний канон. Нетривіальний наявний `if` не перезаписується.
22
+
21
23
  Канон-snippet (документаційний приклад): [deploy-service.yml.snippet.yml](./template/deploy-service.yml.snippet.yml)