@nitra/cursor 3.4.1 → 3.6.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
+ ## [3.6.0] - 2026-06-01
4
+
5
+ ### Added
6
+
7
+ - flow: команда gate — структурований вердикт релізної готовності (PASS/CONCERNS/FAIL + score + причини, синтез verify-гейтів і review-findings); release м'яко попереджає на FAIL
8
+
9
+ ## [3.5.0] - 2026-06-01
10
+
11
+ ### Added
12
+
13
+ - python: нове правило (uv-only, без Poetry) — автоактивація за pyproject.toml, заборона [tool.poetry]/poetry.lock, PEP 621 [project], lint-python (uv lock --check + uv sync --frozen + опц. ruff auto-fix: check --fix & format, mypy), CI workflow з astral-sh/setup-uv
14
+
3
15
  ## [3.4.1] - 2026-06-01
4
16
 
5
17
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "3.4.1",
3
+ "version": "3.6.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -79,7 +79,17 @@ Composer, Claude Code) працює **Пасивний Турнікет**: ти,
79
79
  7. **На провал** — виправ код за виводом і виклич `flow verify` знову. Максимум
80
80
  **3 спроби**; якщо не вдається — зупинись і поклич людину.
81
81
 
82
- 8. **Фініш** лише після зеленого `verify`:
82
+ 8. **Gate (вердикт готовності)** перед фінішем:
83
+
84
+ ```
85
+ npx @nitra/cursor flow gate
86
+ ```
87
+
88
+ Синтезує verify-гейти і review-findings у `PASS / CONCERNS / FAIL` + score +
89
+ причини (пише у `.flow.json`). `FAIL` (провалений gate або high-severity
90
+ finding) → код 1; `release` на FAIL лише попередить (рішення за тобою).
91
+
92
+ 9. **Фініш** — після зеленого `verify` і бажано `gate` ≠ FAIL:
83
93
 
84
94
  ```
85
95
  npx @nitra/cursor flow release --bump <patch|minor|major> --section <Added|Changed|Fixed> --message "<що зроблено>"
@@ -0,0 +1,19 @@
1
+ import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
2
+ import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
3
+
4
+ /**
5
+ * Запускає правило: applies → JS-concerns → policy → mdc-refs (через runStandardRule).
6
+ * Library mode: викликається CLI orchestration через `import + run(ctx)`.
7
+ * @param {import('../../scripts/lib/run-standard-rule.mjs').RuleContext} [ctx] контекст прогону (walkCache тощо)
8
+ * @returns {Promise<number>} 0 — OK, 1 — порушення
9
+ */
10
+ export function run(ctx) {
11
+ return runStandardRule(import.meta.dirname, ctx)
12
+ }
13
+
14
+ if (isRunAsCli(import.meta.url)) {
15
+ // Standalone: bun rules/<id>/fix.mjs — повний еквівалент `npx @nitra/cursor fix <id>`
16
+ // (config-loading + whitelist + summary). Дві ролі fix.mjs: library (run) + standalone (main).
17
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
18
+ process.exit(await runRuleCli(import.meta.dirname))
19
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Перевіряє FS-вимоги правила python.mdc для Python-проєктів на uv.
3
+ *
4
+ * **Що тут лишилося** (FS-existence — не покривається conftest):
5
+ * - наявність `pyproject.toml` у корені (тригер правила);
6
+ * - наявність `uv.lock` поруч (uv-проєкт коммітить lock-файл);
7
+ * - наявність кореневого `package.json` (для `bun run lint-python`);
8
+ * - наявність `.github/workflows/lint-python.yml`;
9
+ * - заборона Poetry-артефактів `poetry.lock` / `poetry.toml` (міграція на uv).
10
+ *
11
+ * **Що покрила Rego** (`npx \@nitra/cursor fix python`):
12
+ * - `python/pyproject_toml/` — заборона `[tool.poetry]` + вимога PEP 621 `[project].name/version`;
13
+ * - `python/package_json/` — наявність скрипта `lint-python` у `package.json`;
14
+ * - `python/lint_python_yml/` — `uses`/`run`-кроки канонічного workflow.
15
+ *
16
+ * `.venv/` навмисно НЕ перевіряється: uv теж створює `.venv`, тож його наявність
17
+ * не є ознакою Poetry й давала б хибні спрацювання.
18
+ */
19
+ import { existsSync } from 'node:fs'
20
+ import { join } from 'node:path'
21
+
22
+ import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
23
+
24
+ /**
25
+ * Перевіряє відповідність проєкту правилам python.mdc.
26
+ * @param {string} [cwd] корінь репозиторію (`process.cwd()` у звичайному прогоні)
27
+ * @returns {number} 0 — все OK, 1 — є проблеми
28
+ */
29
+ export function check(cwd = process.cwd()) {
30
+ const reporter = createCheckReporter()
31
+ const { pass, fail } = reporter
32
+
33
+ if (!existsSync(join(cwd, 'pyproject.toml'))) {
34
+ pass('pyproject.toml не знайдено в корені — правило python не застосовне')
35
+ return reporter.getExitCode()
36
+ }
37
+ pass('pyproject.toml існує — застосовую python.mdc')
38
+
39
+ if (existsSync(join(cwd, 'uv.lock'))) {
40
+ pass('uv.lock є')
41
+ } else {
42
+ fail('uv.lock не знайдено — згенеруй `uv lock` (python.mdc, без Poetry)')
43
+ }
44
+
45
+ // Poetry-артефакти заборонені: uv є єдиним пакет-менеджером (python.mdc).
46
+ for (const poetryFile of ['poetry.lock', 'poetry.toml']) {
47
+ if (existsSync(join(cwd, poetryFile))) {
48
+ fail(`${poetryFile} знайдено — прибери Poetry, мігруй на uv (python.mdc)`)
49
+ } else {
50
+ pass(`${poetryFile} відсутній`)
51
+ }
52
+ }
53
+
54
+ if (existsSync(join(cwd, 'package.json'))) {
55
+ pass('package.json є (наявність lint-python перевіряє fix → python.package_json)')
56
+ } else {
57
+ fail('package.json не знайдено в корені — додай для `bun run lint-python` (python.mdc)')
58
+ }
59
+
60
+ const wfPath = '.github/workflows/lint-python.yml'
61
+ if (existsSync(join(cwd, wfPath))) {
62
+ pass(`${wfPath} є (структуру перевіряє fix → python.lint_python_yml)`)
63
+ } else {
64
+ fail(`${wfPath} не існує — створи згідно python.mdc`)
65
+ }
66
+
67
+ return reporter.getExitCode()
68
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Запуск `lint-python` за правилом python.mdc на базі [uv](https://docs.astral.sh/uv/).
3
+ *
4
+ * Якщо `pyproject.toml` у корені відсутній — вихід 0 без запуску інструментів.
5
+ * Якщо `pyproject.toml` є, але `uv` не знайдено в PATH — це помилка (uv — єдиний
6
+ * пакет-менеджер, без Poetry).
7
+ *
8
+ * Обовʼязкові кроки (uv):
9
+ * - `uv lock --check` — lock-файл актуальний щодо `pyproject.toml`;
10
+ * - `uv sync --frozen` — середовище зібране строго з `uv.lock`.
11
+ *
12
+ * Опційні лінтери запускаються лише якщо доступні через `uv run` (інакше крок
13
+ * пропускається з повідомленням, як optional vendor-tools у php.mdc). `ruff`
14
+ * запускається в auto-fix-режимі (мутує робоче дерево, як `markdownlint-cli2 --fix`
15
+ * у lint-text / `clippy --fix` у lint-rust):
16
+ * - `uv run ruff check --fix .`
17
+ * - `uv run ruff format .`
18
+ * - `uv run mypy .`
19
+ *
20
+ * Канон патерну `lint-*` (серіалізація через `runStandardLint`, без прямого `withLock`) —
21
+ * `.cursor/rules/scripts.mdc`, секція «Серіалізація важких CLI-команд».
22
+ */
23
+ import { spawnSync } from 'node:child_process'
24
+ import { existsSync } from 'node:fs'
25
+ import { join } from 'node:path'
26
+
27
+ import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
28
+ import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
29
+ import { resolveCmd } from '../../../scripts/utils/resolve-cmd.mjs'
30
+ import { runStandardLint } from '../../../scripts/lib/run-standard-lint.mjs'
31
+
32
+ /**
33
+ * Запускає CLI-крок і репортить результат.
34
+ * @param {string} label назва кроку для повідомлень
35
+ * @param {string} cmd абсолютний шлях до CLI
36
+ * @param {string[]} args аргументи
37
+ * @param {(msg: string) => void} pass callback pass
38
+ * @param {(msg: string) => void} fail callback fail
39
+ * @returns {boolean} true якщо крок успішний
40
+ */
41
+ function runTool(label, cmd, args, pass, fail) {
42
+ const r = spawnSync(cmd, args, { stdio: 'inherit', shell: false })
43
+ if (r.status === 0) {
44
+ pass(`lint-python: ${label} — OK`)
45
+ return true
46
+ }
47
+ const code = typeof r.status === 'number' ? r.status : 1
48
+ fail(`lint-python: ${label} — помилка (код ${code}, python.mdc)`)
49
+ return false
50
+ }
51
+
52
+ /**
53
+ * Чи доступний інструмент усередині uv-середовища (`uv run --frozen <tool> --version`).
54
+ * @param {string} uv абсолютний шлях до `uv`
55
+ * @param {string} tool назва бінарника (`ruff`, `mypy`)
56
+ * @returns {boolean} true якщо інструмент відповідає на `--version`
57
+ */
58
+ function uvToolAvailable(uv, tool) {
59
+ const r = spawnSync(uv, ['run', '--frozen', tool, '--version'], { stdio: 'ignore', shell: false })
60
+ return r.status === 0
61
+ }
62
+
63
+ /**
64
+ * Внутрішні кроки `lint-python` без локу.
65
+ * @param {string} [cwd] корінь репозиторію
66
+ * @returns {number} 0 — OK, 1 — є помилки
67
+ */
68
+ export function runLintPythonSteps(cwd = process.cwd()) {
69
+ const reporter = createCheckReporter()
70
+ const { pass, fail } = reporter
71
+
72
+ if (!existsSync(join(cwd, 'pyproject.toml'))) {
73
+ pass('lint-python: немає pyproject.toml у корені — кроки Python пропущено')
74
+ return reporter.getExitCode()
75
+ }
76
+
77
+ const uv = resolveCmd('uv')
78
+ if (!uv) {
79
+ fail('lint-python: `uv` не знайдено в PATH (потрібен при наявному pyproject.toml, python.mdc)')
80
+ return reporter.getExitCode()
81
+ }
82
+
83
+ if (!runTool('uv lock --check', uv, ['lock', '--check'], pass, fail)) return reporter.getExitCode()
84
+ if (!runTool('uv sync --frozen', uv, ['sync', '--frozen'], pass, fail)) return reporter.getExitCode()
85
+
86
+ /**
87
+ * Запускає лінтер через `uv run`, якщо він доступний у середовищі.
88
+ * @param {string} tool назва бінарника
89
+ * @param {string} label назва кроку
90
+ * @param {string[]} args аргументи інструмента
91
+ * @returns {boolean} true, якщо крок успішний або пропущений
92
+ */
93
+ function runOptionalUvTool(tool, label, args) {
94
+ if (!uvToolAvailable(uv, tool)) {
95
+ pass(`lint-python: ${tool} недоступний у uv-середовищі — крок пропущено`)
96
+ return true
97
+ }
98
+ return runTool(label, uv, ['run', '--frozen', tool, ...args], pass, fail)
99
+ }
100
+
101
+ if (!runOptionalUvTool('ruff', 'ruff check --fix', ['check', '--fix', '.'])) return reporter.getExitCode()
102
+ if (!runOptionalUvTool('ruff', 'ruff format', ['format', '.'])) return reporter.getExitCode()
103
+ if (!runOptionalUvTool('mypy', 'mypy', ['.'])) return reporter.getExitCode()
104
+
105
+ return reporter.getExitCode()
106
+ }
107
+
108
+ /**
109
+ * Публічна CLI-форма: серіалізує через `withLock('lint-python')` + дедуп за станом git-дерева.
110
+ * @returns {Promise<number>} код виходу
111
+ */
112
+ export const runLintPython = () => runStandardLint(import.meta.dirname, runLintPythonSteps)
113
+
114
+ if (isRunAsCli(import.meta.url)) {
115
+ process.exitCode = await runLintPython()
116
+ }
@@ -0,0 +1 @@
1
+ { "auto": { "glob": "pyproject.toml" } }
@@ -0,0 +1,59 @@
1
+ # Перевірка `.github/workflows/lint-python.yml` для правила python (python.mdc).
2
+ #
3
+ # Канон надходить через --data: { "template": { "snippet": ... } }
4
+ # Структура --data сформована з template/lint-python.yml.snippet.yml.
5
+ # Перевіряємо (drift-safe — усе ведеться з template, без inline-літералів):
6
+ # - кожен `uses` з template (підмножина): actions/checkout@v6,
7
+ # ./.github/actions/setup-bun-deps, astral-sh/setup-uv@v8.0.0;
8
+ # - кожен `run` з template має бути присутнім (як substring) серед run-кроків
9
+ # input'а: `uv sync --frozen`, `bun run lint-python`.
10
+ # Заборона Poetry-кроків (snok/install-poetry, `poetry install`) — через відсутність
11
+ # у каноні: правило вимагає uv-кроки, а нав'язаних poetry-кроків у template немає.
12
+ # Універсальні workflow-перевірки (name, concurrency, branches) — у `ga.workflow_common`.
13
+ package python.lint_python_yml
14
+
15
+ import rego.v1
16
+
17
+ # Усі `uses` з канону workflow (по всіх job'ах template).
18
+ expected_uses contains u if {
19
+ some job in data.template.snippet.jobs
20
+ some step in job.steps
21
+ u := object.get(step, "uses", "")
22
+ u != ""
23
+ }
24
+
25
+ # Усі `uses` з input workflow.
26
+ actual_uses contains u if {
27
+ some job in object.get(input, "jobs", {})
28
+ some step in object.get(job, "steps", [])
29
+ u := object.get(step, "uses", "")
30
+ u != ""
31
+ }
32
+
33
+ # Конкатенація всіх `run`-кроків з input workflow.
34
+ all_run_text := concat("\n", [run_text |
35
+ some job in object.get(input, "jobs", {})
36
+ some step in object.get(job, "steps", [])
37
+ run_text := step_run_to_text(step)
38
+ ])
39
+
40
+ deny contains msg if {
41
+ some required_use in expected_uses
42
+ not required_use in actual_uses
43
+ msg := sprintf("lint-python.yml: відсутній step з `uses: %s` (python.mdc)", [required_use])
44
+ }
45
+
46
+ deny contains msg if {
47
+ some job in data.template.snippet.jobs
48
+ some step in job.steps
49
+ expected_run := object.get(step, "run", "")
50
+ expected_run != ""
51
+ not contains(all_run_text, expected_run)
52
+ msg := sprintf("lint-python.yml: жоден крок run не містить %q (python.mdc)", [expected_run])
53
+ }
54
+
55
+ step_run_to_text(step) := step.run if is_string(step.run)
56
+
57
+ else := concat("\n", [s | some s in step.run]) if is_array(step.run)
58
+
59
+ else := ""
@@ -0,0 +1,4 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@nitra/cursor/schemas/target.json",
3
+ "files": { "single": ".github/workflows/lint-python.yml" }
4
+ }
@@ -0,0 +1,41 @@
1
+ name: Lint Python
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - dev
7
+ - main
8
+ paths:
9
+ - '**/*.py'
10
+ - 'pyproject.toml'
11
+ - 'uv.lock'
12
+ - '.github/workflows/lint-python.yml'
13
+
14
+ pull_request:
15
+ branches:
16
+ - dev
17
+ - main
18
+
19
+ concurrency:
20
+ group: ${{ github.ref }}-${{ github.workflow }}
21
+ cancel-in-progress: true
22
+
23
+ jobs:
24
+ python:
25
+ runs-on: ubuntu-latest
26
+ permissions:
27
+ contents: read
28
+ steps:
29
+ - uses: actions/checkout@v6
30
+ with:
31
+ persist-credentials: false
32
+
33
+ - uses: ./.github/actions/setup-bun-deps
34
+
35
+ - uses: astral-sh/setup-uv@v8.0.0
36
+
37
+ - name: Install dependencies (uv)
38
+ run: uv sync --frozen
39
+
40
+ - name: Lint Python
41
+ run: bun run lint-python
@@ -0,0 +1,16 @@
1
+ # Перевірка `package.json` (python.mdc).
2
+ #
3
+ # Канон надходить через --data: { "template": { "contains": ... } }
4
+ # Структура --data сформована з template/package.json.contains.json.
5
+ # FS-перевірки (наявність `package.json` як такого) — у JS.
6
+ package python.package_json
7
+
8
+ import rego.v1
9
+
10
+ deny contains msg if {
11
+ some script_name, needles in data.template.contains.scripts
12
+ actual := object.get(object.get(input, "scripts", {}), script_name, "")
13
+ some needle in needles
14
+ not contains(actual, needle)
15
+ msg := sprintf("package.json: scripts.%s має містити %q (python.mdc)", [script_name, needle])
16
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@nitra/cursor/schemas/target.json",
3
+ "files": { "single": "package.json" }
4
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "scripts": {
3
+ "lint-python": ["bun"]
4
+ }
5
+ }
@@ -0,0 +1,39 @@
1
+ # Перевірка `pyproject.toml` (python.mdc).
2
+ #
3
+ # Канон надходить через --data: { "template": { "deny": ... } }
4
+ # Структура --data сформована з template/pyproject.toml.deny.toml.
5
+ # FS-перевірки (`poetry.lock`, `poetry.toml`, `uv.lock`, `package.json`) — у JS.
6
+ #
7
+ # Дві групи правил:
8
+ # 1. Заборона Poetry — `[tool.poetry]` (та інші заборонені під-таблиці `tool`)
9
+ # керується deny-template, drift-safe.
10
+ # 2. PEP 621 — `[project].name` і `[project].version` обовʼязкові (структурна
11
+ # вимога без канонічного літералу, тому inline).
12
+ package python.pyproject_toml
13
+
14
+ import rego.v1
15
+
16
+ # ── Заборона Poetry (deny-template керує переліком) ──────────────────────────
17
+
18
+ deny contains msg if {
19
+ some key, reason in object.get(data.template.deny, "tool", {})
20
+ key in object.keys(object.get(input, "tool", {}))
21
+ msg := sprintf("pyproject.toml: [tool.%s] — %s", [key, reason])
22
+ }
23
+
24
+ # ── PEP 621: обовʼязкові [project].name / [project].version ──────────────────
25
+
26
+ deny contains msg if {
27
+ not project_field_set("name")
28
+ msg := "pyproject.toml: відсутній [project].name (PEP 621 — мігруй з [tool.poetry], python.mdc)"
29
+ }
30
+
31
+ deny contains msg if {
32
+ not project_field_set("version")
33
+ msg := "pyproject.toml: відсутній статичний [project].version (PEP 621, python.mdc)"
34
+ }
35
+
36
+ project_field_set(key) if {
37
+ value := object.get(object.get(input, "project", {}), key, "")
38
+ value != ""
39
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@nitra/cursor/schemas/target.json",
3
+ "files": { "single": "pyproject.toml" }
4
+ }
@@ -0,0 +1,2 @@
1
+ [tool]
2
+ poetry = "Poetry заборонено: мігруй на uv + PEP 621 [project] (python.mdc)"
@@ -0,0 +1,12 @@
1
+ # Канонічний цільовий вигляд після міграції з Poetry на uv (PEP 621).
2
+ # Метадані — у [project] (а не [tool.poetry]); dev-залежності додавай через
3
+ # `uv add --dev <pkg>` (uv тримає їх у [dependency-groups].dev або [tool.uv]).
4
+ [project]
5
+ name = "your-package"
6
+ version = "0.1.0"
7
+ requires-python = ">=3.12"
8
+ dependencies = []
9
+
10
+ [build-system]
11
+ requires = ["hatchling"]
12
+ build-backend = "hatchling.build"
@@ -0,0 +1,47 @@
1
+ ---
2
+ description: Python — пакет-менеджер uv (без Poetry), PEP 621, lint-python
3
+ globs: "**/*.py,pyproject.toml,uv.lock,.github/workflows/lint-python.yml"
4
+ alwaysApply: false
5
+ version: '1.0'
6
+ ---
7
+
8
+ Python-проєкти ведуться **виключно** на [uv](https://docs.astral.sh/uv/) — єдиний пакет-менеджер і резолвер. **Poetry заборонено.**
9
+
10
+ - Метадані проєкту — у секції **`[project]`** (PEP 621), а **не** в `[tool.poetry]`.
11
+ - Lock-файл — **`uv.lock`** (коммітиться). `poetry.lock` / `poetry.toml` мають бути відсутні.
12
+ - Залежності: `uv add <pkg>`; dev-залежності: `uv add --dev <pkg>`.
13
+ - Середовище: `uv sync --frozen` (строго з `uv.lock`).
14
+
15
+ `uv` / `ruff` / `mypy` **не** додаються в кореневі `devDependencies` споживача — це окремий toolchain і ставиться через `astral-sh/setup-uv` у CI або локально (як `composer` / `regal`).
16
+
17
+ ## Міграція з Poetry на uv
18
+
19
+ 1. Прибери `[tool.poetry]` і `poetry.lock` / `poetry.toml`.
20
+ 2. Перенеси метадані в `[project]` (name, version, requires-python, dependencies) за PEP 621.
21
+ 3. Згенеруй lock: `uv lock` → `uv.lock`.
22
+ 4. Dev-залежності: `uv add --dev ruff mypy …`.
23
+
24
+ Канонічний цільовий вигляд `pyproject.toml`: [pyproject.toml.snippet.toml](./policy/pyproject_toml/template/pyproject.toml.snippet.toml)
25
+
26
+ Заборонені під-таблиці `[tool.*]` (Poetry): [pyproject.toml.deny.toml](./policy/pyproject_toml/template/pyproject.toml.deny.toml)
27
+
28
+ ## lint-python
29
+
30
+ Інструменти uv-екосистеми не мають єдиного CLI, що сам обходить репозиторій, тому `lint-python` делегується у JS-скрипт-обгортку (як `lint-php`, `lint-docker`).
31
+
32
+ - Канон `package.json#scripts.lint-python` (substring requirement): [package.json.contains.json](./policy/package_json/template/package.json.contains.json)
33
+
34
+ Скрипт `rules/python/lint/lint.mjs`:
35
+
36
+ - якщо `pyproject.toml` у корені відсутній — вихід 0 (перевірка пропущена);
37
+ - якщо `pyproject.toml` є, але `uv` не знайдено в PATH — це помилка;
38
+ - `uv lock --check` і `uv sync --frozen` — обовʼязкові;
39
+ - `uv run ruff check --fix .` + `uv run ruff format .` — auto-fix (мутують робоче дерево, як `markdownlint-cli2 --fix` у lint-text);
40
+ - `uv run mypy .` — статична перевірка типів;
41
+ - усі `ruff`/`mypy`-кроки запускаються лише якщо інструмент доступний у середовищі (інакше крок пропускається з повідомленням).
42
+
43
+ ## CI: `.github/workflows/lint-python.yml`
44
+
45
+ - Канон: [lint-python.yml.snippet.yml](./policy/lint_python_yml/template/lint-python.yml.snippet.yml)
46
+
47
+ Без кроків `poetry install` / `snok/install-poetry` — лише `astral-sh/setup-uv@v8.0.0` + `uv sync --frozen`.
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { cancel, repair, resume, run } from './lib/active.mjs'
11
11
  import { init, release, verify } from './lib/commands.mjs'
12
+ import { gate } from './lib/gate.mjs'
12
13
  import { plan } from './lib/plan.mjs'
13
14
  import { review } from './lib/review.mjs'
14
15
  import { spec } from './lib/spec.mjs'
@@ -20,6 +21,7 @@ const USAGE = [
20
21
  ' npx @nitra/cursor flow plan [--panel] # Фасад A: фаза плану → docs/plans/<…> + state',
21
22
  ' npx @nitra/cursor flow verify # Фасад A: Quality Gates (pass/fail)',
22
23
  ' npx @nitra/cursor flow review # Фасад A: adversarial diff-review (за level)',
24
+ ' npx @nitra/cursor flow gate # Фасад A: вердикт PASS/CONCERNS/FAIL (verify+review)',
23
25
  ' npx @nitra/cursor flow release # Фасад A: .changes + completion snapshot',
24
26
  ' npx @nitra/cursor flow run "<опис>" # Фасад B: повний 5-фазний цикл',
25
27
  ' npx @nitra/cursor flow resume # продовжити з чекпойнта',
@@ -28,13 +30,13 @@ const USAGE = [
28
30
  ].join('\n')
29
31
 
30
32
  /** Підкоманди flow. */
31
- export const SUBCOMMANDS = ['init', 'spec', 'plan', 'verify', 'review', 'release', 'run', 'resume', 'cancel', 'repair']
33
+ export const SUBCOMMANDS = ['init', 'spec', 'plan', 'verify', 'review', 'gate', 'release', 'run', 'resume', 'cancel', 'repair']
32
34
 
33
35
  /**
34
36
  * Усі handler-и реальні (Ф1 Spec/Plan + Ф2 Турнікет + Ф4 Активний Раннер).
35
37
  * @type {Record<string, (rest: string[], deps: object) => Promise<number>>}
36
38
  */
37
- export const DEFAULT_HANDLERS = { init, spec, plan, verify, review, release, run, resume, cancel, repair }
39
+ export const DEFAULT_HANDLERS = { init, spec, plan, verify, review, gate, release, run, resume, cancel, repair }
38
40
 
39
41
  /**
40
42
  * Точка входу `case 'flow'` у `bin/n-cursor.js`. Парсить підкоманду й
@@ -179,6 +179,10 @@ export async function release(rest, deps = {}) {
179
179
  log('release: стану нема — спершу `flow init`')
180
180
  return 1
181
181
  }
182
+ // М'які ворота: FAIL-гейт — лише попередження, рішення за людиною.
183
+ if (state.gate?.verdict === 'FAIL') {
184
+ log(`⚠️ release: gate = FAIL (score ${state.gate.score}) — релізиш свідомо? (див. flow gate)`)
185
+ }
182
186
 
183
187
  const ch = run('npx', ['@nitra/cursor', 'change', ...rest], { cwd })
184
188
  if ((ch.status ?? 1) !== 0) {
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `flow gate` — структурований вердикт релізної готовності (ідея BMAD qa-gate, у
3
+ * нашому стані). Синтезує механічні гейти `verify` (`state.gates`) і adversarial
4
+ * findings `review` (`state.review.findings`) у єдине PASS/CONCERNS/FAIL + score
5
+ * + причини. Дає traceability «чому готово/не готово». `gate` лише агрегує —
6
+ * рішення verify/review не дублює.
7
+ *
8
+ * Уся IO (`now`) ін'єктується; `computeGate` — чиста (тестується без стану на диску).
9
+ */
10
+ import { cwd as processCwd } from 'node:process'
11
+
12
+ import { flowEventsPath } from './events.mjs'
13
+ import { flowStatePath, readState, recordTransition } from './state-store.mjs'
14
+
15
+ /** Штрафи score за кожен тип проблеми. */
16
+ const PENALTY = { failedGate: 40, high: 25, med: 8, noVerify: 15 }
17
+
18
+ /**
19
+ * Чистий синтез вердикту з наявного стану.
20
+ * @param {{ gates?: { name: string, ok: boolean }[], review?: { findings?: { severity?: string }[] } }} state стан flow
21
+ * @returns {{ verdict: 'PASS' | 'CONCERNS' | 'FAIL', score: number, reasons: string[] }} вердикт
22
+ */
23
+ export function computeGate(state) {
24
+ const gates = state.gates ?? []
25
+ const findings = state.review?.findings ?? []
26
+ const failedGates = gates.filter(g => !g.ok)
27
+ const high = findings.filter(f => f.severity === 'high')
28
+ const med = findings.filter(f => f.severity === 'med')
29
+ const noVerify = gates.length === 0
30
+
31
+ const reasons = []
32
+ for (const g of failedGates) reasons.push(`gate «${g.name}» провалено`)
33
+ if (high.length > 0) reasons.push(`${high.length} high-severity review finding(s)`)
34
+ if (med.length > 0) reasons.push(`${med.length} med-severity review finding(s)`)
35
+ if (noVerify) reasons.push('verify ще не запускався')
36
+
37
+ let verdict = 'PASS'
38
+ if (failedGates.length > 0 || high.length > 0) {
39
+ verdict = 'FAIL'
40
+ } else if (med.length > 0 || noVerify) {
41
+ verdict = 'CONCERNS'
42
+ }
43
+
44
+ const penalty =
45
+ PENALTY.failedGate * failedGates.length +
46
+ PENALTY.high * high.length +
47
+ PENALTY.med * med.length +
48
+ (noVerify ? PENALTY.noVerify : 0)
49
+ const score = Math.max(0, Math.min(100, 100 - penalty))
50
+
51
+ return { verdict, score, reasons }
52
+ }
53
+
54
+ /**
55
+ * `flow gate` — обчислює й фіксує вердикт у `.flow.json`.
56
+ * @param {string[]} _rest аргументи (не використовуються)
57
+ * @param {{ cwd?: string, log?: (m: string) => void, now?: () => number }} [deps] ін'єкції
58
+ * @returns {Promise<number>} exit code (FAIL → 1; PASS/CONCERNS → 0)
59
+ */
60
+ export async function gate(_rest, deps = {}) {
61
+ const cwd = deps.cwd ?? processCwd()
62
+ const log = deps.log ?? console.error
63
+ const now = deps.now ?? Date.now
64
+
65
+ const statePath = flowStatePath(cwd)
66
+ const state = readState(statePath)
67
+ if (!state) {
68
+ log('gate: стану нема — спершу `flow init`')
69
+ return 1
70
+ }
71
+
72
+ const result = computeGate(state)
73
+ recordTransition(
74
+ { statePath, eventsPath: flowEventsPath(cwd) },
75
+ { type: 'gate', verdict: result.verdict },
76
+ s => ({ ...s, gate: { ...result, at: new Date(now()).toISOString() } }),
77
+ now
78
+ )
79
+
80
+ log(`gate: ${result.verdict} (score ${result.score})`)
81
+ for (const r of result.reasons) log(` · ${r}`)
82
+ return result.verdict === 'FAIL' ? 1 : 0
83
+ }