@7n/rules 1.41.0 → 1.43.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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.43.0] - 2026-07-22
4
+
5
+ ### Added
6
+
7
+ - docker: n-rules:bun-no-compile-маркер (# n-rules:bun-no-compile: <причина>) — генералізує native-addon-виняток на будь-яку недосяжну для checker-а причину неможливості bun build --compile (напр. динамічний import() рантайм-конфігу); вимикає вимогу компіляції й дозволяє mirror.gcr.io/oven/bun:* як фінальний stage
8
+
9
+ ## [1.42.0] - 2026-07-22
10
+
11
+ ### Added
12
+
13
+ - warnAboutRulesWithoutConcerns: попередження, якщо rule-id з .n-rules.json#rules не знайдено в жодному rulesDir (ядро+плагіни) — ловить дрейф конфігу після переїзду concern-ів у плагін
14
+
3
15
  ## [1.41.0] - 2026-07-22
4
16
 
5
17
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.41.0",
3
+ "version": "1.43.0",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -0,0 +1,9 @@
1
+ ---
2
+ type: Directory Index
3
+ title: npm/rules/docker/lint
4
+ resource: npm/rules/docker/lint/
5
+ ---
6
+
7
+ | Файл | Тип |
8
+ | ------------------- | --------- |
9
+ | [main.mjs](main.md) | JS Module |
@@ -3,45 +3,49 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/docker/lint/main.mjs
5
5
  docgen:
6
- crc: 26fdea23
7
- model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
- score: 100
9
- issues: judge:inaccurate:0.99
6
+ crc: 7cc8c2c7
7
+ model: openai-codex/gpt-5.4-mini
8
+ tier: cloud-min
9
+ score: 90
10
+ issues: internal-name:checkDockerfile,judge-refine:kept-original,judge:inaccurate:0.99
10
11
  judgeModel: openai-codex/gpt-5.4-mini
11
12
  ---
12
13
 
13
14
  ## Огляд
14
15
 
15
- Please provide the content of the file you want me to document. I need the code or at least the context file to proceed with generating the "Огляд" section based on the provided "Поведінка".
16
+ Файл знаходить Dockerfile і Containerfile в межах репозиторію через `findDockerfilePaths`, визначає stage-структуру через `splitDockerfileStages` і `parseFromStages`, а потім запускає `lint` як fail-safe перевірку без винесення винятків назовні.
17
+ Перевірки спираються на правила з `docker.mdc` і окремо покривають multistage/runtime-узгодженість через `getMultistageAndRuntimeHint`, `getBunCompileHint`, `getNginxAlpineSlimTagHint` і `getNonRootRuntimeHint`, щоб Docker-образи відповідали очікуваній схемі збірки та запуску.
16
18
 
17
19
  ## Поведінка
18
20
 
19
- Поведінка:
20
- isDockerfileName визначає, чи є вказане ім'я файлу Dockerfile або Containerfile.
21
- findDockerfilePaths збирає абсолютні шляхи до всіх Dockerfile / Containerfile, ігноруючи задані шляхи.
22
- parseFromStages витягує всі інструкції `FROM <image>` з вмісту Dockerfile/Containerfile.
23
- splitDockerfileStages розбиває вміст Dockerfile на логічні етапи на основі інструкцій `FROM`.
24
- getMultistageAndRuntimeHint перевіряє, чи відповідає структура Dockerfile вимогам багатоетапної збірки та дозволених runtime-образів (docker.mdc).
25
- getBunCompileHint перевіряє, чи для bun-проєктів на backend runtime виконується необхідна компіляція бінарника та не міститься залишків build tooling.
26
- getNginxAlpineSlimTagHint перевіряє, що для nginx-образів у `FROM` вказано тег `alpine-slim` (docker.mdc).
27
- getNonRootRuntimeHint перевіряє наявність інструкції `USER <non-root>` у фінальному stage (docker.mdc).
28
- main виконує повний перегляд знайдених Dockerfile / Containerfile, застосовуючи перевірки, що спираються на конфіги, зокрема `package.json`, та запускаючи hadolint.
29
- lint є оркестратором, який запускає `main` для перевірки усього репозиторію.
21
+ Detector спочатку знаходить лише Dockerfile і Containerfile у межах репозиторію, відфільтровуючи імена через `isDockerfileName` та враховуючи ignore-маршрути, а далі для кожного файлу запускає послідовну перевірку в межах `lint`.
22
+
23
+ `parseFromStages` і `splitDockerfileStages` дають спільну картину структури файла: перший витягує всі базові образи, другий ділить вміст на stages, щоб наступні перевірки могли оцінювати саме фінальний runtime і build-stage окремо.
24
+
25
+ `getMultistageAndRuntimeHint` використовує ці дані, щоб вимагати multistage і дозволений фінальний runtime відповідно до `docker.mdc`; для bun-runtime робить виняток лише коли є нативний `.node`-аддон або явний `# n-rules:bun-no-compile` маркер. Такий маркер читає `hasBunNoCompileMarker`, і він служить opt-in для випадків, які не можна вивести механічно.
26
+
27
+ `getBunCompileHint` працює лише для bun-проєктів із backend runtime: якщо в образі є `bun install` або `bun i`, але немає compile-кроку в build-stage, або в фінальному stage лишився bun tooling, це вважається порушенням. Вимога спирається на `package.json`, щоб зрозуміти, чи проект справді bun-орієнтований.
28
+
29
+ `getNonRootRuntimeHint` перевіряє, що фінальний runtime не працює як root, а `getNginxAlpineSlimTagHint` звужує окреме правило для nginx-образів до потрібного тегу з `docker.mdc`.
30
+
31
+ Усі ці перевірки збираються в `checkDockerfile`, який для кожного знайденого файла формує violations і додає їх до результату через fail-safe підхід: помилки не виходять назовні, а перетворюються на контрольований lint-результат.
32
+
33
+ `lint` є єдиною точкою запуску для цього detector: вона знаходить файли, проганяє їх через `checkDockerfile` і повертає підсумок для всього репозиторію без запису стану.
30
34
 
31
35
  ## Публічний API
32
36
 
33
- isDockerfileName — визначає, чи є ім'я файлу Dockerfile або Containerfile (включаючи варіації типу `Dockerfile.prod`).
34
- findDockerfilePaths — збирає повні шляхи до файлів Dockerfile або Containerfile, починаючи з поточного каталогу.
35
- parseFromStages — витягує список усіх образів, вказаних у директивах `FROM` у файлі Dockerfile/Containerfile.
36
- splitDockerfileStagesрозділяє Dockerfile на логічні етапи (stages) на основі інструкцій `FROM`.
37
- getMultistageAndRuntimeHintаналізує вимоги до структури багатоетапного збігу: перевіряє, чи є мінімум два етапи з `FROM`, а також чи відповідають образи фінального етапу дозволеним типам (з урахуванням винятків для проєктів з нативними `.node-аддонами`).
38
- getBunCompileHintперевіряє, чи вимагає проєкт на backend runtime, що збірка збігається у бінарний файл.
39
- getNginxAlpineSlimTagHintдля Nginx-образів перевіряє, чи вказано тег `alpine-slim` у відповідному `FROM`, згідно з `docker.mdc`.
40
- getNonRootRuntimeHintперевіряє, чи фінальний етап виконання використовує інструкцію `USER` для роботи від імені не-root користувача, згідно з `docker.mdc`.
41
- mainпроводить лінт-перевірку Dockerfile/Containerfile із використанням hadolint відповідно до `docker.mdc`.
42
- lint — є адаптером, що викликає стандартний лінтер `n-rules lint docker` для обгортання основної логіки.
37
+ - isDockerfileName — Чи є basename Dockerfile / Containerfile (у т.ч. Dockerfile.prod).
38
+ - findDockerfilePaths — Збирає абсолютні шляхи до Dockerfile / Containerfile від кореня cwd.
39
+ - parseFromStages — Витягує всі `FROM <image>` зі вмісту Dockerfile/Containerfile.
40
+ - hasBunNoCompileMarker Явний opt-in консюмера: коментар-рядок `# n-rules:bun-no-compile: <причина>` будь-де у файлі позначає, що `bun build --compile` неможливий з причини поза виявними класами (на відміну від нативних `.node`-аддонів, які виявляються з `package.json#dependencies`).
41
+ - splitDockerfileStages Розбиває Dockerfile на stages за `FROM` (порожній масив, якщо FROM немає).
42
+ - getMultistageAndRuntimeHint Перевіряє multistage (мінімум 2 FROM) і дозволений фінальний runtime-образ (docker.mdc); для нативного `.node`-аддона або `n-rules:bun-no-compile`-маркера додатково дозволяє `mirror.gcr.io/oven/bun:*`.
43
+ - getBunCompileHint Для backend bun-проєкту `bun install`, фінальний FROM — alpine, не frontend, немає `n-rules:bun-no-compile`-маркера) вимагає `bun build --compile` у build stage і відсутність `bun` у фінальному stage.
44
+ - getNginxAlpineSlimTagHint Перевіряє, що для nginx-образів (`mirror.gcr.io/nginxinc/nginx-unprivileged`) у `FROM` вказано тег `alpine-slim` (docker.mdc).
45
+ - getNonRootRuntimeHint Перевіряє, що у фінальному stage є `USER <name|uid>` і це не `root`/`0` (docker.mdc).
46
+ - lint — Detector docker/lint: Dockerfile/Containerfile mirror/multistage/runtime/non-root + hadolint.
43
47
 
44
48
  ## Гарантії поведінки
45
49
 
46
- - Read-only: не виконує операцій запису (ФС/БД).
50
+ - Власних операцій запису (ФС/БД) у файлі немає; виклики імпортованих модулів можуть писати.
47
51
  - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
@@ -15,6 +15,7 @@ const BUN_INSTALL_RE = /\bbun\s+(?:install|i)\b/iu
15
15
  const BUN_BUILD_COMPILE_RE = /\bbun\s+build\b[^\n]*\s--compile\b/iu
16
16
  const BUN_WORD_RE = /\bbun\b/iu
17
17
  const USER_LINE_RE = /^\s*USER\s+([^\s#]+)/iu
18
+ const BUN_NO_COMPILE_MARKER_RE = /^#\s*n-rules:bun-no-compile:(.*)$/iu
18
19
 
19
20
  const NGINX_UNPRIVILEGED_MIRROR_PREFIX = 'mirror.gcr.io/nginxinc/nginx-unprivileged'
20
21
 
@@ -81,22 +82,39 @@ const RUNTIME_IMAGES = /** @type {const} */ ([
81
82
  /** @type {RegExp} */
82
83
  const DEBIAN_VIA_MIRROR_RE = /^mirror\.gcr\.io\/library\/debian:(.+)$/i
83
84
 
84
- /** Bun-рантайм як фінальний stage — легітимний лише за наявності нативного .node-аддона (див. docker.mdc). */
85
+ /** Bun-рантайм як фінальний stage — легітимний лише за наявності нативного .node-аддона або `n-rules:bun-no-compile`-маркера (див. docker.mdc). */
85
86
  const BUN_RUNTIME_IMAGE = 'mirror.gcr.io/oven/bun'
86
87
 
88
+ /**
89
+ * Явний opt-in консюмера: сервіс не можна пакувати через `bun build --compile` з причини, яку
90
+ * checker не може вивести механічно (динамічний `import()` рантайм-конфігу тощо — на відміну
91
+ * від нативних `.node`-аддонів, які виявляються з `package.json#dependencies`). Маркер —
92
+ * коментар-рядок `# n-rules:bun-no-compile: <причина>` будь-де у файлі; той самий канон, що й для
93
+ * native-addon: ship `node_modules` + `bun <entry>` на `mirror.gcr.io/oven/bun:*`.
94
+ * @param {string} fileContent вміст Dockerfile/Containerfile
95
+ * @returns {boolean} true, якщо маркер присутній із непорожньою причиною
96
+ */
97
+ export function hasBunNoCompileMarker(fileContent) {
98
+ return fileContent.split(NEWLINE_RE).some(line => {
99
+ const m = line.trim().match(BUN_NO_COMPILE_MARKER_RE)
100
+ return Boolean(m && m[1].trim().length > 0)
101
+ })
102
+ }
103
+
87
104
  /**
88
105
  * Чи ref фінального `FROM` відповідає дозволеним у docker.mdc (multistage / runtime).
89
106
  * @param {string} lastLower ref без digest, lower case
90
- * @param {boolean} [hasNativeAddon] чи проєкт залежить від нативного .node-аддона (sharp/@img/argon2)
107
+ * @param {boolean} [allowBunRuntime] чи легітимний bun-рантайм як фінальний stage (нативний аддон або `n-rules:bun-no-compile`-маркер)
91
108
  * @returns {boolean} true, якщо образ дозволений як фінальний runtime
92
109
  */
93
- function isAllowedFinalRuntimeImage(lastLower, hasNativeAddon = false) {
110
+ function isAllowedFinalRuntimeImage(lastLower, allowBunRuntime = false) {
94
111
  if (lastLower === 'scratch' || lastLower.startsWith('scratch:')) {
95
112
  return true
96
113
  }
97
- // Для нативних аддонів канон — ship node_modules + `bun <entry>`, тож фінальний stage на
98
- // mirror.gcr.io/oven/bun:* легітимний (compile неможливий, див. docker-native-addon.mjs).
99
- if (hasNativeAddon && (lastLower === BUN_RUNTIME_IMAGE || lastLower.startsWith(`${BUN_RUNTIME_IMAGE}:`))) {
114
+ // Канон — ship node_modules + `bun <entry>`, тож фінальний stage на mirror.gcr.io/oven/bun:*
115
+ // легітимний, коли compile неможливий (native-addon: docker-native-addon.mjs, або явний
116
+ // n-rules:bun-no-compile-маркер: hasBunNoCompileMarker).
117
+ if (allowBunRuntime && (lastLower === BUN_RUNTIME_IMAGE || lastLower.startsWith(`${BUN_RUNTIME_IMAGE}:`))) {
100
118
  return true
101
119
  }
102
120
  const deb = lastLower.match(DEBIAN_VIA_MIRROR_RE)
@@ -132,7 +150,8 @@ export function splitDockerfileStages(fileContent) {
132
150
  * Перевіряє базові вимоги до структури Dockerfile:
133
151
  * - multistage: мінімум 2 FROM
134
152
  * - фінальний FROM: дозволені образи в docker.mdc (alpine, scratch, debian slim, php, python, nginx, openresty, …);
135
- * для проєктів із нативним .node-аддоном додатково дозволено mirror.gcr.io/oven/bun:* (bun-рантайм)
153
+ * для проєктів із нативним .node-аддоном або `n-rules:bun-no-compile`-маркером додатково дозволено
154
+ * mirror.gcr.io/oven/bun:* (bun-рантайм)
136
155
  * @param {string} fileContent вміст Dockerfile/Containerfile
137
156
  * @param {{ hasNativeAddon?: boolean }} [opts] опції: hasNativeAddon — є нативний .node-аддон (sharp/@img/argon2)
138
157
  * @returns {string | null} повідомлення помилки або null
@@ -148,8 +167,9 @@ export function getMultistageAndRuntimeHint(fileContent, { hasNativeAddon = fals
148
167
  const last = stages.at(-1)
149
168
  const lastImage = (last?.image || '').split('@', 1)[0] || ''
150
169
  const lastLower = lastImage.toLowerCase()
170
+ const allowBunRuntime = hasNativeAddon || hasBunNoCompileMarker(fileContent)
151
171
 
152
- if (!isAllowedFinalRuntimeImage(lastLower, hasNativeAddon)) {
172
+ if (!isAllowedFinalRuntimeImage(lastLower, allowBunRuntime)) {
153
173
  return `фінальний FROM має бути дозволеним runtime-образом (див. docker.mdc: multistage), зараз: ${last?.image} (рядок ${last?.line})`
154
174
  }
155
175
 
@@ -161,7 +181,9 @@ export function getMultistageAndRuntimeHint(fileContent, { hasNativeAddon = fals
161
181
  *
162
182
  * Тригер:
163
183
  * - у Dockerfile є крок `bun install` (або `bun i`);
164
- * - фінальний FROM — `mirror.gcr.io/library/alpine:*` (тобто не nginx/openresty frontend).
184
+ * - фінальний FROM — `mirror.gcr.io/library/alpine:*` (тобто не nginx/openresty frontend);
185
+ * - немає `n-rules:bun-no-compile`-маркера (явний opt-in консюмера — compile неможливий з причини поза
186
+ * виявними класами на кшталт нативних аддонів, напр. динамічний `import()` рантайм-конфігу).
165
187
  *
166
188
  * Очікування:
167
189
  * - у build stage є `bun build --compile`;
@@ -170,6 +192,8 @@ export function getMultistageAndRuntimeHint(fileContent, { hasNativeAddon = fals
170
192
  * @returns {string | null} повідомлення помилки або null
171
193
  */
172
194
  export function getBunCompileHint(fileContent) {
195
+ if (hasBunNoCompileMarker(fileContent)) return null
196
+
173
197
  const stages = splitDockerfileStages(fileContent)
174
198
  if (stages.length === 0) return null
175
199
 
@@ -132,6 +132,30 @@ CMD ["bun", "src/index.js"]
132
132
 
133
133
  Для проєктів **без** нативних аддонів standalone-бінарник на alpine лишається каноном (див. розділ вище про компіляцію).
134
134
 
135
+ ## Виняток: явний `n-rules:bun-no-compile`-маркер (причина поза виявними класами)
136
+
137
+ Нативний `.node`-аддон — механічно виявна причина (є в `package.json#dependencies`). Але бувають причини, які checker вивести не може: наприклад, сервіс завантажує конфіг через динамічний `import()` шляху, невідомого на момент компіляції, — `bun build --compile` такий шлях не трейсить, тож бінарник падає в рантаймі так само, як із нативним аддоном.
138
+
139
+ Для цих випадків — явний opt-in консюмера: коментар-рядок `# n-rules:bun-no-compile: <причина>` будь-де у Dockerfile/Containerfile (причина обов'язкова, непорожня). Маркер вимикає і вимогу `bun build --compile` (розділ «Компіляція bun-проєкту в бінарник»), і заборону `mirror.gcr.io/oven/bun:*` як фінального stage (розділ «Multistage build») — той самий канон, що й для нативних аддонів: ship `node_modules` + `bun <entry>` на `mirror.gcr.io/oven/bun:alpine`.
140
+
141
+ ```dockerfile
142
+ # n-rules:bun-no-compile: gateway.config.js вантажиться через динамічний import(), compile не трейсить його
143
+ FROM mirror.gcr.io/oven/bun:alpine AS build-env
144
+ WORKDIR /app
145
+ COPY package.json .
146
+ RUN bun install --production
147
+ COPY ./src ./src
148
+
149
+ FROM mirror.gcr.io/oven/bun:alpine
150
+ WORKDIR /app
151
+ COPY --from=build-env --chown=bun:bun /app/node_modules ./node_modules
152
+ COPY --from=build-env --chown=bun:bun /app/src ./src
153
+ USER bun
154
+ CMD ["bun", "src/index.js"]
155
+ ```
156
+
157
+ Маркер — **не** заміна нативно-аддонного винятку (той визначається автоматично з `package.json`) і **не** привід уникати компіляції там, де вона можлива — це escape hatch для нового, не закодованого в checker класу причин; використовуй лише коли `bun build --compile` доведено не працює. Перевіряють `hasBunNoCompileMarker` / `getBunCompileHint` / `getMultistageAndRuntimeHint` у **`npm/rules/docker/lint/main.mjs`**.
158
+
135
159
  ## Non-root принцип у фінальному stage
136
160
 
137
161
  Для всіх образів потрібно щоб використовувся non-root принцип. **Спосіб** досягнення non-root залежить від **бази**, а не від зміни ОС — змінювати дистрибутив (Alpine→Debian) заради лише non-root **не треба**. Два шляхи:
@@ -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: 128fe3b7
6
+ crc: a0f45fc3
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98
@@ -40,3 +40,5 @@ detectAll виконує прохід лінтера у режим детекц
40
40
  **Multi-dir (плагіни):** `effectiveRulesDirs` додає rules-каталоги плагінів з `.n-rules.json` (hot-path: без install, quiet); `readLintConcernsByRuleMulti` зливає концерни за іменем (перший власник виграє) — плагін може додавати концерни до правила ядра (mixin).
41
41
 
42
42
  **Capability-гейт:** `filterByCapabilities` відкидає концерни з незадоволеним `requires.capability` (capabilities надають встановлені плагіни через маніфест `n-rules.capabilities`; явний `opts.capabilities` у тестах перекриває резолв).
43
+
44
+ **Warning про rule-id без concern-ів:** якщо rule-id з `.n-rules.json#rules` не має жодного concern-а серед усіх `rulesDirs` (ядро + плагіни) — `console.error` попереджає про можливий дрейф конфігу (типово: правило переїхало в плагін, якого консюмер не підключив у `plugins[]`). Rule-id з існуючим каталогом, але без `concern.json` (документаційні правила на кшталт `feedback`), warning не тригерить.
@@ -152,15 +152,70 @@ async function readLintConcernsByRuleMulti(rulesDirs) {
152
152
  return merged
153
153
  }
154
154
 
155
+ /**
156
+ * Імена ВСІХ каталогів верхнього рівня під `rulesDirs` — незалежно від того, чи знайшлись
157
+ * у них concern-и. Потрібно, щоб відрізнити «каталог є, просто без lint-поверхні» (легітимно
158
+ * для суто-документаційних правил на кшталт `feedback`/`local-ai`) від «каталогу немає
159
+ * взагалі ні в ядрі, ні в жодному підключеному плагіні» (ознака дрейфу конфігу — типово
160
+ * правило переїхало в плагін, якого консюмер не підключив).
161
+ * @param {string[]} rulesDirs rules-каталоги (ядро + плагіни).
162
+ * @returns {Promise<Set<string>>} унікальні імена каталогів-правил.
163
+ */
164
+ async function discoverAllRuleDirNames(rulesDirs) {
165
+ const { readdir } = await import('node:fs/promises')
166
+ /** @type {Set<string>} */
167
+ const names = new Set()
168
+ for (const dir of rulesDirs) {
169
+ let entries
170
+ try {
171
+ entries = await readdir(dir, { withFileTypes: true })
172
+ } catch {
173
+ continue
174
+ }
175
+ for (const e of entries) {
176
+ if (e.isDirectory() && !e.name.startsWith('.')) names.add(e.name)
177
+ }
178
+ }
179
+ return names
180
+ }
181
+
182
+ /**
183
+ * Попереджає про rule-id з `.n-rules.json#rules`, яких немає ЖОДНИМ каталогом ні в ядрі, ні
184
+ * в підключених плагінах (не плутати з «каталог є, але без concern-ів» — легітимний випадок
185
+ * для суто-документаційних правил). Типова причина: правило переїхало в окремий плагін
186
+ * (напр. `js` → `@7n/rules-lang-js` з фази 5c), а `plugins[]` консюмера про це не знає —
187
+ * тоді перевірки для цього правила мовчки НЕ виконуються (0 знайдених concern-ів виглядає
188
+ * як «усе чисто», хоча насправді нічого не перевірялось).
189
+ * @param {Record<string, ConcernMeta[]>} byRule concerns згруповані за rule-id (з усіх rulesDirs).
190
+ * @param {import('../read-n-rules-config-lite.mjs').LiteConfig} config розпарсений .n-rules.json.
191
+ * @param {string[]} rulesDirs rules-каталоги (ядро + плагіни), для перевірки «каталог є, але порожній».
192
+ * @returns {Promise<void>}
193
+ */
194
+ async function warnAboutRulesWithoutConcerns(byRule, config, rulesDirs) {
195
+ const missing = config.rules.filter(id => !(id in byRule))
196
+ if (missing.length === 0) return
197
+ const allDirNames = await discoverAllRuleDirNames(rulesDirs)
198
+ for (const ruleId of missing) {
199
+ if (allDirNames.has(ruleId)) continue // каталог є, просто без lint-поверхні — легітимно
200
+ console.error(
201
+ `⚠️ .n-rules.json: правило "${ruleId}" не знайдено НІ В ОДНОМУ з rulesDirs (ні в ядрі, ні в ` +
202
+ `підключених плагінах "plugins") — перевірки для нього НЕ виконуються. Якщо правило нещодавно ` +
203
+ `переїхало в окремий плагін, додай відповідний пакет у "plugins" (і в devDependencies).`
204
+ )
205
+ }
206
+ }
207
+
155
208
  /**
156
209
  * Активні rule-id з `.n-rules.json` (для delta/full режимів).
157
210
  * @param {Record<string, ConcernMeta[]>} byRule concerns згруповані за rule-id.
158
211
  * @param {string} cwd робоча директорія прогону.
212
+ * @param {string[]} rulesDirs rules-каталоги (ядро + плагіни) — для warning про відсутні правила.
159
213
  * @returns {Promise<string[]>} перелік активних rule-id.
160
214
  */
161
- async function enabledRuleIds(byRule, cwd) {
215
+ async function enabledRuleIds(byRule, cwd, rulesDirs) {
162
216
  const config = await readNRulesConfigLite(cwd)
163
217
  if (!config.exists) return []
218
+ await warnAboutRulesWithoutConcerns(byRule, config, rulesDirs)
164
219
  return Object.keys(byRule).filter(id => isRuleEnabled(config, id))
165
220
  }
166
221
 
@@ -189,7 +244,8 @@ function sortEntries(entries) {
189
244
  * @returns {Promise<PlanItem[]>} впорядкований план прогону.
190
245
  */
191
246
  export async function buildDetectPlan(opts) {
192
- const byRule = await filterByCapabilities(await readLintConcernsByRuleMulti(await effectiveRulesDirs(opts)), opts)
247
+ const rulesDirs = await effectiveRulesDirs(opts)
248
+ const byRule = await filterByCapabilities(await readLintConcernsByRuleMulti(rulesDirs), opts)
193
249
  return buildPlan({
194
250
  byRule,
195
251
  full: opts.full === true,
@@ -198,7 +254,8 @@ export async function buildDetectPlan(opts) {
198
254
  pathMode: opts.pathMode === true,
199
255
  repoWide: opts.repoWide === true,
200
256
  baseRef: typeof opts.baseRef === 'string' ? opts.baseRef : null,
201
- cwd: opts.cwd
257
+ cwd: opts.cwd,
258
+ rulesDirs
202
259
  })
203
260
  }
204
261
 
@@ -209,8 +266,9 @@ export async function buildDetectPlan(opts) {
209
266
  * @returns {Promise<{ byRule: Record<string, ConcernMeta[]>, enabledSet: Set<string> }>} concerns і активні правила.
210
267
  */
211
268
  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))
269
+ const rulesDirs = await effectiveRulesDirs(opts)
270
+ const byRule = await filterByCapabilities(await readLintConcernsByRuleMulti(rulesDirs), opts)
271
+ const enabledSet = new Set(await enabledRuleIds(byRule, opts.cwd, rulesDirs))
214
272
  return { byRule, enabledSet }
215
273
  }
216
274
 
@@ -378,6 +436,7 @@ export function computeActiveDomains(byRule, enabledSet, changed) {
378
436
  * @param {boolean} [args.repoWide] `--repo-wide`: лише full-scope concerns, whole-repo.
379
437
  * @param {string|null} [args.baseRef] явна база дельти (`--base <ref>`) замість каскаду main→origin/main.
380
438
  * @param {string} args.cwd робоча директорія прогону.
439
+ * @param {string[]} [args.rulesDirs] rules-каталоги (ядро + плагіни) — для warning про відсутні правила.
381
440
  * @returns {Promise<PlanItem[]>} впорядкований план прогону.
382
441
  */
383
442
  async function buildPlan({
@@ -388,14 +447,15 @@ async function buildPlan({
388
447
  pathMode = false,
389
448
  repoWide = false,
390
449
  baseRef = null,
391
- cwd
450
+ cwd,
451
+ rulesDirs = []
392
452
  }) {
393
453
  // scoped + --path: per-file concerns названих правил × перетин path ∩ дельта
394
454
  if (rules.length > 0 && explicitFiles !== null) return buildScopedDeltaPlan(byRule, rules, explicitFiles)
395
455
  // scoped: усі lint-concerns названих правил, whole-repo
396
456
  if (rules.length > 0) return buildScopedPlan(byRule, rules)
397
457
 
398
- const enabled = await enabledRuleIds(byRule, cwd)
458
+ const enabled = await enabledRuleIds(byRule, cwd, rulesDirs)
399
459
  const enabledSet = new Set(enabled)
400
460
 
401
461
  // repo-wide: лише full-scope concerns (окремий CI-workflow, не гейтить деплой)
@@ -557,7 +617,8 @@ export async function detectAll(opts) {
557
617
  const verbose = opts.verbose === true
558
618
  const baseLog = opts.log ?? (s => process.stdout.write(s))
559
619
 
560
- const byRule = await filterByCapabilities(await readLintConcernsByRuleMulti(await effectiveRulesDirs(opts)), opts)
620
+ const rulesDirs = await effectiveRulesDirs(opts)
621
+ const byRule = await filterByCapabilities(await readLintConcernsByRuleMulti(rulesDirs), opts)
561
622
  const plan = await buildPlan({
562
623
  byRule,
563
624
  full,
@@ -566,7 +627,8 @@ export async function detectAll(opts) {
566
627
  pathMode: opts.pathMode === true,
567
628
  repoWide: opts.repoWide === true,
568
629
  baseRef: typeof opts.baseRef === 'string' ? opts.baseRef : null,
569
- cwd
630
+ cwd,
631
+ rulesDirs
570
632
  })
571
633
 
572
634
  // Detect-only бар — ЛИШЕ в TTY (без тикера «виправлено»). У не-TTY (hooks, CI-gate,
@@ -115,7 +115,7 @@ export function parseProgramOrNull(content, virtualPath) {
115
115
 
116
116
  /**
117
117
  * Парсить файл і повертає `{ program, comments }` або null. Окремий вхід для перевірок,
118
- * яким потрібні коментарі (наприклад, маркер `// allow-unsafe: ...` біля виклику) —
118
+ * яким потрібні коментарі (наприклад, маркер `// n-rules:allow-unsafe: ...` біля виклику) —
119
119
  * базовий `parseProgramOrNull` свідомо лишається без коментарів, щоб не змінювати API.
120
120
  * @param {string} content вихідний код
121
121
  * @param {string} virtualPath шлях для вибору `lang` (також для діагностики)
@@ -3,52 +3,53 @@ type: JS Module
3
3
  title: ast-scan-utils.mjs
4
4
  resource: npm/scripts/utils/ast-scan-utils.mjs
5
5
  docgen:
6
- crc: ed9f189b
6
+ crc: 0a015cfc
7
+ model: openai-codex/gpt-5.4-mini
8
+ tier: cloud-min
9
+ score: 100
10
+ issues: judge:error
11
+ judgeModel: openai-codex/gpt-5.4-mini
7
12
  ---
8
13
 
9
- Цей файл містить утиліти для AST-сканерів JavaScript та TypeScript, що використовуються для аналізу коду та виявлення потенційних проблем. Він надає інструменти для обробки AST, перетворення даних та взаємодії з різними типами вузлів, забезпечуючи основу для створення правил безпеки та аналізу коду. Ці утиліти спрощують розробку сканерів, усуваючи необхідність повторного написання boilerplate-коду.
14
+ ## Огляд
15
+
16
+ Утиліти для AST-сканерів JS/TS на `oxc-parser`: `langFromPath` вибирає мову за шляхом файлу, `offsetToLine` переводить зміщення в номер рядка, `normalizeSnippet` стискає фрагмент коду, а `parseProgramOrNull` і `parseProgramAndCommentsOrNull` безпечно повертають `null` замість винятку, коли розбір не вдався.
17
+
18
+ Файл також надає спільні засоби для аналізу дерева й контексту: `walkAstWithAncestors` обходить AST разом із предками, `isFunctionNode` і `isJoinCall` розпізнають типові вузли, `templateQuasisText` та `isSqlListContextTemplate` допомагають працювати з `TemplateLiteral`, а `requireCallModule` і `dynamicImportModule` виділяють модуль із викликів імпорту.
10
19
 
11
20
  ## Поведінка
12
21
 
13
- langFromPath: Визначає мову (js, jsx, ts, tsx) на основі розширення файлу.
14
- offsetToLine: Перетворює байтове зміщення в номер рядка для текстового файлу.
15
- normalizeSnippet: Стискає текстовий фрагмент до 180 символів, видаляючи пробіли.
16
- isFunctionNode: Визначає, чи є вузол AST функцією (FunctionDeclaration, FunctionExpression, ArrowFunctionExpression).
17
- walkAstWithAncestors: Рекурсивно обходить AST, збираючи предки вузла.
18
- parseProgramOrNull: Парсує файл JS/TS та повертає програму або null, якщо є помилки.
19
- parseProgramAndCommentsOrNull: Парсує файл JS/TS та повертає програму та список коментарів, або null, якщо є помилки.
20
- isJoinCall: Визначає, чи є виклик `join` у TemplateLiteral.
21
- templateQuasisText: Збирає текст quasis з TemplateLiteral.
22
- isSqlListContextTemplate: Визначає, чи є TemplateLiteral контекстом SQL-списку (IN/VALUES).
23
- requireCallModule: Витягує ім'я модуля з аргументу виклику `require`.
24
- dynamicImportModule: Витягує ім'я модуля з аргументу виклику `import`.
22
+ Утиліти працюють як спільний шар для AST-сканерів: спочатку за шляхом файлу визначається мова парсингу, далі текст розбирається в `program`, а для перевірок, яким потрібні коментарі поруч із кодом, — у пару `program` + `comments`. Якщо розбір не вдається, зовнішнім споживачам повертається `null`, щоб сканування не падало на синтаксично проблемних файлах.
23
+
24
+ Обхід дерева будується з урахуванням предків, щоб правила могли відрізняти контекст верхнього рівня від вкладеного всередині функцій. Під час такого обходу вузли-функції та виклики спискових операцій розпізнаються як типові шаблони для аналізу, а текстові фрагменти нормалізуються до компактного вигляду для повідомлень про порушення.
25
+
26
+ Окремий набір хелперів працює з `TemplateLiteral`: один збирає видимий текст усіх частин без вставок, інший за цим текстом визначає, чи схоже місце на SQL-контекст зі списком значень. Це дає змогу знаходити небезпечні або підозрілі шаблони без дублювання однакової логіки в різних правилах.
27
+
28
+ Для аналізу імпортів спільно використовуються перевірки на звичайний `require` і динамічний `import` з рядковим модулем. Обидва хелпери повертають лише назву модуля або порожній результат, щоб сканери могли однаково працювати з різними формами завантаження без прив’язки до конкретного правила.
29
+
30
+ Усі операції побудовані fail-safe: помилки парсингу або несподівані вузли не пробиваються назовні, а переводяться в безпечний результат. Це дозволяє сканерам пропускати проблемні фрагменти й продовжувати перевірку решти коду.
25
31
 
26
32
  ## Публічний API
27
33
 
28
- - langFromPath — Визначає мову Oxc на основі розширення файлу.
29
- - offsetToLine — Перетворює зміщення в буфер на номер рядка.
30
- - normalizeSnippet — Форматує текст повідомлення про порушення, видаляючи зайві пробіли.
31
- - isFunctionNode — Визначає, чи є вузол у абстрактному синтаксичному дереві (AST) функцією.
32
- - walkAstWithAncestors — Рекурсивно обходить AST, враховуючи контекст (чи знаходиться вузол всередині функції).
33
- - parseProgramOrNull — Парсить файл та повертає AST, якщо успішно, або `null` у разі помилки.
34
- - parseProgramAndCommentsOrNull — Парсить файл та повертає об'єкт з AST та коментарями, або `null` у разі помилки.
35
- - isJoinCall Визначає, чи є виклик `.join` (для динамічних списків SQL).
36
- - templateQuasisText Витягує текст з `quasis` у `TemplateLiteral`, ігноруючи вирази.
37
- - isSqlListContextTemplateВизначає, чи є `TemplateLiteral` контекстом SQL-списку (наприклад, `IN` або `VALUES`).
38
- - requireCallModuleПеревіряє, чи є виклик `require` з рядковим ім'ям.
34
+ - langFromPath — Мова для Oxc за шляхом файлу (розширення).
35
+ - offsetToLine — Номер рядка (1-based) за зміщенням у буфері.
36
+ - normalizeSnippet — Стискає пробіли для повідомлення про порушення.
37
+ - isFunctionNode — Чи є вузол функцією.
38
+ - walkAstWithAncestors — Рекурсивний обхід AST з предками, щоб визначати контекст (всередині функції чи ні).
39
+ - parseProgramOrNull — Парсить файл і повертає `program` або null, якщо є синтаксичні помилки чи виняток.
40
+ - parseProgramAndCommentsOrNull — Парсить файл і повертає `{ program, comments }` або null. Окремий вхід для перевірок,
41
+ яким потрібні коментарі (наприклад, маркер `// n-rules:allow-unsafe: ...` біля виклику)
42
+ базовий `parseProgramOrNull` свідомо лишається без коментарів, щоб не змінювати API.
43
+ - isJoinCallЧи це `.join(...)` виклик (типово для динамічних списків у SQL).
44
+ - templateQuasisTextТекст quasis у TemplateLiteral (без expressions).
45
+ - isSqlListContextTemplate — Чи виглядає TemplateLiteral як SQL-контекст зі списком (IN/VALUES (...)).
46
+ - requireCallModule — Перевіряє, чи це виклик `require('<module>')` з рядковим аргументом.
47
+ Спільне для сканерів імпортів (`bunyan-imports`, `redis-imports`, ...).
48
+ - dynamicImportModule — Перевіряє, чи це динамічний `import('<module>')` з рядковим аргументом.
49
+ Спільне для сканерів імпортів.
39
50
 
40
51
  ## Гарантії поведінки
41
52
 
42
- - `langFromPath` повертає назву мови JavaScript або TypeScript на основі розширення файлу.
43
- - `langFromPath` повертає `null`, якщо розширення файлу не підтримується.
44
- - `offsetToLine` перетворює зміщення в коді на номер рядка.
45
- - `offsetToLine` повертає `null`, якщо зміщення недійсне.
46
- - `normalizeSnippet` стискає текст сніпета.
47
- - `normalizeSnippet` повертає `null`, якщо не вдалося стиснути сніпет.
48
- - `isFunctionNode` визначає, чи є вузол AST функцією.
49
- - `isFunctionNode` повертає `true` якщо вузол є функцією, інакше `false`.
50
- - `walkAstWithAncestors` обходить AST, враховуючи предки вузлів.
51
- - `walkAstWithAncestors` не повертає значень.
52
- - `parseProgramOrNull` парсує програму та повертає її як AST або `null`, якщо парсинг не вдається.
53
- - `parseProgramOrNull` повертає `null`, якщо програма не може бути успішно розпарсена.
54
- - `parseProgramAndCommentsOrNull` парсує програму
53
+ - Власних операцій запису (ФС/БД) у файлі немає; виклики імпортованих модулів можуть писати.
54
+ - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
55
+ - За певних помилок повертає порожнє значення (напр. `null`) замість винятку.