@7n/rules 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.12.0] - 2026-07-17
4
+
5
+ ### Changed
6
+
7
+ - Додано прапор `--path <dir>` для `n-rules lint`: звужує файловий набір per-file правил до заданої піддиректорії, лишаючи корінь прогону (root-guard, `.n-rules.json`) незмінним — на відміну від `--cwd`. Несумісний з позиційним rule/concern-фільтром; у парі з `--full` full-вісь ігнорується (без machine-wide локу).
8
+
9
+ ## [1.11.0] - 2026-07-17
10
+
11
+ ### Added
12
+
13
+ - js/eslint: fix-worker.mjs з per-file циклом замість одного великого промпту на весь concern — усунення 100% timeout на драбині, виміряно на реальних lint-прогонах
14
+
15
+ ### Fixed
16
+
17
+ - text/markdownlint: включати причину провалу markdownlint-cli2 у violation-повідомлення; js/knip: fixability=structural (LLM-ладдер завжди приречений на timeout)
18
+
3
19
  ## [1.10.0] - 2026-07-17
4
20
 
5
21
  ### Added
package/bin/n-rules.js CHANGED
@@ -12,7 +12,9 @@
12
12
  * `npx \@7n/rules lint` — data-driven оркестратор lint+конформності по `rules/<id>/meta.json` (`lint: per-file|full`):
13
13
  * за замовчуванням fix-by-default по дельті vs origin (лише `per-file` правила); `--full` =
14
14
  * весь репо (`per-file` ∪ `full`); `--no-fix` = без мутацій/LLM (CI); позиційні
15
- * (не-флаг) аргументи — фільтр правил конформності (мапить колишній `fix <rule>`).
15
+ * (не-флаг) аргументи — фільтр правил конформності (мапить колишній `fix <rule>`);
16
+ * `--path <dir>` = звузити файловий набір до піддиректорії, корінь прогону (root-guard,
17
+ * `.n-rules.json`) лишається поточним каталогом/`--cwd`, не сумісний з rule-фільтром.
16
18
  * CI = `lint --no-fix --full` (весь репо, нуль мутацій/LLM).
17
19
  * `npx \@7n/rules skill list` — скіли пакета без синку в проєкт
18
20
  * `npx \@7n/rules skill taze` — промпт на stdout
@@ -1632,7 +1634,16 @@ function printLintHelp() {
1632
1634
  Один --full на машину: паралельні запуски стають у чергу
1633
1635
  й бачать живий прогрес активного прогону.
1634
1636
  --no-fix Лише детекція, без мутацій.
1635
- --cwd <path> Робочий каталог замість поточного.
1637
+ --cwd <path> Робочий каталог (корінь прогону) замість поточного —
1638
+ root-guard, .n-rules.json і devDependencies читаються
1639
+ з нього.
1640
+ --path <dir> Звузити файловий набір до піддиректорії, лишивши корінь
1641
+ прогону незмінним (config/root-guard — з поточного каталогу
1642
+ чи --cwd). per-file правила фільтруються по файлах <dir>;
1643
+ full-scope правила (jscpd, cspell тощо) при спрацюванні
1644
+ все одно йдуть по всьому репо. Несумісний з позиційним
1645
+ rule/concern-фільтром; з --full full-вісь ігнорується
1646
+ (немає machine-wide локу).
1636
1647
  --verbose Розширений вивід детекції та виправлення.
1637
1648
  --help, -h Ця довідка.
1638
1649
 
@@ -1644,6 +1655,7 @@ function printLintHelp() {
1644
1655
  npx @7n/rules lint --full
1645
1656
  npx @7n/rules lint --no-fix eslint
1646
1657
  npx @7n/rules lint --cwd ./packages/foo --verbose
1658
+ npx @7n/rules lint --path npm/scripts/lib/lint-surface --no-fix
1647
1659
  `)
1648
1660
  }
1649
1661
 
@@ -1717,8 +1729,29 @@ try {
1717
1729
  // Fix-by-default: detect → T0 → LLM-ladder (run-fix). --no-fix: лише detect.
1718
1730
  const cwdIdx = args.indexOf('--cwd')
1719
1731
  const cwdArg = cwdIdx === -1 ? cwd() : resolve(args[cwdIdx + 1])
1720
- const rules = args.filter((a, i) => !a.startsWith('-') && !(cwdIdx !== -1 && i === cwdIdx + 1))
1721
- const lintOpts = { cwd: cwdArg, full: args.includes('--full'), rules, verbose: args.includes('--verbose') }
1732
+ // --path: на відміну від --cwd (підміняє корінь), лишає корінь/config
1733
+ // незмінними й лише звужує файловий набір до заданої піддиректорії
1734
+ // (той самий explicitFiles-шлях, що вже годує hook --post-tool-use/--stop).
1735
+ const pathIdx = args.indexOf('--path')
1736
+ const pathArg = pathIdx === -1 ? null : args[pathIdx + 1]
1737
+ const rules = args.filter(
1738
+ (a, i) => !a.startsWith('-') && !(cwdIdx !== -1 && i === cwdIdx + 1) && !(pathIdx !== -1 && i === pathIdx + 1)
1739
+ )
1740
+ if (pathArg !== null && rules.length > 0) {
1741
+ throw new Error(
1742
+ '--path не можна поєднувати зі scoped rule/concern фільтром (позиційні аргументи) — оберіть щось одне'
1743
+ )
1744
+ }
1745
+ let pathFiles = null
1746
+ if (pathArg !== null) {
1747
+ const { collectPathScopedFiles } = await import('../scripts/lib/lint-surface/path-scope.mjs')
1748
+ pathFiles = await collectPathScopedFiles(cwdArg, pathArg)
1749
+ }
1750
+ // --full + --path: buildPlan ігнорує full, коли передано explicitFiles (той самий
1751
+ // шлях), тож і тут full-вісь вважаємо неактивною — інакше глобальний machine-wide
1752
+ // лок --full брався б для швидкого scoped-прогону без причини.
1753
+ const full = args.includes('--full') && pathArg === null
1754
+ const lintOpts = { cwd: cwdArg, full, rules, verbose: args.includes('--verbose'), files: pathFiles }
1722
1755
  const noFix = args.includes('--no-fix')
1723
1756
  // Глобальна черга full-прогонів (spec 2026-07-03): одночасно виконується один
1724
1757
  // `lint --full` на машину, паралельні --full чекають лока і бачать чергу та
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -0,0 +1,30 @@
1
+ ---
2
+ type: JS Module
3
+ title: fix-worker.mjs
4
+ resource: npm/rules/js/eslint/fix-worker.mjs
5
+ docgen:
6
+ crc: 1eb9c7fc
7
+ model: manual
8
+ ---
9
+
10
+ ## Огляд
11
+
12
+ Custom fix-worker `js/eslint` (перекриває дефолтний `default-worker.mjs`): замість одного `runAgentFix`-виклику з усіма порушеннями з усіх файлів concern-а — окрема агентна сесія на кожен файл, у межах спільного дедлайну rung-а. Мотивація — виміряно на реальних lint-прогонах: одна сесія, що жонглює кількома файлами одразу, стабільно впирається у timeout на всіх 4 rung-ах драбини (local-min→cloud-avg), навіть при малому обсязі порушень; та сама модель, scoped на один файл, укладається в бюджет і закриває більшість порушень.
13
+
14
+ ## Поведінка
15
+
16
+ 1. Групує вхідні `violations` за `file`; порушення без `file` ігноруються.
17
+ 2. Дедлайн — `DEADLINE_FRACTION` (0.8) від `ctx.timeoutMs`; цикл перевіряє дедлайн ПЕРЕД стартом кожного файлу і не починає новий, якщо час вичерпано.
18
+ 3. Кожен файл отримує РЕШТУ бюджету до дедлайну (не фіксований поділ `timeoutMs / files.length`) — перший (часто найважчий) файл отримує найбільше часу.
19
+ 4. На кожен файл — окремий `runAgentFix` із `targetFiles: [file]` і власним `verify` (`verifyFile`): item-scoped canonical re-detect лише цього файлу, не всього concern-а — інакше evidence-гейт хибно вважав би файл незакритим через порушення в ІНШИХ файлах.
20
+ 5. Один файл, що завершився з `error`, не обриває цикл — пропускається, наступні файли все одно обробляються в межах дедлайну.
21
+ 6. Повертає лише `touchedFiles` з успішних (`!error`) викликів; success rung-а все одно визначає whole-concern canonical re-detect runner-а (`runRung`), не цей worker.
22
+
23
+ ## Публічний API
24
+
25
+ - `fixWorker(violations, ctx)` — контракт `FixWorkerFn`; резолвиться автоматично замість `default-worker.mjs`, бо лежить як `fix-worker.mjs` поруч із `main.mjs` concern-а.
26
+
27
+ ## Гарантії поведінки
28
+
29
+ - Пише лише у файли з переданих `violations` (`targetFiles: [file]` на кожен виклик) — той самий semantic-collateral guard, що й у дефолтного worker-а.
30
+ - `recordWrite` (не durable) на кожен файл: rollback-контракт незмінний — якщо після worker-а в concern-і лишилось хоч одне порушення будь-де, `runRung` відкочує ВСІ правки цього rung-а, включно з уже полагодженими файлами.
@@ -0,0 +1,11 @@
1
+ ---
2
+ type: Directory Index
3
+ title: npm/rules/js/eslint
4
+ resource: npm/rules/js/eslint/
5
+ ---
6
+
7
+ | Файл | Тип |
8
+ | ------------------------------- | --------- |
9
+ | [fix-eslint.mjs](fix-eslint.md) | JS Module |
10
+ | [fix-worker.mjs](fix-worker.md) | JS Module |
11
+ | [main.mjs](main.md) | JS Module |
@@ -0,0 +1,99 @@
1
+ /**
2
+ * fix-worker для `js/eslint`: окрема агентна сесія НА ФАЙЛ замість одного великого промпту
3
+ * на всі файли concern-а одразу. Дефолтний worker (`default-worker.mjs`) шле ВСІ порушення
4
+ * з УСІХ файлів в один `runAgentFix`-виклик — на реальних lint-прогонах (tauri-components)
5
+ * це давало 100% timeout на всіх 4 rung-ах драбини (local-min→cloud-avg), навіть коли
6
+ * порушень було лише 20-90 у 1-3 файлах: одна сесія жонглює кількома файлами одразу і не
7
+ * встигає в rung-таймаут. Виміряно (2026-07-17, ручний timing-експеримент поза pipeline):
8
+ * одна сесія, scoped на ОДИН найважчий файл (21 порушення), закрила 76% (16/21) за 91с із
9
+ * бюджету 120с (cloud-min) — per-file scoping вкладається, mega-prompt — ні.
10
+ *
11
+ * Rollback-контракт незмінний: `recordWrite` (не durable) на кожен файл — правки наявного
12
+ * стороннього коду. Якщо після worker-а `runRung`-ів canonical re-detect (whole-concern)
13
+ * знайде хоч одне порушення будь-де, увесь rung однаково відкотиться (S1) — той самий
14
+ * контракт, що й у дефолтного worker-а; батчинг підвищує ймовірність, що ЦЕЙ rung дійде
15
+ * до 0 порушень, а не замінює rollback-семантику.
16
+ *
17
+ * Дедлайн (`DEADLINE_FRACTION` від `ctx.timeoutMs`, той самий підхід, що й
18
+ * `doc-files/check/fix-worker.mjs`): цикл не стартує наступний файл, якщо дедлайн
19
+ * настав — worker повертає часткову роботу штатно, замість фонової сесії, що триває
20
+ * поверх backstop ×1.25 runner-а. Кожен файл отримує РЕШТУ бюджету до дедлайну (не
21
+ * фіксований `timeoutMs / files.length`) — перший (часто найважчий) файл отримує
22
+ * найбільше часу, а не штучно урізаний рівний шматок.
23
+ * @typedef {import('../../../scripts/lib/lint-surface/types.mjs').FixWorkerFn} FixWorkerFn
24
+ */
25
+ import { resolve } from 'node:path'
26
+
27
+ import { anchoredEnabled } from '../../../scripts/lib/lint-surface/default-worker.mjs'
28
+ import { renderViolations } from '../../../scripts/lib/lint-surface/render.mjs'
29
+ import { lint } from './main.mjs'
30
+
31
+ /** Частка ctx.timeoutMs, після якої цикл не стартує наступний файл (запас до backstop ×1.25). */
32
+ const DEADLINE_FRACTION = 0.8
33
+
34
+ /**
35
+ * Item-scoped (один файл) canonical re-detect для evidence-гейта `runAgentFix` —
36
+ * НЕ те саме, що whole-concern `ctx.verify` з `runRung` (той перевірив би ВСІ файли
37
+ * concern-а й дав би хибний "не готово" навіть коли саме ЦЕЙ файл уже чистий).
38
+ * @param {string} cwd абсолютний корінь проєкту
39
+ * @param {string} ruleId id правила
40
+ * @param {string} concernId id concern-а
41
+ * @param {string} file posix-relative шлях файлу, що перевіряється
42
+ * @returns {Promise<{ ok: boolean, output: string }>} вердикт verify-петлі
43
+ */
44
+ async function verifyFile(cwd, ruleId, concernId, file) {
45
+ const { violations: after } = await lint({ cwd, ruleId, concernId, files: [file] })
46
+ const stamped = after.map(v => ({ ...v, ruleId, concernId }))
47
+ return { ok: stamped.length === 0, output: renderViolations(stamped) }
48
+ }
49
+
50
+ /** @type {FixWorkerFn} */
51
+ export async function fixWorker(violations, ctx) {
52
+ // lazy import — тримає detect-шлях вільним від pi/oxc (read-only --no-fix не вантажить їх).
53
+ const [{ runAgentFix }, { isLocalModel }, { extractContext }] = await Promise.all([
54
+ import('@7n/llm-lib/agent-fix'),
55
+ import('@7n/llm-lib/model-tiers'),
56
+ import('../../../scripts/utils/ast-extract.mjs')
57
+ ])
58
+
59
+ /** @type {Map<string, import('../../../scripts/lib/lint-surface/types.mjs').LintViolation[]>} */
60
+ const byFile = new Map()
61
+ for (const v of violations) {
62
+ if (!v.file) continue
63
+ const arr = byFile.get(v.file)
64
+ if (arr) arr.push(v)
65
+ else byFile.set(v.file, [v])
66
+ }
67
+ const files = byFile.keys().toArray()
68
+ if (files.length === 0) return { touchedFiles: [] }
69
+
70
+ const deadlineAt = ctx.timeoutMs ? Date.now() + Math.round(ctx.timeoutMs * DEADLINE_FRACTION) : null
71
+ const anchoredEdits = anchoredEnabled(ctx.model, isLocalModel)
72
+
73
+ const touchedFiles = []
74
+ for (const file of files) {
75
+ if (deadlineAt && Date.now() >= deadlineAt) break
76
+ const callTimeoutMs = deadlineAt ? Math.max(1000, deadlineAt - Date.now()) : ctx.timeoutMs
77
+
78
+ const res = await runAgentFix(ctx.ruleId, renderViolations(byFile.get(file)), ctx.cwd, {
79
+ model: ctx.model,
80
+ tier: ctx.tier,
81
+ timeoutMs: callTimeoutMs,
82
+ feedback: ctx.feedback ?? null,
83
+ caller: `fix:${ctx.ruleId}/${ctx.concernId}:${ctx.tier}:${file}`,
84
+ recordWrite: ctx.recordWrite,
85
+ chain: ctx.chain ?? null,
86
+ targetFiles: [file],
87
+ verify: () => verifyFile(ctx.cwd, ctx.ruleId, ctx.concernId, file),
88
+ verifyMax: ctx.verifyMax,
89
+ anchoredEdits,
90
+ deps: { astContext: p => extractContext(resolve(ctx.cwd, p)) }
91
+ })
92
+
93
+ // Один файл не впорався — не кидаємо, пробуємо решту в межах дедлайну; whole-concern
94
+ // canonical re-detect runner-а (не цей worker) визначить, чи rung закрито.
95
+ if (!res.error) touchedFiles.push(...(res.touchedFiles ?? []))
96
+ }
97
+
98
+ return { touchedFiles }
99
+ }
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "$schema": "https://unpkg.com/@7n/rules/schemas/concern.json",
3
+ "fixability": "structural",
3
4
  "lint": {
4
5
  "scope": "full"
5
6
  }
@@ -3,38 +3,27 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/text/markdownlint/main.mjs
5
5
  docgen:
6
- crc: 08324688
7
- model: openai-codex/gpt-5.5
8
- tier: cloud-avg
9
- score: 95
10
- issues: anchor-miss:(text.mdc),judge:inaccurate:0.98
11
- judgeModel: openai-codex/gpt-5.4-mini
6
+ crc: 6aec4713
7
+ model: manual
12
8
  ---
13
9
 
14
10
  ## Огляд
15
11
 
16
- Файл забезпечує markdown-концерн для двох поверхонь: `policy` перевіряє наявність `.markdownlint-cli2.jsonc`, а `lint` запускає markdownlint-cli2 для Markdown/MDC-файлів. Він потрібен, щоб delta- та full-режими однаково застосовували правила markdownlint до релевантних файлів і повертали повідомлення з маркером ``.
12
+ Multi-surface detector `text/markdownlint`: `policy` перевіряє наявність `.markdownlint-cli2.jsonc`, `lint` запускає сам `markdownlint-cli2` (delta по `ctx.files`, full за glob `**/*.md`/`**/*.mdc`) і повертає обидва результати в одному `LintResult`.
17
13
 
18
14
  ## Поведінка
19
15
 
20
- 1. `lint` перевіряє markdown-концерн у двох площинах: наявність конфігурації markdownlint і фактичну якість Markdown-документів.
21
-
22
- 2. `lint` спочатку додає до результату порушення політики, якщо в проєкті немає `.markdownlint-cli2.jsonc`.
23
-
24
- 3. `lint` у delta-режимі перевіряє лише передані Markdown-файли, а у full-режимі — всі файли Markdown і MDC у робочій директорії.
25
-
26
- 4. `lint` не запускає markdownlint, якщо серед цільових файлів немає Markdown-документів, і повертає лише вже зібрані порушення політики.
27
-
28
- 5. `lint` приглушує прямий вивід markdownlint, щоб detector повертав уніфікований результат lint-перевірки без шуму в консолі.
29
-
30
- 6. `lint` додає порушення з маркером ``, якщо markdownlint знаходить проблеми у Markdown або MDC-файлах.
31
-
32
- 7. `lint` повертає спільний список порушень для policy- та lint-поверхонь, щоб markdown-концерн поводився як один цілісний detector.
16
+ 1. Policy-перевірка (`evaluatePolicyConcern`) додає порушення, якщо `.markdownlint-cli2.jsonc` відсутній.
17
+ 2. Якщо серед цільових файлів немає Markdown/MDC — `markdownlint-cli2` не запускається, повертаються лише policy-порушення.
18
+ 3. `logMessage` (banner, `Finding:`/`Found:`/`Linting:`/`Summary:` прогрес-текст) no-op, не деталь порушення.
19
+ 4. `logError` (один готовий рядок на порушення від дефолтного форматера markdownlint-cli2: `<file>:<line>:<col> <rule> <опис> [<деталь>]`) — накопичується в масив і вбудовується у violation-повідомлення. Без цього LLM fix-worker (і non-verbose підсумок) бачив лише голе "markdownlint знайшов порушення", без файлу/рядка/правила.
20
+ 5. Ненульовий exit-код `markdownlint-cli2` одне порушення `reason: 'markdownlint'` з накопиченою деталлю.
33
21
 
34
22
  ## Публічний API
35
23
 
36
- - lint — знаходить для `text.mdc` маркери повідомлень, контролює наявність policy-конфігурації та запускає markdownlint; результати обох етапів збирає в один звіт lint.
24
+ - `lint(ctx)`detector-контракт unified lint surface; повертає `{ violations }` (policy + lint-порушення разом).
37
25
 
38
26
  ## Гарантії поведінки
39
27
 
40
- - Read-only: не виконує операцій запису (ФС/БД).
28
+ - Read-only: не пише `.markdownlint-cli2.jsonc` сам — генерація конфігу окремим T0 (не в цьому detector-і).
29
+ - `logError`-деталь — єдине джерело причини провалу; текстові евристики за кодом виходу не використовуються.
@@ -32,22 +32,30 @@ export async function lint(ctx) {
32
32
  const targets = ctx.files === undefined ? DEFAULT_MD_GLOBS : ctx.files.filter(f => MD_EXT_RE.test(f))
33
33
  if (targets.length === 0) return { violations }
34
34
 
35
+ // logError отримує один готовий рядок на порушення через default output formatter
36
+ // markdownlint-cli2 ("<file>:<line>:<col> <rule> <опис> [<деталь>]") — раніше глушився,
37
+ // detector повертав лише голе "щось не пройшло" без файлу/правила/причини, тож LLM
38
+ // fix-worker (як і non-verbose підсумок) не мав інформації, що саме виправляти
39
+ // (той самий патерн, що й text/run-v8r до фіксу). logMessage лишається no-op —
40
+ // banner/Finding/Found/Linting/Summary прогрес-текст, не деталь порушення.
41
+ const errorLines = []
35
42
  const code = await markdownlintCli2({
36
43
  directory: ctx.cwd,
37
44
  argv: targets,
38
45
  logMessage: () => {
39
- // вивід markdownlint-cli2 глушимоdetector лише повертає код
46
+ // прогрес-статус markdownlint-cli2 (banner, Finding/Found/Linting/Summary) не деталь
40
47
  },
41
- logError: () => {
42
- // помилки markdownlint-cli2 глушимо — detector лише повертає код
48
+ logError: message => {
49
+ errorLines.push(message)
43
50
  }
44
51
  })
45
52
  if (code !== 0) {
53
+ const detail = errorLines.length > 0 ? `:\n${errorLines.join('\n')}` : ''
46
54
  violations.push({
47
55
  ruleId: ctx.ruleId,
48
56
  concernId: ctx.concernId,
49
57
  reason: 'markdownlint',
50
- message: 'markdownlint знайшов порушення у *.md/*.mdc (text.mdc)'
58
+ message: `markdownlint знайшов порушення у *.md/*.mdc (text.mdc)${detail}`
51
59
  })
52
60
  }
53
61
 
@@ -13,6 +13,7 @@ resource: npm/scripts/lib/lint-surface/
13
13
  | [ladder.mjs](ladder.md) | JS Module |
14
14
  | [lint-lock.mjs](lint-lock.md) | JS Module |
15
15
  | [mt-tail.mjs](mt-tail.md) | JS Module |
16
+ | [path-scope.mjs](path-scope.md) | JS Module |
16
17
  | [policy-lint-adapter.mjs](policy-lint-adapter.md) | JS Module |
17
18
  | [policy-test-step.mjs](policy-test-step.md) | JS Module |
18
19
  | [progress.mjs](progress.md) | JS Module |
@@ -0,0 +1,31 @@
1
+ ---
2
+ type: JS Module
3
+ title: path-scope.mjs
4
+ resource: npm/scripts/lib/lint-surface/path-scope.mjs
5
+ docgen:
6
+ crc: 0971e78e
7
+ model: openai-codex/gpt-5.4-mini
8
+ tier: cloud-min
9
+ score: 100
10
+ issues: judge:inaccurate:0.94
11
+ judgeModel: openai-codex/gpt-5.4-mini
12
+ ---
13
+
14
+ ## Огляд
15
+
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.
17
+
18
+ ## Поведінка
19
+
20
+ 1. `collectPathScopedFiles` приймає корінь прогону та значення `--path`, звужує область lint до каталогу всередині цього кореня й не змінює сам корінь прогону.
21
+ 2. `collectPathScopedFiles` відхиляє шлях, якщо він веде поза межі кореня або не є каталогом.
22
+ 3. `collectPathScopedFiles` збирає всі файли в межах вказаного каталогу, відсікаючи виключення з кореневих ignore-налаштувань і `.gitignore`-поведінки, а порожній каталог вважає валідним порожнім результатом.
23
+ 4. `collectPathScopedFiles` повертає відсортований список шляхів відносно кореня прогону у форматі, придатному для передачі в `buildPlan` як `explicitFiles`.
24
+
25
+ ## Публічний API
26
+
27
+ - collectPathScopedFiles — збирає posix-відносні від `cwd` шляхи всіх файлів у каталозі з `--path`, з урахуванням `.gitignore` і `.n-rules.json:ignore` у корені; порожній каталог повертає порожній набір без помилки
28
+
29
+ ## Гарантії поведінки
30
+
31
+ - Read-only: не виконує операцій запису (ФС/БД).
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Резолвер explicit-files списку для `n-rules lint --path <dir>`.
3
+ *
4
+ * На відміну від `--cwd` (підміняє корінь прогону — root-guard, `.n-rules.json`,
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-режим).
11
+ */
12
+ import { existsSync, statSync } from 'node:fs'
13
+ import { isAbsolute, relative, resolve, sep } from 'node:path'
14
+
15
+ import { loadCursorIgnorePaths } from '../load-cursor-config.mjs'
16
+ import { walkDir } from '../../utils/walkDir.mjs'
17
+
18
+ /**
19
+ * Перевіряє, що резолвлений `--path` лежить усередині `cwd` (не traversal
20
+ * через `..` і не абсолютний шлях поза коренем прогону).
21
+ * @param {string} cwd абсолютний корінь прогону.
22
+ * @param {string} target абсолютний резолв значення `--path`.
23
+ * @returns {void}
24
+ */
25
+ function assertWithinCwd(cwd, target) {
26
+ const rel = relative(cwd, target)
27
+ if (rel === '') return
28
+ if (rel.startsWith('..') || isAbsolute(rel)) {
29
+ throw new Error(`--path має вказувати каталог усередині ${cwd} (отримано поза межами: ${target})`)
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Збирає posix-відносні (від `cwd`) шляхи всіх файлів під `--path`-каталогом,
35
+ * поважаючи `.gitignore` і `.n-rules.json:ignore` кореня. Порожній каталог —
36
+ * валідний порожній результат (план виявиться порожнім), не помилка.
37
+ * @param {string} cwd абсолютний корінь прогону (root-guard уже пройдено).
38
+ * @param {string} pathArg значення `--path` (відносний або абсолютний шлях).
39
+ * @returns {Promise<string[]>} відсортовані posix-відносні шляхи від `cwd`.
40
+ */
41
+ 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
+ }
47
+ const ignorePaths = await loadCursorIgnorePaths(cwd)
48
+ /** @type {string[]} */
49
+ const out = []
50
+ await walkDir(
51
+ target,
52
+ abs => {
53
+ out.push(relative(cwd, abs).split(sep).join('/'))
54
+ },
55
+ ignorePaths
56
+ )
57
+ return out.toSorted((a, b) => a.localeCompare(b))
58
+ }