@nitra/cursor 1.8.185 → 1.8.189
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/.claude-template/hooks/capture-decisions.sh +154 -0
- package/CHANGELOG.md +42 -0
- package/bin/n-cursor.js +3 -1
- package/mdc/adr.mdc +86 -0
- package/mdc/js-run.mdc +13 -1
- package/mdc/vue.mdc +41 -2
- package/package.json +1 -1
- package/scripts/check-adr.mjs +248 -0
- package/scripts/check-js-run.mjs +37 -1
- package/scripts/check-vue.mjs +45 -0
- package/scripts/sync-claude-config.mjs +88 -18
- package/scripts/utils/promise-settimeout-scan.mjs +129 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Stop hook: extract ADR/Runbook/Knowledge drafts from session transcript.
|
|
3
|
+
# Runs async. Recursion guard: env var prevents the inner LLM CLI from
|
|
4
|
+
# re-triggering this hook (the inner session inherits CAPTURE_DECISIONS_RUNNING=1).
|
|
5
|
+
#
|
|
6
|
+
# LLM CLI selection (first available wins):
|
|
7
|
+
# 1. claude — use `claude -p --model "$CAPTURE_DECISIONS_CLAUDE_MODEL"` (default: sonnet)
|
|
8
|
+
# 2. cursor-agent — use `cursor-agent -p --mode ask --model "$CAPTURE_DECISIONS_CURSOR_MODEL"`
|
|
9
|
+
# (default: claude-4.6-sonnet-medium)
|
|
10
|
+
# neither — exit 0 silently
|
|
11
|
+
#
|
|
12
|
+
# Bundled with @nitra/cursor; project copy is auto-synced by the `adr` rule.
|
|
13
|
+
set -euo pipefail
|
|
14
|
+
|
|
15
|
+
if [[ -n "${CAPTURE_DECISIONS_RUNNING:-}" ]]; then
|
|
16
|
+
exit 0
|
|
17
|
+
fi
|
|
18
|
+
export CAPTURE_DECISIONS_RUNNING=1
|
|
19
|
+
|
|
20
|
+
INPUT=$(cat)
|
|
21
|
+
TRANSCRIPT_PATH=$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty')
|
|
22
|
+
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // "unknown"')
|
|
23
|
+
|
|
24
|
+
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
25
|
+
INBOX="$PROJECT_ROOT/docs/adr/_inbox"
|
|
26
|
+
LOG_DIR="$PROJECT_ROOT/.claude/hooks"
|
|
27
|
+
LOG="$LOG_DIR/capture-decisions.log"
|
|
28
|
+
mkdir -p "$INBOX" "$LOG_DIR"
|
|
29
|
+
|
|
30
|
+
log() { printf '%s %s\n' "$(date -Iseconds)" "$*" >> "$LOG"; }
|
|
31
|
+
|
|
32
|
+
log "fired: $SESSION_ID"
|
|
33
|
+
|
|
34
|
+
if [[ -z "$TRANSCRIPT_PATH" || ! -f "$TRANSCRIPT_PATH" ]]; then
|
|
35
|
+
log " → no transcript path"
|
|
36
|
+
exit 0
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
# Extract role + text + thinking + tool_use names from JSONL transcript.
|
|
40
|
+
# We keep reasoning/decisions visible to the analyzer but drop large tool outputs.
|
|
41
|
+
TRANSCRIPT=$(jq -r '
|
|
42
|
+
select(.type == "user" or .type == "assistant")
|
|
43
|
+
| .message as $m
|
|
44
|
+
| ($m.role // .type) as $role
|
|
45
|
+
| ($m.content
|
|
46
|
+
| if type == "string" then .
|
|
47
|
+
else (
|
|
48
|
+
map(
|
|
49
|
+
if .type == "text" then .text
|
|
50
|
+
elif .type == "thinking" then "[thinking]\n" + (.thinking // "")
|
|
51
|
+
elif .type == "tool_use" then
|
|
52
|
+
"[tool: " + .name + "]" +
|
|
53
|
+
(if .input then
|
|
54
|
+
" " + (.input | tostring | .[0:300])
|
|
55
|
+
else "" end)
|
|
56
|
+
elif .type == "tool_result" then
|
|
57
|
+
"[tool_result] " + (
|
|
58
|
+
(.content
|
|
59
|
+
| if type == "string" then . else (map(select(.type=="text") | .text) | join(" ")) end
|
|
60
|
+
) // "" | .[0:300]
|
|
61
|
+
)
|
|
62
|
+
else "" end
|
|
63
|
+
) | map(select(length > 0)) | join("\n")
|
|
64
|
+
)
|
|
65
|
+
end) as $body
|
|
66
|
+
| select($body | length > 0)
|
|
67
|
+
| "[" + $role + "]\n" + $body
|
|
68
|
+
' "$TRANSCRIPT_PATH" 2>/dev/null || true)
|
|
69
|
+
|
|
70
|
+
# Cap input size to keep latency/cost predictable.
|
|
71
|
+
MAX_CHARS=120000
|
|
72
|
+
if (( ${#TRANSCRIPT} > MAX_CHARS )); then
|
|
73
|
+
TRANSCRIPT="${TRANSCRIPT: -$MAX_CHARS}"
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
if [[ -z "$TRANSCRIPT" ]]; then
|
|
77
|
+
exit 0
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
PROMPT=$(cat <<'EOF'
|
|
81
|
+
You analyze a Claude Code session transcript and produce a durable knowledge artifact (ADR, Runbook, or Knowledge note) capturing what was done and why.
|
|
82
|
+
|
|
83
|
+
LANGUAGE: Write the ENTIRE output in Ukrainian. This applies to the title, all section content, prose, and rationale. Keep code identifiers, file paths, commands, and tool or library names in their original form (do not translate `walkDir`, `package.json`, `npm`, etc.) — only translate the natural-language prose around them. Section labels themselves stay in Ukrainian per the template below.
|
|
84
|
+
|
|
85
|
+
IMPORTANT: by "decision" we mean the design choice expressed in the session — even if the user pre-specified it in their brief. The user dictating the approach IS the decision; capture it, including the rationale they gave or that became apparent during implementation. Do NOT return NONE just because the user gave detailed instructions upfront.
|
|
86
|
+
|
|
87
|
+
OUTPUT RULES:
|
|
88
|
+
- Emit one or more markdown blocks in this exact shape (no preamble, no trailing prose):
|
|
89
|
+
|
|
90
|
+
## [ADR|Runbook|Knowledge] <короткий заголовок українською>
|
|
91
|
+
**Контекст:** <яка проблема / ситуація це спричинила — 1-2 речення>
|
|
92
|
+
**Рішення/Процедура/Факт:** <що було зроблено — конкретно: змінені файли, введена семантика, кроки>
|
|
93
|
+
**Обґрунтування:** <чому саме такий підхід — з ТЗ користувача або міркувань асистента>
|
|
94
|
+
**Розглянуті альтернативи:** <перелік явно обговорених, або «не обговорювалися»>
|
|
95
|
+
**Зачіпає:** <файли, модулі, публічні API, конфіги>
|
|
96
|
+
|
|
97
|
+
WHEN TO PICK EACH TYPE:
|
|
98
|
+
- ADR: a design choice (library, schema, pattern, semantics of a field/API). Most substantive code work qualifies.
|
|
99
|
+
- Runbook: a procedure to operate, fix, deploy, or reproduce something.
|
|
100
|
+
- Knowledge: a non-obvious constraint, gotcha, or invariant uncovered (without a corresponding code change).
|
|
101
|
+
|
|
102
|
+
OUTPUT NONE ONLY IF the session is genuinely trivial:
|
|
103
|
+
- A single typo fix, comment edit, or lint cleanup with no design content
|
|
104
|
+
- A pure question/answer with no code change and no surprising fact
|
|
105
|
+
- An aborted/empty session
|
|
106
|
+
|
|
107
|
+
When in doubt, emit a block. Capturing too much is acceptable; missing real work is not.
|
|
108
|
+
|
|
109
|
+
TRANSCRIPT FOLLOWS:
|
|
110
|
+
---
|
|
111
|
+
EOF
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
PROMPT_FULL=$(printf '%s\n%s\n' "$PROMPT" "$TRANSCRIPT")
|
|
115
|
+
|
|
116
|
+
CLAUDE_MODEL="${CAPTURE_DECISIONS_CLAUDE_MODEL:-sonnet}"
|
|
117
|
+
CURSOR_MODEL="${CAPTURE_DECISIONS_CURSOR_MODEL:-claude-4.6-sonnet-medium}"
|
|
118
|
+
|
|
119
|
+
if command -v claude >/dev/null 2>&1; then
|
|
120
|
+
log " → using claude CLI (model: $CLAUDE_MODEL)"
|
|
121
|
+
RESPONSE=$(printf '%s' "$PROMPT_FULL" | claude -p --model "$CLAUDE_MODEL" 2>>"$LOG" || true)
|
|
122
|
+
elif command -v cursor-agent >/dev/null 2>&1; then
|
|
123
|
+
log " → using cursor-agent CLI (model: $CURSOR_MODEL)"
|
|
124
|
+
RESPONSE=$(cursor-agent -p --mode ask --output-format text --model "$CURSOR_MODEL" -- "$PROMPT_FULL" 2>>"$LOG" || true)
|
|
125
|
+
else
|
|
126
|
+
log " → no LLM CLI found (claude/cursor-agent), skipping"
|
|
127
|
+
exit 0
|
|
128
|
+
fi
|
|
129
|
+
|
|
130
|
+
RESPONSE_TRIMMED=$(printf '%s' "$RESPONSE" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
|
|
131
|
+
|
|
132
|
+
log " → response length: ${#RESPONSE_TRIMMED}, first 200: ${RESPONSE_TRIMMED:0:200}"
|
|
133
|
+
|
|
134
|
+
if [[ -z "$RESPONSE_TRIMMED" ]]; then
|
|
135
|
+
log " → empty response from LLM CLI"
|
|
136
|
+
exit 0
|
|
137
|
+
fi
|
|
138
|
+
if [[ "$RESPONSE_TRIMMED" == "NONE" ]]; then
|
|
139
|
+
log " → NONE"
|
|
140
|
+
exit 0
|
|
141
|
+
fi
|
|
142
|
+
if ! printf '%s' "$RESPONSE_TRIMMED" | grep -q '^## '; then
|
|
143
|
+
log " → response missing '## ' header"
|
|
144
|
+
exit 0
|
|
145
|
+
fi
|
|
146
|
+
|
|
147
|
+
TS=$(date +%Y%m%d-%H%M%S)
|
|
148
|
+
OUT="$INBOX/$TS-${SESSION_ID:0:8}.md"
|
|
149
|
+
{
|
|
150
|
+
printf -- '---\nsession: %s\ncaptured: %s\ntranscript: %s\n---\n\n' \
|
|
151
|
+
"$SESSION_ID" "$(date -Iseconds)" "$TRANSCRIPT_PATH"
|
|
152
|
+
printf '%s\n' "$RESPONSE_TRIMMED"
|
|
153
|
+
} > "$OUT"
|
|
154
|
+
log "wrote: $OUT"
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,48 @@
|
|
|
4
4
|
|
|
5
5
|
Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
|
|
6
6
|
|
|
7
|
+
## [1.8.189] - 2026-05-07
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Нове правило `adr` (вмикається **вручну** через `.n-cursor.json` `rules`): автоматичне копіювання канонічного `.claude/hooks/capture-decisions.sh` з пакета та керована Stop-група у `.claude/settings.json`, яка викликає скрипт асинхронно (`async: true`, timeout `180`s). Скрипт зчитує JSONL-транскрипт сесії, передає дайджест у LLM CLI і пише чернетки ADR/Runbook/Knowledge у `docs/adr/_inbox/`.
|
|
12
|
+
- `capture-decisions.sh`: fallback `claude` → `cursor-agent` (LLM CLI). Якщо `claude` відсутній, береться `cursor-agent -p --mode ask --output-format text`. Моделі задаються через ENV `CAPTURE_DECISIONS_CLAUDE_MODEL` (default `sonnet`) і `CAPTURE_DECISIONS_CURSOR_MODEL` (default `claude-4.6-sonnet-medium`).
|
|
13
|
+
- `check-adr.mjs`: програмна перевірка наявності та канонічності `.claude/hooks/capture-decisions.sh`, ADR-групи у `.claude/settings.json`, відсутності дубля у `.claude/settings.local.json`, ігнорування `.claude/hooks/capture-decisions.log` у `.gitignore`, інформативно — наявність бодай одного LLM CLI (`claude`/`cursor-agent`) у `PATH`.
|
|
14
|
+
- `tests/check-adr.test.mjs` (7 кейсів) і нові кейси у `tests/sync-claude-config.test.mjs`: copy + Stop-merge + ідемпотентність + автоматичне видалення managed-групи при видаленні `adr` з `rules`.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- `sync-claude-config.mjs`: `MANAGED_HOOK_COMMAND_MARKERS` (масив) замість одиничного маркера; `mergeSettings(existing, template, { includeAdrHook })`; `syncClaudeConfig` приймає `rules` і умовно копіює ADR Stop-hook script + додає managed-групу до Stop. `syncClaudeConfig` повертає додатковий прапорець `adrHook`.
|
|
19
|
+
- `bin/n-cursor.js`: передає `rules` у `syncClaudeConfig` і логує `.claude/hooks/capture-decisions.sh` у підсумку Claude-конфіга.
|
|
20
|
+
|
|
21
|
+
## [1.8.188] - 2026-05-07
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- `vue` (mdc v1.6 → v1.7): для Volar/асетів канонічно лише **`jsconfig.json`** у корені пакета — прибрано альтернативу з `tsconfig.json`. `check-vue.mjs`: перевіряється лише наявність `jsconfig.json`.
|
|
26
|
+
|
|
27
|
+
## [1.8.187] - 2026-05-07
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- `check-vue.mjs`: перевірка `src/vite-env.d.ts` з `/// <reference types="vite/client" />` та наявності `jsconfig.json` або `tsconfig.json` у корені кожного Vue-пакета (типи для імпортів асетів у `.vue`).
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- `vue` (mdc v1.5 → v1.6): секція **«Vite client types (Volar, імпорти асетів)»** — обов’язкові `vite-env.d.ts`, jsconfig/tsconfig; застереження щодо вузького `compilerOptions.types`. Оновлено блок **«Перевірка»**.
|
|
36
|
+
|
|
37
|
+
## [1.8.186] - 2026-05-07
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- `check-js-run.mjs` + `scripts/utils/promise-settimeout-scan.mjs`: програмна перевірка нової секції js-run «Паузи через setTimeout». AST-сканер на `oxc-parser` ловить `new Promise(resolve => setTimeout(resolve, ms))` (з `await` чи без, arrow та function expression, concise та block body, тривіально загорнутий callback `() => resolve()`). Паттерни з передачею значення (`r => setTimeout(() => r(value), ms)`), іншим callback-ом замість resolve, або з додатковими стейтментами в блоці — поза правилом (це не «чиста» пауза).
|
|
42
|
+
- `tests/promise-settimeout-scan.test.mjs`: 13 модульних тестів (await/без, block-body, function expression, обгорнутий callback, false-positive guards, multiline номер рядка, кілька входжень, фільтр розширень).
|
|
43
|
+
- `tests/check-js-run-fixture.test.mjs`: 2 інтеграційні кейси на `check()` — fail при `await new Promise(r => setTimeout(r, ms))` у workspace-пакеті, pass при `await setTimeout(ms)` з `node:timers/promises`.
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- `js-run` (mdc v1.3 → v1.4): додано секцію **«Паузи через setTimeout»** — заборонено `await new Promise(resolve => setTimeout(resolve, ms))`, замість цього треба `await setTimeout(ms)` з `node:timers/promises`. Зауваження про затінення глобального `setTimeout` у тому ж файлі (за потреби callback-варіант імпортувати під іншим іменем, наприклад `setTimeoutCb` з `node:timers`).
|
|
48
|
+
|
|
7
49
|
## [1.8.185] - 2026-05-06
|
|
8
50
|
|
|
9
51
|
### Changed
|
package/bin/n-cursor.js
CHANGED
|
@@ -1177,7 +1177,8 @@ async function runSync() {
|
|
|
1177
1177
|
const result = await syncClaudeConfig({
|
|
1178
1178
|
projectRoot: cwd(),
|
|
1179
1179
|
bundledPackageRoot: effectivePackageRoot,
|
|
1180
|
-
enabled: claudeConfigEnabled
|
|
1180
|
+
enabled: claudeConfigEnabled,
|
|
1181
|
+
rules
|
|
1181
1182
|
})
|
|
1182
1183
|
if (!claudeConfigEnabled) {
|
|
1183
1184
|
console.log('🤖 Claude-конфіг: пропущено (claude-config: false у .n-cursor.json)')
|
|
@@ -1187,6 +1188,7 @@ async function runSync() {
|
|
|
1187
1188
|
if (result.settings) parts.push('.claude/settings.json')
|
|
1188
1189
|
if (result.npmClaudeMd) parts.push('npm/CLAUDE.md')
|
|
1189
1190
|
if (result.commands.length > 0) parts.push(`${result.commands.length} slash-commands`)
|
|
1191
|
+
if (result.adrHook) parts.push('.claude/hooks/capture-decisions.sh')
|
|
1190
1192
|
if (parts.length > 0) {
|
|
1191
1193
|
console.log(`🤖 Claude-конфіг: ${parts.join(', ')}`)
|
|
1192
1194
|
}
|
package/mdc/adr.mdc
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Автоматичний збір ADR/Runbook/Knowledge-чернеток із Stop-хука Claude Code (capture-decisions)
|
|
3
|
+
alwaysApply: true
|
|
4
|
+
version: '1.0'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Правило вмикається **вручну** — додай `"adr"` у масив `rules` файлу `.n-cursor.json`. У `auto-rules.md` його немає, бо корисність залежить від робочого процесу команди (не кожен проєкт хоче літати ADR-чернеткам у `docs/`).
|
|
8
|
+
|
|
9
|
+
Коли правило увімкнене, **`npx @nitra/cursor`** автоматично:
|
|
10
|
+
|
|
11
|
+
- копіює канонічний bash-скрипт у **`.claude/hooks/capture-decisions.sh`** (executable, повністю керується пакетом — на кожен sync перезаписується);
|
|
12
|
+
- додає managed-групу у **`hooks.Stop`** в **`.claude/settings.json`**, яка викликає цей скрипт асинхронно (`async: true`, `timeout: 180`);
|
|
13
|
+
- ці зміни — додаткові до lint Stop-hook (`@nitra/cursor stop-hook`); обидві групи живуть поряд у `Stop`.
|
|
14
|
+
|
|
15
|
+
## Що робить механізм
|
|
16
|
+
|
|
17
|
+
Stop-hook Claude Code зчитує JSONL-транскрипт сесії (через `jq`), витягає текст, `thinking`-блоки та назви `tool_use`-викликів, передає компактний дайджест у LLM CLI з промптом українською і записує результат у **`docs/adr/_inbox/<timestamp>-<session>.md`**, якщо модель повернула блок з шапкою `## ADR|Runbook|Knowledge …`. Якщо модель повернула `NONE` (тривіальна сесія) — нічого не пишеться. Рекурсію з внутрішнього виклику моделі блокує env-var `CAPTURE_DECISIONS_RUNNING=1`.
|
|
18
|
+
|
|
19
|
+
## LLM CLI: claude → cursor-agent fallback
|
|
20
|
+
|
|
21
|
+
Скрипт сам обирає доступний CLI (порядок фіксований):
|
|
22
|
+
|
|
23
|
+
1. **`claude`** (Anthropic Claude Code CLI) — `claude -p --model "$CAPTURE_DECISIONS_CLAUDE_MODEL"` (default `sonnet`).
|
|
24
|
+
2. **`cursor-agent`** (Cursor IDE CLI) — `cursor-agent -p --mode ask --output-format text --model "$CAPTURE_DECISIONS_CURSOR_MODEL"` (default `claude-4.6-sonnet-medium`).
|
|
25
|
+
3. Жодного CLI у `PATH` — скрипт виходить з кодом `0` і нічого не пише.
|
|
26
|
+
|
|
27
|
+
`--mode ask` для cursor-agent навмисно: режим Q&A read-only, без shell/edit-інструментів — для класифікації сесії інструменти не потрібні. Моделі можна перевизначити через ENV: `CAPTURE_DECISIONS_CLAUDE_MODEL`, `CAPTURE_DECISIONS_CURSOR_MODEL`.
|
|
28
|
+
|
|
29
|
+
## Структура каталогу
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
docs/adr/
|
|
33
|
+
└── _inbox/ # чернетки, що пишуться Stop-хуком
|
|
34
|
+
└── YYYYMMDD-HHMMSS-<sid>.md # session_id (перші 8 символів)
|
|
35
|
+
.claude/hooks/
|
|
36
|
+
├── capture-decisions.sh # auto-synced з пакета
|
|
37
|
+
└── capture-decisions.log # лог запусків (НЕ коміти)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`.gitignore` повинен містити рядок **`.claude/hooks/capture-decisions.log`**, щоб лог не потрапляв у git.
|
|
41
|
+
|
|
42
|
+
`docs/adr/_inbox/` — це робоча скринька; чернетки звідти переносяться у структуровані ADR-файли вручну (під час оглядів) або тримаються як архів сесій. Каталог сам створюється скриптом, тому пустим у git тримати не потрібно.
|
|
43
|
+
|
|
44
|
+
## Stop-hook у `.claude/settings.json`
|
|
45
|
+
|
|
46
|
+
Канонічний запис, який вставляє sync (поряд із lint stop-hook):
|
|
47
|
+
|
|
48
|
+
```json title=".claude/settings.json"
|
|
49
|
+
{
|
|
50
|
+
"hooks": {
|
|
51
|
+
"Stop": [
|
|
52
|
+
{
|
|
53
|
+
"matcher": "",
|
|
54
|
+
"hooks": [
|
|
55
|
+
{
|
|
56
|
+
"type": "command",
|
|
57
|
+
"command": "npx --no @nitra/cursor stop-hook",
|
|
58
|
+
"timeout": 60
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"matcher": "",
|
|
64
|
+
"hooks": [
|
|
65
|
+
{
|
|
66
|
+
"type": "command",
|
|
67
|
+
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/capture-decisions.sh\"",
|
|
68
|
+
"async": true,
|
|
69
|
+
"timeout": 180
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Обидві групи ідентифікуються пакетом за маркером у `command` (`@nitra/cursor stop-hook` і `.claude/hooks/capture-decisions.sh`) — користувацькі hook-групи поряд не чіпаються. Якщо `adr` прибрати з `rules`, ADR-група автоматично видаляється на наступному `npx @nitra/cursor`.
|
|
79
|
+
|
|
80
|
+
## Локальні vs project-shared налаштування
|
|
81
|
+
|
|
82
|
+
Stop-hook ADR живе у **project-shared** `.claude/settings.json` (закомічений), щоб механізм працював у всіх членів команди. Якщо хук колись був у `.claude/settings.local.json` — прибери дубль вручну: project-shared і local-копія створили б два запуски на одну подію.
|
|
83
|
+
|
|
84
|
+
## Перевірка
|
|
85
|
+
|
|
86
|
+
`npx @nitra/cursor check adr`
|
package/mdc/js-run.mdc
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Це правила для backend проектів на JavaScript/Node.js, сюди входять і job і WEB сервери.
|
|
3
3
|
alwaysApply: true
|
|
4
|
-
version: '1.
|
|
4
|
+
version: '1.4'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
## Область застосування
|
|
@@ -136,6 +136,18 @@ console.log(env.OPTIONAL_ENV_VAR)
|
|
|
136
136
|
`// @nitra/cursor ignore-next-line checkEnv` безпосередньо перед використанням
|
|
137
137
|
(escape-hatch для legacy-коду, не для нових файлів).
|
|
138
138
|
|
|
139
|
+
## Паузи через setTimeout
|
|
140
|
+
|
|
141
|
+
Заборонено робити паузи через `await new Promise(resolve => setTimeout(resolve, ms))` — таку обгортку треба замінити на promise-варіант `setTimeout` з `node:timers/promises`:
|
|
142
|
+
|
|
143
|
+
```javascript title="Замість new Promise + setTimeout"
|
|
144
|
+
import { setTimeout } from 'node:timers/promises'
|
|
145
|
+
|
|
146
|
+
await setTimeout(500)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Імпорт `setTimeout` з `node:timers/promises` затіняє глобальний таймер у файлі — якщо в тому ж файлі потрібен callback-варіант, імпортуй його під іншим іменем (наприклад, `import { setTimeout as setTimeoutCb } from 'node:timers'`).
|
|
150
|
+
|
|
139
151
|
## depcheck у GitHub Actions з path-фільтром
|
|
140
152
|
|
|
141
153
|
Якщо в `.github/workflows/*.yml` є тригер з `paths:`, який обмежує запуск workflow змінами в каталозі конкретного backend-пакета, в job цього workflow має бути крок `npx depcheck` з `working-directory`, який вказує на той самий каталог пакета. Це гарантує, що декларація залежностей у `package.json` пакета відповідає реальним імпортам — інакше можна випадково зламати білд після видалення «зайвої» залежності, яка насправді використовується через побічний імпорт.
|
package/mdc/vue.mdc
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Vue
|
|
3
3
|
alwaysApply: true
|
|
4
|
-
version: '1.
|
|
4
|
+
version: '1.7'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Vue 3 Composition API — правила для .cursorrules
|
|
@@ -208,6 +208,45 @@ export default defineConfig({
|
|
|
208
208
|
})
|
|
209
209
|
```
|
|
210
210
|
|
|
211
|
+
## Vite client types (Volar, імпорти асетів)
|
|
212
|
+
|
|
213
|
+
Без типів **Vite** редактор (Volar / TypeScript) не знає, що імпорт статичного файлу (`import url from './hero.avif'`, `*.png`, `*.svg` тощо) відповідає модулю з `string` URL. Тоді у `.vue` з’являється помилка на кшталт **Cannot find module '…' or its corresponding type declarations**.
|
|
214
|
+
|
|
215
|
+
У **кожному** workspace-пакеті з **Vue + Vite** обов’язково:
|
|
216
|
+
|
|
217
|
+
1. **`src/vite-env.d.ts`** — рівно з посиланням на клієнтські типи Vite (одного рядка достатньо):
|
|
218
|
+
|
|
219
|
+
```ts title="src/vite-env.d.ts"
|
|
220
|
+
/// <reference types="vite/client" />
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Так підтягуються декларації з `vite/client.d.ts` (`declare module '*.avif'`, `*.png`, …).
|
|
224
|
+
|
|
225
|
+
2. **Корінь пакета:** **`jsconfig.json`** із **`include`**, що охоплює `src` (наприклад `"include": ["src/**/*"]`), щоб мова служби бачила `vite-env.d.ts` і SFC.
|
|
226
|
+
|
|
227
|
+
Мінімальний приклад для JS-пакета:
|
|
228
|
+
|
|
229
|
+
```json title="jsconfig.json"
|
|
230
|
+
{
|
|
231
|
+
"compilerOptions": {
|
|
232
|
+
"target": "ESNext",
|
|
233
|
+
"module": "ESNext",
|
|
234
|
+
"moduleResolution": "bundler",
|
|
235
|
+
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
|
236
|
+
"jsx": "preserve",
|
|
237
|
+
"strict": true,
|
|
238
|
+
"noEmit": true,
|
|
239
|
+
"skipLibCheck": true,
|
|
240
|
+
"resolveJsonModule": true,
|
|
241
|
+
"isolatedModules": true,
|
|
242
|
+
"allowJs": true
|
|
243
|
+
},
|
|
244
|
+
"include": ["src/**/*"]
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
**Не** звужуй без потреби **`compilerOptions.types`** до `["vite/client"]`: це може відрізати інші пакети з `@types` і зламати інші підказки. Достатньо `/// <reference types="vite/client" />` у `vite-env.d.ts` і коректного `include`.
|
|
249
|
+
|
|
211
250
|
## Тести
|
|
212
251
|
|
|
213
252
|
Проекту повинен бути покритий тестами E2E за допомогою Playwright.
|
|
@@ -287,4 +326,4 @@ import path from 'node:path'
|
|
|
287
326
|
|
|
288
327
|
## Перевірка
|
|
289
328
|
|
|
290
|
-
`npx @nitra/cursor check vue` — перевіряє залежності, `vite.config`,
|
|
329
|
+
`npx @nitra/cursor check vue` — перевіряє залежності, `vite.config`, наявність **`src/vite-env.d.ts`** з `/// <reference types="vite/client" />` та **`jsconfig.json`** у корені Vue-пакета; обходить джерела Vue-пакета (`.vue`, `.ts`, `.js` тощо) на заборонені value-імпорти з модуля `vue` (дозволені лише type-only та side-effect `import 'vue'`) і додатково сканує `.vue` SFC на імпорти Node-нативних модулів (`node:*` префікс або bare-ім’я вбудованого модуля Node — `fs`, `path`, `timers/promises` тощо). Імпорти аналізуються через **oxc-parser** (`module.staticImports`); для `.vue` вміст `<script>` витягується з SFC, далі той самий парсер (логіка в `npm/scripts/utils/vue-forbidden-imports.mjs`).
|
package/package.json
CHANGED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Перевіряє вимоги правила adr.mdc: ADR Stop-hook capture-decisions.sh у Claude Code.
|
|
3
|
+
*
|
|
4
|
+
* Очікування:
|
|
5
|
+
* - `.claude/hooks/capture-decisions.sh` існує і байт-у-байт збігається з канонічним
|
|
6
|
+
* `.claude-template/hooks/capture-decisions.sh` пакета (sync керує файлом повністю).
|
|
7
|
+
* - `.claude/settings.json` (project-shared) має managed-групу у `hooks.Stop`, яка
|
|
8
|
+
* викликає цей bash-скрипт; маркер у `command` — `.claude/hooks/capture-decisions.sh`.
|
|
9
|
+
* - `.claude/settings.local.json` (якщо існує) НЕ має дубля цієї managed-групи —
|
|
10
|
+
* після переходу на project-shared такий запис створив би два запуски на одну подію.
|
|
11
|
+
* - `.gitignore` у корені містить шаблон, який покриває `.claude/hooks/capture-decisions.log`.
|
|
12
|
+
*
|
|
13
|
+
* LLM CLI (`claude` або `cursor-agent`) у `PATH` — інформативна перевірка: якщо жодного
|
|
14
|
+
* немає, скрипт працює, але мовчки виходить, тому це warning, а не fail.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync } from 'node:fs'
|
|
17
|
+
import { readFile } from 'node:fs/promises'
|
|
18
|
+
import { delimiter, dirname, join } from 'node:path'
|
|
19
|
+
import { env } from 'node:process'
|
|
20
|
+
import { fileURLToPath } from 'node:url'
|
|
21
|
+
|
|
22
|
+
import { createCheckReporter } from './utils/check-reporter.mjs'
|
|
23
|
+
|
|
24
|
+
const PROJECT_HOOK_PATH = '.claude/hooks/capture-decisions.sh'
|
|
25
|
+
const PROJECT_SETTINGS_PATH = '.claude/settings.json'
|
|
26
|
+
const PROJECT_LOCAL_SETTINGS_PATH = '.claude/settings.local.json'
|
|
27
|
+
const PROJECT_LOG_PATH = '.claude/hooks/capture-decisions.log'
|
|
28
|
+
const HOOK_COMMAND_MARKER = '.claude/hooks/capture-decisions.sh'
|
|
29
|
+
|
|
30
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
31
|
+
/** Канонічний bundled-скрипт у пакеті — джерело правди для звірки з проєктним. */
|
|
32
|
+
const BUNDLED_HOOK_PATH = join(here, '..', '.claude-template', 'hooks', 'capture-decisions.sh')
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Чи містить рядок `.gitignore` шаблон, який покриває `.claude/hooks/capture-decisions.log`.
|
|
36
|
+
* Враховує точний шлях, glob `.claude/hooks/*.log` та широкий glob `**\/*.log`.
|
|
37
|
+
* @param {string} line одна нормалізована (trim) лінія `.gitignore`
|
|
38
|
+
* @returns {boolean} `true`, якщо лінія матчить лог-файл хука
|
|
39
|
+
*/
|
|
40
|
+
function gitignoreLineCoversHookLog(line) {
|
|
41
|
+
if (!line || line.startsWith('#')) {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
if (line === PROJECT_LOG_PATH) {
|
|
45
|
+
return true
|
|
46
|
+
}
|
|
47
|
+
if (line === '.claude/hooks/*.log' || line === '.claude/hooks/**/*.log') {
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
if (line === '*.log' || line === '**/*.log') {
|
|
51
|
+
return true
|
|
52
|
+
}
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Перевіряє наявність і канонічність `.claude/hooks/capture-decisions.sh` у проєкті.
|
|
58
|
+
* @param {import('./utils/check-reporter.mjs').CheckReporter} reporter репортер для збору результатів
|
|
59
|
+
* @returns {Promise<void>}
|
|
60
|
+
*/
|
|
61
|
+
async function checkHookScript(reporter) {
|
|
62
|
+
const { pass, fail } = reporter
|
|
63
|
+
if (!existsSync(PROJECT_HOOK_PATH)) {
|
|
64
|
+
fail(`${PROJECT_HOOK_PATH} не існує — запусти \`npx @nitra/cursor\` (правило adr копіює канонічний скрипт)`)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
if (!existsSync(BUNDLED_HOOK_PATH)) {
|
|
68
|
+
fail(`канонічний скрипт у пакеті не знайдено: ${BUNDLED_HOOK_PATH} — перевстанови @nitra/cursor`)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
const [project, bundled] = await Promise.all([readFile(PROJECT_HOOK_PATH, 'utf8'), readFile(BUNDLED_HOOK_PATH, 'utf8')])
|
|
72
|
+
if (project === bundled) {
|
|
73
|
+
pass(`${PROJECT_HOOK_PATH} збігається з канонічним`)
|
|
74
|
+
} else {
|
|
75
|
+
fail(`${PROJECT_HOOK_PATH} відрізняється від канонічного — запусти \`npx @nitra/cursor\` для повторного синку`)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Знаходить у `hooks.Stop` групу, де `command` будь-якого hook-а містить маркер.
|
|
81
|
+
* @param {unknown} settings розпарсений `.claude/settings.json`
|
|
82
|
+
* @returns {boolean} `true`, якщо знайдено хоч одну групу з маркером
|
|
83
|
+
*/
|
|
84
|
+
function settingsHaveAdrHookGroup(settings) {
|
|
85
|
+
if (!settings || typeof settings !== 'object') {
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
const hooks = /** @type {Record<string, unknown>} */ (settings).hooks
|
|
89
|
+
if (!hooks || typeof hooks !== 'object') {
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
const stopGroups = /** @type {Record<string, unknown>} */ (hooks).Stop
|
|
93
|
+
if (!Array.isArray(stopGroups)) {
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
return stopGroups.some(group => {
|
|
97
|
+
const inner = group && typeof group === 'object' ? /** @type {Record<string, unknown>} */ (group).hooks : null
|
|
98
|
+
if (!Array.isArray(inner)) {
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
return inner.some(h => {
|
|
102
|
+
const cmd = h && typeof h === 'object' ? /** @type {Record<string, unknown>} */ (h).command : null
|
|
103
|
+
return typeof cmd === 'string' && cmd.includes(HOOK_COMMAND_MARKER)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Зчитує JSON-файл або повертає `undefined`, якщо файл відсутній чи невалідний.
|
|
110
|
+
* @param {string} path відносний шлях до JSON-файлу
|
|
111
|
+
* @returns {Promise<unknown | undefined>} розпарсений вміст або `undefined`
|
|
112
|
+
*/
|
|
113
|
+
async function readJsonOrUndefined(path) {
|
|
114
|
+
if (!existsSync(path)) {
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(await readFile(path, 'utf8'))
|
|
119
|
+
} catch {
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Перевіряє project-shared `.claude/settings.json` на наявність ADR Stop-hook'а.
|
|
126
|
+
* @param {import('./utils/check-reporter.mjs').CheckReporter} reporter репортер для збору результатів
|
|
127
|
+
* @returns {Promise<void>}
|
|
128
|
+
*/
|
|
129
|
+
async function checkProjectSettings(reporter) {
|
|
130
|
+
const { pass, fail } = reporter
|
|
131
|
+
const settings = await readJsonOrUndefined(PROJECT_SETTINGS_PATH)
|
|
132
|
+
if (settings === undefined) {
|
|
133
|
+
fail(`${PROJECT_SETTINGS_PATH} не існує або невалідний — запусти \`npx @nitra/cursor\``)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
if (settingsHaveAdrHookGroup(settings)) {
|
|
137
|
+
pass(`${PROJECT_SETTINGS_PATH} містить ADR Stop-hook (capture-decisions.sh)`)
|
|
138
|
+
} else {
|
|
139
|
+
fail(
|
|
140
|
+
`${PROJECT_SETTINGS_PATH}: у hooks.Stop немає групи з \`${HOOK_COMMAND_MARKER}\` — переконайся, що "adr" у rules і запусти \`npx @nitra/cursor\``
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Перевіряє, що `.claude/settings.local.json` не дублює ADR Stop-hook (project-shared — джерело правди).
|
|
147
|
+
* @param {import('./utils/check-reporter.mjs').CheckReporter} reporter репортер для збору результатів
|
|
148
|
+
* @returns {Promise<void>}
|
|
149
|
+
*/
|
|
150
|
+
async function checkLocalSettingsNoDuplicate(reporter) {
|
|
151
|
+
const { pass, fail } = reporter
|
|
152
|
+
if (!existsSync(PROJECT_LOCAL_SETTINGS_PATH)) {
|
|
153
|
+
pass(`${PROJECT_LOCAL_SETTINGS_PATH} відсутній — дубля немає`)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
const local = await readJsonOrUndefined(PROJECT_LOCAL_SETTINGS_PATH)
|
|
157
|
+
if (local === undefined) {
|
|
158
|
+
pass(`${PROJECT_LOCAL_SETTINGS_PATH} нечитабельний — дубля немає`)
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
if (settingsHaveAdrHookGroup(local)) {
|
|
162
|
+
fail(
|
|
163
|
+
`${PROJECT_LOCAL_SETTINGS_PATH} містить дубль ADR Stop-hook (capture-decisions.sh) — прибери, бо project-shared settings.json уже керує цим`
|
|
164
|
+
)
|
|
165
|
+
} else {
|
|
166
|
+
pass(`${PROJECT_LOCAL_SETTINGS_PATH} не дублює ADR Stop-hook`)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Перевіряє `.gitignore` на ігнорування лог-файлу хука.
|
|
172
|
+
* @param {import('./utils/check-reporter.mjs').CheckReporter} reporter репортер для збору результатів
|
|
173
|
+
* @returns {Promise<void>}
|
|
174
|
+
*/
|
|
175
|
+
async function checkGitignore(reporter) {
|
|
176
|
+
const { pass, fail } = reporter
|
|
177
|
+
if (!existsSync('.gitignore')) {
|
|
178
|
+
fail(`.gitignore не існує — додай рядок \`${PROJECT_LOG_PATH}\``)
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
const content = await readFile('.gitignore', 'utf8')
|
|
182
|
+
const covers = content
|
|
183
|
+
.split(/\r?\n/u)
|
|
184
|
+
.map(l => l.trim())
|
|
185
|
+
.some(gitignoreLineCoversHookLog)
|
|
186
|
+
if (covers) {
|
|
187
|
+
pass(`.gitignore покриває ${PROJECT_LOG_PATH}`)
|
|
188
|
+
} else {
|
|
189
|
+
fail(`.gitignore не ігнорує \`${PROJECT_LOG_PATH}\` — додай цей рядок`)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Чи виконуваний бінарник з іменем `name` доступний у `PATH` поточного процесу.
|
|
195
|
+
* Перевірка без spawn — просто шукаємо файл у каталогах PATH (як `which`).
|
|
196
|
+
* @param {string} name ім'я бінарника без розширення
|
|
197
|
+
* @returns {boolean} `true`, якщо знайдено в одному з каталогів `PATH`
|
|
198
|
+
*/
|
|
199
|
+
function isBinaryInPath(name) {
|
|
200
|
+
const path = env.PATH ?? ''
|
|
201
|
+
if (!path) {
|
|
202
|
+
return false
|
|
203
|
+
}
|
|
204
|
+
for (const dir of path.split(delimiter)) {
|
|
205
|
+
if (!dir) continue
|
|
206
|
+
if (existsSync(join(dir, name))) {
|
|
207
|
+
return true
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Інформативна перевірка: чи доступний бодай один LLM CLI (`claude` або `cursor-agent`).
|
|
215
|
+
* Якщо жодного немає — це warning (`pass` з підказкою), бо хук просто мовчки no-op'ає.
|
|
216
|
+
* @param {import('./utils/check-reporter.mjs').CheckReporter} reporter репортер для збору результатів
|
|
217
|
+
* @returns {void}
|
|
218
|
+
*/
|
|
219
|
+
function checkLlmCliAvailable(reporter) {
|
|
220
|
+
const { pass } = reporter
|
|
221
|
+
const hasClaude = isBinaryInPath('claude')
|
|
222
|
+
const hasCursor = isBinaryInPath('cursor-agent')
|
|
223
|
+
if (hasClaude && hasCursor) {
|
|
224
|
+
pass('LLM CLI: знайдено `claude` і `cursor-agent`')
|
|
225
|
+
} else if (hasClaude) {
|
|
226
|
+
pass('LLM CLI: знайдено `claude` (cursor-agent відсутній — fallback не використовується)')
|
|
227
|
+
} else if (hasCursor) {
|
|
228
|
+
pass('LLM CLI: знайдено `cursor-agent` (claude відсутній — буде використано fallback)')
|
|
229
|
+
} else {
|
|
230
|
+
pass(
|
|
231
|
+
'LLM CLI: жодного з `claude`/`cursor-agent` не знайдено у PATH — Stop-hook буде мовчки no-op до встановлення CLI'
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Перевіряє відповідність проєкту правилам adr.mdc.
|
|
238
|
+
* @returns {Promise<number>} 0 — все OK, 1 — є проблеми
|
|
239
|
+
*/
|
|
240
|
+
export async function check() {
|
|
241
|
+
const reporter = createCheckReporter()
|
|
242
|
+
await checkHookScript(reporter)
|
|
243
|
+
await checkProjectSettings(reporter)
|
|
244
|
+
await checkLocalSettingsNoDuplicate(reporter)
|
|
245
|
+
await checkGitignore(reporter)
|
|
246
|
+
checkLlmCliAvailable(reporter)
|
|
247
|
+
return reporter.getExitCode()
|
|
248
|
+
}
|
package/scripts/check-js-run.mjs
CHANGED
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
* - «depcheck у GitHub Actions з path-фільтром»: для кожного workflow з `paths:`,
|
|
22
22
|
* обмеженим каталогом цього пакета (`<rootDir>/...`), має бути крок
|
|
23
23
|
* `npx depcheck --ignores="graphql,bun"` (плюс інші, за потреби) з
|
|
24
|
-
* `working-directory: <rootDir>` (див. `utils/depcheck-workflow.mjs`)
|
|
24
|
+
* `working-directory: <rootDir>` (див. `utils/depcheck-workflow.mjs`);
|
|
25
|
+
* - «Паузи через setTimeout»: `new Promise(resolve => setTimeout(resolve, ms))` (з/без `await`)
|
|
26
|
+
* треба замінити на `await setTimeout(ms)` з `node:timers/promises`
|
|
27
|
+
* (див. `utils/promise-settimeout-scan.mjs`).
|
|
25
28
|
*/
|
|
26
29
|
import { existsSync } from 'node:fs'
|
|
27
30
|
import { readFile } from 'node:fs/promises'
|
|
@@ -42,6 +45,10 @@ import {
|
|
|
42
45
|
resolveConnDirFromPackageJson
|
|
43
46
|
} from './utils/conn-imports-scan.mjs'
|
|
44
47
|
import { loadCursorIgnorePaths } from './utils/load-cursor-config.mjs'
|
|
48
|
+
import {
|
|
49
|
+
findPromiseSetTimeoutInText,
|
|
50
|
+
isPromiseSetTimeoutScanSourceFile
|
|
51
|
+
} from './utils/promise-settimeout-scan.mjs'
|
|
45
52
|
import { walkDir } from './utils/walkDir.mjs'
|
|
46
53
|
import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
|
|
47
54
|
|
|
@@ -162,6 +169,30 @@ async function checkProcessEnvUsage(absPackageRoot, sourcePaths, label, fail) {
|
|
|
162
169
|
return violations
|
|
163
170
|
}
|
|
164
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Сканує джерела пакета на паттерн `new Promise(resolve => setTimeout(resolve, ms))`.
|
|
174
|
+
* @param {string} absPackageRoot абсолютний корінь пакета
|
|
175
|
+
* @param {string[]} sourcePaths абсолютні шляхи до файлів
|
|
176
|
+
* @param {string} label префікс повідомлення `[<pkg>] `
|
|
177
|
+
* @param {(msg: string) => void} fail callback при помилці
|
|
178
|
+
* @returns {Promise<number>} кількість порушень
|
|
179
|
+
*/
|
|
180
|
+
async function checkPromiseSetTimeoutPause(absPackageRoot, sourcePaths, label, fail) {
|
|
181
|
+
let violations = 0
|
|
182
|
+
for (const absPath of sourcePaths) {
|
|
183
|
+
const rel = relPosix(absPackageRoot, absPath)
|
|
184
|
+
if (!isPromiseSetTimeoutScanSourceFile(rel)) continue
|
|
185
|
+
const content = await readFile(absPath, 'utf8')
|
|
186
|
+
for (const v of findPromiseSetTimeoutInText(content, rel)) {
|
|
187
|
+
violations++
|
|
188
|
+
fail(
|
|
189
|
+
`${label}${rel}:${v.line} — заміни 'new Promise(r => setTimeout(r, ms))' на 'await setTimeout(ms)' з 'node:timers/promises': ${v.snippet}`
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return violations
|
|
194
|
+
}
|
|
195
|
+
|
|
165
196
|
/**
|
|
166
197
|
* Перевіряє відповідність правилам js-run.mdc для одного workspace-пакета.
|
|
167
198
|
* @param {string} rootDir відносний шлях workspace (не `'.'`)
|
|
@@ -205,6 +236,11 @@ async function checkWorkspacePackage(rootDir, ignorePaths, workflows, fail, pass
|
|
|
205
236
|
)
|
|
206
237
|
}
|
|
207
238
|
|
|
239
|
+
const pauseViolations = await checkPromiseSetTimeoutPause(absPackageRoot, sourcePaths, label, fail)
|
|
240
|
+
if (pauseViolations === 0) {
|
|
241
|
+
passFn(`${label}немає 'new Promise(r => setTimeout(r, ms))' — паузи через 'node:timers/promises'`)
|
|
242
|
+
}
|
|
243
|
+
|
|
208
244
|
await checkOtelConfigmap(rootDir, label, fail, passFn)
|
|
209
245
|
|
|
210
246
|
checkDepcheckInWorkflows(rootDir, workflows, label, fail, passFn)
|
package/scripts/check-vue.mjs
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* Версії Vite та плагінів, vue-macros, auto-import, layouts, вміст `vite.config`;
|
|
5
5
|
* у репозиторії — рекомендацію розширення Vue.volar.
|
|
6
6
|
*
|
|
7
|
+
* У кожному Vue+Vite-пакеті очікується `src/vite-env.d.ts` з `/// <reference types="vite/client" />`
|
|
8
|
+
* та `jsconfig.json` у корені пакета (типи для імпортів асетів у `.vue`).
|
|
9
|
+
*
|
|
7
10
|
* У `vite.config.*` заборонено використовувати `process.env.npm_lifecycle_event` (Bun не підставляє його як npm),
|
|
8
11
|
* натомість використовуй `mode` з `defineConfig(({ mode }) => ...)`.
|
|
9
12
|
*
|
|
@@ -32,6 +35,9 @@ import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
|
|
|
32
35
|
const MAJOR_VERSION_RE = /(\d+)/
|
|
33
36
|
const ESBUILD_RE = /\besbuild\b/
|
|
34
37
|
|
|
38
|
+
/** Регулярний вираз для triple-slash `reference types="vite/client"` у `src/vite-env.d.ts`. */
|
|
39
|
+
const VITE_CLIENT_REFERENCE_RE = /\/\/\/\s*<reference\s+types\s*=\s*["']vite\/client["']\s*\/>/
|
|
40
|
+
|
|
35
41
|
/**
|
|
36
42
|
* Визначає, чи можна сканувати файл як текст на згадки `esbuild`.
|
|
37
43
|
* @param {string} relPosix відносний шлях у posix-форматі
|
|
@@ -202,6 +208,43 @@ function checkRequiredDep(deps, name, prefix, passFn, fail, hint = `${name} ві
|
|
|
202
208
|
* @param {(msg: string) => void} passFn callback при успішній перевірці
|
|
203
209
|
* @param {(msg: string) => void} fail callback при помилці
|
|
204
210
|
*/
|
|
211
|
+
/**
|
|
212
|
+
* Перевіряє `src/vite-env.d.ts` і наявність `jsconfig.json` для підтягування типів асетів Vite у IDE.
|
|
213
|
+
* @param {string} rootDir відносний шлях до кореня пакета
|
|
214
|
+
* @param {string} prefix префікс повідомлень
|
|
215
|
+
* @param {(msg: string) => void} passFn успіх
|
|
216
|
+
* @param {(msg: string) => void} fail помилка
|
|
217
|
+
* @returns {Promise<void>}
|
|
218
|
+
*/
|
|
219
|
+
async function checkViteClientEnvAndEditorConfig(rootDir, prefix, passFn, fail) {
|
|
220
|
+
const envRel = join(rootDir, 'src/vite-env.d.ts')
|
|
221
|
+
if (!existsSync(envRel)) {
|
|
222
|
+
fail(
|
|
223
|
+
`${prefix}немає src/vite-env.d.ts — додай файл з рядком /// <reference types="vite/client" /> ` +
|
|
224
|
+
`(інакше TS/Volar не бачать типів для імпортів асетів: png, avif, css як URL).`
|
|
225
|
+
)
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
const envContent = await readFile(envRel, 'utf8')
|
|
229
|
+
if (!VITE_CLIENT_REFERENCE_RE.test(envContent)) {
|
|
230
|
+
fail(
|
|
231
|
+
`${prefix}src/vite-env.d.ts має містити /// <reference types="vite/client" /> ` +
|
|
232
|
+
`(без цього імпорти статичних файлів у .vue дають «Cannot find module … type declarations»).`
|
|
233
|
+
)
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
passFn(`${prefix}src/vite-env.d.ts посилається на vite/client`)
|
|
237
|
+
|
|
238
|
+
if (!existsSync(join(rootDir, 'jsconfig.json'))) {
|
|
239
|
+
fail(
|
|
240
|
+
`${prefix}немає jsconfig.json у корені пакета — додай файл з "include": ["src/**/*"] тощо, ` +
|
|
241
|
+
`щоб IDE підхопила vite-env.d.ts і .vue.`
|
|
242
|
+
)
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
passFn(`${prefix}jsconfig.json присутній`)
|
|
246
|
+
}
|
|
247
|
+
|
|
205
248
|
function checkViteVersion(devDeps, prefix, passFn, fail) {
|
|
206
249
|
const v = devDeps.vite
|
|
207
250
|
if (!v) {
|
|
@@ -442,6 +485,8 @@ async function checkVuePackage(rootDir, ignorePaths, fail, passFn) {
|
|
|
442
485
|
'vite-plugin-vue-layouts-next відсутній — bun add -d vite-plugin-vue-layouts-next'
|
|
443
486
|
)
|
|
444
487
|
|
|
488
|
+
await checkViteClientEnvAndEditorConfig(rootDir, prefix, passFn, fail)
|
|
489
|
+
|
|
445
490
|
const { hasVueAutoImport } = await checkViteConfig(rootDir, prefix, passFn, fail)
|
|
446
491
|
await checkVueImportViolations(
|
|
447
492
|
rootDir,
|
|
@@ -1,31 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Синхронізує конфігурацію Claude Code (`.claude/settings.json`, `npm/CLAUDE.md`,
|
|
3
|
-
* slash-команди для checks) у поточний проєкт із темплейтів пакету
|
|
3
|
+
* slash-команди для checks, ADR Stop-hook) у поточний проєкт із темплейтів пакету
|
|
4
4
|
* `npm/.claude-template/`.
|
|
5
5
|
*
|
|
6
6
|
* Архітектура:
|
|
7
7
|
* - `settings.json` — **merge**: користувацькі поля зберігаються; наші hooks
|
|
8
|
-
* ідентифікуються командою-маркером (`
|
|
8
|
+
* ідентифікуються командою-маркером (`MANAGED_HOOK_COMMAND_MARKERS`) і
|
|
9
9
|
* перезаписуються; permissions.allow зливається через union (із дедублікацією).
|
|
10
10
|
* - `npm/CLAUDE.md` — **fully owned**: завжди перезаписується; пропускається,
|
|
11
11
|
* якщо в проєкті немає каталогу `npm/`.
|
|
12
12
|
* - `.claude/commands/n-check.md` — fully owned slash-команда.
|
|
13
|
+
* - `.claude/hooks/capture-decisions.sh` — fully owned bash-скрипт ADR Stop-hook;
|
|
14
|
+
* копіюється з `.claude-template/hooks/`, лише коли в `.n-cursor.json` `rules`
|
|
15
|
+
* присутнє `adr` (правило вмикається вручну). Якщо правила немає, керована
|
|
16
|
+
* ADR-група в hooks так само автоматично прибирається з settings.json.
|
|
13
17
|
*
|
|
14
18
|
* Опт-аут — `claude-config: false` у `.n-cursor.json`.
|
|
15
19
|
*/
|
|
16
20
|
import { existsSync } from 'node:fs'
|
|
17
|
-
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
|
21
|
+
import { chmod, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
|
18
22
|
import { join } from 'node:path'
|
|
19
23
|
|
|
20
|
-
/** Маркер
|
|
24
|
+
/** Маркер lint Stop-hook'а (`npx --no @nitra/cursor stop-hook`). */
|
|
21
25
|
export const MANAGED_HOOK_COMMAND_MARKER = '@nitra/cursor stop-hook'
|
|
26
|
+
/** Маркер ADR Stop-hook'а — підрядок шляху до bash-скрипта. */
|
|
27
|
+
export const ADR_HOOK_COMMAND_MARKER = '.claude/hooks/capture-decisions.sh'
|
|
28
|
+
/** Усі маркери managed-hook'ів пакета — за ними відрізняємо свої записи від користувацьких. */
|
|
29
|
+
export const MANAGED_HOOK_COMMAND_MARKERS = Object.freeze([MANAGED_HOOK_COMMAND_MARKER, ADR_HOOK_COMMAND_MARKER])
|
|
22
30
|
|
|
23
31
|
const CLAUDE_DIR = '.claude'
|
|
24
32
|
const CLAUDE_SETTINGS_FILE = `${CLAUDE_DIR}/settings.json`
|
|
25
33
|
const CLAUDE_COMMANDS_DIR = `${CLAUDE_DIR}/commands`
|
|
34
|
+
const CLAUDE_HOOKS_DIR = `${CLAUDE_DIR}/hooks`
|
|
35
|
+
const ADR_HOOK_SCRIPT_NAME = 'capture-decisions.sh'
|
|
26
36
|
const NPM_CLAUDE_MD_FILE = 'npm/CLAUDE.md'
|
|
27
37
|
const TEMPLATE_DIR_NAME = '.claude-template'
|
|
28
38
|
|
|
39
|
+
/** Канонічна група hooks для ADR Stop-hook'а — додається в settings, коли `adr` у `rules`. */
|
|
40
|
+
const ADR_STOP_HOOK_GROUP = Object.freeze({
|
|
41
|
+
matcher: '',
|
|
42
|
+
hooks: Object.freeze([
|
|
43
|
+
Object.freeze({
|
|
44
|
+
type: 'command',
|
|
45
|
+
command: `bash "$CLAUDE_PROJECT_DIR/${ADR_HOOK_COMMAND_MARKER}"`,
|
|
46
|
+
async: true,
|
|
47
|
+
timeout: 180
|
|
48
|
+
})
|
|
49
|
+
])
|
|
50
|
+
})
|
|
51
|
+
|
|
29
52
|
/**
|
|
30
53
|
* @typedef {object} HookEntry
|
|
31
54
|
* @property {string} type тип hook'а у форматі Claude Code (зазвичай `'command'`)
|
|
@@ -46,15 +69,17 @@ const TEMPLATE_DIR_NAME = '.claude-template'
|
|
|
46
69
|
*/
|
|
47
70
|
|
|
48
71
|
/**
|
|
49
|
-
* Чи hook-група містить лише наші managed-команди (за
|
|
72
|
+
* Чи hook-група містить лише наші managed-команди (за будь-яким із маркерів пакета).
|
|
50
73
|
* @param {HookGroup} group hook-група з .claude/settings.json
|
|
51
|
-
* @returns {boolean} `true`, якщо всі hooks мають маркер `
|
|
74
|
+
* @returns {boolean} `true`, якщо всі hooks мають маркер з `MANAGED_HOOK_COMMAND_MARKERS`
|
|
52
75
|
*/
|
|
53
76
|
function isManagedHookGroup(group) {
|
|
54
77
|
if (!group?.hooks?.length) {
|
|
55
78
|
return false
|
|
56
79
|
}
|
|
57
|
-
return group.hooks.every(
|
|
80
|
+
return group.hooks.every(
|
|
81
|
+
h => typeof h?.command === 'string' && MANAGED_HOOK_COMMAND_MARKERS.some(marker => h.command.includes(marker))
|
|
82
|
+
)
|
|
58
83
|
}
|
|
59
84
|
|
|
60
85
|
/**
|
|
@@ -103,20 +128,39 @@ export function mergeHooks(existing, fromTemplate) {
|
|
|
103
128
|
return out
|
|
104
129
|
}
|
|
105
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Будує копію темплейту із додатковою ADR Stop hook-групою у `Stop`.
|
|
133
|
+
* Темплейт залишається незмінним; повертається новий об'єкт з доданою групою.
|
|
134
|
+
* @param {ClaudeSettings} template вихідний темплейт із `.claude-template/settings.template.json`
|
|
135
|
+
* @returns {ClaudeSettings} копія з доданою ADR-групою у `hooks.Stop`
|
|
136
|
+
*/
|
|
137
|
+
function templateWithAdrHook(template) {
|
|
138
|
+
/** @type {Record<string, HookGroup[]>} */
|
|
139
|
+
const hooks = {}
|
|
140
|
+
for (const [event, groups] of Object.entries(template.hooks ?? {})) {
|
|
141
|
+
hooks[event] = Array.isArray(groups) ? [...groups] : []
|
|
142
|
+
}
|
|
143
|
+
hooks.Stop = [...(hooks.Stop ?? []), /** @type {HookGroup} */ (ADR_STOP_HOOK_GROUP)]
|
|
144
|
+
return { ...template, hooks }
|
|
145
|
+
}
|
|
146
|
+
|
|
106
147
|
/**
|
|
107
148
|
* Повертає об'єднаний об'єкт settings.json.
|
|
108
149
|
* @param {ClaudeSettings | undefined} existing існуючий вміст `.claude/settings.json` користувача (або undefined, якщо файла нема)
|
|
109
150
|
* @param {ClaudeSettings} template settings із темплейту пакета `@nitra/cursor`
|
|
151
|
+
* @param {object} [options] опції merge-у
|
|
152
|
+
* @param {boolean} [options.includeAdrHook] чи додати ADR Stop-hook групу до managed-hooks (коли в `.n-cursor.json` `rules` присутнє `adr`)
|
|
110
153
|
* @returns {ClaudeSettings} результат merge-у (користувацькі поля збережено, наші перевизначено)
|
|
111
154
|
*/
|
|
112
|
-
export function mergeSettings(existing, template) {
|
|
155
|
+
export function mergeSettings(existing, template, options = {}) {
|
|
156
|
+
const effectiveTemplate = options.includeAdrHook ? templateWithAdrHook(template) : template
|
|
113
157
|
/** @type {ClaudeSettings} */
|
|
114
158
|
const merged = { ...existing }
|
|
115
|
-
const mergedAllow = mergeAllowList(existing?.permissions?.allow,
|
|
159
|
+
const mergedAllow = mergeAllowList(existing?.permissions?.allow, effectiveTemplate.permissions?.allow)
|
|
116
160
|
if (mergedAllow.length > 0) {
|
|
117
161
|
merged.permissions = { ...existing?.permissions, allow: mergedAllow }
|
|
118
162
|
}
|
|
119
|
-
const mergedHooks = mergeHooks(existing?.hooks,
|
|
163
|
+
const mergedHooks = mergeHooks(existing?.hooks, effectiveTemplate.hooks)
|
|
120
164
|
if (Object.keys(mergedHooks).length > 0) {
|
|
121
165
|
merged.hooks = mergedHooks
|
|
122
166
|
} else {
|
|
@@ -146,9 +190,11 @@ async function readJsonOrUndefined(path) {
|
|
|
146
190
|
* користувацьких полів.
|
|
147
191
|
* @param {string} projectRoot корінь проєкту, куди писати
|
|
148
192
|
* @param {string} templateDir каталог `.claude-template/` усередині пакету
|
|
193
|
+
* @param {object} [options] опції merge-у
|
|
194
|
+
* @param {boolean} [options.includeAdrHook] чи додавати ADR Stop-hook (правило `adr` увімкнене у `rules`)
|
|
149
195
|
* @returns {Promise<{ written: boolean, path: string }>} результат: чи писали файл, та його відносний шлях
|
|
150
196
|
*/
|
|
151
|
-
export async function syncClaudeSettings(projectRoot, templateDir) {
|
|
197
|
+
export async function syncClaudeSettings(projectRoot, templateDir, options = {}) {
|
|
152
198
|
const templatePath = join(templateDir, 'settings.template.json')
|
|
153
199
|
if (!existsSync(templatePath)) {
|
|
154
200
|
return { written: false, path: '' }
|
|
@@ -156,12 +202,33 @@ export async function syncClaudeSettings(projectRoot, templateDir) {
|
|
|
156
202
|
const template = /** @type {ClaudeSettings} */ (JSON.parse(await readFile(templatePath, 'utf8')))
|
|
157
203
|
const settingsPath = join(projectRoot, CLAUDE_SETTINGS_FILE)
|
|
158
204
|
const existing = await readJsonOrUndefined(settingsPath)
|
|
159
|
-
const merged = mergeSettings(existing, template)
|
|
205
|
+
const merged = mergeSettings(existing, template, options)
|
|
160
206
|
await mkdir(join(projectRoot, CLAUDE_DIR), { recursive: true })
|
|
161
207
|
await writeFile(settingsPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8')
|
|
162
208
|
return { written: true, path: CLAUDE_SETTINGS_FILE }
|
|
163
209
|
}
|
|
164
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Копіює канонічний `.claude/hooks/capture-decisions.sh` з темплейту пакета.
|
|
213
|
+
* Файл повністю керується пакетом — на кожен sync перезаписується (як setup-bun-deps).
|
|
214
|
+
* @param {string} projectRoot корінь проєкту, куди писати
|
|
215
|
+
* @param {string} templateDir каталог `.claude-template/` усередині пакету
|
|
216
|
+
* @returns {Promise<{ written: boolean, path: string }>} результат: чи писали файл, та його відносний шлях
|
|
217
|
+
*/
|
|
218
|
+
export async function syncAdrHookScript(projectRoot, templateDir) {
|
|
219
|
+
const templatePath = join(templateDir, 'hooks', ADR_HOOK_SCRIPT_NAME)
|
|
220
|
+
if (!existsSync(templatePath)) {
|
|
221
|
+
return { written: false, path: '' }
|
|
222
|
+
}
|
|
223
|
+
const content = await readFile(templatePath, 'utf8')
|
|
224
|
+
const hooksDir = join(projectRoot, CLAUDE_HOOKS_DIR)
|
|
225
|
+
await mkdir(hooksDir, { recursive: true })
|
|
226
|
+
const destPath = join(hooksDir, ADR_HOOK_SCRIPT_NAME)
|
|
227
|
+
await writeFile(destPath, content, 'utf8')
|
|
228
|
+
await chmod(destPath, 0o755)
|
|
229
|
+
return { written: true, path: `${CLAUDE_HOOKS_DIR}/${ADR_HOOK_SCRIPT_NAME}` }
|
|
230
|
+
}
|
|
231
|
+
|
|
165
232
|
/**
|
|
166
233
|
* Копіює `npm/CLAUDE.md` з темплейту, якщо в проєкті є каталог `npm/`.
|
|
167
234
|
* @param {string} projectRoot корінь проєкту, куди писати
|
|
@@ -215,18 +282,21 @@ export async function syncClaudeCommands(projectRoot, templateDir) {
|
|
|
215
282
|
* @param {string} options.projectRoot корінь проєкту-споживача
|
|
216
283
|
* @param {string} options.bundledPackageRoot корінь установленого `@nitra/cursor`
|
|
217
284
|
* @param {boolean} options.enabled чи увімкнено sync (з `.n-cursor.json` `claude-config`)
|
|
218
|
-
* @
|
|
285
|
+
* @param {string[]} [options.rules] список увімкнених правил із `.n-cursor.json` — впливає на ADR Stop-hook (`adr`)
|
|
286
|
+
* @returns {Promise<{ settings: boolean, npmClaudeMd: boolean, commands: string[], adrHook: boolean }>} прапорці записів settings/CLAUDE.md/ADR-hook та список записаних slash-команд
|
|
219
287
|
*/
|
|
220
|
-
export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enabled }) {
|
|
288
|
+
export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enabled, rules = [] }) {
|
|
221
289
|
if (!enabled) {
|
|
222
|
-
return { settings: false, npmClaudeMd: false, commands: [] }
|
|
290
|
+
return { settings: false, npmClaudeMd: false, commands: [], adrHook: false }
|
|
223
291
|
}
|
|
224
292
|
const templateDir = join(bundledPackageRoot, TEMPLATE_DIR_NAME)
|
|
225
293
|
if (!existsSync(templateDir)) {
|
|
226
|
-
return { settings: false, npmClaudeMd: false, commands: [] }
|
|
294
|
+
return { settings: false, npmClaudeMd: false, commands: [], adrHook: false }
|
|
227
295
|
}
|
|
228
|
-
const
|
|
296
|
+
const includeAdrHook = Array.isArray(rules) && rules.includes('adr')
|
|
297
|
+
const adrHook = includeAdrHook ? await syncAdrHookScript(projectRoot, templateDir) : { written: false, path: '' }
|
|
298
|
+
const settings = await syncClaudeSettings(projectRoot, templateDir, { includeAdrHook })
|
|
229
299
|
const npmClaudeMd = await syncNpmClaudeMd(projectRoot, templateDir)
|
|
230
300
|
const commands = await syncClaudeCommands(projectRoot, templateDir)
|
|
231
|
-
return { settings: settings.written, npmClaudeMd: npmClaudeMd.written, commands }
|
|
301
|
+
return { settings: settings.written, npmClaudeMd: npmClaudeMd.written, commands, adrHook: adrHook.written }
|
|
232
302
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Знаходить паттерн `new Promise(resolve => setTimeout(resolve, ms))` (з `await` чи без)
|
|
3
|
+
* у джерелах — таку обгортку треба замінити на `setTimeout` з `node:timers/promises`
|
|
4
|
+
* згідно з js-run.mdc, секція «Паузи через setTimeout».
|
|
5
|
+
*
|
|
6
|
+
* Семантика — структурна (без regex по тілу): `NewExpression` з ідентифікатор-callee `Promise`
|
|
7
|
+
* і єдиним аргументом-функцією, тіло якої — виклик `setTimeout(<resolve>, ms)`. Перший
|
|
8
|
+
* аргумент `setTimeout` має передавати `resolve` напряму або тривіально загорнутим у
|
|
9
|
+
* безпараметричну функцію `() => resolve()` / `function () { resolve() }` без жодних
|
|
10
|
+
* аргументів — інакше це не «чиста пауза», і паттерн не вмикається.
|
|
11
|
+
*
|
|
12
|
+
* Сканер не вимагає, щоб файл компілювався: при синтаксичних помилках повертається
|
|
13
|
+
* порожній результат (як інші сканери — спочатку треба полагодити синтаксис).
|
|
14
|
+
*/
|
|
15
|
+
import { normalizeSnippet, offsetToLine, parseProgramOrNull } from './ast-scan-utils.mjs'
|
|
16
|
+
|
|
17
|
+
const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Чи аргумент, який передають у `setTimeout`, — це «голий» виклик `resolve`
|
|
21
|
+
* (тобто сам ідентифікатор або `() => resolve()` без аргументів).
|
|
22
|
+
* @param {Record<string, unknown> | null | undefined} arg AST-вузол першого аргументу `setTimeout`
|
|
23
|
+
* @param {string} paramName ім'я параметра-resolve у тіла-функції Promise
|
|
24
|
+
* @returns {boolean} `true`, якщо це чиста передача resolve без значення
|
|
25
|
+
*/
|
|
26
|
+
function isBareResolveCallback(arg, paramName) {
|
|
27
|
+
if (!arg || typeof arg !== 'object') return false
|
|
28
|
+
if (arg.type === 'Identifier' && arg.name === paramName) return true
|
|
29
|
+
if (arg.type !== 'ArrowFunctionExpression' && arg.type !== 'FunctionExpression') return false
|
|
30
|
+
if ((arg.params?.length ?? 0) !== 0) return false
|
|
31
|
+
const callExpr = extractSingleCallExpression(arg.body)
|
|
32
|
+
if (!callExpr) return false
|
|
33
|
+
if (callExpr.callee?.type !== 'Identifier' || callExpr.callee.name !== paramName) return false
|
|
34
|
+
return !Array.isArray(callExpr.arguments) || callExpr.arguments.length === 0
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Якщо тіло функції — рівно один `CallExpression` (концизне `() => foo()` або
|
|
39
|
+
* `{ foo() }` без інших стейтментів), повертає його. Інакше — `null`.
|
|
40
|
+
* @param {unknown} body тіло функції з AST
|
|
41
|
+
* @returns {Record<string, unknown> | null} AST-вузол `CallExpression` або `null`
|
|
42
|
+
*/
|
|
43
|
+
function extractSingleCallExpression(body) {
|
|
44
|
+
if (!body || typeof body !== 'object') return null
|
|
45
|
+
if (body.type === 'CallExpression') return body
|
|
46
|
+
if (body.type !== 'BlockStatement') return null
|
|
47
|
+
if (!Array.isArray(body.body) || body.body.length !== 1) return null
|
|
48
|
+
const stmt = body.body[0]
|
|
49
|
+
if (!stmt || stmt.type !== 'ExpressionStatement') return null
|
|
50
|
+
const expr = stmt.expression
|
|
51
|
+
return expr?.type === 'CallExpression' ? expr : null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Чи це `NewExpression` виду `new Promise(<resolve> => setTimeout(<resolve>, ms))`.
|
|
56
|
+
* Параметр-resolve має бути простим Identifier; setTimeout — глобальним викликом
|
|
57
|
+
* за іменем (з будь-якого джерела — node:timers, global, тощо: значення для нас має
|
|
58
|
+
* лише структурний паттерн).
|
|
59
|
+
* @param {Record<string, unknown> | null | undefined} node AST-вузол
|
|
60
|
+
* @returns {boolean} `true`, якщо це проблемний паттерн «обгортки таймера у Promise»
|
|
61
|
+
*/
|
|
62
|
+
function isPromiseSetTimeoutDelay(node) {
|
|
63
|
+
if (!node || node.type !== 'NewExpression') return false
|
|
64
|
+
if (node.callee?.type !== 'Identifier' || node.callee.name !== 'Promise') return false
|
|
65
|
+
if (!Array.isArray(node.arguments) || node.arguments.length !== 1) return false
|
|
66
|
+
const fn = node.arguments[0]
|
|
67
|
+
if (!fn || (fn.type !== 'ArrowFunctionExpression' && fn.type !== 'FunctionExpression')) return false
|
|
68
|
+
if (!Array.isArray(fn.params) || fn.params.length === 0) return false
|
|
69
|
+
const firstParam = fn.params[0]
|
|
70
|
+
if (!firstParam || firstParam.type !== 'Identifier') return false
|
|
71
|
+
const setTimeoutCall = extractSingleCallExpression(fn.body)
|
|
72
|
+
if (!setTimeoutCall) return false
|
|
73
|
+
if (setTimeoutCall.callee?.type !== 'Identifier' || setTimeoutCall.callee.name !== 'setTimeout') return false
|
|
74
|
+
if (!Array.isArray(setTimeoutCall.arguments) || setTimeoutCall.arguments.length < 1) return false
|
|
75
|
+
return isBareResolveCallback(setTimeoutCall.arguments[0], firstParam.name)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Простий рекурсивний обхід AST: заходимо в усі об'єкти/масиви, щоб знайти `NewExpression`.
|
|
80
|
+
* @param {unknown} node корінь або під-вузол AST
|
|
81
|
+
* @param {(n: Record<string, unknown>) => void} visit виклик для кожного об'єкта-вузла з `type`
|
|
82
|
+
* @returns {void}
|
|
83
|
+
*/
|
|
84
|
+
function walkAst(node, visit) {
|
|
85
|
+
if (!node || typeof node !== 'object') return
|
|
86
|
+
if (Array.isArray(node)) {
|
|
87
|
+
for (const item of node) walkAst(item, visit)
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
if (typeof node.type === 'string') {
|
|
91
|
+
visit(node)
|
|
92
|
+
}
|
|
93
|
+
for (const key of Object.keys(node)) {
|
|
94
|
+
if (key === 'parent') continue
|
|
95
|
+
const v = node[key]
|
|
96
|
+
if (v && typeof v === 'object') walkAst(v, visit)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Знаходить усі `new Promise(resolve => setTimeout(resolve, ms))` у тексті.
|
|
102
|
+
* @param {string} content вихідний код
|
|
103
|
+
* @param {string} [virtualPath] шлях для вибору `lang` (наприклад `pkg/src/foo.ts`)
|
|
104
|
+
* @returns {{ line: number, snippet: string }[]} список порушень
|
|
105
|
+
*/
|
|
106
|
+
export function findPromiseSetTimeoutInText(content, virtualPath = 'scan.ts') {
|
|
107
|
+
const program = parseProgramOrNull(content, virtualPath)
|
|
108
|
+
if (!program) return []
|
|
109
|
+
/** @type {{ line: number, snippet: string }[]} */
|
|
110
|
+
const out = []
|
|
111
|
+
walkAst(program, node => {
|
|
112
|
+
if (!isPromiseSetTimeoutDelay(node)) return
|
|
113
|
+
out.push({
|
|
114
|
+
line: offsetToLine(content, node.start),
|
|
115
|
+
snippet: normalizeSnippet(content.slice(node.start, node.end))
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Чи сканувати цей файл за розширенням (JS/TS-сім'я, виключно з `.d.ts`).
|
|
123
|
+
* @param {string} relativePath відносний шлях до файлу
|
|
124
|
+
* @returns {boolean} `true`, якщо розширення підходить для сканування
|
|
125
|
+
*/
|
|
126
|
+
export function isPromiseSetTimeoutScanSourceFile(relativePath) {
|
|
127
|
+
if (!SOURCE_FILE_RE.test(relativePath)) return false
|
|
128
|
+
return !relativePath.endsWith('.d.ts')
|
|
129
|
+
}
|