@7n/rules 1.16.1 → 1.17.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 +10 -0
- package/bin/n-rules.js +81 -19
- package/package.json +1 -1
- package/rules/bun/licensee/docs/main.md +4 -4
- package/rules/bun/licensee/main.mjs +18 -10
- package/scripts/lib/changed-files.mjs +5 -2
- package/scripts/lib/docs/changed-files.md +3 -3
- package/scripts/lib/lint-surface/ci-plan.mjs +221 -0
- package/scripts/lib/lint-surface/docs/ci-plan.md +38 -0
- package/scripts/lib/lint-surface/docs/index.md +1 -0
- package/scripts/lib/lint-surface/docs/path-scope.md +9 -8
- package/scripts/lib/lint-surface/docs/run-detectors.md +5 -1
- package/scripts/lib/lint-surface/path-scope.mjs +56 -10
- package/scripts/lib/lint-surface/run-detectors.mjs +115 -5
- package/skills/taze/js/docs/rust-provider.md +1 -1
- package/skills/taze/js/rust-provider.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.17.0] - 2026-07-18
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- lint --path став diff-aware (перетин path ∩ git diff, full-scope концерни виключені; стара поведінка — --path --full), дозволено `lint <domain> --path`, нові --base/--repo-wide і команда ci plan (outputs для GitHub Actions/Azure)
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- bun/licensee: crash самого тула (stderr/die) — fail-open warn-діагностика замість блокувального порушення; несумісність @npmcli/arborist із деревом bun install перманентно червонила repo-wide CI-гейт
|
|
12
|
+
|
|
3
13
|
## [1.16.1] - 2026-07-18
|
|
4
14
|
|
|
5
15
|
### 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>` =
|
|
17
|
-
* `.n-rules.json`) лишається поточним
|
|
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).
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
(
|
|
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
|
|
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
|
-
|
|
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,58 @@ 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) =>
|
|
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
|
|
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 } =
|
|
1748
|
-
|
|
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 = {
|
|
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
|
+
}
|
|
1755
1807
|
const noFix = args.includes('--no-fix')
|
|
1756
1808
|
// Глобальна черга full-прогонів (spec 2026-07-03): одночасно виконується один
|
|
1757
1809
|
// `lint --full` на машину, паралельні --full чекають лока і бачать чергу та
|
|
@@ -1778,6 +1830,16 @@ try {
|
|
|
1778
1830
|
|
|
1779
1831
|
break
|
|
1780
1832
|
}
|
|
1833
|
+
case 'ci': {
|
|
1834
|
+
// n-rules ci plan — skip-логіка сервіс-орієнтованого CI-канону: рахує
|
|
1835
|
+
// перетин git-дельти з --path (каталог сервісу) і віддає job outputs
|
|
1836
|
+
// «які lint-домени запускати» для GitHub Actions (--github →
|
|
1837
|
+
// $GITHUB_OUTPUT) та Azure Pipelines (--azure → ##vso-рядки).
|
|
1838
|
+
const { runCiPlanCli } = await import('../scripts/lib/lint-surface/ci-plan.mjs')
|
|
1839
|
+
process.exitCode = await runCiPlanCli(args)
|
|
1840
|
+
|
|
1841
|
+
break
|
|
1842
|
+
}
|
|
1781
1843
|
case 'taze': {
|
|
1782
1844
|
// n-rules taze diff — read-only semver-diff package.json ↔ package.json.taze-bak
|
|
1783
1845
|
// (root + воркспейси) для скілу n-taze: скрипт класифікує major-оновлення,
|
|
@@ -1816,7 +1878,7 @@ try {
|
|
|
1816
1878
|
default: {
|
|
1817
1879
|
console.error(`❌ Невідома команда: ${command}`)
|
|
1818
1880
|
console.error(
|
|
1819
|
-
` Очікується: (без аргументів) синхронізація правил, rename-yaml-extensions, hook, adr-normalize-local, lint (включно зі scope: lint ga|rego|k8s|docker|text), taze, release, skill, doc-aggregate`
|
|
1881
|
+
` Очікується: (без аргументів) синхронізація правил, rename-yaml-extensions, hook, adr-normalize-local, lint (включно зі scope: lint ga|rego|k8s|docker|text), ci plan, taze, release, skill, doc-aggregate`
|
|
1820
1882
|
)
|
|
1821
1883
|
process.exitCode = 1
|
|
1822
1884
|
}
|
package/package.json
CHANGED
|
@@ -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:
|
|
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` сам впав з помилкою (
|
|
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 або необроблений виняток усередині тула), НЕ як ліцензійне порушення:
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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:
|
|
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
|
-
- Якщо
|
|
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:
|
|
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`.
|
|
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.
|
|
21
|
-
2. `
|
|
22
|
-
3. `collectPathScopedFiles` збирає всі файли
|
|
23
|
-
4.
|
|
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
|
-
-
|
|
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:
|
|
6
|
+
crc: 37567fc8
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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 =
|
|
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,24 @@ 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,
|
|
198
200
|
cwd: opts.cwd
|
|
199
201
|
})
|
|
200
202
|
}
|
|
201
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Discovery-фасад для споживачів поза detect/fix-конвеєром (`ci plan`):
|
|
206
|
+
* concerns за rule-id (ядро + плагіни, capability-фільтр) і set активних правил.
|
|
207
|
+
* @param {{ rulesDir?: string, rulesDirs?: string[], capabilities?: Iterable<string>, cwd: string }} opts опції прогону.
|
|
208
|
+
* @returns {Promise<{ byRule: Record<string, ConcernMeta[]>, enabledSet: Set<string> }>} concerns і активні правила.
|
|
209
|
+
*/
|
|
210
|
+
export async function loadEnabledLintRules(opts) {
|
|
211
|
+
const byRule = await filterByCapabilities(await readLintConcernsByRuleMulti(await effectiveRulesDirs(opts)), opts)
|
|
212
|
+
const enabledSet = new Set(await enabledRuleIds(byRule, opts.cwd))
|
|
213
|
+
return { byRule, enabledSet }
|
|
214
|
+
}
|
|
215
|
+
|
|
202
216
|
/**
|
|
203
217
|
* scoped-режим: усі lint-concerns названих правил, whole-repo.
|
|
204
218
|
* @param {Record<string, ConcernMeta[]>} byRule concerns згруповані за rule-id.
|
|
@@ -213,6 +227,52 @@ function buildScopedPlan(byRule, rules) {
|
|
|
213
227
|
return plan.toSorted((a, b) => a.entry.ruleId.localeCompare(b.entry.ruleId))
|
|
214
228
|
}
|
|
215
229
|
|
|
230
|
+
/**
|
|
231
|
+
* scoped+`--path` режим (`lint js --path run/nexus`): ЛИШЕ per-file concerns
|
|
232
|
+
* названих правил × явний файловий набір (перетин path ∩ дельта). Full-scope
|
|
233
|
+
* concerns виключені повністю — деплой-гейт сервісу не блокується whole-repo
|
|
234
|
+
* порушеннями поза сервісом (repo-wide workflow — їхнє канонічне місце).
|
|
235
|
+
* enabledSet не застосовується (явний запит правила, як у buildScopedPlan).
|
|
236
|
+
* @param {Record<string, ConcernMeta[]>} byRule concerns згруповані за rule-id.
|
|
237
|
+
* @param {string[]} rules scoped rule-id.
|
|
238
|
+
* @param {string[]} files явний файловий набір (перетин).
|
|
239
|
+
* @returns {PlanItem[]} впорядкований план прогону.
|
|
240
|
+
*/
|
|
241
|
+
function buildScopedDeltaPlan(byRule, rules, files) {
|
|
242
|
+
/** @type {PlanItem[]} */
|
|
243
|
+
const plan = []
|
|
244
|
+
for (const ruleId of rules) {
|
|
245
|
+
for (const concern of byRule[ruleId] ?? []) {
|
|
246
|
+
if (concern.lint.scope !== 'per-file') continue
|
|
247
|
+
const item = planConcernForDelta(ruleId, concern, files)
|
|
248
|
+
if (item) plan.push(item)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return plan.toSorted(
|
|
252
|
+
(a, b) => a.entry.ruleId.localeCompare(b.entry.ruleId) || a.entry.concern.name.localeCompare(b.entry.concern.name)
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* `--repo-wide` режим: ЛИШЕ full-scope concerns enabled-правил, whole-repo.
|
|
258
|
+
* Канонічне місце перевірок без path-підтримки (knip, jscpd, dep-policy) —
|
|
259
|
+
* окремий CI-workflow, що не гейтить деплой сервісів.
|
|
260
|
+
* @param {Record<string, ConcernMeta[]>} byRule concerns згруповані за rule-id.
|
|
261
|
+
* @param {Set<string>} enabledSet активні rule-id.
|
|
262
|
+
* @returns {PlanItem[]} впорядкований план (whole-repo для кожного entry).
|
|
263
|
+
*/
|
|
264
|
+
function buildRepoWidePlan(byRule, enabledSet) {
|
|
265
|
+
/** @type {LintEntry[]} */
|
|
266
|
+
const entries = []
|
|
267
|
+
for (const [ruleId, concerns] of Object.entries(byRule)) {
|
|
268
|
+
if (!enabledSet.has(ruleId)) continue
|
|
269
|
+
for (const concern of concerns) {
|
|
270
|
+
if (concern.lint.scope === 'full') entries.push({ ruleId, concern })
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return sortEntries(entries).map(entry => ({ entry, files: undefined }))
|
|
274
|
+
}
|
|
275
|
+
|
|
216
276
|
/**
|
|
217
277
|
* full-режим: усі per-file + full concerns enabled-правил, whole-repo.
|
|
218
278
|
* @param {Record<string, ConcernMeta[]>} byRule concerns згруповані за rule-id.
|
|
@@ -252,17 +312,21 @@ function planConcernForDelta(ruleId, concern, changed) {
|
|
|
252
312
|
|
|
253
313
|
/**
|
|
254
314
|
* delta/explicit-files режим: concern-и enabled-правил, зіставлені зі зміненими файлами.
|
|
315
|
+
* `perFileOnly` (path-режим сервіс-канону) виключає full-scope concerns —
|
|
316
|
+
* перетин path ∩ дельта перевіряють лише per-file правила.
|
|
255
317
|
* @param {Record<string, ConcernMeta[]>} byRule concerns згруповані за rule-id.
|
|
256
318
|
* @param {Set<string>} enabledSet активні rule-id.
|
|
257
319
|
* @param {string[]} changed перелік змінених файлів.
|
|
320
|
+
* @param {{ perFileOnly?: boolean }} [opts] фільтр full-scope concerns.
|
|
258
321
|
* @returns {PlanItem[]} впорядкований план (за ruleId, потім concern.name).
|
|
259
322
|
*/
|
|
260
|
-
function buildDeltaPlan(byRule, enabledSet, changed) {
|
|
323
|
+
function buildDeltaPlan(byRule, enabledSet, changed, { perFileOnly = false } = {}) {
|
|
261
324
|
/** @type {PlanItem[]} */
|
|
262
325
|
const plan = []
|
|
263
326
|
for (const [ruleId, concerns] of Object.entries(byRule)) {
|
|
264
327
|
if (!enabledSet.has(ruleId)) continue
|
|
265
328
|
for (const concern of concerns) {
|
|
329
|
+
if (perFileOnly && concern.lint.scope !== 'per-file') continue
|
|
266
330
|
const item = planConcernForDelta(ruleId, concern, changed)
|
|
267
331
|
if (item) plan.push(item)
|
|
268
332
|
}
|
|
@@ -272,6 +336,35 @@ function buildDeltaPlan(byRule, enabledSet, changed) {
|
|
|
272
336
|
)
|
|
273
337
|
}
|
|
274
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Активність доменів (rule-id) для заданого файлового набору — єдине джерело
|
|
341
|
+
* правди для `ci plan`: домен «активний», якщо хоч один його **per-file**
|
|
342
|
+
* concern тригериться на цих файлах (та сама таблиця planConcernForDelta, що
|
|
343
|
+
* й `lint <domain> --path` → «plan сказав true» ⇔ «lint щось запустить»).
|
|
344
|
+
* Правила без жодного per-file concern не потрапляють у результат (їхні
|
|
345
|
+
* full-scope перевірки — справа `--repo-wide`).
|
|
346
|
+
* @param {Record<string, ConcernMeta[]>} byRule concerns згруповані за rule-id.
|
|
347
|
+
* @param {Set<string>} enabledSet активні rule-id.
|
|
348
|
+
* @param {string[]} changed файловий набір (перетин path ∩ дельта або дельта).
|
|
349
|
+
* @returns {Map<string, { triggered: boolean, matchedFiles: number }>} стан за rule-id.
|
|
350
|
+
*/
|
|
351
|
+
export function computeActiveDomains(byRule, enabledSet, changed) {
|
|
352
|
+
/** @type {Map<string, { triggered: boolean, matchedFiles: number }>} */
|
|
353
|
+
const out = new Map()
|
|
354
|
+
for (const [ruleId, concerns] of Object.entries(byRule)) {
|
|
355
|
+
if (!enabledSet.has(ruleId)) continue
|
|
356
|
+
const perFile = concerns.filter(c => c.lint.scope === 'per-file')
|
|
357
|
+
if (perFile.length === 0) continue
|
|
358
|
+
const matched = new Set()
|
|
359
|
+
for (const concern of perFile) {
|
|
360
|
+
const item = planConcernForDelta(ruleId, concern, changed)
|
|
361
|
+
for (const f of item?.files ?? []) matched.add(f)
|
|
362
|
+
}
|
|
363
|
+
out.set(ruleId, { triggered: matched.size > 0, matchedFiles: matched.size })
|
|
364
|
+
}
|
|
365
|
+
return out
|
|
366
|
+
}
|
|
367
|
+
|
|
275
368
|
/**
|
|
276
369
|
* Будує план: список entries + чи кожен запускається whole-repo (files=undefined)
|
|
277
370
|
* чи per-file (files=[...]). Реалізує таблицю lint.scope зі специфікації.
|
|
@@ -280,22 +373,29 @@ function buildDeltaPlan(byRule, enabledSet, changed) {
|
|
|
280
373
|
* @param {boolean} args.full whole-repo режим.
|
|
281
374
|
* @param {string[]} args.rules scoped rule-id (порожній → delta/full).
|
|
282
375
|
* @param {string[]|null} args.explicitFiles явний перелік файлів або null.
|
|
376
|
+
* @param {boolean} [args.pathMode] `--path`-дельта: лише per-file concerns.
|
|
377
|
+
* @param {boolean} [args.repoWide] `--repo-wide`: лише full-scope concerns, whole-repo.
|
|
283
378
|
* @param {string} args.cwd робоча директорія прогону.
|
|
284
379
|
* @returns {Promise<PlanItem[]>} впорядкований план прогону.
|
|
285
380
|
*/
|
|
286
|
-
async function buildPlan({ byRule, full, rules, explicitFiles, cwd }) {
|
|
381
|
+
async function buildPlan({ byRule, full, rules, explicitFiles, pathMode = false, repoWide = false, cwd }) {
|
|
382
|
+
// scoped + --path: per-file concerns названих правил × перетин path ∩ дельта
|
|
383
|
+
if (rules.length > 0 && explicitFiles !== null) return buildScopedDeltaPlan(byRule, rules, explicitFiles)
|
|
287
384
|
// scoped: усі lint-concerns названих правил, whole-repo
|
|
288
385
|
if (rules.length > 0) return buildScopedPlan(byRule, rules)
|
|
289
386
|
|
|
290
387
|
const enabled = await enabledRuleIds(byRule, cwd)
|
|
291
388
|
const enabledSet = new Set(enabled)
|
|
292
389
|
|
|
390
|
+
// repo-wide: лише full-scope concerns (окремий CI-workflow, не гейтить деплой)
|
|
391
|
+
if (repoWide) return buildRepoWidePlan(byRule, enabledSet)
|
|
392
|
+
|
|
293
393
|
// full: усі per-file + full concerns enabled-правил, whole-repo
|
|
294
394
|
if (full && explicitFiles === null) return buildFullPlan(byRule, enabledSet)
|
|
295
395
|
|
|
296
|
-
// delta / explicit-files
|
|
396
|
+
// delta / explicit-files; path-режим виключає full-scope concerns
|
|
297
397
|
const changed = explicitFiles ?? (await collectChangedFilesSince(await resolveChangedBase(cwd), cwd))
|
|
298
|
-
return buildDeltaPlan(byRule, enabledSet, changed)
|
|
398
|
+
return buildDeltaPlan(byRule, enabledSet, changed, { perFileOnly: pathMode })
|
|
299
399
|
}
|
|
300
400
|
|
|
301
401
|
/**
|
|
@@ -430,6 +530,8 @@ function sortViolations(violations) {
|
|
|
430
530
|
* @param {boolean} [opts.full] whole-repo режим.
|
|
431
531
|
* @param {string[]} [opts.rules] scoped rule-id (порожній → delta/full).
|
|
432
532
|
* @param {string[]|null} [opts.files] явний перелік файлів або null.
|
|
533
|
+
* @param {boolean} [opts.pathMode] `--path`-дельта: лише per-file concerns.
|
|
534
|
+
* @param {boolean} [opts.repoWide] `--repo-wide`: лише full-scope concerns.
|
|
433
535
|
* @param {boolean} [opts.verbose] докладний лог прогону.
|
|
434
536
|
* @param {(s: string) => void} [opts.log] функція логування.
|
|
435
537
|
* @param {boolean} [opts.isTTY] override TTY-режиму ProgressReporter (тести); типово isTTY stdout.
|
|
@@ -445,7 +547,15 @@ export async function detectAll(opts) {
|
|
|
445
547
|
const baseLog = opts.log ?? (s => process.stdout.write(s))
|
|
446
548
|
|
|
447
549
|
const byRule = await filterByCapabilities(await readLintConcernsByRuleMulti(await effectiveRulesDirs(opts)), opts)
|
|
448
|
-
const plan = await buildPlan({
|
|
550
|
+
const plan = await buildPlan({
|
|
551
|
+
byRule,
|
|
552
|
+
full,
|
|
553
|
+
rules,
|
|
554
|
+
explicitFiles,
|
|
555
|
+
pathMode: opts.pathMode === true,
|
|
556
|
+
repoWide: opts.repoWide === true,
|
|
557
|
+
cwd
|
|
558
|
+
})
|
|
449
559
|
|
|
450
560
|
// Detect-only бар — ЛИШЕ в TTY (без тикера «виправлено»). У не-TTY (hooks, CI-gate,
|
|
451
561
|
// пайпи) append-рядки ⏱ на кожен концерн засмітили б вивід кожного PostToolUse-хука,
|
|
@@ -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
|
-
|
|
135
|
+
const rustProvider = {
|
|
136
136
|
id: 'rust-cargo',
|
|
137
137
|
title: 'Rust-крейти',
|
|
138
138
|
manifestNoun: 'Cargo.toml',
|