@7n/rules 1.16.1 → 1.17.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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.17.1] - 2026-07-18
4
+
5
+ ### Fixed
6
+
7
+ - lint: `--base <ref>` тепер діє і в чистому delta-режимі (без `--path`) — раніше явна база застосовувалась лише до перетину path ∩ дельта
8
+
9
+ ## [1.17.0] - 2026-07-18
10
+
11
+ ### Changed
12
+
13
+ - lint --path став diff-aware (перетин path ∩ git diff, full-scope концерни виключені; стара поведінка — --path --full), дозволено `lint <domain> --path`, нові --base/--repo-wide і команда ci plan (outputs для GitHub Actions/Azure)
14
+
15
+ ### Fixed
16
+
17
+ - bun/licensee: crash самого тула (stderr/die) — fail-open warn-діагностика замість блокувального порушення; несумісність @npmcli/arborist із деревом bun install перманентно червонила repo-wide CI-гейт
18
+
3
19
  ## [1.16.1] - 2026-07-18
4
20
 
5
21
  ### Fixed
package/bin/n-rules.js CHANGED
@@ -13,9 +13,13 @@
13
13
  * за замовчуванням fix-by-default по дельті vs origin (лише `per-file` правила); `--full` =
14
14
  * весь репо (`per-file` ∪ `full`); `--no-fix` = без мутацій/LLM (CI); позиційні
15
15
  * (не-флаг) аргументи — фільтр правил конформності (мапить колишній `fix <rule>`);
16
- * `--path <dir>` = звузити файловий набір до піддиректорії, корінь прогону (root-guard,
17
- * `.n-rules.json`) лишається поточним каталогом/`--cwd`, не сумісний з rule-фільтром.
16
+ * `--path <dir>` = перетин піддиректорії з git-дельтою (лише per-file правила; з `--full` —
17
+ * все піддерево), корінь прогону (root-guard, `.n-rules.json`) лишається поточним
18
+ * каталогом/`--cwd`; сумісний із rule-фільтром (`lint js --path run/nexus`);
19
+ * `--repo-wide` = лише full-scope правила (knip/jscpd/dep-policy) по всьому репо.
18
20
  * CI = `lint --no-fix --full` (весь репо, нуль мутацій/LLM).
21
+ * `npx \@7n/rules ci plan` — skip-логіка сервіс-орієнтованого CI-канону: перетин дельти з `--path` → job outputs
22
+ * «які lint-домени запускати» (`--github` → $GITHUB_OUTPUT, `--azure` → ##vso).
19
23
  * `npx \@7n/rules skill list` — скіли пакета без синку в проєкт
20
24
  * `npx \@7n/rules skill taze` — промпт на stdout
21
25
  * `npx \@7n/rules skill cursor taze ["task"]` — Cursor CLI (`cursor-agent -p`)
@@ -1639,11 +1643,18 @@ function printLintHelp() {
1639
1643
  з нього.
1640
1644
  --path <dir> Звузити файловий набір до піддиректорії, лишивши корінь
1641
1645
  прогону незмінним (config/root-guard — з поточного каталогу
1642
- чи --cwd). per-file правила фільтруються по файлах <dir>;
1643
- full-scope правила (jscpd, cspell тощо) при спрацюванні
1644
- все одно йдуть по всьому репо. Несумісний з позиційним
1645
- rule/concern-фільтром; з --full full-вісь ігнорується
1646
- (немає machine-wide локу).
1646
+ чи --cwd). Дефолт ПЕРЕТИН піддерева з git-дельтою
1647
+ (vs merge-base main/origin/main або --base), лише per-file
1648
+ правила (сервіс-орієнтований CI-канон). З --full все
1649
+ піддерево, full-scope правила при спрацюванні йдуть по
1650
+ всьому репо (історична поведінка; без machine-wide локу).
1651
+ Сумісний із позиційним rule/concern-фільтром
1652
+ (lint js --path run/nexus).
1653
+ --base <ref> Явна база дельти (merge-base HEAD <ref>) замість каскаду
1654
+ main → origin/main — для CI-чекаутів.
1655
+ --repo-wide ЛИШЕ full-scope правила (knip, jscpd, dep-policy тощо),
1656
+ весь репозиторій — окремий CI-workflow, що не гейтить
1657
+ деплой сервісів. Не поєднується з --path/фільтром.
1647
1658
  --verbose Розширений вивід детекції та виправлення.
1648
1659
  --help, -h Ця довідка.
1649
1660
 
@@ -1655,7 +1666,12 @@ function printLintHelp() {
1655
1666
  npx @7n/rules lint --full
1656
1667
  npx @7n/rules lint --no-fix eslint
1657
1668
  npx @7n/rules lint --cwd ./packages/foo --verbose
1658
- npx @7n/rules lint --path npm/scripts/lib/lint-surface --no-fix
1669
+ npx @7n/rules lint js --path run/nexus --no-fix --base origin/main
1670
+ npx @7n/rules lint --repo-wide --no-fix
1671
+
1672
+ Skip-логіка CI (яка джоба взагалі потрібна): npx @7n/rules ci plan
1673
+ ci plan [--path <dir>] [--base <ref>] [--github|--azure|--json]
1674
+ --github → outputs у $GITHUB_OUTPUT; --azure → ##vso-рядки в stdout.
1659
1675
  `)
1660
1676
  }
1661
1677
 
@@ -1700,7 +1716,9 @@ try {
1700
1716
  if (ROOT_GUARDED_COMMANDS.has(command)) {
1701
1717
  assertCwdIsProjectRoot(effectiveRoot, describeRootGuardedAction(command))
1702
1718
  }
1703
- await ensureNRulesInRootDevDependencies(effectiveRoot)
1719
+ // `ci` (plan) — read-only гейт-команда для CI-джоб: не мутує package.json
1720
+ // (ensure дописав би devDependency прямо в чекауті pipeline-агента).
1721
+ if (command !== 'ci') await ensureNRulesInRootDevDependencies(effectiveRoot)
1704
1722
  // Підкоманди-оркестратори (hook/lint/skill/adr-normalize-local/taze/release тощо)
1705
1723
  // можуть спавнити внутрішню agent/LLM-сесію — ADR Stop-hooks (capture/normalize)
1706
1724
  // мають пропустити її як технічний шум, не людську думку (spec 2026-06-30).
@@ -1734,24 +1752,59 @@ try {
1734
1752
  // (той самий explicitFiles-шлях, що вже годує hook --post-tool-use/--stop).
1735
1753
  const pathIdx = args.indexOf('--path')
1736
1754
  const pathArg = pathIdx === -1 ? null : args[pathIdx + 1]
1755
+ // --base <ref>: явна база дельти для CI (merge-base HEAD ↔ ref замість
1756
+ // каскаду main→origin/main) — надійний diff у checkout-ах з fetch.
1757
+ const baseIdx = args.indexOf('--base')
1758
+ const baseRef = baseIdx === -1 ? null : args[baseIdx + 1]
1759
+ const repoWide = args.includes('--repo-wide')
1737
1760
  const rules = args.filter(
1738
- (a, i) => !a.startsWith('-') && !(cwdIdx !== -1 && i === cwdIdx + 1) && !(pathIdx !== -1 && i === pathIdx + 1)
1761
+ (a, i) =>
1762
+ !a.startsWith('-') &&
1763
+ !(cwdIdx !== -1 && i === cwdIdx + 1) &&
1764
+ !(pathIdx !== -1 && i === pathIdx + 1) &&
1765
+ !(baseIdx !== -1 && i === baseIdx + 1)
1739
1766
  )
1740
- if (pathArg !== null && rules.length > 0) {
1741
- throw new Error(
1742
- '--path не можна поєднувати зі scoped rule/concern фільтром (позиційні аргументи) — оберіть щось одне'
1743
- )
1767
+ if (repoWide && (pathArg !== null || rules.length > 0)) {
1768
+ throw new Error('--repo-wide не поєднується з --path чи scoped rule/concern фільтром — оберіть щось одне')
1744
1769
  }
1770
+ // --path (сервіс-канон): дефолт — перетин піддерева з git-дельтою, лише
1771
+ // per-file concerns (pathMode). --path --full — історична поведінка: все
1772
+ // піддерево, full-scope concerns при збігу glob ідуть whole-repo. База
1773
+ // дельти не резолвиться → fail-open на повне піддерево (не мовчазний скіп).
1745
1774
  let pathFiles = null
1775
+ let pathMode = false
1746
1776
  if (pathArg !== null) {
1747
- const { collectPathScopedFiles } = await import('../scripts/lib/lint-surface/path-scope.mjs')
1748
- pathFiles = await collectPathScopedFiles(cwdArg, pathArg)
1777
+ const { collectPathScopedFiles, collectPathScopedChangedFiles } =
1778
+ await import('../scripts/lib/lint-surface/path-scope.mjs')
1779
+ if (args.includes('--full')) {
1780
+ pathFiles = await collectPathScopedFiles(cwdArg, pathArg)
1781
+ } else {
1782
+ const scoped = await collectPathScopedChangedFiles(cwdArg, pathArg, { baseRef })
1783
+ if (scoped.baseResolved) {
1784
+ pathFiles = scoped.files
1785
+ pathMode = true
1786
+ } else {
1787
+ console.warn(
1788
+ '⚠️ lint --path: база дельти не резолвиться (немає main/origin/main чи --base-ref) — fail-open: лінтиться все піддерево'
1789
+ )
1790
+ pathFiles = await collectPathScopedFiles(cwdArg, pathArg)
1791
+ }
1792
+ }
1749
1793
  }
1750
1794
  // --full + --path: buildPlan ігнорує full, коли передано explicitFiles (той самий
1751
1795
  // шлях), тож і тут full-вісь вважаємо неактивною — інакше глобальний machine-wide
1752
1796
  // лок --full брався б для швидкого scoped-прогону без причини.
1753
- const full = args.includes('--full') && pathArg === null
1754
- const lintOpts = { cwd: cwdArg, full, rules, verbose: args.includes('--verbose'), files: pathFiles }
1797
+ const full = args.includes('--full') && pathArg === null && !repoWide
1798
+ const lintOpts = {
1799
+ cwd: cwdArg,
1800
+ full,
1801
+ rules,
1802
+ verbose: args.includes('--verbose'),
1803
+ files: pathFiles,
1804
+ pathMode,
1805
+ repoWide,
1806
+ baseRef
1807
+ }
1755
1808
  const noFix = args.includes('--no-fix')
1756
1809
  // Глобальна черга full-прогонів (spec 2026-07-03): одночасно виконується один
1757
1810
  // `lint --full` на машину, паралельні --full чекають лока і бачать чергу та
@@ -1778,6 +1831,16 @@ try {
1778
1831
 
1779
1832
  break
1780
1833
  }
1834
+ case 'ci': {
1835
+ // n-rules ci plan — skip-логіка сервіс-орієнтованого CI-канону: рахує
1836
+ // перетин git-дельти з --path (каталог сервісу) і віддає job outputs
1837
+ // «які lint-домени запускати» для GitHub Actions (--github →
1838
+ // $GITHUB_OUTPUT) та Azure Pipelines (--azure → ##vso-рядки).
1839
+ const { runCiPlanCli } = await import('../scripts/lib/lint-surface/ci-plan.mjs')
1840
+ process.exitCode = await runCiPlanCli(args)
1841
+
1842
+ break
1843
+ }
1781
1844
  case 'taze': {
1782
1845
  // n-rules taze diff — read-only semver-diff package.json ↔ package.json.taze-bak
1783
1846
  // (root + воркспейси) для скілу n-taze: скрипт класифікує major-оновлення,
@@ -1816,7 +1879,7 @@ try {
1816
1879
  default: {
1817
1880
  console.error(`❌ Невідома команда: ${command}`)
1818
1881
  console.error(
1819
- ` Очікується: (без аргументів) синхронізація правил, rename-yaml-extensions, hook, adr-normalize-local, lint (включно зі scope: lint ga|rego|k8s|docker|text), taze, release, skill, doc-aggregate`
1882
+ ` Очікується: (без аргументів) синхронізація правил, rename-yaml-extensions, hook, adr-normalize-local, lint (включно зі scope: lint ga|rego|k8s|docker|text), ci plan, taze, release, skill, doc-aggregate`
1820
1883
  )
1821
1884
  process.exitCode = 1
1822
1885
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.16.1",
3
+ "version": "1.17.1",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -3,13 +3,13 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/bun/licensee/main.mjs
5
5
  docgen:
6
- crc: 48c72e2b
6
+ crc: 3f4c4d25
7
7
  model: manual
8
8
  ---
9
9
 
10
10
  ## Огляд
11
11
 
12
- Read-only detector `bun/licensee`: перевіряє ліцензії npm-залежностей проєкту через зовнішній інструмент `licensee` (`bun x licensee`), звіряючи їх з allowlist у `.licensee.json`. Розрізняє два принципово різні провали: `licensee` знайшов реально заборонену ліцензію (`license-violation`) — і `licensee` сам впав з помилкою (`licensee-crashed`), наприклад через несумісність `@npmcli/arborist` (яким він читає `node_modules`) з деревом, зібраним `bun install`.
12
+ Read-only detector `bun/licensee`: перевіряє ліцензії npm-залежностей проєкту через зовнішній інструмент `licensee` (`bun x licensee`), звіряючи їх з allowlist у `.licensee.json`. Розрізняє два принципово різні провали: `licensee` знайшов реально заборонену ліцензію (`license-violation`) — і `licensee` сам впав з помилкою (fail-open діагностика, не порушення), наприклад через несумісність `@npmcli/arborist` (яким він читає `node_modules`) з деревом, зібраним `bun install`.
13
13
 
14
14
  ## Поведінка
15
15
 
@@ -17,12 +17,12 @@ Read-only detector `bun/licensee`: перевіряє ліцензії npm-за
17
17
  2. Якщо `bun` не знайдено в PATH — `bun-missing`.
18
18
  3. Запускає `bun x licensee --production --errors-only` (без `--quiet` — реальне порушення потребує деталі: ім'я/версія/ліцензія пакета, які `licensee` пише в stdout через `print()`).
19
19
  4. Статус 0 — без порушень.
20
- 5. Статус ≠ 0 і є вивід у **stderr** — трактується як crash/`die()` самого `licensee` (invalid config або необроблений виняток усередині тула), НЕ як ліцензійне порушення: `licensee-crashed`, повідомлення включає stderr і підказку перевірити вручну `bunx licensee --production`.
20
+ 5. Статус ≠ 0 і є вивід у **stderr** — трактується як crash/`die()` самого `licensee` (invalid config або необроблений виняток усередині тула), НЕ як ліцензійне порушення: **fail-open** — warn-діагностика (видима у `--verbose`, не блокує exit code) зі stderr і підказкою перевірити вручну `bunx licensee --production`; fail-closed тут перманентно червонив би CI bun-монорепо.
21
21
  6. Статус ≠ 0 і stderr порожній, є вивід у **stdout** — це справжній `NOT APPROVED`-звіт від `licensee` (`--errors-only` друкує лише невідповідні пакети): `license-violation` з деталлю пакета/ліцензії у повідомленні.
22
22
 
23
23
  ## Публічний API
24
24
 
25
- - `lint(ctx)` — detector-контракт unified lint surface; повертає `{ violations }`.
25
+ - `lint(ctx)` — detector-контракт unified lint surface; повертає `{ violations }` (при crash тула — `{ violations: [], diagnostics }`).
26
26
 
27
27
  ## Гарантії поведінки
28
28
 
@@ -44,17 +44,25 @@ export function lint(ctx) {
44
44
  if (r.status !== 0) {
45
45
  const stderr = (r.stderr ?? '').trim().slice(0, 2000)
46
46
  if (stderr) {
47
- fail(
48
- `lint-bun: licensee інструмент завершився з помилкою, це НЕ підтверджене ліцензійне порушення ` +
49
- `(код ${r.status}, bun.mdc). Ймовірна причина — несумісність @npmcli/arborist (яким licensee читає ` +
50
- `node_modules) з деревом, зібраним bun install. Перевір вручну: \`bunx licensee --production\`.\n${stderr}`,
51
- 'licensee-crashed'
52
- )
53
- } else {
54
- const stdout = (r.stdout ?? '').trim().slice(0, 2000)
55
- const detail = stdout ? `\n${stdout}` : ''
56
- fail(`lint-bun: licensee — порушення ліцензій (код ${r.status}, bun.mdc)${detail}`, 'license-violation')
47
+ // Crash самого тула — НЕ підтверджене ліцензійне порушення: fail-open
48
+ // діагностикою (не блокує CI-гейт). Fail-closed тут перманентно червонив би
49
+ // bun-монорепо: @npmcli/arborist (яким licensee читає node_modules)
50
+ // несумісний із деревом bun install.
51
+ const result = reporter.result()
52
+ result.diagnostics = [
53
+ {
54
+ level: 'warn',
55
+ message:
56
+ `lint-bun: licensee — інструмент завершився з помилкою, це НЕ підтверджене ліцензійне порушення ` +
57
+ `(код ${r.status}, bun.mdc). Ймовірна причина — несумісність @npmcli/arborist з деревом bun install. ` +
58
+ `Перевір вручну: \`bunx licensee --production\`.\n${stderr}`
59
+ }
60
+ ]
61
+ return result
57
62
  }
63
+ const stdout = (r.stdout ?? '').trim().slice(0, 2000)
64
+ const detail = stdout ? `\n${stdout}` : ''
65
+ fail(`lint-bun: licensee — порушення ліцензій (код ${r.status}, bun.mdc)${detail}`, 'license-violation')
58
66
  }
59
67
  return reporter.result()
60
68
  }
@@ -49,11 +49,14 @@ function dropWorktreeCheckouts(paths) {
49
49
  * Визначає git base для scoped-перевірок без зовнішнього runtime-стану.
50
50
  * Пріоритет: локальна `main`, потім `origin/main`; якщо обидві відсутні,
51
51
  * повертає null і caller порівнює лише робоче дерево з HEAD.
52
+ * Явний `baseRef` (CI: `--base origin/main` після fetch) вимикає каскад —
53
+ * merge-base рахується лише проти нього.
52
54
  * @param {string} [cwd] корінь репо
55
+ * @param {string|null} [baseRef] явний ref бази замість каскаду main→origin/main
53
56
  * @returns {string|null} merge-base commit або null
54
57
  */
55
- export function resolveChangedBase(cwd = process.cwd()) {
56
- for (const ref of ['main', 'origin/main']) {
58
+ export function resolveChangedBase(cwd = process.cwd(), baseRef = null) {
59
+ for (const ref of baseRef ? [baseRef] : ['main', 'origin/main']) {
57
60
  const result = spawnSync('git', ['merge-base', 'HEAD', ref], { cwd, encoding: 'utf8' })
58
61
  const base = result.status === 0 && !result.error ? result.stdout.trim() : ''
59
62
  if (base) return base
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: changed-files.mjs
4
4
  resource: npm/scripts/lib/changed-files.mjs
5
5
  docgen:
6
- crc: 483db255
6
+ crc: 254d852a
7
7
  score: 100
8
8
  ---
9
9
 
@@ -19,8 +19,8 @@ collectChangedFilesSince Збирає змінені та незакомічен
19
19
 
20
20
  - collectChangedFiles: Збирає список змінених та невідстежених файлів з робочого дерева відносно робочого дерева.
21
21
  - resolveChangedBase: Визначає базовий комміт для обмежених перевірок без використання зовнішнього стану.
22
- - Пріоритет: локальна `main`, потім `origin/main`.
23
- - Якщо обидві відсутні, повертає null, і викликаючий порівнює лише робоче дерево з HEAD.
22
+ - Пріоритет: локальна `main`, потім `origin/main`; явний другий аргумент `baseRef` (CI: `--base origin/main`) вимикає каскад — merge-base рахується лише проти нього.
23
+ - Якщо ref-и відсутні, повертає null, і викликаючий порівнює лише робоче дерево з HEAD.
24
24
  - collectChangedFilesSince: Збирає список змінених та невідстежених файлів відносно базового комміту.
25
25
  - git diff <base>: Порівнює комміт `base` з поточним робочим деревом (включаючи закомічені та незакомічені модифікації).
26
26
  - Якщо `base` відсутній, використовується `collectChangedFiles`.
@@ -0,0 +1,221 @@
1
+ /**
2
+ * `n-rules ci plan` — skip-логіка сервіс-орієнтованого CI-канону (спільна для
3
+ * GitHub Actions і Azure Pipelines): рахує перетин git-дельти з `--path`
4
+ * (каталог сервісу) і по glob-ах per-file concerns визначає, які lint-домени
5
+ * взагалі мають запускатись. Виходи — job outputs обох CI: гейт-джоба `plan`
6
+ * емить `js=true|false`, а lint-джоби умовно скіпаються (`if:`/`condition:`).
7
+ *
8
+ * Read-only: без глобального лока, без мутації package.json, без root-guard.
9
+ * Джерело правди активності домену — `computeActiveDomains` (та сама таблиця
10
+ * planConcernForDelta, що й `lint <domain> --path`): «plan сказав true» ⇔
11
+ * «lint щось запустить».
12
+ *
13
+ * Fail-open: якщо база дельти не резолвиться (немає main/origin/main чи
14
+ * `--base`-ref) — warning і ВСІ домени true (запускаємо більше, ніколи не
15
+ * скіпаємо мовчки).
16
+ */
17
+ import { appendFileSync } from 'node:fs'
18
+ import { env } from 'node:process'
19
+
20
+ import picomatch from 'picomatch'
21
+
22
+ import { resolveChangedBase, collectChangedFilesSince } from '../changed-files.mjs'
23
+ import { collectPathScopedChangedFiles, collectPathScopedFiles } from './path-scope.mjs'
24
+ import { loadEnabledLintRules, computeActiveDomains } from './run-detectors.mjs'
25
+
26
+ /** Глоби тест-файлів для виходу `has_tests` (bun test / vitest / pytest). */
27
+ const TEST_FILE_GLOBS = ['**/*.test.*', '**/*.spec.*', '**/test_*.py', '**/*_test.py', '**/tests/**']
28
+
29
+ /** Зарезервовані ключі outputs — домен із таким ключем був би колізією. */
30
+ const RESERVED_OUTPUT_KEYS = new Set(['any', 'has_tests', 'domains'])
31
+
32
+ /**
33
+ * Rule-id → ключ output-змінної: `-` → `_` (GA/Azure не люблять дефіси в
34
+ * іменах змінних; `npm-module` → `npm_module`).
35
+ * @param {string} ruleId rule-id домену.
36
+ * @returns {string} ключ output.
37
+ */
38
+ function domainKey(ruleId) {
39
+ return ruleId.replaceAll('-', '_')
40
+ }
41
+
42
+ /**
43
+ * @typedef {object} CiPlan
44
+ * @property {string|null} path значення `--path` (каталог сервісу) або null (repo-wide).
45
+ * @property {boolean} baseResolved чи резолвнулась база дельти (false → fail-open, всі true).
46
+ * @property {number|null} changedCount кількість файлів у наборі (null при fail-open).
47
+ * @property {boolean} hasChanges чи набір непорожній (гейт тест-джоби `any`).
48
+ * @property {boolean} hasTests чи є тест-файли в піддереві (статично, незалежно від дельти).
49
+ * @property {{ id: string, key: string, triggered: boolean, matchedFiles: number }[]} domains стан доменів (сортовано за id).
50
+ */
51
+
52
+ /**
53
+ * Обчислює план CI для `--path` (сервіс) або всього репо (без `--path`).
54
+ * @param {{ cwd: string, pathArg?: string|null, baseRef?: string|null, rulesDir?: string, rulesDirs?: string[] }} opts корінь, каталог сервісу, явна база; rulesDir(s) — для тестів.
55
+ * @returns {Promise<CiPlan>} план для рендерерів.
56
+ */
57
+ export async function computeCiPlan({ cwd, pathArg = null, baseRef = null, rulesDir, rulesDirs }) {
58
+ const { byRule, enabledSet } = await loadEnabledLintRules({ cwd, rulesDir, rulesDirs })
59
+
60
+ /** @type {string[]|null} */
61
+ let changed = null
62
+ if (pathArg === null) {
63
+ const base = resolveChangedBase(cwd, baseRef)
64
+ if (base !== null) changed = collectChangedFilesSince(base, cwd)
65
+ } else {
66
+ const r = await collectPathScopedChangedFiles(cwd, pathArg, { baseRef })
67
+ if (r.baseResolved) changed = r.files
68
+ }
69
+ const baseResolved = changed !== null
70
+
71
+ // Домени = enabled-правила з ≥1 per-file concern (computeActiveDomains
72
+ // не повертає правила без per-file поверхонь — їм нема чого скіпати).
73
+ const active = computeActiveDomains(byRule, enabledSet, changed ?? [])
74
+ const domains = Array.from(active.entries(), ([id, st]) => ({
75
+ id,
76
+ key: domainKey(id),
77
+ triggered: baseResolved ? st.triggered : true,
78
+ matchedFiles: baseResolved ? st.matchedFiles : 0
79
+ })).toSorted((a, b) => a.id.localeCompare(b.id))
80
+
81
+ const keys = new Set()
82
+ for (const d of domains) {
83
+ if (keys.has(d.key) || RESERVED_OUTPUT_KEYS.has(d.key)) {
84
+ throw new Error(`ci plan: колізія ключа output «${d.key}» (домен ${d.id})`)
85
+ }
86
+ keys.add(d.key)
87
+ }
88
+
89
+ // has_tests — статична наявність тест-файлів у піддереві (не залежить від
90
+ // дельти): консюмер вирішує, чи взагалі мати test-джобу в pipeline сервісу.
91
+ const subtree = await collectPathScopedFiles(cwd, pathArg ?? '.')
92
+ const isTest = picomatch(TEST_FILE_GLOBS, { dot: true })
93
+ const hasTests = subtree.some(f => isTest(f))
94
+
95
+ return {
96
+ path: pathArg,
97
+ baseResolved,
98
+ changedCount: baseResolved ? /** @type {string[]} */ (changed).length : null,
99
+ hasChanges: baseResolved ? /** @type {string[]} */ (changed).length > 0 : true,
100
+ hasTests,
101
+ domains
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Людиночитаний рендер плану (дефолтний stdout-вивід).
107
+ * @param {CiPlan} plan обчислений план.
108
+ * @returns {string} багаторядковий текст.
109
+ */
110
+ export function renderCiPlanHuman(plan) {
111
+ const lines = []
112
+ const where = plan.path === null ? 'весь репозиторій' : `--path ${plan.path}`
113
+ if (plan.baseResolved) {
114
+ lines.push(`📋 ci plan (${where}): ${plan.changedCount} змінених файлів у наборі`)
115
+ } else {
116
+ lines.push(`⚠️ ci plan (${where}): база дельти не резолвиться — fail-open, усі домени true`)
117
+ }
118
+ for (const d of plan.domains) {
119
+ const suffix = d.matchedFiles > 0 ? ` (${d.matchedFiles} файл(ів))` : ''
120
+ lines.push(` ${d.triggered ? '✅' : '⏭️'} ${d.id}${suffix}`)
121
+ }
122
+ lines.push(` any=${plan.hasChanges} has_tests=${plan.hasTests}`)
123
+ return `${lines.join('\n')}\n`
124
+ }
125
+
126
+ /**
127
+ * Рядки `name=value` для `$GITHUB_OUTPUT` (по одному на домен + агрегати).
128
+ * Значення — лише `true|false` або JSON-масив id ([a-z0-9_-]) — інʼєкція в
129
+ * output-файл неможлива за конструкцією.
130
+ * @param {CiPlan} plan обчислений план.
131
+ * @returns {string[]} рядки для append у файл `$GITHUB_OUTPUT`.
132
+ */
133
+ export function renderCiPlanGithubLines(plan) {
134
+ const lines = plan.domains.map(d => `${d.key}=${d.triggered}`)
135
+ lines.push(
136
+ `any=${plan.hasChanges}`,
137
+ `has_tests=${plan.hasTests}`,
138
+ `domains=${JSON.stringify(plan.domains.filter(d => d.triggered).map(d => d.id))}`
139
+ )
140
+ return lines
141
+ }
142
+
143
+ /**
144
+ * Один logging command Azure Pipelines для output-змінної.
145
+ * @param {string} k ключ змінної.
146
+ * @param {boolean|string} v значення.
147
+ * @returns {string} `##vso`-рядок.
148
+ */
149
+ function vso(k, v) {
150
+ return `##vso[task.setvariable variable=${k};isOutput=true]${v}`
151
+ }
152
+
153
+ /**
154
+ * Logging commands Azure Pipelines (`##vso[task.setvariable …;isOutput=true]`)
155
+ * — stdout-рядки; downstream-джоби читають `dependencies.<job>.outputs['plan.<key>']`
156
+ * (крок мусить мати `name: plan`).
157
+ * @param {CiPlan} plan обчислений план.
158
+ * @returns {string[]} рядки для stdout.
159
+ */
160
+ export function renderCiPlanAzureLines(plan) {
161
+ const lines = plan.domains.map(d => vso(d.key, d.triggered))
162
+ lines.push(
163
+ vso('any', plan.hasChanges),
164
+ vso('has_tests', plan.hasTests),
165
+ vso('domains', JSON.stringify(plan.domains.filter(d => d.triggered).map(d => d.id)))
166
+ )
167
+ return lines
168
+ }
169
+
170
+ /**
171
+ * CLI-хендлер `n-rules ci <subcommand>`. Наразі єдина підкоманда — `plan`.
172
+ * Прапори: `--path <dir>`, `--base <ref>`, `--cwd <dir>`, `--github` (append
173
+ * у `$GITHUB_OUTPUT`), `--azure` (`##vso`-рядки в stdout), `--json`.
174
+ * @param {string[]} args аргументи після `ci`.
175
+ * @returns {Promise<number>} exit code.
176
+ */
177
+ export async function runCiPlanCli(args) {
178
+ const [sub, ...rest] = args
179
+ if (sub !== 'plan') {
180
+ console.error(
181
+ `❌ Невідома підкоманда ci: ${sub ?? '(порожньо)'} — очікується: n-rules ci plan [--path <dir>] [--base <ref>] [--github|--azure|--json]`
182
+ )
183
+ return 1
184
+ }
185
+ const valueOf = flag => {
186
+ const i = rest.indexOf(flag)
187
+ return i === -1 ? null : (rest[i + 1] ?? null)
188
+ }
189
+ const cwd = valueOf('--cwd') ?? process.cwd()
190
+ const pathArg = valueOf('--path')
191
+ const baseRef = valueOf('--base')
192
+
193
+ const plan = await computeCiPlan({ cwd, pathArg, baseRef })
194
+ if (!plan.baseResolved) {
195
+ console.error(
196
+ '⚠️ ci plan: база дельти не резолвиться (немає main/origin/main чи --base-ref у клоні) — fail-open: усі домени true. У CI перевірте fetch-depth/git fetch і прапорець --base.'
197
+ )
198
+ }
199
+
200
+ if (rest.includes('--json')) {
201
+ console.log(JSON.stringify(plan, null, 2))
202
+ return 0
203
+ }
204
+ if (rest.includes('--github')) {
205
+ const outFile = env.GITHUB_OUTPUT
206
+ if (!outFile) {
207
+ console.error('❌ ci plan --github: змінна середовища GITHUB_OUTPUT відсутня (запуск поза GitHub Actions?)')
208
+ return 1
209
+ }
210
+ appendFileSync(outFile, `${renderCiPlanGithubLines(plan).join('\n')}\n`)
211
+ process.stdout.write(renderCiPlanHuman(plan))
212
+ return 0
213
+ }
214
+ if (rest.includes('--azure')) {
215
+ process.stdout.write(`${renderCiPlanAzureLines(plan).join('\n')}\n`)
216
+ process.stdout.write(renderCiPlanHuman(plan))
217
+ return 0
218
+ }
219
+ process.stdout.write(renderCiPlanHuman(plan))
220
+ return 0
221
+ }
@@ -0,0 +1,38 @@
1
+ ---
2
+ type: JS Module
3
+ title: ci-plan.mjs
4
+ resource: npm/scripts/lib/lint-surface/ci-plan.mjs
5
+ docgen:
6
+ crc: a532483e
7
+ model: openai-codex/gpt-5.4-mini
8
+ tier: cloud-min
9
+ score: 90
10
+ issues: internal-name:computeActiveDomains,judge:inaccurate:0.99
11
+ judgeModel: openai-codex/gpt-5.4-mini
12
+ ---
13
+
14
+ ## Огляд
15
+
16
+ Файл визначає спільний для GitHub Actions і Azure Pipelines CI-план для сервіс-орієнтованого запуску lint-доменів: за git-дельтою та `--path` він вирішує, які домени мають узагалі стартувати. Джерело правди активності домену — та сама таблиця, що й у `lint <domain> --path`: якщо `plan` сказав `true`, відповідний lint-домен щось запускатиме; якщо `false` — CI-джоби скіпаються через `if:`/`condition:`. Гейт-джоба `plan` емить `js=true|false` для наступних CI-джоб. Команда read-only: без глобального лока, без мутації `package.json`, без root-guard. Якщо база дельти не резолвиться (`main`, `origin/main` чи `--base`-ref недоступні), код переходить у fail-open: показує warning і вмикає всі домени.
17
+
18
+ ## Поведінка
19
+
20
+ - `computeCiPlan` — обчислює CI-план для всього репозиторію або для `--path`, визначає стан доменів, ознаку змін, наявність тестів у піддереві та переходить у fail-open, якщо база дельти не резолвиться.
21
+ - `renderCiPlanHuman` — перетворює CI-план у лаконічний людиночитаний звіт для stdout, показуючи стан бази, домени та агреговані прапори.
22
+ - `renderCiPlanGithubLines` — формує рядки `name=value` для `$GITHUB_OUTPUT`, щоб GitHub Actions міг використовувати результати плану як outputs.
23
+ - `renderCiPlanAzureLines` — формує Azure Pipelines logging commands для output-змінних, щоб downstream-джоби могли читати результати плану.
24
+ - `runCiPlanCli` — обробляє CLI-підкоманду `n-rules ci plan`, обирає формат виводу (`--json`, `--github`, `--azure` або human), і повідомляє про fail-open, якщо база дельти не визначилась.
25
+
26
+ ## Публічний API
27
+
28
+ - computeCiPlan — формує набір CI-доменів для запуску: або для сервісу з `--path`, або для всього репо без нього.
29
+ - renderCiPlanHuman — виводить план у зручному для людини вигляді в stdout.
30
+ - renderCiPlanGithubLines — готує `name=value` рядки для `$GITHUB_OUTPUT`: окремо для кожного домену та для агрегованих значень.
31
+ - renderCiPlanAzureLines — виводить Azure Pipelines logging commands у stdout, щоб downstream-джоби читали значення з output variables.
32
+ - runCiPlanCli — обробляє `n-rules ci plan` і підтримує `--path`, `--base`, `--cwd`, `--github`, `--azure`, `--json`.
33
+
34
+ Changelog: не перевірявся
35
+
36
+ ## Гарантії поведінки
37
+
38
+ - (специфічних машинно-виведених гарантій немає)
@@ -7,6 +7,7 @@ resource: npm/scripts/lib/lint-surface/
7
7
  | Файл | Тип |
8
8
  | ----------------------------------------------------------- | --------- |
9
9
  | [blocking-inventory.mjs](blocking-inventory.md) | JS Module |
10
+ | [ci-plan.mjs](ci-plan.md) | JS Module |
10
11
  | [codegen-opa-wrapper.mjs](codegen-opa-wrapper.md) | JS Module |
11
12
  | [collateral-veto.mjs](collateral-veto.md) | JS Module |
12
13
  | [default-worker.mjs](default-worker.md) | JS Module |
@@ -3,9 +3,8 @@ type: JS Module
3
3
  title: path-scope.mjs
4
4
  resource: npm/scripts/lib/lint-surface/path-scope.mjs
5
5
  docgen:
6
- crc: 0971e78e
6
+ crc: 40697c53
7
7
  model: openai-codex/gpt-5.4-mini
8
- tier: cloud-min
9
8
  score: 100
10
9
  issues: judge:inaccurate:0.94
11
10
  judgeModel: openai-codex/gpt-5.4-mini
@@ -13,18 +12,20 @@ docgen:
13
12
 
14
13
  ## Огляд
15
14
 
16
- Файл звужує `n-rules lint --path <dir>` до каталогу всередині вже вибраного кореня прогону, не змінюючи сам root і не підміняючи правила з `.n-rules.json`. Він збирає всі файли під заданою піддиректорією та передає їх у `buildPlan` як `explicitFiles`, тим самим шляхом, що вже живить `hook --post-tool-use`/`--stop`; per-file concerns фільтруються за цими файлами, а `full`-scope concerns запускаються при збігу glob і все одно проходять whole-repo.
15
+ Файл звужує `n-rules lint --path <dir>` до каталогу всередині вже вибраного кореня прогону, не змінюючи сам root і не підміняючи правила з `.n-rules.json`. Результат передається у `buildPlan` як `explicitFiles` тим самим шляхом, що вже живить `hook --post-tool-use`/`--stop`. Два режими збору: дефолтний `--path` — **перетин** піддерева з git-дельтою vs merge-base (сервіс-орієнтований CI-канон: перевіряються лише змінені файли сервісу); `--path --full` — всі файли піддерева (історична поведінка, full-scope concerns при збігу glob ідуть whole-repo).
17
16
 
18
17
  ## Поведінка
19
18
 
20
- 1. `collectPathScopedFiles` приймає корінь прогону та значення `--path`, звужує область lint до каталогу всередині цього кореня й не змінює сам корінь прогону.
21
- 2. `collectPathScopedFiles` відхиляє шлях, якщо він веде поза межі кореня або не є каталогом.
22
- 3. `collectPathScopedFiles` збирає всі файли в межах вказаного каталогу, відсікаючи виключення з кореневих ignore-налаштувань і `.gitignore`-поведінки, а порожній каталог вважає валідним порожнім результатом.
23
- 4. `collectPathScopedFiles` повертає відсортований список шляхів відносно кореня прогону у форматі, придатному для передачі в `buildPlan` як `explicitFiles`.
19
+ 1. Обидва збирачі відхиляють шлях, якщо він веде поза межі кореня (traversal через `..`, абсолютний шлях назовні) або не є каталогом; порожній результат — валідний, не помилка.
20
+ 2. `collectPathScopedChangedFiles` рахує git-дельту vs merge-base (`main` `origin/main` або явний `baseRef` із `--base`) і лишає тільки файли під `--path`-каталогом, мінус `.n-rules.json:ignore`. Якщо база не резолвиться — повертає `baseResolved: false` без обчислення дельти: caller робить fail-open fallback на повне піддерево (мовчазного скіпу не існує).
21
+ 3. `collectPathScopedFiles` збирає всі файли піддерева, поважаючи `.gitignore` і `.n-rules.json:ignore` кореня.
22
+ 4. Обидва повертають відсортовані posix-відносні від `cwd` шляхи.
24
23
 
25
24
  ## Публічний API
26
25
 
27
- - collectPathScopedFilesзбирає posix-відносні від `cwd` шляхи всіх файлів у каталозі з `--path`, з урахуванням `.gitignore` і `.n-rules.json:ignore` у корені; порожній каталог повертає порожній набір без помилки
26
+ - collectPathScopedChangedFilesперетин git-дельти з піддеревом `--path`: `{ files, baseResolved }`; опція `baseRef` явна база (`--base <ref>`)
27
+ - collectPathScopedFiles — всі файли піддерева `--path` (режим `--full`)
28
+ - resolveAndAssertPathDir — резолв і валідація `--path` (усередині `cwd`, існує, каталог); спільний вхід обох збирачів
28
29
 
29
30
  ## Гарантії поведінки
30
31
 
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: run-detectors.mjs
4
4
  resource: npm/scripts/lib/lint-surface/run-detectors.mjs
5
5
  docgen:
6
- crc: 631fa41c
6
+ crc: 128fe3b7
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98
@@ -28,6 +28,10 @@ detectAll виконує прохід лінтера у режим детекц
28
28
  * DEFAULT_RULES_DIR — Містить набір стандартних правил для перевірок.
29
29
  * buildDetectPlan — Створює план виконання перевірок, охоплюючи визначені області.
30
30
  * detectAll — Виконує повний прохід пошуку проблем і повертає всі виявлені порушення та код виходу.
31
+ * loadEnabledLintRules — Discovery-фасад для споживачів поза detect/fix-конвеєром (`ci plan`): concerns за rule-id + set активних правил.
32
+ * computeActiveDomains — Активність доменів для файлового набору (лише per-file concerns) — єдине джерело правди для `ci plan`: «plan сказав true» ⇔ «lint щось запустить».
33
+
34
+ **Осі плану (сервіс-канон):** scoped + explicit files (`lint js --path <dir>`) → лише per-file concerns названих правил × перетин; `pathMode` (дефолтний `--path`) → full-scope concerns виключені з delta-плану; `repoWide` (`--repo-wide`) → ЛИШЕ full-scope concerns enabled-правил, whole-repo (окремий CI-workflow, не гейтить деплой).
31
35
 
32
36
  ## Гарантії поведінки
33
37
 
@@ -3,16 +3,22 @@
3
3
  *
4
4
  * На відміну від `--cwd` (підміняє корінь прогону — root-guard, `.n-rules.json`,
5
5
  * devDependencies), `--path` лишає корінь незмінним і лише звужує файловий
6
- * набір: збирає всі файли під заданою піддиректорією й передає їх у
7
- * `buildPlan` як `explicitFiles`, тим самим шляхом, що вже годує
8
- * `hook --post-tool-use`/`--stop` (per-file concerns фільтруються по цих
9
- * файлах; `full`-scope concerns запускаються при збігу glob, але самі
10
- * все одно проходять whole-repoтак уже поводиться delta-режим).
6
+ * набір, передаючи його у `buildPlan` як `explicitFiles` тим самим шляхом,
7
+ * що вже годує `hook --post-tool-use`/`--stop`.
8
+ *
9
+ * Два режими збору:
10
+ * - `collectPathScopedChangedFiles` (дефолт `--path`)**перетин** піддерева
11
+ * з git-дельтою vs merge-base (сервіс-орієнтований CI-канон: перевіряються
12
+ * лише змінені файли сервісу);
13
+ * - `collectPathScopedFiles` (`--path --full`) — всі файли піддерева, як
14
+ * історична поведінка `--path` (full-scope concerns при збігу glob ідуть
15
+ * whole-repo — так поводиться delta-режим).
11
16
  */
12
17
  import { existsSync, statSync } from 'node:fs'
13
18
  import { isAbsolute, relative, resolve, sep } from 'node:path'
14
19
 
15
20
  import { loadCursorIgnorePaths } from '../load-cursor-config.mjs'
21
+ import { collectChangedFilesSince, resolveChangedBase } from '../changed-files.mjs'
16
22
  import { walkDir } from '../../utils/walkDir.mjs'
17
23
 
18
24
  /**
@@ -30,6 +36,50 @@ function assertWithinCwd(cwd, target) {
30
36
  }
31
37
  }
32
38
 
39
+ /**
40
+ * Резолвить `--path` в абсолютний шлях і валідує: усередині `cwd`, існує,
41
+ * є каталогом. Спільний вхід обох режимів збору.
42
+ * @param {string} cwd абсолютний корінь прогону.
43
+ * @param {string} pathArg значення `--path` (відносний або абсолютний шлях).
44
+ * @returns {string} абсолютний шлях каталогу.
45
+ */
46
+ export function resolveAndAssertPathDir(cwd, pathArg) {
47
+ const target = resolve(cwd, pathArg)
48
+ assertWithinCwd(cwd, target)
49
+ if (!existsSync(target) || !statSync(target).isDirectory()) {
50
+ throw new Error(`--path не є каталогом: ${target}`)
51
+ }
52
+ return target
53
+ }
54
+
55
+ /**
56
+ * Перетин git-дельти (vs merge-base) з піддеревом `--path`: posix-відносні
57
+ * шляхи змінених/untracked файлів під каталогом, мінус `.n-rules.json:ignore`.
58
+ * База не резолвиться (немає main/origin/main або заданого `baseRef`) —
59
+ * `baseResolved: false` без обчислення дельти: caller робить fail-open
60
+ * fallback на повне піддерево (мовчазного скіпу не існує).
61
+ * @param {string} cwd абсолютний корінь прогону (root-guard уже пройдено).
62
+ * @param {string} pathArg значення `--path` (відносний або абсолютний шлях).
63
+ * @param {{ baseRef?: string|null }} [opts] явний ref бази (`--base`).
64
+ * @returns {Promise<{ files: string[], baseResolved: boolean }>} відсортований перетин і статус бази.
65
+ */
66
+ export async function collectPathScopedChangedFiles(cwd, pathArg, { baseRef = null } = {}) {
67
+ const target = resolveAndAssertPathDir(cwd, pathArg)
68
+ const base = resolveChangedBase(cwd, baseRef)
69
+ if (base === null) return { files: [], baseResolved: false }
70
+ const changed = collectChangedFilesSince(base, cwd)
71
+ const relDir = relative(cwd, target).split(sep).join('/')
72
+ const prefix = relDir === '' ? '' : `${relDir}/`
73
+ // git уже поважає .gitignore; .n-rules.json:ignore застосовуємо явно (як walkDir).
74
+ const ignorePaths = await loadCursorIgnorePaths(cwd)
75
+ const ignorePrefixes = ignorePaths
76
+ .map(p => relative(cwd, resolve(p)).split(sep).join('/'))
77
+ .filter(rel => rel !== '' && !rel.startsWith('..'))
78
+ .map(rel => `${rel}/`)
79
+ const files = changed.filter(f => f.startsWith(prefix)).filter(f => ignorePrefixes.every(ip => !f.startsWith(ip)))
80
+ return { files: files.toSorted((a, b) => a.localeCompare(b)), baseResolved: true }
81
+ }
82
+
33
83
  /**
34
84
  * Збирає posix-відносні (від `cwd`) шляхи всіх файлів під `--path`-каталогом,
35
85
  * поважаючи `.gitignore` і `.n-rules.json:ignore` кореня. Порожній каталог —
@@ -39,11 +89,7 @@ function assertWithinCwd(cwd, target) {
39
89
  * @returns {Promise<string[]>} відсортовані posix-відносні шляхи від `cwd`.
40
90
  */
41
91
  export async function collectPathScopedFiles(cwd, pathArg) {
42
- const target = resolve(cwd, pathArg)
43
- assertWithinCwd(cwd, target)
44
- if (!existsSync(target) || !statSync(target).isDirectory()) {
45
- throw new Error(`--path не є каталогом: ${target}`)
46
- }
92
+ const target = resolveAndAssertPathDir(cwd, pathArg)
47
93
  const ignorePaths = await loadCursorIgnorePaths(cwd)
48
94
  /** @type {string[]} */
49
95
  const out = []
@@ -195,10 +195,25 @@ export async function buildDetectPlan(opts) {
195
195
  full: opts.full === true,
196
196
  rules: Array.isArray(opts.rules) ? opts.rules : [],
197
197
  explicitFiles: Array.isArray(opts.files) ? opts.files : null,
198
+ pathMode: opts.pathMode === true,
199
+ repoWide: opts.repoWide === true,
200
+ baseRef: typeof opts.baseRef === 'string' ? opts.baseRef : null,
198
201
  cwd: opts.cwd
199
202
  })
200
203
  }
201
204
 
205
+ /**
206
+ * Discovery-фасад для споживачів поза detect/fix-конвеєром (`ci plan`):
207
+ * concerns за rule-id (ядро + плагіни, capability-фільтр) і set активних правил.
208
+ * @param {{ rulesDir?: string, rulesDirs?: string[], capabilities?: Iterable<string>, cwd: string }} opts опції прогону.
209
+ * @returns {Promise<{ byRule: Record<string, ConcernMeta[]>, enabledSet: Set<string> }>} concerns і активні правила.
210
+ */
211
+ export async function loadEnabledLintRules(opts) {
212
+ const byRule = await filterByCapabilities(await readLintConcernsByRuleMulti(await effectiveRulesDirs(opts)), opts)
213
+ const enabledSet = new Set(await enabledRuleIds(byRule, opts.cwd))
214
+ return { byRule, enabledSet }
215
+ }
216
+
202
217
  /**
203
218
  * scoped-режим: усі lint-concerns названих правил, whole-repo.
204
219
  * @param {Record<string, ConcernMeta[]>} byRule concerns згруповані за rule-id.
@@ -213,6 +228,52 @@ function buildScopedPlan(byRule, rules) {
213
228
  return plan.toSorted((a, b) => a.entry.ruleId.localeCompare(b.entry.ruleId))
214
229
  }
215
230
 
231
+ /**
232
+ * scoped+`--path` режим (`lint js --path run/nexus`): ЛИШЕ per-file concerns
233
+ * названих правил × явний файловий набір (перетин path ∩ дельта). Full-scope
234
+ * concerns виключені повністю — деплой-гейт сервісу не блокується whole-repo
235
+ * порушеннями поза сервісом (repo-wide workflow — їхнє канонічне місце).
236
+ * enabledSet не застосовується (явний запит правила, як у buildScopedPlan).
237
+ * @param {Record<string, ConcernMeta[]>} byRule concerns згруповані за rule-id.
238
+ * @param {string[]} rules scoped rule-id.
239
+ * @param {string[]} files явний файловий набір (перетин).
240
+ * @returns {PlanItem[]} впорядкований план прогону.
241
+ */
242
+ function buildScopedDeltaPlan(byRule, rules, files) {
243
+ /** @type {PlanItem[]} */
244
+ const plan = []
245
+ for (const ruleId of rules) {
246
+ for (const concern of byRule[ruleId] ?? []) {
247
+ if (concern.lint.scope !== 'per-file') continue
248
+ const item = planConcernForDelta(ruleId, concern, files)
249
+ if (item) plan.push(item)
250
+ }
251
+ }
252
+ return plan.toSorted(
253
+ (a, b) => a.entry.ruleId.localeCompare(b.entry.ruleId) || a.entry.concern.name.localeCompare(b.entry.concern.name)
254
+ )
255
+ }
256
+
257
+ /**
258
+ * `--repo-wide` режим: ЛИШЕ full-scope concerns enabled-правил, whole-repo.
259
+ * Канонічне місце перевірок без path-підтримки (knip, jscpd, dep-policy) —
260
+ * окремий CI-workflow, що не гейтить деплой сервісів.
261
+ * @param {Record<string, ConcernMeta[]>} byRule concerns згруповані за rule-id.
262
+ * @param {Set<string>} enabledSet активні rule-id.
263
+ * @returns {PlanItem[]} впорядкований план (whole-repo для кожного entry).
264
+ */
265
+ function buildRepoWidePlan(byRule, enabledSet) {
266
+ /** @type {LintEntry[]} */
267
+ const entries = []
268
+ for (const [ruleId, concerns] of Object.entries(byRule)) {
269
+ if (!enabledSet.has(ruleId)) continue
270
+ for (const concern of concerns) {
271
+ if (concern.lint.scope === 'full') entries.push({ ruleId, concern })
272
+ }
273
+ }
274
+ return sortEntries(entries).map(entry => ({ entry, files: undefined }))
275
+ }
276
+
216
277
  /**
217
278
  * full-режим: усі per-file + full concerns enabled-правил, whole-repo.
218
279
  * @param {Record<string, ConcernMeta[]>} byRule concerns згруповані за rule-id.
@@ -252,17 +313,21 @@ function planConcernForDelta(ruleId, concern, changed) {
252
313
 
253
314
  /**
254
315
  * delta/explicit-files режим: concern-и enabled-правил, зіставлені зі зміненими файлами.
316
+ * `perFileOnly` (path-режим сервіс-канону) виключає full-scope concerns —
317
+ * перетин path ∩ дельта перевіряють лише per-file правила.
255
318
  * @param {Record<string, ConcernMeta[]>} byRule concerns згруповані за rule-id.
256
319
  * @param {Set<string>} enabledSet активні rule-id.
257
320
  * @param {string[]} changed перелік змінених файлів.
321
+ * @param {{ perFileOnly?: boolean }} [opts] фільтр full-scope concerns.
258
322
  * @returns {PlanItem[]} впорядкований план (за ruleId, потім concern.name).
259
323
  */
260
- function buildDeltaPlan(byRule, enabledSet, changed) {
324
+ function buildDeltaPlan(byRule, enabledSet, changed, { perFileOnly = false } = {}) {
261
325
  /** @type {PlanItem[]} */
262
326
  const plan = []
263
327
  for (const [ruleId, concerns] of Object.entries(byRule)) {
264
328
  if (!enabledSet.has(ruleId)) continue
265
329
  for (const concern of concerns) {
330
+ if (perFileOnly && concern.lint.scope !== 'per-file') continue
266
331
  const item = planConcernForDelta(ruleId, concern, changed)
267
332
  if (item) plan.push(item)
268
333
  }
@@ -272,6 +337,35 @@ function buildDeltaPlan(byRule, enabledSet, changed) {
272
337
  )
273
338
  }
274
339
 
340
+ /**
341
+ * Активність доменів (rule-id) для заданого файлового набору — єдине джерело
342
+ * правди для `ci plan`: домен «активний», якщо хоч один його **per-file**
343
+ * concern тригериться на цих файлах (та сама таблиця planConcernForDelta, що
344
+ * й `lint <domain> --path` → «plan сказав true» ⇔ «lint щось запустить»).
345
+ * Правила без жодного per-file concern не потрапляють у результат (їхні
346
+ * full-scope перевірки — справа `--repo-wide`).
347
+ * @param {Record<string, ConcernMeta[]>} byRule concerns згруповані за rule-id.
348
+ * @param {Set<string>} enabledSet активні rule-id.
349
+ * @param {string[]} changed файловий набір (перетин path ∩ дельта або дельта).
350
+ * @returns {Map<string, { triggered: boolean, matchedFiles: number }>} стан за rule-id.
351
+ */
352
+ export function computeActiveDomains(byRule, enabledSet, changed) {
353
+ /** @type {Map<string, { triggered: boolean, matchedFiles: number }>} */
354
+ const out = new Map()
355
+ for (const [ruleId, concerns] of Object.entries(byRule)) {
356
+ if (!enabledSet.has(ruleId)) continue
357
+ const perFile = concerns.filter(c => c.lint.scope === 'per-file')
358
+ if (perFile.length === 0) continue
359
+ const matched = new Set()
360
+ for (const concern of perFile) {
361
+ const item = planConcernForDelta(ruleId, concern, changed)
362
+ for (const f of item?.files ?? []) matched.add(f)
363
+ }
364
+ out.set(ruleId, { triggered: matched.size > 0, matchedFiles: matched.size })
365
+ }
366
+ return out
367
+ }
368
+
275
369
  /**
276
370
  * Будує план: список entries + чи кожен запускається whole-repo (files=undefined)
277
371
  * чи per-file (files=[...]). Реалізує таблицю lint.scope зі специфікації.
@@ -280,22 +374,39 @@ function buildDeltaPlan(byRule, enabledSet, changed) {
280
374
  * @param {boolean} args.full whole-repo режим.
281
375
  * @param {string[]} args.rules scoped rule-id (порожній → delta/full).
282
376
  * @param {string[]|null} args.explicitFiles явний перелік файлів або null.
377
+ * @param {boolean} [args.pathMode] `--path`-дельта: лише per-file concerns.
378
+ * @param {boolean} [args.repoWide] `--repo-wide`: лише full-scope concerns, whole-repo.
379
+ * @param {string|null} [args.baseRef] явна база дельти (`--base <ref>`) замість каскаду main→origin/main.
283
380
  * @param {string} args.cwd робоча директорія прогону.
284
381
  * @returns {Promise<PlanItem[]>} впорядкований план прогону.
285
382
  */
286
- async function buildPlan({ byRule, full, rules, explicitFiles, cwd }) {
383
+ async function buildPlan({
384
+ byRule,
385
+ full,
386
+ rules,
387
+ explicitFiles,
388
+ pathMode = false,
389
+ repoWide = false,
390
+ baseRef = null,
391
+ cwd
392
+ }) {
393
+ // scoped + --path: per-file concerns названих правил × перетин path ∩ дельта
394
+ if (rules.length > 0 && explicitFiles !== null) return buildScopedDeltaPlan(byRule, rules, explicitFiles)
287
395
  // scoped: усі lint-concerns названих правил, whole-repo
288
396
  if (rules.length > 0) return buildScopedPlan(byRule, rules)
289
397
 
290
398
  const enabled = await enabledRuleIds(byRule, cwd)
291
399
  const enabledSet = new Set(enabled)
292
400
 
401
+ // repo-wide: лише full-scope concerns (окремий CI-workflow, не гейтить деплой)
402
+ if (repoWide) return buildRepoWidePlan(byRule, enabledSet)
403
+
293
404
  // full: усі per-file + full concerns enabled-правил, whole-repo
294
405
  if (full && explicitFiles === null) return buildFullPlan(byRule, enabledSet)
295
406
 
296
- // delta / explicit-files
297
- const changed = explicitFiles ?? (await collectChangedFilesSince(await resolveChangedBase(cwd), cwd))
298
- return buildDeltaPlan(byRule, enabledSet, changed)
407
+ // delta / explicit-files; path-режим виключає full-scope concerns
408
+ const changed = explicitFiles ?? (await collectChangedFilesSince(await resolveChangedBase(cwd, baseRef), cwd))
409
+ return buildDeltaPlan(byRule, enabledSet, changed, { perFileOnly: pathMode })
299
410
  }
300
411
 
301
412
  /**
@@ -430,6 +541,8 @@ function sortViolations(violations) {
430
541
  * @param {boolean} [opts.full] whole-repo режим.
431
542
  * @param {string[]} [opts.rules] scoped rule-id (порожній → delta/full).
432
543
  * @param {string[]|null} [opts.files] явний перелік файлів або null.
544
+ * @param {boolean} [opts.pathMode] `--path`-дельта: лише per-file concerns.
545
+ * @param {boolean} [opts.repoWide] `--repo-wide`: лише full-scope concerns.
433
546
  * @param {boolean} [opts.verbose] докладний лог прогону.
434
547
  * @param {(s: string) => void} [opts.log] функція логування.
435
548
  * @param {boolean} [opts.isTTY] override TTY-режиму ProgressReporter (тести); типово isTTY stdout.
@@ -445,7 +558,16 @@ export async function detectAll(opts) {
445
558
  const baseLog = opts.log ?? (s => process.stdout.write(s))
446
559
 
447
560
  const byRule = await filterByCapabilities(await readLintConcernsByRuleMulti(await effectiveRulesDirs(opts)), opts)
448
- const plan = await buildPlan({ byRule, full, rules, explicitFiles, cwd })
561
+ const plan = await buildPlan({
562
+ byRule,
563
+ full,
564
+ rules,
565
+ explicitFiles,
566
+ pathMode: opts.pathMode === true,
567
+ repoWide: opts.repoWide === true,
568
+ baseRef: typeof opts.baseRef === 'string' ? opts.baseRef : null,
569
+ cwd
570
+ })
449
571
 
450
572
  // Detect-only бар — ЛИШЕ в TTY (без тикера «виправлено»). У не-TTY (hooks, CI-gate,
451
573
  // пайпи) append-рядки ⏱ на кожен концерн засмітили б вивід кожного PostToolUse-хука,
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: rust-provider.mjs
4
4
  resource: npm/skills/taze/js/rust-provider.mjs
5
5
  docgen:
6
- crc: db99881b
6
+ crc: 5f9de45d
7
7
  model: openai-codex/gpt-5.4-mini
8
8
  tier: cloud-min
9
9
  score: 100
@@ -132,7 +132,7 @@ function runCargo(args, cwd, spawnFn) {
132
132
  * (spec 2026-07-18-lang-plugins-extraction), далі виїде в `@7n/rules-lang-rust`.
133
133
  * @type {import('../../../scripts/lib/plugin-api.mjs').EcosystemProvider}
134
134
  */
135
- export const rustProvider = {
135
+ const rustProvider = {
136
136
  id: 'rust-cargo',
137
137
  title: 'Rust-крейти',
138
138
  manifestNoun: 'Cargo.toml',