@nitra/cursor 3.19.0 → 3.21.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.
Files changed (49) hide show
  1. package/.claude-template/hooks/capture-decisions.sh +1 -1
  2. package/.claude-template/hooks/normalize-decisions.sh +8 -4
  3. package/CHANGELOG.md +33 -0
  4. package/bin/n-cursor.js +53 -0
  5. package/package.json +1 -1
  6. package/rules/adr/adr.mdc +5 -5
  7. package/rules/adr/js/templates/hooks/.gitignore.snippet +1 -0
  8. package/rules/changelog/changelog.mdc +1 -1
  9. package/rules/changelog/js/consistency.mjs +69 -12
  10. package/rules/ci4/ci4.mdc +2 -2
  11. package/rules/docker/docker.mdc +3 -3
  12. package/rules/docker/js/lint.mjs +1 -1
  13. package/rules/docker/lib/docker-hadolint.mjs +27 -55
  14. package/rules/ga/lint/lint.mjs +18 -54
  15. package/rules/js-run/js/runtime.mjs +32 -0
  16. package/rules/js-run/js-run.mdc +6 -0
  17. package/rules/js-run/lib/temporal-scan.mjs +52 -0
  18. package/rules/k8s/lint/lint.mjs +3 -10
  19. package/rules/nginx-default-tpl/js/template.mjs +39 -1
  20. package/rules/nginx-default-tpl/nginx-default-tpl.mdc +3 -1
  21. package/rules/npm-module/js/skill_meta.mjs +12 -0
  22. package/rules/npm-module/npm-module.mdc +1 -1
  23. package/rules/npm-module/policy/npm_publish_yml/target.json +1 -0
  24. package/rules/rego/lint/lint.mjs +10 -55
  25. package/rules/release/change.mjs +34 -5
  26. package/rules/release/lib/change-file.mjs +26 -11
  27. package/rules/text/lint/lint.mjs +11 -40
  28. package/rules/worktree/policy/vscode_settings/target.json +5 -0
  29. package/rules/worktree/policy/vscode_settings/template/settings.json.snippet.json +8 -0
  30. package/rules/worktree/policy/zed_settings/target.json +5 -0
  31. package/rules/worktree/policy/zed_settings/template/settings.json.snippet.json +12 -0
  32. package/rules/worktree/worktree.mdc +52 -0
  33. package/schemas/target.json +5 -0
  34. package/scripts/lib/assert-project-root.mjs +80 -0
  35. package/scripts/lib/ensure-tool.mjs +352 -0
  36. package/scripts/lib/root-notice.mjs +64 -0
  37. package/scripts/lib/run-conftest-batch.mjs +6 -28
  38. package/scripts/lib/run-rule.mjs +61 -5
  39. package/scripts/lib/skill-meta.mjs +16 -2
  40. package/scripts/lib/template.mjs +29 -3
  41. package/scripts/lib/worktree-notice.mjs +121 -73
  42. package/scripts/sync-claude-config.mjs +2 -2
  43. package/skills/fix/SKILL.md +4 -4
  44. package/skills/llm-patch/meta.json +1 -1
  45. package/skills/publish-telegram/meta.json +1 -1
  46. package/skills/start-check/meta.json +1 -1
  47. package/skills/worktree/meta.json +1 -1
  48. package/types/bin/n-cursor.d.ts +1 -1
  49. package/rules/npm-module/policy/npm_publish_yml/npm_publish_yml.rego +0 -87
@@ -1,6 +1,6 @@
1
1
  /**
2
- * CLI-обгортка над канонічним `lint-ga` (ga.mdc): робить preflight на `shellcheck`, `uv` (для `uvx`)
3
- * і `conftest` (для rego-полісі у `check-ga`),
2
+ * CLI-обгортка над канонічним `lint-ga` (ga.mdc): авто-встановлює `shellcheck` і `conftest`
3
+ * через `ensureTool` (brew/scoop/GitHub Release per-platform), перевіряє наявність `uv` (для `uvx`),
4
4
  * тоді послідовно виконує `bunx github-actionlint`, `uvx zizmor --offline --collect=workflows .` і
5
5
  * делегує до `rules/ga/fix.mjs::check()` — там і Rego-частина (через `runConftestBatch`),
6
6
  * і JS cross-file перевірки правил `ga.mdc`.
@@ -13,14 +13,10 @@
13
13
  *
14
14
  * Без preflight `actionlint` (через `bunx github-actionlint`) мовчки пропускає shell-перевірки в
15
15
  * `run:` блоках, коли `shellcheck` відсутній у PATH; локально `bun lint-ga` лишається зеленим, а CI
16
- * на ubuntu-latest (де shellcheck передвстановлений) падає. Preflight робить цю різницю явною.
16
+ * на ubuntu-latest (де shellcheck передвстановлений) падає. ensureTool('shellcheck') усуває цю різницю.
17
17
  *
18
- * `uv` потрібен для `uvx zizmor`. Якщо його нема — `uvx zizmor` падає неінформативно («command not
19
- * found»); підказка з командою встановлення коротша й корисніша.
20
- *
21
- * `conftest` потрібен для `rules/ga/fix.mjs::runAllGaRego` (`runConftestBatch`). Без preflight крок
22
- * check-ga кидає виняток, який глобальний `catch` у `bin/n-cursor.js` раніше ковтав без логу —
23
- * локально це виглядало як мовчазний exit 1.
18
+ * `uv` потрібен для `uvx zizmor`. Якщо його нема — `uvx zizmor` падає неінформативно; підказка
19
+ * з командою встановлення коротша й корисніша. `uv` не в реєстрі ensureTool → hint-only.
24
20
  *
25
21
  * Експортовано окремо `runLintGaCli` — використовується з `bin/n-cursor.js` як підкоманда `lint-ga`.
26
22
  *
@@ -33,6 +29,7 @@ import { check as checkGa } from '../js/workflows.mjs'
33
29
  import { resolveCmd } from '../../../scripts/utils/resolve-cmd.mjs'
34
30
  import { runLintStep } from '../../../scripts/lib/run-lint-step.mjs'
35
31
  import { runStandardLint } from '../../../scripts/lib/run-standard-lint.mjs'
32
+ import { ensureTool } from '../../../scripts/lib/ensure-tool.mjs'
36
33
 
37
34
  /**
38
35
  * Опис залежності preflight-ом: бінарник, для чого потрібен, і команди встановлення.
@@ -44,23 +41,6 @@ import { runStandardLint } from '../../../scripts/lib/run-standard-lint.mjs'
44
41
  * @property {string} successMsg повідомлення на pass-шлях
45
42
  */
46
43
 
47
- /** @type {PreflightDep} */
48
- const SHELLCHECK_PREFLIGHT = {
49
- bin: 'shellcheck',
50
- winBins: ['shellcheck.exe'],
51
- explanation: [
52
- 'Без нього `actionlint` пропускає shell-перевірки в run: блоках,',
53
- 'тож локальний прогін зеленіє, а CI на ubuntu-latest (де shellcheck',
54
- 'передвстановлений) падає на тих самих workflow.'
55
- ].join('\n '),
56
- install: [
57
- 'macOS: brew install shellcheck',
58
- 'Debian/Ubuntu: sudo apt-get install -y shellcheck',
59
- 'Arch: sudo pacman -S shellcheck'
60
- ],
61
- successMsg: '✅ shellcheck знайдено в PATH — actionlint виконуватиме SC-правила, як у CI'
62
- }
63
-
64
44
  /** @type {PreflightDep} */
65
45
  const UV_PREFLIGHT = {
66
46
  bin: 'uv',
@@ -77,18 +57,6 @@ const UV_PREFLIGHT = {
77
57
  successMsg: '✅ uv знайдено в PATH — uvx zizmor запуститься'
78
58
  }
79
59
 
80
- /** @type {PreflightDep} */
81
- const CONFTEST_PREFLIGHT = {
82
- bin: 'conftest',
83
- winBins: ['conftest.exe'],
84
- explanation: [
85
- 'Без нього не запускається пер-документна валідація через rego-полісі (npm/rules/*/policy/)',
86
- 'у кроці check-ga — `runConftestBatch` завершується hard-fail.'
87
- ].join('\n '),
88
- install: ['macOS: brew install conftest', 'Universal: https://www.conftest.dev/install/'],
89
- successMsg: '✅ conftest знайдено в PATH — check-ga виконає rego-полісі через runConftestBatch'
90
- }
91
-
92
60
  /**
93
61
  * Шукає бінарник у PATH з урахуванням Windows: спершу `winBins`, потім `bin`.
94
62
  * @param {PreflightDep} dep опис залежності
@@ -134,29 +102,25 @@ function preflight(dep) {
134
102
  }
135
103
 
136
104
  /**
137
- * Виконує канонічний `lint-ga` з preflight-перевірками і делегує до `check-ga.check()`.
105
+ * Виконує канонічний `lint-ga` авто-встановлює shellcheck/conftest, перевіряє uv, запускає actionlint/zizmor/check-ga.
138
106
  *
139
107
  * Послідовність:
140
- * 1) preflight: `shellcheck`, `uv` (для `uvx zizmor`) і `conftest` (для check-ga); відсутній → exit 1;
141
- * 2) `bunx github-actionlint`;
142
- * 3) `uvx zizmor --offline --collect=workflows .`;
143
- * 4) `rules/ga/fix.mjs::check()` Rego-полісі (батч conftest з `npm/policy/ga/`) + JS cross-file
108
+ * 1) ensureTool: `shellcheck` і `conftest` (авто-install або hard-fail);
109
+ * 2) preflight: `uv` (для `uvx zizmor`) — hint-only, без авто-install;
110
+ * 3) `bunx github-actionlint`;
111
+ * 4) `uvx zizmor --offline --collect=workflows .`;
112
+ * 5) `rules/ga/fix.mjs::check()` — Rego-полісі (батч conftest з `npm/policy/ga/`) + JS cross-file
144
113
  * перевірки правил `ga.mdc`. Це **те саме**, що робить `npx \@nitra/cursor check ga`, тож
145
114
  * `lint-ga` тепер є суперсетом перевірки правила: external-tools + check.
146
- *
147
- * Якщо хоча б один preflight не пройшов — виходимо одразу з кодом 1, **до** запуску actionlint/zizmor,
148
- * бо їхні власні повідомлення про відсутність залежностей менш інформативні (особливо для shellcheck —
149
- * actionlint мовчки пропускає SC-правила; ця перевірка — головний сенс обгортки).
150
- *
151
- * Першу помилку від actionlint/zizmor/check повертаємо як код виходу; наступні кроки не запускаються.
152
115
  * @returns {Promise<number>} 0 — все OK, інакше — код першого кроку, що впав
153
116
  */
154
117
  async function runLintGaSteps() {
155
- let preflightOk = true
156
- for (const dep of [SHELLCHECK_PREFLIGHT, UV_PREFLIGHT, CONFTEST_PREFLIGHT]) {
157
- if (!preflight(dep)) preflightOk = false
158
- }
159
- if (!preflightOk) return 1
118
+ // Auto-install: throws on failure → propagates as exit 1 from runStandardLint
119
+ ensureTool('shellcheck')
120
+ ensureTool('conftest')
121
+
122
+ // uv is hint-only (not in auto-install registry)
123
+ if (!preflight(UV_PREFLIGHT)) return 1
160
124
 
161
125
  const actionlintCode = runLintStep('actionlint', 'bunx', ['github-actionlint'])
162
126
  if (actionlintCode !== 0) return actionlintCode
@@ -26,6 +26,8 @@
26
26
  * - «Паузи через setTimeout»: `new Promise(resolve => setTimeout(resolve, ms))` (з/без `await`)
27
27
  * треба замінити на `await setTimeout(ms)` з `node:timers/promises`
28
28
  * (див. `utils/promise-settimeout-scan.mjs`);
29
+ * - «Temporal у Bun runtime»: identifier `Temporal` заборонений, бо поточний Bun runtime
30
+ * не має глобального Temporal API (див. `utils/temporal-scan.mjs`);
29
31
  * - «jsconfig.json»: у backend-пакеті з каталогом `src/` у корені має бути `jsconfig.json`,
30
32
  * вміст якого збігається з каноном js-run.mdc (NodeNext і include на дерево `src`).
31
33
  *
@@ -50,6 +52,7 @@ import {
50
52
  } from '../lib/conn-imports-scan.mjs'
51
53
  import { loadCursorIgnorePaths } from '../../../scripts/lib/load-cursor-config.mjs'
52
54
  import { findPromiseSetTimeoutInText, isPromiseSetTimeoutScanSourceFile } from '../lib/promise-settimeout-scan.mjs'
55
+ import { findTemporalUsageInText, isTemporalScanSourceFile } from '../lib/temporal-scan.mjs'
53
56
  import { walkDir } from '../../../scripts/utils/walkDir.mjs'
54
57
  import { getMonorepoPackageRootDirs } from '../../../scripts/lib/workspaces.mjs'
55
58
 
@@ -313,6 +316,30 @@ async function checkPromiseSetTimeoutPause(absPackageRoot, sourcePaths, label, f
313
316
  return violations
314
317
  }
315
318
 
319
+ /**
320
+ * Сканує джерела пакета на `Temporal`, який у Bun runtime ще недоступний.
321
+ * @param {string} absPackageRoot абсолютний корінь пакета
322
+ * @param {string[]} sourcePaths абсолютні шляхи до файлів
323
+ * @param {string} label префікс повідомлення `[<pkg>] `
324
+ * @param {(msg: string) => void} fail callback при помилці
325
+ * @returns {Promise<number>} кількість порушень
326
+ */
327
+ async function checkTemporalUsage(absPackageRoot, sourcePaths, label, fail) {
328
+ let violations = 0
329
+ for (const absPath of sourcePaths) {
330
+ const rel = relPosix(absPackageRoot, absPath)
331
+ if (!isTemporalScanSourceFile(rel)) continue
332
+ const content = await readFile(absPath, 'utf8')
333
+ for (const v of findTemporalUsageInText(content, rel)) {
334
+ violations++
335
+ fail(
336
+ `${label}${rel}:${v.line} — Temporal API заборонений у Bun runtime; використовуй Date або інʼєктований timestamp`
337
+ )
338
+ }
339
+ }
340
+ return violations
341
+ }
342
+
316
343
  /**
317
344
  * Перевіряє відповідність правилам js-run.mdc для одного workspace-пакета.
318
345
  * @param {string} rootDir відносний шлях workspace (не `'.'`)
@@ -371,6 +398,11 @@ async function checkWorkspacePackage(rootDir, ignorePaths, fail, passFn, cwd) {
371
398
  passFn(`${label}немає 'new Promise(r => setTimeout(r, ms))' — паузи через 'node:timers/promises'`)
372
399
  }
373
400
 
401
+ const temporalViolations = await checkTemporalUsage(absPackageRoot, sourcePaths, label, fail)
402
+ if (temporalViolations === 0) {
403
+ passFn(`${label}немає Temporal API у Bun runtime-коді`)
404
+ }
405
+
374
406
  checkOtelConfigmap(rootDir, passFn, cwd)
375
407
  }
376
408
 
@@ -30,6 +30,12 @@ version: '1.11'
30
30
 
31
31
  Це **не** стосується поля `engines.node` (мінімальна версія Node для сумісності інструментів) і **не** стосується frontend-пакетів з `vite` у `devDependencies`.
32
32
 
33
+ ## Temporal API
34
+
35
+ У backend/Bun runtime-коді **не використовуй `Temporal`** (`Temporal.Now`, `Temporal.Instant`, імпорти з polyfill тощо). Поточний Bun runtime ще не має глобального `Temporal` (`typeof Temporal === "undefined"`), тому агентам треба лишатися на сумісному `Date` API або передавати timestamp у чисті функції через параметр.
36
+
37
+ Перевірка `npx @nitra/cursor fix js-run` сканує JS/TS AST і падає на identifier `Temporal` у backend workspace-коді.
38
+
33
39
  Канон заборонених патернів у `scripts`: [package.json.deny.json](./policy/package_json/template/package.json.deny.json) (`scriptsForbidden`).
34
40
 
35
41
  ## Структура проекту
@@ -0,0 +1,52 @@
1
+ /**
2
+ * AST-сканер заборони `Temporal` у Bun runtime-коді.
3
+ *
4
+ * Bun 1.3.x ще не має глобального `Temporal`, тому правило js-run забороняє
5
+ * будь-який identifier `Temporal` у backend workspace-коді. Заборона свідомо
6
+ * охоплює polyfill/import-сценарії: у цьому репозиторії канон для часу лишається
7
+ * через `Date` або ін'єкцію timestamp у чисті функції.
8
+ */
9
+ import {
10
+ normalizeSnippet,
11
+ offsetToLine,
12
+ parseProgramOrNull,
13
+ walkAstWithAncestors
14
+ } from '../../../scripts/utils/ast-scan-utils.mjs'
15
+
16
+ const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
17
+
18
+ /**
19
+ * Знаходить використання identifier `Temporal` у тексті.
20
+ * @param {string} content вихідний код
21
+ * @param {string} [virtualPath] шлях для вибору `lang` (наприклад `pkg/src/foo.ts`)
22
+ * @returns {{ line: number, snippet: string }[]} список порушень
23
+ */
24
+ export function findTemporalUsageInText(content, virtualPath = 'scan.ts') {
25
+ const program = parseProgramOrNull(content, virtualPath)
26
+ if (!program) return []
27
+ /** @type {{ line: number, snippet: string }[]} */
28
+ const out = []
29
+ /** @type {Set<string>} */
30
+ const seen = new Set()
31
+ walkAstWithAncestors(program, [], node => {
32
+ if (node.type !== 'Identifier' || node.name !== 'Temporal') return
33
+ const key = `${node.start}:${node.end}`
34
+ if (seen.has(key)) return
35
+ seen.add(key)
36
+ out.push({
37
+ line: offsetToLine(content, node.start),
38
+ snippet: normalizeSnippet(content.slice(node.start, node.end))
39
+ })
40
+ })
41
+ return out
42
+ }
43
+
44
+ /**
45
+ * Чи сканувати цей файл за розширенням (JS/TS-сім'я, виключно з `.d.ts`).
46
+ * @param {string} relativePath відносний шлях до файлу
47
+ * @returns {boolean} `true`, якщо розширення підходить для сканування
48
+ */
49
+ export function isTemporalScanSourceFile(relativePath) {
50
+ if (!SOURCE_FILE_RE.test(relativePath)) return false
51
+ return !relativePath.endsWith('.d.ts')
52
+ }
@@ -24,6 +24,7 @@ import { basename, dirname, join, relative } from 'node:path'
24
24
  import { parse } from 'yaml'
25
25
 
26
26
  import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
27
+ import { ensureTool } from '../../../scripts/lib/ensure-tool.mjs'
27
28
  import { loadCursorIgnorePaths } from '../../../scripts/lib/load-cursor-config.mjs'
28
29
  import { resolveCmd } from '../../../scripts/utils/resolve-cmd.mjs'
29
30
  import { walkDir } from '../../../scripts/utils/walkDir.mjs'
@@ -123,11 +124,7 @@ function runKubeconform(dirs) {
123
124
  '-ignore-missing-schemas',
124
125
  ...dirs
125
126
  ]
126
- const kubeconformPath = resolveCmd('kubeconform')
127
- if (!kubeconformPath) {
128
- console.error('kubeconform не знайдено в PATH. Встанови з https://github.com/yannh/kubeconform#readme')
129
- return 127
130
- }
127
+ const kubeconformPath = ensureTool('kubeconform')
131
128
  const r = spawnSync(kubeconformPath, args, { stdio: 'inherit', shell: false })
132
129
  if (r.error && 'code' in r.error && r.error.code === 'ENOENT') {
133
130
  console.error('kubeconform не знайдено в PATH. Встанови з https://github.com/yannh/kubeconform#readme')
@@ -297,11 +294,7 @@ async function runKubescape(dirs, root) {
297
294
  if (exceptionsArgs.length > 0) {
298
295
  console.log(`run-k8s: kubescape exceptions — ${KUBESCAPE_EXCEPTIONS_FILE}`)
299
296
  }
300
- const kubescapePath = resolveCmd('kubescape')
301
- if (!kubescapePath) {
302
- console.error(KUBESCAPE_MISSING_HINT)
303
- return 127
304
- }
297
+ const kubescapePath = ensureTool('kubescape')
305
298
  let kubectlPath = null
306
299
  for (const d of dirs) {
307
300
  const kdirs = await findKustomizationDirs(d)
@@ -12,6 +12,9 @@
12
12
  * У дереві від **cwd** усі **default.tpl.conf** стають **default.conf.template**: перейменування, або
13
13
  * якщо **default.conf.template** уже є — він перезаписується вмістом **default.tpl.conf**, після чого
14
14
  * **default.tpl.conf** видаляється. Якщо після міграції шаблону немає — перевірка пропускається (0).
15
+ *
16
+ * Невалідна директива **`error_log off;`** (nginx трактує "off" як ім'я файлу `/etc/nginx/off` і падає під
17
+ * readOnlyRootFilesystem) автоматично замінюється на **`error_log /dev/null crit;`** у кожному шаблоні.
15
18
  */
16
19
  import { existsSync } from 'node:fs'
17
20
  import { readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
@@ -33,6 +36,11 @@ const FIND_CMD_RE = /\bfind\b/u
33
36
  const GZIP_CMD_RE = /\bgzip\b/u
34
37
  const GZIP_EXTENSION_RE = /\*\.(?:js|css)/u
35
38
 
39
+ // `error_log off;` — НЕ валідний nginx: "off" трактується як ім'я файлу (/etc/nginx/off)
40
+ // і падає під readOnlyRootFilesystem. /dev/null — writable device, тому канон — `error_log /dev/null crit;`.
41
+ const ERROR_LOG_OFF_RE = /error_log\s+off\s*;/gu
42
+ const ERROR_LOG_CANONICAL = 'error_log /dev/null crit;'
43
+
36
44
  /**
37
45
  * Збирає абсолютні шляхи до **default.conf.template** у репозиторії; будь-який сегмент
38
46
  * `fixtures/` у шляху виключається — це тестові артефакти (як `tests/fixtures/` так і
@@ -98,6 +106,28 @@ export async function migrateDefaultTplConfFiles(root, ignorePaths = []) {
98
106
  return { renamed, overwritten }
99
107
  }
100
108
 
109
+ /**
110
+ * Замінює невалідну директиву `error_log off;` на `error_log /dev/null crit;` у всіх
111
+ * **default.conf.template** від `root`. `error_log off;` — НЕ валідний nginx: "off" трактується
112
+ * як ім'я файлу (`/etc/nginx/off`) і падає під readOnlyRootFilesystem; `/dev/null` — writable device.
113
+ * @param {string} root корінь обходу (зазвичай cwd репозиторію)
114
+ * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
115
+ * @returns {Promise<string[]>} відносні шляхи виправлених шаблонів (для звіту)
116
+ */
117
+ export async function migrateErrorLogOffDirective(root, ignorePaths = []) {
118
+ const templates = await findDefaultConfTemplatePaths(root, ignorePaths)
119
+ /** @type {string[]} */
120
+ const fixed = []
121
+ for (const abs of templates) {
122
+ const body = await readFile(abs, 'utf8')
123
+ const next = body.replace(ERROR_LOG_OFF_RE, ERROR_LOG_CANONICAL)
124
+ if (next === body) continue
125
+ await writeFile(abs, next, 'utf8')
126
+ fixed.push(relative(root, abs).replaceAll('\\', '/') || abs)
127
+ }
128
+ return fixed
129
+ }
130
+
101
131
  /**
102
132
  * Імена змінних з ini (рядки KEY=value, без коментарів і порожніх).
103
133
  * @param {string} iniText вміст *.ini
@@ -131,7 +161,10 @@ export function nginxTemplateViolations(content) {
131
161
  { msg: 'відсутнє listen 8080', ok: c => c.includes('listen 8080') },
132
162
  { msg: 'відсутнє server_name _', ok: c => c.includes('server_name _') },
133
163
  { msg: 'відсутнє access_log off', ok: c => c.includes('access_log off') },
134
- { msg: 'відсутнє error_log off', ok: c => c.includes('error_log off') },
164
+ {
165
+ msg: 'відсутнє error_log /dev/null crit (error_log off — НЕ валідний nginx, падає під readOnlyRootFilesystem)',
166
+ ok: c => c.includes('error_log /dev/null crit')
167
+ },
135
168
  { msg: 'відсутнє root /usr/share/nginx/html', ok: c => c.includes('root /usr/share/nginx/html') },
136
169
  {
137
170
  msg: 'location /healthz має повертати healthy (див. nginx-default-tpl.mdc)',
@@ -416,6 +449,11 @@ export async function check(cwd = process.cwd()) {
416
449
  pass(`Перезаписано default.conf.template змістом default.tpl.conf: ${rel}`)
417
450
  }
418
451
 
452
+ const errorLogFixed = await migrateErrorLogOffDirective(root, ignorePaths)
453
+ for (const rel of errorLogFixed) {
454
+ pass(`Замінено невалідне error_log off; → error_log /dev/null crit; у ${rel}`)
455
+ }
456
+
419
457
  const templates = await findDefaultConfTemplatePaths(root, ignorePaths)
420
458
 
421
459
  if (templates.length === 0) {
@@ -19,7 +19,9 @@ server {
19
19
 
20
20
  # disable all log
21
21
  access_log off;
22
- error_log off;
22
+ # `error_log off;` — НЕ валідний nginx: "off" трактується як ім'я файлу (/etc/nginx/off)
23
+ # і падає під readOnlyRootFilesystem. /dev/null — writable device.
24
+ error_log /dev/null crit;
23
25
 
24
26
  # This would be the directory where your Vite app's static files are stored at
25
27
  root /usr/share/nginx/html;
@@ -4,6 +4,8 @@
4
4
  * Кожен `npm/skills/<id>/` має містити валідний `meta.json`:
5
5
  * - `worktree` присутнє і boolean;
6
6
  * - `auto` (якщо присутнє) — розпізнане (`"завжди"` або непорожній масив рядків);
7
+ * - `requireRoot` (якщо присутнє) — boolean; не може бути `false` при `worktree:true`
8
+ * (worktree вже вимагає кореня — суперечність вводить в оману);
7
9
  * - залишковий `auto.md` заборонено (міграція на meta.json завершена).
8
10
  *
9
11
  * Концерн застосовний лише в репо самого пакета (де є `npm/skills/`); у споживача
@@ -52,6 +54,16 @@ export function check(cwd = process.cwd()) {
52
54
  reporter.fail(`skills/${id}: meta.json.auto нерозпізнане — очікується "завжди" або непорожній масив правил`)
53
55
  skillOk = false
54
56
  }
57
+ if (raw.requireRoot !== undefined && typeof raw.requireRoot !== 'boolean') {
58
+ reporter.fail(`skills/${id}: meta.json.requireRoot має бути boolean`)
59
+ skillOk = false
60
+ }
61
+ if (raw.worktree === true && raw.requireRoot === false) {
62
+ reporter.fail(
63
+ `skills/${id}: requireRoot:false суперечить worktree:true (worktree вже вимагає кореня — прибери поле)`
64
+ )
65
+ skillOk = false
66
+ }
55
67
  if (skillOk) {
56
68
  reporter.pass(`skills/${id}: meta.json валідний`)
57
69
  }
@@ -65,7 +65,7 @@ bunx -p typescript tsc src/**/*.js --declaration --allowJs --emitDeclarationOnly
65
65
 
66
66
  **`npm-publish.yml`:** push у **`main`**, **`on.push.paths`** з **`npm/**`**, **`JS-DevTools/npm-publish@v4.1.5`**, **`with.package: npm/package.json`**, **`permissions.id-token: write`** (OIDC на npm).
67
67
 
68
- Workflow робить **release + publish** одним job (`release-publish`): крок **`Release (bump + CHANGELOG + tag)`** (`node npm/bin/n-cursor.js release` — агрегує change-файли, bump `version`, генерує секцію `CHANGELOG.md`, ставить git-тег) виконується **перед** публікацією. Тому потрібні **`permissions.contents: write`** і **`persist-credentials: true`** з **`fetch-depth: 0`** на `checkout` (release пушить commit-back версії та тег), а також локальний composite **`./.github/actions/setup-bun-deps`** і крок `Configure git identity`. Це узгоджено з **`n-changelog`**: `version`/`CHANGELOG.md` змінює лише `n-cursor release` у CI на `main`. Програмна перевірка (`npm_module.npm_publish_yml`) лишається subset-of enforce-ить `on.push.paths`/`branches`, `id-token: write` і наявність publish-кроку; додаткові кроки (release, git identity) дозволені.
68
+ Workflow робить **release + publish** одним job (`release-publish`): крок **`Release (bump + CHANGELOG + tag)`** (`node npm/bin/n-cursor.js release` — агрегує change-файли, bump `version`, генерує секцію `CHANGELOG.md`, ставить git-тег) виконується **перед** публікацією. Тому потрібні **`permissions.contents: write`** і **`persist-credentials: true`** з **`fetch-depth: 0`** на `checkout` (release пушить commit-back версії та тег), а також локальний composite **`./.github/actions/setup-bun-deps`** і крок `Configure git identity`. Це узгоджено з **`n-changelog`**: `version`/`CHANGELOG.md` змінює лише `n-cursor release` у CI на `main`. Програмна перевірка (`npm_module.npm_publish_yml`) звіряє **весь канонічний сніпет** напряму (`target.json:"check":"template"`, generic deep-subset): усі поля й кроки сніпета (`on.push.paths`/`branches`, `concurrency`, `permissions.contents/id-token`, `checkout` з `persist-credentials/fetch-depth`, `setup-bun-deps`, `Configure git identity`, `Release`, publish-крок) **обовʼязкові**; зайві кроки/поля дозволені (subset-of), масиви матчаться за наявністю (порядок кроків не важить). Сніпет — єдине джерело істини: його редагування одразу змінює enforce, без правок rego й без міграторів.
69
69
 
70
70
  - Канон: [npm-publish.yml.snippet.yml](./policy/npm_publish_yml/template/npm-publish.yml.snippet.yml)
71
71
 
@@ -1,4 +1,5 @@
1
1
  {
2
2
  "$schema": "https://unpkg.com/@nitra/cursor/schemas/target.json",
3
+ "check": "template",
3
4
  "files": { "single": ".github/workflows/npm-publish.yml" }
4
5
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Лінт Rego-полісі (`conftest.mdc` + `rego.mdc`): preflight на `opa` і `regal`,
3
- * далі послідовно `opa check --strict`, `regal lint` і опційний
4
- * `conftest verify` (для `*_test.rego`-файлів) якщо conftest у PATH.
2
+ * Лінт Rego-полісі (`conftest.mdc` + `rego.mdc`): `ensureTool` на `opa` і `regal`
3
+ * (авто-install per-platform або hard-fail), далі послідовно `opa check --strict`,
4
+ * `regal lint` і опційний `conftest verify` (для `*_test.rego`-файлів) якщо conftest у PATH.
5
5
  *
6
6
  * Чому два-три інструменти:
7
7
  * - `opa check --strict` — компіляція з типами і строгим режимом (мертвий код, неоднозначні
@@ -13,10 +13,10 @@
13
13
  * Якщо conftest відсутній у PATH — пропускаємо без помилки (тести опційні в локальному середовищі;
14
14
  * у CI потрібно встановити conftest).
15
15
  *
16
- * Без preflight-у на бінарники лінт мовчки злетить з невиразним повідомленням від shell —
17
- * друкуємо явні install-hints (як це робить `lint-ga.mjs` для shellcheck/uv). `opa` додатково
18
- * потрібен VS Code-розширенню `tsandall.opa` (LSP, format-on-save через `opa fmt`) — деталі в
19
- * `mdc/rego.mdc`.
16
+ * `opa`/`regal` резолвляться через `ensureTool` (PATH кеш авто-install brew/scoop/GitHub
17
+ * Release hard-fail) без них лінт мовчки злетів би з невиразним повідомленням від shell.
18
+ * `opa` додатково потрібен VS Code-розширенню `tsandall.opa` (LSP, format-on-save через
19
+ * `opa fmt`) — деталі в `mdc/rego.mdc`.
20
20
  *
21
21
  * Цілі лінту: `npm/rules/` (де живуть Rego-полісі пакета `@nitra/cursor` — у
22
22
  * `npm/rules/<id>/policy/<concern>/`). Усі три інструменти приймають один шлях
@@ -31,47 +31,13 @@ import { existsSync } from 'node:fs'
31
31
  import { resolve } from 'node:path'
32
32
 
33
33
  import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
34
+ import { ensureTool } from '../../../scripts/lib/ensure-tool.mjs'
34
35
  import { resolveCmd } from '../../../scripts/utils/resolve-cmd.mjs'
35
36
  import { runStandardLint } from '../../../scripts/lib/run-standard-lint.mjs'
36
37
 
37
38
  /** Шляхи з Rego-полісі (відносно cwd). Існують не всі на ранніх стадіях — фільтруємо нижче. */
38
39
  const LINT_TARGETS = ['npm/rules']
39
40
 
40
- /**
41
- * Друкує підказку зі встановлення `opa` (потрібен для `opa check --strict` і VS Code LSP).
42
- * @returns {void}
43
- */
44
- function printOpaInstallHints() {
45
- process.stderr.write(
46
- [
47
- '❌ opa не знайдено в PATH.',
48
- ' Без нього не запускається `opa check --strict` (типи + мертвий код у *.rego),',
49
- ' і не працює VS Code-розширення `tsandall.opa` (LSP, format-on-save через opa fmt).',
50
- ' Встанови:',
51
- ' macOS: brew install opa',
52
- ' Universal: https://www.openpolicyagent.org/docs/latest/#1-download-opa',
53
- ''
54
- ].join('\n')
55
- )
56
- }
57
-
58
- /**
59
- * Друкує підказку зі встановлення `regal`.
60
- * @returns {void}
61
- */
62
- function printRegalInstallHints() {
63
- process.stderr.write(
64
- [
65
- '❌ regal не знайдено в PATH.',
66
- ' Без нього не перевіряється rego.v1 синтаксис у *.rego (правило `conftest`).',
67
- ' Встанови:',
68
- ' macOS: brew install regal',
69
- ' Universal: https://docs.styra.com/regal#installation',
70
- ''
71
- ].join('\n')
72
- )
73
- }
74
-
75
41
  /**
76
42
  * Запускає крок з відображенням команди користувачу. Stdout/stderr передаємо як є
77
43
  * (`stdio: 'inherit'`), щоб виглядало як прямий виклик у shell.
@@ -101,19 +67,8 @@ function runStep(bin, args, cwd) {
101
67
  */
102
68
  export function runLintRegoSteps(cwd = process.cwd()) {
103
69
  const root = resolve(cwd)
104
- const opa = resolveCmd('opa')
105
- const regal = resolveCmd('regal')
106
-
107
- let preflightOk = true
108
- if (!opa) {
109
- printOpaInstallHints()
110
- preflightOk = false
111
- }
112
- if (!regal) {
113
- printRegalInstallHints()
114
- preflightOk = false
115
- }
116
- if (!preflightOk) return 1
70
+ const opa = ensureTool('opa')
71
+ const regal = ensureTool('regal')
117
72
 
118
73
  const targets = LINT_TARGETS.filter(rel => existsSync(resolve(root, rel)))
119
74
  if (targets.length === 0) {
@@ -1,11 +1,40 @@
1
1
  /**
2
- * `n-cursor change` — пише один change-файл `<ws>/.changes/<timestamp>-<rand>.md`.
2
+ * `n-cursor change` — пише один change-файл `<ws>/.changes/YYMMDD-HHMM.md`.
3
+ * Якщо файл за ту саму хвилину вже існує, додає `-2`, `-3` тощо.
3
4
  * Замінює ручне редагування CHANGELOG у feature-флоу (n-changelog.mdc v3.0).
4
5
  */
5
6
  import { mkdir, writeFile } from 'node:fs/promises'
6
7
  import { join } from 'node:path'
7
8
 
8
- import { CHANGES_DIR, newChangeFileName, parseChangeFile, serializeChangeFile } from './lib/change-file.mjs'
9
+ import { CHANGES_DIR, changeFileName, parseChangeFile, serializeChangeFile } from './lib/change-file.mjs'
10
+
11
+ /**
12
+ * @param {unknown} error помилка `writeFile`
13
+ * @returns {boolean} true, якщо файл уже існує
14
+ */
15
+ function isFileExistsError(error) {
16
+ return error instanceof Error && 'code' in error && error.code === 'EEXIST'
17
+ }
18
+
19
+ /**
20
+ * Записує change-файл create-only, додаючи числовий suffix лише при локальній колізії.
21
+ * @param {string} dir абсолютний шлях до `.changes`
22
+ * @param {string} content вміст change-файлу
23
+ * @param {number} timestamp epoch milliseconds
24
+ * @returns {Promise<string>} створене ім'я файла
25
+ */
26
+ async function writeUniqueChangeFile(dir, content, timestamp) {
27
+ for (let sequence = 1; ; sequence++) {
28
+ const name = changeFileName(timestamp, sequence)
29
+ try {
30
+ await writeFile(join(dir, name), content, { flag: 'wx' })
31
+ return name
32
+ } catch (error) {
33
+ if (isFileExistsError(error)) continue
34
+ throw error
35
+ }
36
+ }
37
+ }
9
38
 
10
39
  /**
11
40
  * @param {object} params параметри
@@ -14,9 +43,10 @@ import { CHANGES_DIR, newChangeFileName, parseChangeFile, serializeChangeFile }
14
43
  * @param {string} params.message опис
15
44
  * @param {string} [params.ws] workspace (за замовчуванням `.`)
16
45
  * @param {string} [params.cwd] корінь
46
+ * @param {number} [params.timestamp] epoch milliseconds для детермінованих тестів
17
47
  * @returns {Promise<string>} відносний шлях створеного файлу (від ws)
18
48
  */
19
- export async function writeChange({ bump, section, message, ws = '.', cwd = process.cwd() }) {
49
+ export async function writeChange({ bump, section, message, ws = '.', cwd = process.cwd(), timestamp = Date.now() }) {
20
50
  const description = (message ?? '').trim()
21
51
  const content = serializeChangeFile({ bump, section, description })
22
52
  // Валідація полів: parseChangeFile кидає зрозумілу помилку на невалідних bump/section/порожньому описі.
@@ -24,8 +54,7 @@ export async function writeChange({ bump, section, message, ws = '.', cwd = proc
24
54
 
25
55
  const dir = join(cwd, ws, CHANGES_DIR)
26
56
  await mkdir(dir, { recursive: true })
27
- const name = newChangeFileName()
28
- await writeFile(join(dir, name), content)
57
+ const name = await writeUniqueChangeFile(dir, content, timestamp)
29
58
  return join(CHANGES_DIR, name)
30
59
  }
31
60
 
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Один change-файл `<ws>/.changes/<timestamp>-<rand>.md`: YAML-подібний frontmatter
2
+ * Один change-файл `<ws>/.changes/YYMMDD-HHMM.md`: YAML-подібний frontmatter
3
3
  * із двома ключами (`bump`, `section`) + текст опису. Парсер мінімальний — лише ці два
4
- * ключі, без зовнішніх залежностей.
4
+ * ключі, без зовнішніх залежностей. Якщо файл за ту саму хвилину вже існує, writer додає
5
+ * числовий suffix (`-2`, `-3`) атомарним create-only записом.
5
6
  */
6
7
 
7
- import { randomBytes } from 'node:crypto'
8
8
  import { existsSync } from 'node:fs'
9
9
  import { readdir, readFile } from 'node:fs/promises'
10
10
  import { join } from 'node:path'
@@ -65,21 +65,36 @@ export function serializeChangeFile(entry) {
65
65
  export const CHANGES_DIR = '.changes'
66
66
 
67
67
  /**
68
- * @param {number} timestamp `Date.now()`
69
- * @param {string} suffix короткий випадковий суфікс (hex)
70
- * @returns {string} `<timestamp>-<suffix>.md`
68
+ * @param {number} timestamp epoch milliseconds
69
+ * @returns {string} local timestamp prefix `YYMMDD-HHMM`
71
70
  */
72
- export function changeFileName(timestamp, suffix) {
73
- return `${timestamp}-${suffix}.md`
71
+ function formatChangeTimestamp(timestamp) {
72
+ const d = new Date(timestamp)
73
+ const yy = String(d.getFullYear()).slice(-2)
74
+ const month = String(d.getMonth() + 1).padStart(2, '0')
75
+ const day = String(d.getDate()).padStart(2, '0')
76
+ const hour = String(d.getHours()).padStart(2, '0')
77
+ const minute = String(d.getMinutes()).padStart(2, '0')
78
+ return `${yy}${month}${day}-${hour}${minute}`
74
79
  }
75
80
 
76
81
  /**
77
- * Унікальне ім'я для нового change-файлу: timestamp (порядок) + rand (анти-колізія
78
- * для паралельних агентів у різних worktree, що пишуть у ту саму мілісекунду).
82
+ * @param {number} timestamp epoch milliseconds
83
+ * @param {number} [sequence] collision sequence; `1`/omitted has no suffix
84
+ * @returns {string} `YYMMDD-HHMM.md` or `YYMMDD-HHMM-<n>.md`
85
+ */
86
+ export function changeFileName(timestamp, sequence = 1) {
87
+ const base = formatChangeTimestamp(timestamp)
88
+ return sequence > 1 ? `${base}-${sequence}.md` : `${base}.md`
89
+ }
90
+
91
+ /**
92
+ * Базове ім'я для нового change-файлу. Унікальність забезпечує writer: він спершу
93
+ * пробує `YYMMDD-HHMM.md`, а suffix додає лише при локальному `EEXIST`.
79
94
  * @returns {string} результат
80
95
  */
81
96
  export function newChangeFileName() {
82
- return changeFileName(Date.now(), randomBytes(3).toString('hex'))
97
+ return changeFileName(Date.now())
83
98
  }
84
99
 
85
100
  /**