@nitra/cursor 1.13.63 → 1.13.65
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 +17 -0
- package/bin/n-cursor.js +1 -0
- package/package.json +1 -1
- package/rules/adr/adr.mdc +1 -1
- package/rules/adr/fix/hooks/template/.gitignore.snippet +8 -0
- package/rules/bun/fix/layout/check.mjs +5 -2
- package/rules/changelog/fix/consistency/check.mjs +5 -7
- package/rules/image-avif/fix/avif_generation/check.mjs +1 -1
- package/rules/k8s/fix/manifests/check.mjs +28 -35
- package/rules/k8s/lint/lint.mjs +52 -22
- package/scripts/sync-claude-config.mjs +72 -4
- package/scripts/utils/inline-template-links.mjs +4 -3
- package/scripts/utils/package-manifest.mjs +3 -9
- package/scripts/utils/run-conftest-batch.mjs +2 -10
- package/scripts/utils/template.mjs +10 -5
- package/skills/abie-clean/SKILL.md +13 -0
- package/skills/lint/SKILL.md +12 -12
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@
|
|
|
4
4
|
|
|
5
5
|
Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
|
|
6
6
|
|
|
7
|
+
## [1.13.65] - 2026-05-20
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Скіл **`n-abie-clean`**: розділ перекладів — апострофи та спецсимволи в англійських рядках (`tr`); після очистки — обовʼязкова локальна перевірка `bun vite build` поряд із `check abie`.
|
|
12
|
+
|
|
13
|
+
## [1.13.64] - 2026-05-20
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **`sync-claude-config`**: при увімкненому правилі `adr` `npx @nitra/cursor` дописує в кореневий `.gitignore` канонічний фрагмент `rules/adr/fix/hooks/template/.gitignore.snippet` (`.claude/hooks/*.log`, `.normalize-state`, `.normalize.lock`) — логи ADR Stop-hook більше не потрапляють у git status.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Правило **`adr`**: посилання на канон `.gitignore.snippet` і згадка автоматичного дописування під час sync.
|
|
22
|
+
- **`.gitignore.snippet`**: додано базові рядки `node_modules/`, `dist/`, `*.secret` (як у кореневому `.gitignore` пакета).
|
|
23
|
+
|
|
7
24
|
## [1.13.63] - 2026-05-20
|
|
8
25
|
|
|
9
26
|
### Fixed
|
package/bin/n-cursor.js
CHANGED
|
@@ -1274,6 +1274,7 @@ async function runSync() {
|
|
|
1274
1274
|
if (result.commands.length > 0) parts.push(`${result.commands.length} slash-commands`)
|
|
1275
1275
|
if (result.adrHook) parts.push('.claude/hooks/capture-decisions.sh')
|
|
1276
1276
|
if (result.adrNormalizeHook) parts.push('.claude/hooks/normalize-decisions.sh')
|
|
1277
|
+
if (result.gitignoreAdr) parts.push('.gitignore (adr fragment)')
|
|
1277
1278
|
if (parts.length > 0) {
|
|
1278
1279
|
console.log(`🤖 Claude-конфіг: ${parts.join(', ')}`)
|
|
1279
1280
|
}
|
package/package.json
CHANGED
package/rules/adr/adr.mdc
CHANGED
|
@@ -95,7 +95,7 @@ docs/adr/
|
|
|
95
95
|
└── hooks.json # Cursor Agent stop-hooks для тих самих скриптів
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
`.gitignore` повинен містити
|
|
98
|
+
`.gitignore` у корені проєкту повинен містити базові рядки (`node_modules/`, `dist/`, `*.secret`) і патерни для ADR Stop-hook (**`.claude/hooks/*.log`**, `.claude/hooks/.normalize-state`, `.claude/hooks/.normalize.lock`). Канонічний фрагмент (дописується `npx @nitra/cursor`, коли правило `adr` увімкнене): [.gitignore.snippet](./fix/hooks/template/.gitignore.snippet).
|
|
99
99
|
|
|
100
100
|
## Stop-hook у `.claude/settings.json`
|
|
101
101
|
|
|
@@ -22,6 +22,9 @@ import { readFile } from 'node:fs/promises'
|
|
|
22
22
|
|
|
23
23
|
import { createCheckReporter } from '../../../../scripts/utils/check-reporter.mjs'
|
|
24
24
|
|
|
25
|
+
/** Розділювач токенів у `scripts.lint` (послідовність пробільних символів). */
|
|
26
|
+
const WHITESPACE_RE = /\s+/u
|
|
27
|
+
|
|
25
28
|
// Перевірка `devDependencies` кореневого `package.json` (дозволено лише `@nitra/*`)
|
|
26
29
|
// — у rego (`npm/policy/bun/package_json/`). JS-копії `isAllowedRootDevDependency`
|
|
27
30
|
// видалено, щоб не було двох джерел істини.
|
|
@@ -53,8 +56,8 @@ async function loadNCursorRules() {
|
|
|
53
56
|
*/
|
|
54
57
|
function lintChainHasScript(lintScript, target) {
|
|
55
58
|
if (!lintScript) return false
|
|
56
|
-
const
|
|
57
|
-
return
|
|
59
|
+
const tokens = lintScript.split(WHITESPACE_RE)
|
|
60
|
+
return tokens.some((tok, i) => tok === 'bun' && tokens[i + 1] === 'run' && tokens[i + 2] === target)
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
/**
|
|
@@ -158,13 +158,11 @@ async function resolveChangelogComparisonPoint(branch) {
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
if (branch === 'main') {
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
(originMainSha === headSha || (await isGitAncestor('origin/main', 'HEAD')))
|
|
167
|
-
) {
|
|
161
|
+
const originMainRaw = await gitOrNull(['rev-parse', '--verify', '--quiet', 'origin/main'])
|
|
162
|
+
const originMainSha = originMainRaw?.trim()
|
|
163
|
+
const headRaw = await gitOrNull(['rev-parse', 'HEAD'])
|
|
164
|
+
const headSha = headRaw?.trim()
|
|
165
|
+
if (originMainSha && headSha && (originMainSha === headSha || (await isGitAncestor('origin/main', 'HEAD')))) {
|
|
168
166
|
return { ref: 'origin/main', label: 'main' }
|
|
169
167
|
}
|
|
170
168
|
const parent = await gitOrNull(['rev-parse', '--verify', '--quiet', 'HEAD~1'])
|
|
@@ -287,7 +287,7 @@ async function checkVueAvifImports(ignorePaths, usedAvifAbs, stats, pass, fail)
|
|
|
287
287
|
* переписати на AVIF-двійник (через `import x from '...png'` або `<img src="...png" />`).
|
|
288
288
|
*
|
|
289
289
|
* Якщо false — весь подальший етап `image-avif` пропускаємо: ні `npx --avif`, ні rewrite,
|
|
290
|
-
* ні cleanup-сиріт не дали б ніяких змін. Сенс — не запускати дорогий `npx
|
|
290
|
+
* ні cleanup-сиріт не дали б ніяких змін. Сенс — не запускати дорогий `npx \@nitra/minify-image`
|
|
291
291
|
* у проєктах, де AVIF не вживається (а опційно і не плануються).
|
|
292
292
|
*
|
|
293
293
|
* Скан робиться тими самими regexp-ами, що й основний rewrite-пасс (`VUE_RASTER_IMPORT_RE`
|
|
@@ -1319,15 +1319,11 @@ export async function collectResourceDescriptorsForKustomizationWalk(kustAbs, ro
|
|
|
1319
1319
|
*/
|
|
1320
1320
|
const out = []
|
|
1321
1321
|
|
|
1322
|
-
/*
|
|
1323
|
-
* @param {string} ref шлях з resources/bases/…
|
|
1324
|
-
|
|
1325
|
-
* @returns {Promise<void>} результат
|
|
1326
|
-
*/
|
|
1327
1322
|
/**
|
|
1328
|
-
*
|
|
1329
|
-
* @
|
|
1330
|
-
*/
|
|
1323
|
+
* @param {string} ref шлях з resources/bases/…
|
|
1324
|
+
* @returns {Promise<void>} результат
|
|
1325
|
+
*/
|
|
1326
|
+
async function handleResourceDescriptorPathRef(ref) {
|
|
1331
1327
|
if (typeof ref !== 'string' || ref.includes('://')) {
|
|
1332
1328
|
return
|
|
1333
1329
|
}
|
|
@@ -2903,15 +2899,11 @@ export function collectGatewayApiRouteBackendServiceNames(spec) {
|
|
|
2903
2899
|
*/
|
|
2904
2900
|
const out = []
|
|
2905
2901
|
|
|
2906
|
-
/*
|
|
2907
|
-
* @param {unknown} node вузол для обходу
|
|
2908
|
-
|
|
2909
|
-
* @returns {void} результат
|
|
2910
|
-
*/
|
|
2911
2902
|
/**
|
|
2912
|
-
*
|
|
2913
|
-
* @
|
|
2914
|
-
*/
|
|
2903
|
+
* @param {unknown} node вузол для обходу
|
|
2904
|
+
* @returns {void} результат
|
|
2905
|
+
*/
|
|
2906
|
+
function walk(node) {
|
|
2915
2907
|
if (node === null || node === undefined) return
|
|
2916
2908
|
if (Array.isArray(node)) {
|
|
2917
2909
|
for (const x of node) {
|
|
@@ -2947,15 +2939,11 @@ export function collectGatewayApiRouteBackendRefsWithRedundantNamespace(spec, ro
|
|
|
2947
2939
|
*/
|
|
2948
2940
|
const out = []
|
|
2949
2941
|
|
|
2950
|
-
/*
|
|
2951
|
-
* @param {unknown} node вузол для обходу
|
|
2952
|
-
|
|
2953
|
-
* @returns {void} результат
|
|
2954
|
-
*/
|
|
2955
2942
|
/**
|
|
2956
|
-
*
|
|
2957
|
-
* @
|
|
2958
|
-
*/
|
|
2943
|
+
* @param {unknown} node вузол для обходу
|
|
2944
|
+
* @returns {void} результат
|
|
2945
|
+
*/
|
|
2946
|
+
function walk(node) {
|
|
2959
2947
|
if (node === null || node === undefined) return
|
|
2960
2948
|
if (Array.isArray(node)) {
|
|
2961
2949
|
for (const x of node) {
|
|
@@ -6385,6 +6373,21 @@ function networkPolicyHasLegacyCatchAllEgress(doc) {
|
|
|
6385
6373
|
return false
|
|
6386
6374
|
}
|
|
6387
6375
|
|
|
6376
|
+
/**
|
|
6377
|
+
* Витягує `spec.podSelector.matchLabels.app` як рядок (порожній — якщо ланцюжок неповний).
|
|
6378
|
+
* @param {unknown} spec значення `spec` NetworkPolicy-документа
|
|
6379
|
+
* @returns {string} app-label або '' (коли структура не відповідає очікуваній)
|
|
6380
|
+
*/
|
|
6381
|
+
function networkPolicyPodSelectorAppLabel(spec) {
|
|
6382
|
+
if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return ''
|
|
6383
|
+
const podSelector = /** @type {Record<string, unknown>} */ (spec).podSelector
|
|
6384
|
+
if (podSelector === null || typeof podSelector !== 'object' || Array.isArray(podSelector)) return ''
|
|
6385
|
+
const matchLabels = /** @type {Record<string, unknown>} */ (podSelector).matchLabels
|
|
6386
|
+
if (matchLabels === null || typeof matchLabels !== 'object' || Array.isArray(matchLabels)) return ''
|
|
6387
|
+
const app = /** @type {Record<string, unknown>} */ (matchLabels).app
|
|
6388
|
+
return typeof app === 'string' ? app : ''
|
|
6389
|
+
}
|
|
6390
|
+
|
|
6388
6391
|
/**
|
|
6389
6392
|
* Migrate legacy `networkpolicy.yaml`: якщо хоч один документ має catch-all in-cluster egress —
|
|
6390
6393
|
* перезаписати **всі** документи у файлі через `buildNetworkPolicyYaml(name, appLabel)`. Деталі — k8s.mdc.
|
|
@@ -6404,17 +6407,7 @@ export async function regenerateLegacyNetworkPolicyDocsInFile(npAbs) {
|
|
|
6404
6407
|
for (const doc of docs) {
|
|
6405
6408
|
const name = manifestMetadataName(doc)
|
|
6406
6409
|
const spec = /** @type {Record<string, unknown>} */ (doc).spec
|
|
6407
|
-
|
|
6408
|
-
if (spec !== null && typeof spec === 'object' && !Array.isArray(spec)) {
|
|
6409
|
-
const podSelector = /** @type {Record<string, unknown>} */ (spec).podSelector
|
|
6410
|
-
if (podSelector !== null && typeof podSelector === 'object' && !Array.isArray(podSelector)) {
|
|
6411
|
-
const matchLabels = /** @type {Record<string, unknown>} */ (podSelector).matchLabels
|
|
6412
|
-
if (matchLabels !== null && typeof matchLabels === 'object' && !Array.isArray(matchLabels)) {
|
|
6413
|
-
const a = /** @type {Record<string, unknown>} */ (matchLabels).app
|
|
6414
|
-
if (typeof a === 'string') appLabel = a
|
|
6415
|
-
}
|
|
6416
|
-
}
|
|
6417
|
-
}
|
|
6410
|
+
const appLabel = networkPolicyPodSelectorAppLabel(spec)
|
|
6418
6411
|
if (typeof name === 'string' && name !== '' && appLabel !== '') specs.push({ name, appLabel })
|
|
6419
6412
|
}
|
|
6420
6413
|
if (specs.length === 0) return false
|
package/rules/k8s/lint/lint.mjs
CHANGED
|
@@ -31,6 +31,9 @@ const KUBESCAPE_EXCEPTIONS_FILE = '.kubescape-exceptions.json'
|
|
|
31
31
|
/** Назва kustomization-файлу (під `k8s` дозволено лише `.yaml`, див. k8s.mdc). */
|
|
32
32
|
const KUSTOMIZATION_FILE = 'kustomization.yaml'
|
|
33
33
|
|
|
34
|
+
/** Підказка встановлення kubescape (PATH miss / ENOENT). */
|
|
35
|
+
const KUBESCAPE_MISSING_HINT = 'kubescape не знайдено в PATH. Встанови з https://github.com/kubescape/kubescape#readme'
|
|
36
|
+
|
|
34
37
|
const PATH_SEPARATOR_RE = /[/\\]/u
|
|
35
38
|
const YAML_EXT_RE = /\.yaml$/iu
|
|
36
39
|
|
|
@@ -223,6 +226,50 @@ function runKubescapeManifest(kubescapePath, manifest, exceptionsArgs) {
|
|
|
223
226
|
}
|
|
224
227
|
}
|
|
225
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Сирий dir-скан kubescape для `…/k8s`-кореня без білдабельного `kustomization.yaml`.
|
|
231
|
+
* @param {string} kubescapePath абсолютний шлях до бінарника kubescape
|
|
232
|
+
* @param {string} dir абсолютний шлях до `…/k8s`
|
|
233
|
+
* @param {string[]} exceptionsArgs `['--exceptions', '<file>']` або `[]`
|
|
234
|
+
* @returns {number} 0 при успіху, 127 якщо kubescape зник з PATH, інакше код процесу
|
|
235
|
+
*/
|
|
236
|
+
function scanRawK8sDir(kubescapePath, dir, exceptionsArgs) {
|
|
237
|
+
console.log(`run-k8s: kubescape scan ${dir} (без kustomization — сирий dir-скан)`)
|
|
238
|
+
const r = spawnSync(kubescapePath, ['scan', dir, '--severity-threshold', 'high', ...exceptionsArgs], {
|
|
239
|
+
stdio: 'inherit',
|
|
240
|
+
shell: false
|
|
241
|
+
})
|
|
242
|
+
if (r.error && 'code' in r.error && r.error.code === 'ENOENT') {
|
|
243
|
+
console.error(KUBESCAPE_MISSING_HINT)
|
|
244
|
+
return 127
|
|
245
|
+
}
|
|
246
|
+
return r.status ?? 1
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Скан kubescape'ом зібраних kustomize-маніфестів: для кожного `kustomization.yaml`-каталогу
|
|
251
|
+
* `kubectl kustomize <dir>` → `kubescape scan <tmp>`.
|
|
252
|
+
* @param {string} kubectlPath абсолютний шлях до бінарника kubectl
|
|
253
|
+
* @param {string} kubescapePath абсолютний шлях до бінарника kubescape
|
|
254
|
+
* @param {string[]} kdirs абсолютні шляхи каталогів з білдабельним `kustomization.yaml`
|
|
255
|
+
* @param {string[]} exceptionsArgs `['--exceptions', '<file>']` або `[]`
|
|
256
|
+
* @returns {number} 0 при успіху, 127 якщо kubescape зник з PATH, інакше код невдалого процесу
|
|
257
|
+
*/
|
|
258
|
+
function scanKustomizeK8sDirs(kubectlPath, kubescapePath, kdirs, exceptionsArgs) {
|
|
259
|
+
for (const kdir of kdirs) {
|
|
260
|
+
console.log(`run-k8s: kubectl kustomize ${kdir} | kubescape scan <tmp>`)
|
|
261
|
+
const build = runKustomizeBuild(kubectlPath, kdir)
|
|
262
|
+
if (build.status !== 0) return build.status
|
|
263
|
+
const ks = runKubescapeManifest(kubescapePath, build.stdout, exceptionsArgs)
|
|
264
|
+
if (ks.enoent) {
|
|
265
|
+
console.error(KUBESCAPE_MISSING_HINT)
|
|
266
|
+
return 127
|
|
267
|
+
}
|
|
268
|
+
if (ks.status !== 0) return ks.status
|
|
269
|
+
}
|
|
270
|
+
return 0
|
|
271
|
+
}
|
|
272
|
+
|
|
226
273
|
/**
|
|
227
274
|
* Запускає kubescape по зібраному kustomize-маніфесту для кожного `…/k8s`-кореня. Для кожного
|
|
228
275
|
* dir-у з `kustomization.yaml` (крім `kind: Component`) робимо `kubectl kustomize <dir>` і
|
|
@@ -248,23 +295,15 @@ async function runKubescape(dirs, root) {
|
|
|
248
295
|
}
|
|
249
296
|
const kubescapePath = resolveCmd('kubescape')
|
|
250
297
|
if (!kubescapePath) {
|
|
251
|
-
console.error(
|
|
298
|
+
console.error(KUBESCAPE_MISSING_HINT)
|
|
252
299
|
return 127
|
|
253
300
|
}
|
|
254
301
|
let kubectlPath = null
|
|
255
302
|
for (const d of dirs) {
|
|
256
303
|
const kdirs = await findKustomizationDirs(d)
|
|
257
304
|
if (kdirs.length === 0) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
stdio: 'inherit',
|
|
261
|
-
shell: false
|
|
262
|
-
})
|
|
263
|
-
if (r.error && 'code' in r.error && r.error.code === 'ENOENT') {
|
|
264
|
-
console.error('kubescape не знайдено в PATH. Встанови з https://github.com/kubescape/kubescape#readme')
|
|
265
|
-
return 127
|
|
266
|
-
}
|
|
267
|
-
if (r.status !== 0) return r.status ?? 1
|
|
305
|
+
const rawStatus = scanRawK8sDir(kubescapePath, d, exceptionsArgs)
|
|
306
|
+
if (rawStatus !== 0) return rawStatus
|
|
268
307
|
continue
|
|
269
308
|
}
|
|
270
309
|
if (kubectlPath === null) {
|
|
@@ -274,17 +313,8 @@ async function runKubescape(dirs, root) {
|
|
|
274
313
|
return 127
|
|
275
314
|
}
|
|
276
315
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const build = runKustomizeBuild(kubectlPath, kdir)
|
|
280
|
-
if (build.status !== 0) return build.status
|
|
281
|
-
const ks = runKubescapeManifest(kubescapePath, build.stdout, exceptionsArgs)
|
|
282
|
-
if (ks.enoent) {
|
|
283
|
-
console.error('kubescape не знайдено в PATH. Встанови з https://github.com/kubescape/kubescape#readme')
|
|
284
|
-
return 127
|
|
285
|
-
}
|
|
286
|
-
if (ks.status !== 0) return ks.status
|
|
287
|
-
}
|
|
316
|
+
const buildStatus = scanKustomizeK8sDirs(kubectlPath, kubescapePath, kdirs, exceptionsArgs)
|
|
317
|
+
if (buildStatus !== 0) return buildStatus
|
|
288
318
|
}
|
|
289
319
|
return 0
|
|
290
320
|
}
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
* Stop-hook (батч-нормалізація чернеток); умови — ті самі, що для `capture`.
|
|
19
19
|
* - `.cursor/hooks.json` — **merge**: користувацькі hooks зберігаються; ADR stop
|
|
20
20
|
* entries додаються, коли правило `adr` увімкнене, і видаляються, коли вимкнене.
|
|
21
|
+
* - `.gitignore` — **merge** (лише з `adr`): дописує відсутні рядки з канонічного
|
|
22
|
+
* фрагмента `rules/adr/fix/hooks/template/.gitignore.snippet` (`node_modules/`, `dist/`,
|
|
23
|
+
* `*.secret`, логи capture/normalize, `.normalize-state`, `.normalize.lock`); існуючі
|
|
24
|
+
* рядки не перезаписуються.
|
|
21
25
|
*
|
|
22
26
|
* Опт-аут — `claude-config: false` у `.n-cursor.json`.
|
|
23
27
|
*/
|
|
@@ -51,6 +55,10 @@ const CURSOR_HOOKS_FILE = `${CURSOR_DIR}/hooks.json`
|
|
|
51
55
|
const ADR_HOOK_SCRIPT_NAME = 'capture-decisions.sh'
|
|
52
56
|
const ADR_NORMALIZE_HOOK_SCRIPT_NAME = 'normalize-decisions.sh'
|
|
53
57
|
const TEMPLATE_DIR_NAME = '.claude-template'
|
|
58
|
+
/** Відносний шлях до канонічного фрагмента `.gitignore` для ADR Stop-hook'ів у tarball пакета. */
|
|
59
|
+
export const ADR_GITIGNORE_SNIPPET_REL = 'rules/adr/fix/hooks/template/.gitignore.snippet'
|
|
60
|
+
const GITIGNORE_FILE = '.gitignore'
|
|
61
|
+
const EOL_RE = /\r?\n/u
|
|
54
62
|
|
|
55
63
|
/** Канонічна група hooks для ADR capture Stop-hook'а — додається в settings, коли `adr` у `rules`. */
|
|
56
64
|
const ADR_STOP_HOOK_GROUP = Object.freeze({
|
|
@@ -387,6 +395,60 @@ export function syncAdrNormalizeHookScript(projectRoot, templateDir) {
|
|
|
387
395
|
return syncHookScript(projectRoot, templateDir, ADR_NORMALIZE_HOOK_SCRIPT_NAME)
|
|
388
396
|
}
|
|
389
397
|
|
|
398
|
+
/**
|
|
399
|
+
* Повертає змістовні (не коментар, не порожній) рядки з text-фрагмента `.gitignore`.
|
|
400
|
+
* @param {string} raw вміст snippet-файлу
|
|
401
|
+
* @returns {string[]} нормалізовані рядки патернів
|
|
402
|
+
*/
|
|
403
|
+
function parseGitignoreFragmentLines(raw) {
|
|
404
|
+
return raw
|
|
405
|
+
.split(EOL_RE)
|
|
406
|
+
.map(l => l.trim())
|
|
407
|
+
.filter(l => l !== '' && !l.startsWith('#'))
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Дописує в кореневий `.gitignore` проєкту відсутні рядки з канонічного ADR-фрагмента.
|
|
412
|
+
* @param {string} projectRoot корінь проєкту-споживача
|
|
413
|
+
* @param {string} bundledPackageRoot корінь установленого `@nitra/cursor`
|
|
414
|
+
* @returns {Promise<{ written: boolean, path: string }>} чи змінено файл і відносний шлях
|
|
415
|
+
*/
|
|
416
|
+
export async function syncGitignoreAdrFragment(projectRoot, bundledPackageRoot) {
|
|
417
|
+
const snippetPath = join(bundledPackageRoot, ADR_GITIGNORE_SNIPPET_REL)
|
|
418
|
+
if (!existsSync(snippetPath)) {
|
|
419
|
+
return { written: false, path: '' }
|
|
420
|
+
}
|
|
421
|
+
const fragment = await readFile(snippetPath, 'utf8')
|
|
422
|
+
const required = parseGitignoreFragmentLines(fragment)
|
|
423
|
+
if (required.length === 0) {
|
|
424
|
+
return { written: false, path: '' }
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const destPath = join(projectRoot, GITIGNORE_FILE)
|
|
428
|
+
const existing = existsSync(destPath) ? await readFile(destPath, 'utf8') : ''
|
|
429
|
+
const existingLines = new Set(
|
|
430
|
+
existing
|
|
431
|
+
.split(EOL_RE)
|
|
432
|
+
.map(l => l.trim())
|
|
433
|
+
.filter(l => l !== '' && !l.startsWith('#'))
|
|
434
|
+
)
|
|
435
|
+
const missing = required.filter(l => !existingLines.has(l))
|
|
436
|
+
if (missing.length === 0) {
|
|
437
|
+
return { written: false, path: GITIGNORE_FILE }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const sectionHeader = '# @nitra/cursor (adr) — локальні артефакти Stop-hook, не коміти'
|
|
441
|
+
const hasHeader = existing.split(EOL_RE).some(l => l.trim() === sectionHeader)
|
|
442
|
+
const block = hasHeader ? missing.join('\n') : [sectionHeader, ...missing].join('\n')
|
|
443
|
+
let prefix = ''
|
|
444
|
+
if (existing.length > 0) {
|
|
445
|
+
prefix = existing.endsWith('\n') ? existing : `${existing}\n`
|
|
446
|
+
}
|
|
447
|
+
const next = `${prefix}${block}\n`
|
|
448
|
+
await writeFile(destPath, next, 'utf8')
|
|
449
|
+
return { written: true, path: GITIGNORE_FILE }
|
|
450
|
+
}
|
|
451
|
+
|
|
390
452
|
/**
|
|
391
453
|
* Копіює всі slash-команди з `templateDir/commands/` у `.claude/commands/`.
|
|
392
454
|
* Команди ідентифікуються тим, що вони лежать у темплейті — не перетинаються
|
|
@@ -422,7 +484,7 @@ export async function syncClaudeCommands(projectRoot, templateDir) {
|
|
|
422
484
|
* @param {string} options.bundledPackageRoot корінь установленого `@nitra/cursor`
|
|
423
485
|
* @param {boolean} options.enabled чи увімкнено sync (з `.n-cursor.json` `claude-config`)
|
|
424
486
|
* @param {string[]} [options.rules] список увімкнених правил із `.n-cursor.json` — впливає на ADR Stop-hook (`adr`)
|
|
425
|
-
* @returns {Promise<{ settings: boolean, cursorHooks: boolean, commands: string[], adrHook: boolean, adrNormalizeHook: boolean }>} прапорці записів settings/Cursor hooks/ADR-hook(s) та список
|
|
487
|
+
* @returns {Promise<{ settings: boolean, cursorHooks: boolean, commands: string[], adrHook: boolean, adrNormalizeHook: boolean, gitignoreAdr: boolean }>} прапорці записів settings/Cursor hooks/ADR-hook(s)/`.gitignore` та список slash-команд
|
|
426
488
|
*/
|
|
427
489
|
export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enabled, rules = [] }) {
|
|
428
490
|
if (!enabled) {
|
|
@@ -431,7 +493,8 @@ export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enable
|
|
|
431
493
|
cursorHooks: false,
|
|
432
494
|
commands: [],
|
|
433
495
|
adrHook: false,
|
|
434
|
-
adrNormalizeHook: false
|
|
496
|
+
adrNormalizeHook: false,
|
|
497
|
+
gitignoreAdr: false
|
|
435
498
|
}
|
|
436
499
|
}
|
|
437
500
|
const templateDir = join(bundledPackageRoot, TEMPLATE_DIR_NAME)
|
|
@@ -441,7 +504,8 @@ export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enable
|
|
|
441
504
|
cursorHooks: false,
|
|
442
505
|
commands: [],
|
|
443
506
|
adrHook: false,
|
|
444
|
-
adrNormalizeHook: false
|
|
507
|
+
adrNormalizeHook: false,
|
|
508
|
+
gitignoreAdr: false
|
|
445
509
|
}
|
|
446
510
|
}
|
|
447
511
|
const includeAdrHook = Array.isArray(rules) && rules.includes('adr')
|
|
@@ -449,6 +513,9 @@ export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enable
|
|
|
449
513
|
const adrNormalizeHook = includeAdrHook
|
|
450
514
|
? await syncAdrNormalizeHookScript(projectRoot, templateDir)
|
|
451
515
|
: { written: false, path: '' }
|
|
516
|
+
const gitignoreAdr = includeAdrHook
|
|
517
|
+
? await syncGitignoreAdrFragment(projectRoot, bundledPackageRoot)
|
|
518
|
+
: { written: false, path: '' }
|
|
452
519
|
const settings = await syncClaudeSettings(projectRoot, templateDir, { includeAdrHook })
|
|
453
520
|
const cursorHooks = await syncCursorHooksConfig(projectRoot, { includeAdrHook })
|
|
454
521
|
const commands = await syncClaudeCommands(projectRoot, templateDir)
|
|
@@ -457,6 +524,7 @@ export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enable
|
|
|
457
524
|
cursorHooks: cursorHooks.written,
|
|
458
525
|
commands,
|
|
459
526
|
adrHook: adrHook.written,
|
|
460
|
-
adrNormalizeHook: adrNormalizeHook.written
|
|
527
|
+
adrNormalizeHook: adrNormalizeHook.written,
|
|
528
|
+
gitignoreAdr: gitignoreAdr.written
|
|
461
529
|
}
|
|
462
530
|
}
|
|
@@ -4,7 +4,8 @@ import { basename, extname, join } from 'node:path'
|
|
|
4
4
|
|
|
5
5
|
const MD_LINK_RE = /\[([^\]]{1,200})\]\((\.\/[^)]{1,500})\)/g
|
|
6
6
|
const TEMPLATE_SEGMENT_RE = /\/template\//
|
|
7
|
-
|
|
7
|
+
/** Статичні regexp-літерали `^(.+)\.<slot>\.<ext>$` — без `RegExp(variable)`. */
|
|
8
|
+
const SLOT_SUFFIX_RES = [/^(.+)\.snippet\.[^.]+$/, /^(.+)\.deny\.[^.]+$/, /^(.+)\.contains\.[^.]+$/]
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* @param {string} filePath шлях до файлу
|
|
@@ -25,8 +26,8 @@ function langFromExt(filePath) {
|
|
|
25
26
|
* @returns {string} ім'я реального target-файлу
|
|
26
27
|
*/
|
|
27
28
|
function normalizeTargetName(fileBasename) {
|
|
28
|
-
for (const
|
|
29
|
-
const m = fileBasename.match(
|
|
29
|
+
for (const re of SLOT_SUFFIX_RES) {
|
|
30
|
+
const m = fileBasename.match(re)
|
|
30
31
|
if (m) return m[1]
|
|
31
32
|
}
|
|
32
33
|
return fileBasename
|
|
@@ -11,23 +11,17 @@ import { parse as parseToml } from 'smol-toml'
|
|
|
11
11
|
import { getMonorepoPackageRootDirs } from './workspaces.mjs'
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
|
|
14
|
+
* @typedef {'npm' | 'python'} PackageKind
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
18
|
* @typedef {object} PackageManifest
|
|
19
|
-
|
|
20
|
-
* @property {PackageKind} kind поле
|
|
19
|
+
* @property {PackageKind} kind тип маніфесту
|
|
21
20
|
* @property {string} ws відносний шлях воркспейсу (`'.'` для кореня)
|
|
22
|
-
|
|
23
21
|
* @property {string} manifestRel `package.json` | `pyproject.toml`
|
|
24
|
-
|
|
25
22
|
* @property {string | null} name ім'я пакета (npm / PyPI)
|
|
26
|
-
|
|
27
23
|
* @property {string | null} version semver-рядок
|
|
28
|
-
|
|
29
24
|
* @property {boolean} registryPublishable чи застосовується режим порівняння з реєстром
|
|
30
|
-
|
|
31
25
|
* @property {string[] | null} [npmFiles] лише npm: `files` з package.json
|
|
32
26
|
*/
|
|
33
27
|
|
|
@@ -47,27 +47,19 @@ function failConftestMissing() {
|
|
|
47
47
|
)
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
/**
|
|
51
51
|
* @typedef {object} ConftestViolation
|
|
52
|
-
|
|
53
52
|
* @property {string} filename абсолютний шлях до файла, що дав порушення (з output conftest)
|
|
54
|
-
|
|
55
53
|
* @property {string} message текст порушення (як у `deny` rego-пакета)
|
|
56
|
-
|
|
57
54
|
* @property {string} namespace namespace rego-пакета (наприклад `abie.base_deployment_preem`)
|
|
58
55
|
*/
|
|
59
56
|
|
|
60
|
-
|
|
57
|
+
/**
|
|
61
58
|
* @typedef {object} ConftestBatchOptions
|
|
62
|
-
|
|
63
59
|
* @property {string} policyDirRel шлях до підкаталогу `npm/policy/...` (наприклад `abie/base_deployment_preem`)
|
|
64
|
-
|
|
65
60
|
* @property {string} namespace повне імʼя rego-пакета (наприклад `abie.base_deployment_preem`)
|
|
66
|
-
|
|
67
61
|
* @property {string[]} files список абсолютних шляхів файлів для перевірки (порожній — повертаємо порожньо)
|
|
68
|
-
|
|
69
62
|
* @property {string[]} [extraArgs] додаткові аргументи для conftest (наприклад `--combine` для крос-документних правил)
|
|
70
|
-
|
|
71
63
|
* @property {object} [templateData] опціональне merged-дерево; серіалізується у JSON `{ "template": <data> }` і передається як `--data <tmpfile>` (cleanup після завершення)
|
|
72
64
|
*/
|
|
73
65
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* by target basename. For each <target>, returns whichever of snippet/deny/contains
|
|
4
4
|
* exist (parsed in native format by extension).
|
|
5
5
|
* @param {string} concernDir absolute path to fix/<concern>/ or policy/<concern>/
|
|
6
|
-
* @returns {Promise<Record<string, { snippet?:
|
|
6
|
+
* @returns {Promise<Record<string, { snippet?: unknown, deny?: unknown, contains?: unknown }>>}
|
|
7
7
|
*/
|
|
8
8
|
import { existsSync } from 'node:fs'
|
|
9
9
|
import { readdir, readFile, stat } from 'node:fs/promises'
|
|
@@ -11,7 +11,12 @@ import { basename as _basename, extname, join, relative } from 'node:path'
|
|
|
11
11
|
|
|
12
12
|
import { parse as parseToml } from 'smol-toml'
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/** `<target>.<slot>.<ext>` класифікатори — статичні regexp-літерали (без `RegExp(variable)`). */
|
|
15
|
+
const SLOT_CLASSIFIERS = [
|
|
16
|
+
{ slot: 'snippet', re: /^(?<target>.+)\.snippet\.[^.]+$/ },
|
|
17
|
+
{ slot: 'deny', re: /^(?<target>.+)\.deny\.[^.]+$/ },
|
|
18
|
+
{ slot: 'contains', re: /^(?<target>.+)\.contains\.[^.]+$/ }
|
|
19
|
+
]
|
|
15
20
|
const IDENT_RE = /^[a-zA-Z_$][\w$]*$/
|
|
16
21
|
const NEWLINE_RE = /\r?\n/
|
|
17
22
|
const LEADING_BANG_RE = /^!/
|
|
@@ -66,8 +71,8 @@ async function walk(dir, base = dir) {
|
|
|
66
71
|
*/
|
|
67
72
|
function classifyTemplateFile(relPath) {
|
|
68
73
|
// Try ".<slot>." suffix detection
|
|
69
|
-
for (const slot of
|
|
70
|
-
const m = relPath.match(
|
|
74
|
+
for (const { slot, re } of SLOT_CLASSIFIERS) {
|
|
75
|
+
const m = relPath.match(re)
|
|
71
76
|
if (m?.groups?.target) return { target: m.groups.target, slot }
|
|
72
77
|
}
|
|
73
78
|
// No slot suffix → text-only canon for the literal target name
|
|
@@ -241,7 +246,7 @@ export function checkTextSubset(actual, template, opts) {
|
|
|
241
246
|
|
|
242
247
|
/**
|
|
243
248
|
* @param {string} concernDir абсолютний шлях до fix/<concern>/ або policy/<concern>/
|
|
244
|
-
* @returns {Promise<Record<string, { snippet?:
|
|
249
|
+
* @returns {Promise<Record<string, { snippet?: unknown, deny?: unknown, contains?: unknown }>>} merged template-дерево, індексоване за target
|
|
245
250
|
*/
|
|
246
251
|
export async function loadTemplate(concernDir) {
|
|
247
252
|
const tplDir = join(concernDir, 'template')
|
|
@@ -196,8 +196,21 @@ const tr = {
|
|
|
196
196
|
Ні: { ru: 'No' }
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
### 5.1. Апострофи та спецсимволи в рядках перекладу
|
|
200
|
+
|
|
201
|
+
Український текст рідко містить апостроф у сенсі JS-рядка; у **англійських** перекладах часті скорочення з `'` (`today's`, `can't`, `don't`, `you're` тощо). Після кожної зміни `tr` переглянь результат на символи `'`, `"` та `\` і переконайся, що **роздільники рядка** парсяться коректно.
|
|
202
|
+
|
|
203
|
+
**Одинарні лапки.** Якщо англійський переклад містить апостроф і рядок обгорнуто в `'…'`:
|
|
204
|
+
|
|
205
|
+
- **Неправильно:** `'today\\'s route'` — подвійний backslash + кінець рядка, синтаксична помилка.
|
|
206
|
+
- **Правильно:** `'today\'s route'` — один backslash екранує апостроф.
|
|
207
|
+
- **Краще:** `"today's route"` — подвійні лапки, екранування не потрібне.
|
|
208
|
+
|
|
209
|
+
**Безпечне правило:** якщо в англійському перекладі є апостроф — переходь на **подвійні** лапки як роздільник рядка.
|
|
210
|
+
|
|
199
211
|
## 6. Після очистки
|
|
200
212
|
|
|
201
213
|
- Переконайся, що `kustomization.yaml` у кожній директорії `k8s/` не посилається на видалені overlay або файли.
|
|
202
214
|
- Пройдись `git grep` по репозиторію на залишки: `git grep -n -i -e '\bru\b' -e cr\.yandex -e country/ru -e prod-ru -e values-ru -e "'ya'"` — переглянь усі знахідки вручну, бо `ru` як слово може траплятися в легітимних контекстах (наприклад, `truncate`, `Aurum`, `cruft`). Видаляй лише ті входження, що належать ru-середовищу. `git grep` за замовчуванням пропускає невідстежувані шляхи, тож `node_modules/` у вихід не потрапить, поки воно у `.gitignore`.
|
|
203
215
|
- Перевір CI локально: `npx @nitra/cursor check abie` (якщо правило **abie** ввімкнене у проєкті).
|
|
216
|
+
- Після змін у вихідних файлах (особливо рядках перекладу) переконайся, що збірка **компілюється**: `bun vite build` у корені відповідного пакета (або там, де в проєкті налаштований Vite). Синтаксична помилка в об’єкті `tr` ламає всі три CI-пайплайни (android, ios, site) — локальний build ловить її до коміту.
|
package/skills/lint/SKILL.md
CHANGED
|
@@ -44,13 +44,13 @@ bun run lint
|
|
|
44
44
|
|
|
45
45
|
**Конфіги і коментарі, які потребують зупинки** (неповний список — будь-який аналог):
|
|
46
46
|
|
|
47
|
-
| Інструмент
|
|
48
|
-
|
|
|
49
|
-
| **jscpd**
|
|
50
|
-
| **cspell**
|
|
51
|
-
| **knip**
|
|
47
|
+
| Інструмент | Типові файли / зміни |
|
|
48
|
+
| ------------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
49
|
+
| **jscpd** | `.jscpd.json` → `ignore`, `minLines` |
|
|
50
|
+
| **cspell** | `.cspell.json` → `words`, `ignorePaths`; `.cspellignore` |
|
|
51
|
+
| **knip** | `knip.json` → `ignore`, `ignoreDependencies`, `ignoreBinaries`, `entry` |
|
|
52
52
|
| **oxlint / ESLint** | `.oxlintrc.json` → `ignorePatterns`; `eslint.config.js` → `ignores`; `eslint-disable` / `oxlint-disable` у коді |
|
|
53
|
-
| **інше**
|
|
53
|
+
| **інше** | `.v8rignore`, `.stylelintignore`, `.trufflehog-exclude`, розширення `ignores` у workflow-конфігах |
|
|
54
54
|
|
|
55
55
|
Політика узгоджена з **`.cursor/rules/`** (зокрема **n-js-lint**, **n-text**): виняток допустимий лише з **обґрунтованою** причиною, не як заміна рефакторингу для справжніх клонів / дублікатів.
|
|
56
56
|
|
|
@@ -64,12 +64,12 @@ bun run lint
|
|
|
64
64
|
|
|
65
65
|
**Варіанти відповіді** (мінімум такі; `allow_multiple: false`):
|
|
66
66
|
|
|
67
|
-
| id
|
|
68
|
-
|
|
|
69
|
-
| `refactor`
|
|
70
|
-
| `ignore-once` | **Точковий виняток у конфігу** — додати ignore/words/minLines з обґрунтуванням у коментарі PR/відповіді | Після вибору — мінімальна зміна конфігу + 1 речення **чому** це не рефакторинг
|
|
71
|
-
| `skip`
|
|
72
|
-
| `explain`
|
|
67
|
+
| id | label (українською) | Дія агента |
|
|
68
|
+
| ------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
|
69
|
+
| `refactor` | **Рефакторинг коду** — усунути дублікат / помилку в коді (рекомендовано за замовчуванням) | Рефакторинг; конфіг **не** чіпати |
|
|
70
|
+
| `ignore-once` | **Точковий виняток у конфігу** — додати ignore/words/minLines з обґрунтуванням у коментарі PR/відповіді | Після вибору — мінімальна зміна конфігу + 1 речення **чому** це не рефакторинг |
|
|
71
|
+
| `skip` | **Залишити як є** — не чіпати ні код, ні конфіг зараз | Не змінювати; у фінальному резюме — що лишилось червоним |
|
|
72
|
+
| `explain` | **Потрібні деталі** — поясни варіанти глибше | Розгорнути порівняння refactor vs ignore; **знову** запитати той самий набір варіантів |
|
|
73
73
|
|
|
74
74
|
Якщо користувач обрав **`ignore-once`** — у відповіді після зміни зафіксуй: який ключ конфігу змінено, який glob/слово додано, чому рефакторинг був недоречний (генерований код, формальний шаблон, легітимний термін без перекладу тощо).
|
|
75
75
|
|