diff --git a/.gitattributes b/.gitattributes index d65fbf8..54ebe78 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,13 +1,13 @@ -# Git hooks и скрипты ДОЛЖНЫ быть LF — CRLF в shebang (`#!/usr/bin/env node\r`) -# ломает запуск на macOS/Linux ("node\r: not found"). Форсим LF независимо от autocrlf. +# Git hooks and scripts must stay LF; CRLF in a shebang (`#!/usr/bin/env node\r`) +# breaks macOS/Linux execution ("node\r: not found"). Force LF regardless of autocrlf. hooks/*.js text eol=lf hooks/**/*.js text eol=lf install.js text eol=lf *.sh text eol=lf -# Конфиги — тоже LF: иначе на Windows (autocrlf=true) рабочая копия становится CRLF, -# и `node hooks/doctor.js`, читающий рабочее дерево, даёт ложный FAIL (в индексе и на -# Linux-CI всё равно LF). Пинним форматы явно. +# Config files are LF too: otherwise Windows with autocrlf=true can make the working +# tree CRLF, and `node hooks/doctor.js` reads the working tree and reports a false +# FAIL even when the index and Linux CI are LF. Pin these formats explicitly. *.toml text eol=lf *.yml text eol=lf *.yaml text eol=lf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..0bca446 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,5 @@ +# Code owners for this target repository. +# No owner is configured yet, so the installed ruleset keeps code-owner +# review disabled and relies on the regular approving-review requirement. +# Re-run install.js with --code-owner @org/team or edit this file and +# enable require_code_owner_review in .github/rulesets/main.json. diff --git a/.github/rulesets/main.json b/.github/rulesets/main.json index 9486754..2bab91d 100644 --- a/.github/rulesets/main.json +++ b/.github/rulesets/main.json @@ -1,21 +1,28 @@ { - "_comment": "Versioned GitHub branch ruleset — the REAL enforcement layer (server-side, cannot be skipped by `git ... --no-verify` or by editing local hooks). Apply with: node hooks/apply-ruleset.js. NOTE: private repos need GitHub Pro/Team/Enterprise for rulesets; on Free make the repo public or upgrade (BACKLOG P0-0). require_code_owner_review is FALSE on purpose: a solo maintainer cannot approve their own PR — enabling it would deadlock main. integration_id 15368 pins the required check to GitHub Actions: without it ANYONE with write access can satisfy the check by posting a fake commit status named 'verify' via the API.", + "_comment": "Installed GitHub branch ruleset for llm-dev-harness: the server-side gate that local hooks cannot replace. It requires PRs, the GitHub Actions verify check pinned by integration_id, and blocks force-push/delete on main. Code-owner review is disabled because install.js was run without --code-owner; the regular approving-review requirement remains enabled. Re-run install.js with --code-owner @org/team to require CODEOWNERS review.", "name": "protect-main", "target": "branch", "enforcement": "active", "conditions": { "ref_name": { - "include": ["refs/heads/main", "refs/heads/master"], + "include": [ + "refs/heads/main", + "refs/heads/master" + ], "exclude": [] } }, "rules": [ - { "type": "deletion" }, - { "type": "non_fast_forward" }, + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, { "type": "pull_request", "parameters": { - "required_approving_review_count": 0, + "required_approving_review_count": 1, "dismiss_stale_reviews_on_push": true, "require_code_owner_review": false, "require_last_push_approval": false, @@ -27,7 +34,10 @@ "parameters": { "strict_required_status_checks_policy": true, "required_status_checks": [ - { "context": "verify", "integration_id": 15368 } + { + "context": "verify", + "integration_id": 15368 + } ] } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ecdc2d..4898e32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,22 +1,107 @@ -name: CI +name: verify +# CI mirror of the local harness - the SAME checks, server-side where local hooks and +# `--no-verify` cannot skip them. The job id `verify` is the context referenced by the +# branch ruleset (.github/rulesets/main.json -> required_status_checks). Renaming the job +# means updating the ruleset too. +# +# On GitHub Free + private this workflow RUNS and reports status but cannot be *required* +# (rulesets need Pro/Team or a public repo - see BACKLOG P0-0). +# +# Actions are pinned to full commit SHAs. The trailing comment keeps Dependabot's +# github-actions updater able to identify the source action and desired major. on: + # Push runs only on main for post-merge control. Branches are checked through PRs; + # otherwise every PR commit would run CI twice (push + pull_request). push: branches: [main] pull_request: + branches: [main] + +permissions: + contents: read jobs: verify: runs-on: windows-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # actions/setup-node@v6 + with: + node-version: "22" + + - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" - - uses: actions/setup-dotnet@v5 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # actions/setup-go@v6 with: - dotnet-version: 10.0.x + go-version: "1.24.x" + + # Doctor catches bootstrap drift before the required check can go green by name only. + - name: Doctor (harness contract) + run: node hooks/doctor.js + + # Secrets - gitleaks. Install from the Go module instead of gitleaks-action: + # the action's Windows asset resolver requested a non-existent .tar.gz. + - name: Secret scan (gitleaks) + shell: bash + env: + GITLEAKS_VERSION: "v8.24.3" + GITLEAKS_CONFIG: ".gitleaks.toml" + run: | + go install github.com/zricethezav/gitleaks/v8@$GITLEAKS_VERSION + gitleaks detect --source . --config "$GITLEAKS_CONFIG" --redact --no-banner + + # Conventional Commits across the PR's commits - cocogitto (`cog check`). + # cocogitto-action is Linux-only; install the Windows binary explicitly. + - name: Install cocogitto + if: github.event_name == 'pull_request' + shell: bash + env: + COG_VERSION: "7.0.0" + COG_SHA256: "074f68f05d270da5c0d69d3e234ec362bec4c6e3189c21d1c948d038603655d7" + run: | + curl -fsSL -o cog.tar.gz "https://github.com/cocogitto/cocogitto/releases/download/$COG_VERSION/cocogitto-$COG_VERSION-x86_64-pc-windows-msvc.tar.gz" + echo "$COG_SHA256 cog.tar.gz" | sha256sum -c - + tar -xzf cog.tar.gz + "$PWD/x86_64-pc-windows-msvc/cog.exe" --version + + # `cog.toml` is release-tag aware, but PR checks must also work before the + # first release tag exists. Use the PR base SHA explicitly instead of + # relying on from_latest_tag. + - name: Conventional commit range + if: github.event_name == 'pull_request' + shell: bash + run: ./x86_64-pc-windows-msvc/cog.exe check "${{ github.event.pull_request.base.sha }}..HEAD" --ignore-merge-commits + + # Executable VERIFY: auto-detects stacks and runs lint/build/test fail-fast. + # For this repo that runs the harness self-test suite (node hooks/test.js). + - name: VERIFY (verify.js) + run: node hooks/verify.js - - name: Build - run: dotnet build src/Dropwheel -c Release + # AgentShield is an offline security scan of agent configuration: secrets, + # broad tool/MCP permissions, hook injections, and unsafe CLAUDE.md/AGENTS.md + # patterns. The version is pinned rather than using a floating tag/action, in + # the same supply-chain spirit as action SHA pinning. We do not use `--opus`; + # the baseline scan runs offline without a key. It is advisory for now + # (continue-on-error) while we collect false-positive data on our configs. + # Later: remove continue-on-error and add this as a second required check. + - name: AgentShield (agent-config security scan, advisory) + continue-on-error: true + shell: bash + env: + AGENTSHIELD_VERSION: "1.4.0" + AGENTSHIELD_INTEGRITY: "sha512-R98OO1Ujyk2lezDLb+iQmMhF6FwTJCHajy3G4FCB6x7wkSTqR9f8+eAelC5KDzYDsGSbc0sOZvjXOOPRBtMpDg==" + NPM_CONFIG_IGNORE_SCRIPTS: "true" + run: | + node -e "const {execFileSync}=require('child_process'); const got=execFileSync('npm',['view','ecc-agentshield@'+process.env.AGENTSHIELD_VERSION,'dist.integrity'],{encoding:'utf8'}).trim(); if(got!==process.env.AGENTSHIELD_INTEGRITY){ console.error('ecc-agentshield integrity mismatch: '+got); process.exit(1); }" + npx --yes ecc-agentshield@$AGENTSHIELD_VERSION scan --path . - - name: Test - run: dotnet test tests/Dropwheel.Tests -c Release + # DESIGN-gate: UI changes require an approved mockup set touched in the same diff. + - name: DESIGN-gate + if: github.event_name == 'pull_request' + run: node hooks/design-gate.js --strict --base ${{ github.event.pull_request.base.sha }} diff --git a/.gitleaks.toml b/.gitleaks.toml index 2d1813c..c119ec0 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -1,4 +1,4 @@ -# .gitleaks.toml — gitleaks config (replaces hooks/secret-scan.js). +# .gitleaks.toml - gitleaks config (replaces hooks/secret-scan.js). # Uses the full default ruleset (100+ high-precision detectors) and layers a small allowlist. # Inline exception on a line: gitleaks:allow (legacy `secret-scan:allow` also honored below). diff --git a/AGENTS.md b/AGENTS.md index 6379b73..8dde87f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,164 +1,235 @@ -# AGENTS.md — Dev Loop +# AGENTS.md - Dev Loop -> Каждый заход агента в код идёт через один цикл. Всё, что можно проверить кодом, -> гарантируют хуки (см. «Слои харнесса»); здесь остаётся только то, что хуками не покрыть. +Every agent pass through code follows one loop. Anything that can be checked by +code belongs in hooks; this document keeps the behavior that hooks cannot fully +enforce. ## Loop -``` -1. EXPLORE — изучить кодбазу, паттерны, риски -2. PLAN — план → ⏸ APPROVAL пользователя -2.5 DESIGN (GUI) — ≥4 мокапа → ⏸ APPROVAL → файл APPROVED -3. IMPLEMENT+TEST — код + тесты вместе (edge cases как тесты) -4. VERIFY — node hooks/verify.js + чтение вывода + git diff self-review - ├─ провал/новые warnings → вернуться к 3 - └─ зелёное → шаг 5 -5. COMMIT on branch → PR (не в main) -6. REPORT — изменено / проверено / осталось / как тестировать -7. ⏸ USER DECISION — принять = DONE; доработать → к 2/3; отклонить → откат +```text +1. EXPLORE - read the codebase, patterns, and risks +2. PLAN - plan -> user approval +2.5 DESIGN (GUI) - >=4 mockups -> user approval -> APPROVED file +3. IMPLEMENT+TEST - code and tests together; edge cases become tests +4. VERIFY - node hooks/verify.js + read output + git diff self-review + - failure/new warnings -> return to step 3 + - green -> step 5 +5. COMMIT on branch -> PR, never directly on main +6. REPORT - changed / verified / remaining / manual test notes +7. USER DECISION - accept = DONE; revise -> 2 or 3; reject -> revert ``` -**Сокращение:** trivial-фикс (опечатка/однострочник) — можно без plan mode; -VERIFY и feature-ветка обязательны всегда. +Shortcut: a trivial typo or one-line fix can skip plan mode, but VERIFY and a +feature branch are always required. ## Bootstrap -Перед тем как требовать этот loop от целевого репозитория, харнесс должен быть -закоммичен в `main` отдельным bootstrap PR. Минимальный набор: `hooks/`, `AGENTS.md`, +Before requiring this loop from a target repository, commit the harness to +`main` through a separate bootstrap PR. Minimum set: `hooks/`, `AGENTS.md`, `harness.config.json`, `lefthook.yml`, `cog.toml`, `.gitleaks.toml`, -`settings.example.json` и, если включён CI/ruleset, `.github/`. +`settings.example.json`, and `.github/` when CI/rulesets are enabled. + +`node hooks/doctor.js` checks not only that these files exist, but also that they +are tracked in git. If harness files are untracked, a clean worktree from +`origin/main` cannot run `node hooks/verify.js`, `design-gate.js`, or release +through `cog bump --auto`. In that state, create the bootstrap PR first; release +flow is not fully enforceable. -`node hooks/doctor.js` проверяет не только наличие этих файлов, но и то, что они -tracked в git. Если файлы лежат локально untracked, clean worktree от `origin/main` -не сможет выполнить `node hooks/verify.js`, `design-gate.js` или release через -`cog bump --auto`. В таком состоянии сначала делается bootstrap PR; release flow -считается не fully enforceable. +## Stage Rules -## Правила по этапам +**1. EXPLORE.** Do not assume structure. Verify it with `rg`, file reads, and +existing tests. Changes across more than two or three files, or behavior changes, +need a plan. -**1. EXPLORE.** Не предполагать структуру — проверять (`grep`/`find`/Read). -Задача на >2–3 файла или смену поведения ⇒ нужен план. +**2. PLAN.** Non-trivial work goes through plan mode and user approval before +implementation. The plan names the files, rationale, tests, risks, and edge +cases. -**2. PLAN.** Нетривиальное — только через plan mode (`EnterPlanMode` → `ExitPlanMode`, -реализация после approval). В плане: что меняем, почему, как тестируем, риски/edge cases. +**2.5 DESIGN (GUI).** GUI work matching `harness.config.json -> ui.globs` is +designed before code. New GUI work needs at least four stylistically distinct +mockups from `node hooks/new-mockups.js `, user selection, and +`design/mockups//APPROVED`. GUI changes also need a mockup for the new +state. `hooks/design-gate.js` passes UI changes only when the approved set is +touched in the same branch diff; to reuse an old set, append a date/branch line +to its `APPROVED` file. -**2.5 DESIGN (GUI).** UI-работа (глобы в `harness.config.json → ui`) до кода: -новый GUI → ≥4 стилистически разных мокапа (`node hooks/new-mockups.js `), -показать пользователю, выбрать направление; правка GUI → мокап нового состояния. -После approval — файл `design/mockups//APPROVED`. -Гейт `hooks/design-gate.js` (pre-push + CI) пропускает UI-изменения только если -одобренный набор **затронут в diff этой же ветки**; повторное использование старого -набора — допиши строку в его APPROVED (дата/ветка), чтобы он попал в diff. +**3. IMPLEMENT+TEST.** Code and tests move together. If a target project has no +test runner, report that and propose a minimal one. -**3. IMPLEMENT+TEST.** Код и тесты вместе. Нет тест-раннера — сказать в отчёте -и предложить минимальный. +**4. VERIFY before commit.** `node hooks/verify.js` auto-detects common stacks: +Python uses ruff and pytest; C# uses dotnet format, build with warnings as +errors, and test; Rust uses fmt, clippy with warnings as errors, and test; Node +uses npm lint/build/test. Overrides live in `harness.config.json -> verify`. +`--list` prints the plan without running it. -**4. VERIFY (до коммита).** `node hooks/verify.js` — авто-детект стеков -(Python→ruff+pytest; C#→dotnet format/build -warnaserror/test; Rust→fmt+clippy -D warnings+test; -Node→npm lint/build/test), fail-fast, warnings-as-errors зашиты в шаги; переопределение — -`harness.config.json → verify`; `--list` — план без запуска. Параллельно — **debug-аудит** -изменённых файлов: hard-маркеры (`debugger;`/`breakpoint()`/`pdb.set_trace()`/`dbg!()`) -валят VERIFY, soft (`console.log`/`print`) — заметка (`debugAudit` в конфиге). Сверх exit-кода: прочитать -вывод билда (новые warnings, deprecation, «falling back to …» — чинить или явно отметить -в отчёте) и сделать `git diff` self-review (debug-логи, закомментированный код, мусор). +VERIFY also runs a debug audit on changed files. Hard markers such as +`debugger;`, `breakpoint()`, `pdb.set_trace()`, and `dbg!()` fail VERIFY. Soft +markers such as `console.log` and `print` are notes when configured. Beyond the +exit code, read build output for new warnings, deprecations, and fallbacks, and +run a git diff self-review for debug logs, commented-out code, and debris. -**5. COMMIT → PR.** Только feature-ветка (`feat/…`, `fix/…`, `docs/…`), не main. -Conventional Commits: `(): `; `feat:`→MINOR, `fix:`→PATCH, -`!`/`BREAKING CHANGE:`→MAJOR. Без соавторства (`Co-Authored-By`, «Generated with …») — -lefthook отклонит. `git push` / `--force` / `reset --hard` — только по явному запросу -пользователя. +**5. COMMIT -> PR.** Work on a feature branch such as `feat/...`, `fix/...`, or +`docs/...`, never directly on `main`. Use Conventional Commits: +`(): `. `feat:` means MINOR, `fix:` means PATCH, and `!` +or `BREAKING CHANGE:` means MAJOR. Lefthook rejects co-author and generated-by +trailers. `git push`, force-push, and `reset --hard` require an explicit user +request. -**6. REPORT.** Что изменено / чем проверено (команды + результат) / что осталось / -как пользователю проверить руками. +**6. REPORT.** Report what changed, what was verified with commands and results, +what remains, and how the user can test manually. -**7. USER DECISION.** Loop завершён только когда пользователь принял результат. +**7. USER DECISION.** The loop is complete only when the user accepts the result. -## Release flow (только по явной просьбе, после merge в main) +## Release Flow -| Шаг | Действие | Гейт | +Run this only on explicit request and after merge to `main`. + +| Step | Action | Gate | |---|---|---| -| R1 | clean worktree от `origin/main`, `node hooks/doctor.js` зелёный; версия: `git describe --tags --abbrev=0` | | -| R2 | SemVer из conventional-commits (`cog bump --auto`): annotated tag + CHANGELOG | ⏸ показать тэг/diff/notes → approval | -| R3 | `git push origin && git push origin vX.Y.Z` | ⏸ только после явного «да» | -| R4 | `gh run watch` — релизный workflow зелёный, без skipped-шагов | | -| R5 | `gh release view vX.Y.Z` — Published, артефакты на месте | | -| R6 | скачать артефакт, smoke-тест, версия в бинарнике = тэг | | -| R7 | знать откат: до публикации — пересоздать тэг; после — `gh release delete` + revert (с approval) | | - -Hotfix: ветка от тэга (`git checkout -b hotfix/x.y.z vX.Y.Z-1`) → fix → PR в main + тэг по R2–R6. -Легитимный коммит на main (CHANGELOG релиза): `HARNESS_ALLOW_MAIN=1 git commit …`. - -После `gh pr merge --delete-branch` GitHub может успешно смержить PR server-side, -а локальный post-merge pull/rebase всё равно упадёт из-за dirty worktree. В этом -случае отдельно проверь `gh pr view --json state,mergedAt,mergeCommit`; если -state = `MERGED`, merge состоялся. Локальную синхронизацию делай только из чистого -дерева: `git fetch origin` → `git merge --ff-only origin/main`. Для release лучше -создать новый clean worktree от `origin/main`, а не продолжать из грязной рабочей -папки с bootstrap/untracked файлами. - -## Слои харнесса - -**Слой 0 — серверный ruleset (единственный настоящий enforcement).** -`.github/rulesets/main.json`, ставится `node hooks/apply-ruleset.js`: require PR, -required check `verify`, блок force-push/delete main. Не обходится локально. -(Free+private: ruleset требует Pro или публичный репо — BACKLOG P0-0.) - -**Слой 1 — lefthook (гигиена, для любого агента/человека).** `lefthook install`: -commit-msg → `cog verify` + запрет соавторства; pre-commit → gitleaks + запрет коммита -на main; pre-push → `verify.js` + `design-gate.js`. -На Windows диагностику отдельных команд запускай через `.cmd`, чтобы не упереться -в PowerShell ExecutionPolicy: `lefthook.cmd run pre-commit --command branch-guard --force --verbose`. -Для `commit-msg` передавай файл сообщения позиционным аргументом: -`$msg = Join-Path $env:TEMP "commit-msg.txt"; Set-Content $msg "fix(hooks): test"; lefthook.cmd run commit-msg $msg --command no-coauthor --force --verbose`. -Флаг singular: `--command`, не `--commands`. - -**Слой 2 — agent-adapter (опционально, per-runtime).** Один хук `hooks/agent/guard.js` -на PreToolUse + `stop-reminder.js` на Stop (молчит при чистом дереве и при -`stop_hook_active`). Вход — нормализованный JSON (`hooks/agent/_input.js`), поэтому -подходит любому раннеру. Логика guard — в экспортируемой `run(ctx, env) → -{exitCode, stdout, stderr}` без побочных эффектов: тесты и диспетчеры зовут её -in-process (без ~50-100мс спавна), CLI-обёртка — для раннеров. -Stop-reminder — напоминание, не enforcement: первый Stop при dirty tree блокирует -и показывает статус, повторный Stop с тем же `git status` пропускает осознанно -оставленные uncommitted/bootstrap/local файлы. -Строгость: `HARNESS_PROFILE=minimal|standard|strict` (minimal — только анти-обход -и защита файлов харнесса; strict — пороги циклов вдвое ниже) и -`HARNESS_DISABLED_CHECKS=` для точечного отключения — это ручки ЧЕЛОВЕКА -в env раннера, команды агента на env хуков не влияют. Контракт: exit 0 = allow, exit 2 = block; заметка — -`{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"…"}}` -(+ top-level дубль для простых раннеров, stderr-зеркало для человека); Stop-хук -доносит текст ТОЛЬКО через `{"decision":"block","reason":"…"}`. -Подключение: скопировать блок `hooks` из `settings.example.json` в `.claude/settings.json`. - -| guard.js ловит | Тип | +| R1 | Start from a clean worktree at `origin/main`; `node hooks/doctor.js` is green; read current version with `git describe --tags --abbrev=0`. | | +| R2 | Derive SemVer from Conventional Commits: first `cog bump --auto --dry-run`, then `cog bump --auto --annotated "vX.Y.Z"` to create an annotated tag and changelog. | Show tag, diff, and notes; wait for approval. | +| R2.5 | `node hooks/release-preflight.js --tag vX.Y.Z --base origin/main`: clean tree, tag points to HEAD, remote tag absent, project/package versions match the tag. | | +| R3 | `git push origin && git push origin vX.Y.Z`. | Only after an explicit yes. | +| R4 | If release workflow exists: `gh run watch`; release workflow is green and has no skipped steps. | | +| R5 | `gh release view vX.Y.Z`: release is published and artifacts exist. | | +| R6 | Download artifact, smoke-test it, and verify binary version equals the tag. | | +| R7 | Know rollback: before publication, recreate the tag; after publication, use `gh release delete` plus revert with approval. | | + +Hotfix: branch from the previous tag, fix, PR to `main`, then tag through R2-R6. +A legitimate release commit on `main` can use `HARNESS_ALLOW_MAIN=1 git commit ...`. + +`release-preflight.js` intentionally fails if the tag/changelog are ready but a +project manifest still reports an old version. If no version manifest exists, it +warns; R6 still verifies the binary version. + +After `gh pr merge --delete-branch`, GitHub can merge the PR server-side even if +the local post-merge pull/rebase fails because of a dirty worktree. Verify with +`gh pr view --json state,mergedAt,mergeCommit`; if `state` is `MERGED`, the +merge happened. Sync locally only from a clean tree with `git fetch origin` and +`git merge --ff-only origin/main`. For releases, prefer a new clean worktree from +`origin/main`. + +## Harness Layers + +**Layer 0 - server ruleset.** The only real enforcement layer. Versioned in +`.github/rulesets/main.json` and applied with `node hooks/apply-ruleset.js`. +It requires PRs, the `verify` required check, and blocks force-push/delete on +main. It cannot be bypassed locally. Private repositories need a plan that +supports rulesets, or the repository must be public. +This source repository uses a solo-maintainer variant: approving/code-owner +review is advisory, while target installs keep regular approving review by +default through `install.js`. + +**Layer 1 - lefthook.** Local hygiene for humans and agents: +commit-msg runs `cog verify` and rejects co-author trailers; pre-commit runs +gitleaks and branch guard; pre-push runs `verify.js` and `design-gate.js`. + +On Windows, diagnose commands through `.cmd` to avoid PowerShell ExecutionPolicy: + +```powershell +lefthook.cmd run pre-commit --command branch-guard --force --verbose +$msg = Join-Path $env:TEMP "commit-msg.txt"; Set-Content $msg "fix(hooks): test"; lefthook.cmd run commit-msg $msg --command no-coauthor --force --verbose +``` + +Use `--command`, not `--commands`. + +**Layer 2 - agent adapter.** Optional per-runtime hooks. `hooks/agent/guard.js` +runs on PreToolUse and `hooks/agent/stop-reminder.js` runs on Stop. Input is +normalized JSON through `hooks/agent/_input.js`. Guard logic is exported as +`run(ctx, env) -> {exitCode, stdout, stderr}` so tests and dispatchers can call +it in-process; the CLI wrapper is for runners. + +Stop-reminder is a reminder, not hard enforcement. First Stop with a dirty tree +blocks and shows status; a second Stop with the same status passes so intentional +uncommitted bootstrap/local files do not trap the user. + +Strictness: + +- `HARNESS_PROFILE=minimal|standard|strict`; minimal keeps only anti-bypass and + harness-file protection, strict halves loop thresholds. +- `HARNESS_DISABLED_CHECKS=` disables targeted checks. These are human + runner env knobs; agent shell commands should not change hook env. + +Contract: exit 0 allows, exit 2 blocks. Notes are emitted through +`hookSpecificOutput.additionalContext` plus a top-level duplicate for simpler +runners. Stop communicates only through `{"decision":"block","reason":"..."}`. +Install by copying the `hooks` block from `settings.example.json` to +`.claude/settings.json`. + +| guard.js catches | Type | |---|---| -| Обход харнесса: `--no-verify`/`commit -n`, `core.hooksPath` (config и `-c`), `LEFTHOOK=0`, `lefthook uninstall`, запись в `.git/hooks` | блок | -| Правку файлов харнесса (`hooks/`, `lefthook.yml`, конфиги, workflows, `.claude/settings.json`) — file-tools **и** shell (POSIX `sed -i`/`rm`/`mv`/`tee`/редирект + cmd/PowerShell `del`/`move`/`Remove-Item`/`Set-Content`…); пути нормализуются (`./`, `..`, регистр, `/` и `\`) | блок | -| Правку **существующего** lint/format-конфига проекта (`.eslintrc*`, `ruff.toml`, `biome.json`, `clippy.toml`, `pytest.ini`…) — красный VERIFY чинится кодом, а не ослаблением конфига; создание нового конфига с нуля разрешено; смешанные файлы (`pyproject.toml`, `package.json`, `tsconfig.json`) намеренно не в списке | блок | -| Дегенеративные циклы: серия тривиальных команд; N× одно действие подряд; чередование A-B-A-B — на shell **и** на file-tools (Read/Write/Edit) | блок | -| Мусор tool-разметки / низкоэнтропийную команду (признак сбоя стриминга/парсинга) | блок | -| Обрезанный или нечитаемый входной payload (fail-closed, всегда включён) | блок | -| git commit/merge/push или правку файлов на `main`/`master` | note | -| Правку UI-файла — напоминание про DESIGN-стадию (≥N мокапов + `APPROVED`) | note | -| fact-force: правку существующего файла, не читанного в этой сессии (EXPLORE → IMPLEMENT) | note | - -Осознанный, одобренный пользователем обход блока: `HARNESS_ACK_BYPASS=1` (аудит-заметка в контекст). -Что guard **не** ловит: «тонкое» зацикливание из внешне осмысленных шагов и состязательный обход -(подстановка переменных, конкатенация кавычек) — это закрывают TodoWrite + этап 7 (пользователь -видит, что todo не двигаются) и настоящий серверный ruleset. +| Harness bypass: `--no-verify`, `commit -n`, `core.hooksPath`, `LEFTHOOK=0`, `lefthook uninstall`, writes under `.git/hooks`. | block | +| Edits to harness files: `hooks/`, `lefthook.yml`, configs, workflows, `.claude/settings.json`; both file tools and shell writes are covered, including quoted paths. | block | +| Edits to existing lint/format configs such as `.eslintrc*`, `ruff.toml`, `biome.json`, `clippy.toml`, `pytest.ini`. Mixed files such as `pyproject.toml`, `package.json`, and `tsconfig.json` are intentionally excluded. | block | +| Degenerate loops: trivial command streaks, same action repeated N times, A-B-A-B alternation, for shell and file tools. | block | +| Tool markup debris, low-entropy commands, and unreadable non-empty hook payloads. | block | +| git commit/merge/push or edits while on `main`/`master`. | note | +| UI file edit requiring DESIGN stage. | note | +| fact-force: editing an existing file before reading it in the session. | note | + +Approved bypass of a guard block: `HARNESS_ACK_BYPASS=1`, with an audit note in +context. Guard does not catch subtle meaningful-looking loops or adversarial +string construction; Todo progress, user decision, and the server ruleset cover +that boundary. ## Env -Ручки для ЧЕЛОВЕКА — задаются в env раннера; команды агента на env хуков не влияют. +Human runner knobs: -| Переменная | Назначение | Default | +| Variable | Purpose | Default | |---|---|---| -| `HARNESS_ALLOW_MAIN=1` | легитимный коммит на `main` (релиз/hotfix/bootstrap); снимает branch-guard в pre-commit | — | -| `HARNESS_ACK_BYPASS=1` | одобренный пользователем обход guard.js (блок → аудит-заметка) | — | -| `HARNESS_PROFILE` | строгость guard: `minimal` (только анти-обход + защита файлов харнесса), `standard`, `strict` (пороги циклов вдвое ниже) | `standard` | -| `HARNESS_DISABLED_CHECKS` | точечно выключить проверки: `loops,entropy,lintconfig,design-note,fact-force,…` | — | -| `HARNESS_LOOP_THRESHOLD` | порог циклов shell-команд | 5 | -| `HARNESS_TOOLLOOP_THRESHOLD` | порог циклов file-tools (Read/Write/Edit) | 12 | -| `HARNESS_SESSION_ID` / `HARNESS_PROJECT_DIR` | ключ состояния guard, если раннер не задал своих (`CLAUDE_*`/`ZCODE_*` тоже читаются) | — | -| `HARNESS_ROOT` | корень для `new-mockups.js` при scaffolding мокапов | корень репо | -| `LEFTHOOK=0` | пропуск lefthook-хуков (только человек; агенту блокирует guard) | — | +| `HARNESS_ALLOW_MAIN=1` | Legitimate release/hotfix/bootstrap commit on `main`; bypasses branch guard in pre-commit. | unset | +| `HARNESS_ACK_BYPASS=1` | User-approved bypass of a guard block; emits an audit note. | unset | +| `HARNESS_PROFILE` | Guard strictness: `minimal`, `standard`, `strict`. | `standard` | +| `HARNESS_DISABLED_CHECKS` | Disable targeted checks: `loops,entropy,lintconfig,design-note,fact-force,...`. | unset | +| `HARNESS_LOOP_THRESHOLD` | Shell loop threshold. | `5` | +| `HARNESS_TOOLLOOP_THRESHOLD` | File-tool loop threshold. | `12` | +| `HARNESS_SESSION_ID` / `HARNESS_PROJECT_DIR` | Guard state key when the runner did not provide one. | unset | +| `HARNESS_ROOT` | Root for `new-mockups.js` when scaffolding mockups. | repo root | +| `LEFTHOOK=0` | Skip lefthook for humans only; guard blocks agent use. | unset | + +## Dropwheel Canary Inbox + +Dropwheel at `C:\Users\poweruser\projects\csharp\dropwheel` is the canary +target for this harness. + +When a report arrives under `inbox/dropwheel` or from a Dropwheel Codex thread, +use `$harness-triage` and follow `docs/dropwheel-harness-inbox.md`. + +Routing rule: + +- installer, doctor, harness syntax, guard, design gate, verify runner, or + generated harness file failure caused by source harness behavior belongs in + this repo; +- installer-created files that only need to be accepted/tracked in Dropwheel are + `dropwheel-harness-update` and should be routed back to Dropwheel auto-fix; +- Dropwheel build/test failure after a green install and doctor belongs in + Dropwheel unless the report proves a bad harness contract; +- unclear owner starts here as triage, then routes to the right project. + +For valid harness bugs, reproduce against a disposable Dropwheel canary +worktree, fix the smallest harness behavior, add regression coverage, run +`node hooks\verify.js`, and rerun: + +```powershell +powershell -ExecutionPolicy Bypass -File C:\Users\poweruser\projects\csharp\dropwheel\scripts\harness-canary.ps1 +``` + +The Dropwheel inbox automation is allowed to perform these harness fixes +automatically. If the main checkout is dirty, make code changes in an isolated +local worktree under `.codex\auto-fix-worktrees` and keep inbox bookkeeping in +the main checkout. After `node hooks\verify.js` and the Dropwheel canary pass, +create a local feature-branch commit with a Conventional Commit message. Do not +push, merge, release, reset, force-push, or bypass hooks without an explicit +user request. + +## Color Team Review + +When the user asks for Color Team Review, use `$color-team-review` instead of +expanding a long prompt inline. Keep the compact format: verdict, evidence-based +findings, what is good, priority fixes, minimal safe plan, useful tests, and +verdict-changing questions only. + +For Dropwheel handoffs, read `docs/dropwheel-harness-inbox.md` and process +`inbox/dropwheel/review-handoff-*` files as first-class harness triage inputs, +even when the related canary report is green. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b31053..a587092 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,44 @@ # Changelog - - - +## [v0.12.0](https://github.com/IvanLarinDev/dropwheel/compare/647c2ca2c4eb3981b191d0f87277f725d693e1a2..v0.12.0) - 2026-07-10 +#### Features +- (**overlay**) enrich browser URL targets - ([b387999](https://github.com/IvanLarinDev/dropwheel/commit/b3879996c89fef78c32f6dffe94a84848cebe121)) - Ivan Larin +- (**overlay**) stage telegram drops on clipboard - ([75947d7](https://github.com/IvanLarinDev/dropwheel/commit/75947d79dfc2d3fe319c8d6ab9643687401d23ee)) - Ivan Larin +- (**overlay**) add link quick-access targets - ([b60c4fc](https://github.com/IvanLarinDev/dropwheel/commit/b60c4fc0d0543c88bbee7509fdfc1ad9314104d4)) - Ivan Larin +#### Bug Fixes +- (**config**) merge unknown enum config load - ([0aa30ee](https://github.com/IvanLarinDev/dropwheel/commit/0aa30eeb3352c64beb9d6c937d44bab4a60c608e)) - Ivan Larin +- (**config**) preserve config on unknown enum load - ([fe9fad2](https://github.com/IvanLarinDev/dropwheel/commit/fe9fad2f2a4a1b21cd702c50ea5cb48a3580238b)) - Ivan Larin +- (**overlay**) accept delayed telegram text drops - ([a32c791](https://github.com/IvanLarinDev/dropwheel/commit/a32c7918b73e0160fcab218715182557f8a7de4e)) - Ivan Larin +- (**overlay**) accept move-only telegram text drops - ([a247351](https://github.com/IvanLarinDev/dropwheel/commit/a247351e92916d6c80f992508ae5f839d3a4ffdc)) - Ivan Larin +- (**overlay**) paste telegram drops into topic - ([c3e3b05](https://github.com/IvanLarinDev/dropwheel/commit/c3e3b05bc1b34240150604ea29b9b23165076907)) - Ivan Larin +- (**overlay**) open telegram links in desktop app - ([599a833](https://github.com/IvanLarinDev/dropwheel/commit/599a833f1ef07869a27105e56f6e08aada15cbf5)) - Ivan Larin +- (**overlay**) use compatible add-target drag effect - ([1f888da](https://github.com/IvanLarinDev/dropwheel/commit/1f888dab7ce575311ce1a9b2a47eae59ff219ccd)) - Ivan Larin +- (**overlay**) accept bare telegram links - ([0f80897](https://github.com/IvanLarinDev/dropwheel/commit/0f808971a0c97a3993a814e0d6dc2c3a07973860)) - Ivan Larin +- (**overlay**) handle saved messages chat drops - ([82c26fb](https://github.com/IvanLarinDev/dropwheel/commit/82c26fb650a0b8ee894d4b98e7b5793f8c18cdf3)) - Ivan Larin +- (**overlay**) prioritize link drops over text saves - ([401b20d](https://github.com/IvanLarinDev/dropwheel/commit/401b20d2762591f29a4b979da9ff3aadc368b19d)) - Ivan Larin +- (**overlay**) normalize virtual sorter roots - ([cee2d28](https://github.com/IvanLarinDev/dropwheel/commit/cee2d282dca3dd696536873882fe488f05210c47)) - Ivan Larin +- (**sorter**) skip same-folder drop operations - ([c0e10b1](https://github.com/IvanLarinDev/dropwheel/commit/c0e10b1ee11d8f3d255f4b503356bca106df55d1)) - Ivan Larin +- (**text**) avoid directory-name drop collisions - ([a5571a1](https://github.com/IvanLarinDev/dropwheel/commit/a5571a1f646e5a975963cf087c5a1250ce204ad2)) - Ivan Larin +- (**watcher**) gate queued sort after stop - ([98f7985](https://github.com/IvanLarinDev/dropwheel/commit/98f7985fb7eacefc66a6edf861ac7c853fad67a4)) - Ivan Larin +- (**watcher**) cancel queued work on stop - ([9eceb44](https://github.com/IvanLarinDev/dropwheel/commit/9eceb44134b457582fac717c11a87dd469b6049a)) - Ivan Larin +#### Documentation +- (**readme**) document link and telegram targets - ([b939ed2](https://github.com/IvanLarinDev/dropwheel/commit/b939ed2e02146a37c97192ffe516ee76506b4143)) - Ivan Larin +#### Tests +- (**watcher**) add autosort collision regression - ([87768e3](https://github.com/IvanLarinDev/dropwheel/commit/87768e362170c0bb5d0b63159be91610a8871c3f)) - Ivan Larin +#### Miscellaneous Chores +- (**dropwheel**) merge telegram quick access target - ([32724a2](https://github.com/IvanLarinDev/dropwheel/commit/32724a29a90136c71fb216be7bebce76b2c7341e)) - Ivan Larin +- (**dropwheel**) merge watcher collision test - ([afa0552](https://github.com/IvanLarinDev/dropwheel/commit/afa0552c6b31a2a41e1f7c7aa85c2bac5d4b3e4d)) - Ivan Larin +- (**dropwheel**) merge overlay root fix - ([32e6373](https://github.com/IvanLarinDev/dropwheel/commit/32e637324d1ad0c5560fdbee6d191896d9f8d559)) - Ivan Larin +- (**dropwheel**) merge watcher stop race guard - ([f9d1735](https://github.com/IvanLarinDev/dropwheel/commit/f9d17355e50e4f3db348fd6105ff5132d14e56cf)) - Ivan Larin +- (**dropwheel**) merge watcher stop fix - ([9595985](https://github.com/IvanLarinDev/dropwheel/commit/9595985328b4f85593e76ee90d248ece32b82093)) - Ivan Larin +- (**dropwheel**) merge text drop collision fix - ([f279424](https://github.com/IvanLarinDev/dropwheel/commit/f279424c87b0bb71821913347c87e7a7a244e173)) - Ivan Larin +- (**dropwheel**) merge verified harness and sorter fixes - ([e57dc51](https://github.com/IvanLarinDev/dropwheel/commit/e57dc512cd7d4754aeb6f87bb5db3b699c3190f3)) - Ivan Larin +- (**harness**) update generated harness - ([647c2ca](https://github.com/IvanLarinDev/dropwheel/commit/647c2ca2c4eb3981b191d0f87277f725d693e1a2)) - Ivan Larin +- (**version**) set project version v0.12.0 - ([d3c2e92](https://github.com/IvanLarinDev/dropwheel/commit/d3c2e92e58e4f30d3ce2cac986d591874659f402)) - Ivan Larin + +- - - + ## [v0.11.0](https://github.com/IvanLarinDev/dropwheel/compare/d6ab8bfd1380a84d043c5da4e76865ca14a07456..v0.11.0) - 2026-07-09 #### Features - (**overlay**) animate tile reorder - ([84916d1](https://github.com/IvanLarinDev/dropwheel/commit/84916d16712bba456b5ae042f32d4b9d456180f5)) - Ivan Larin diff --git a/README.md b/README.md index e55ec59..1087e81 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ common loops: `run.cmd [run|build|publish|stop]`. Run the tests with | Force copy / move | hold Ctrl / Shift while dropping | | Undo last drop | click “Undo” in the toast (6 s) | | Edit a target | right-click its tile | -| Add a target | drop a folder/exe onto the “+” tile or the orb | +| Add a target | drop a folder/exe/link onto the “+” tile or the orb | | Create a group | right-click the orb → “New group…” | | Enter a group | click its tile, or hover it for 0.5 s while dragging | | Sort a sorter now | middle-click a sorter tile | @@ -151,6 +151,20 @@ behaviour, shown with a ▶ badge. Scripts the shell would only open in an edito (`.ps1`, `.py`, `.jar`) are launched through their interpreter. This is a launch, not a file operation, so it isn't undoable. +## Link targets + +Drop a link such as `https://example.com`, `tg://resolve?domain=telegram`, or +`https://t.me/c/4379453334/1` onto the orb or the “+” tile to create a +quick-access target. Browser URL drags keep the page title when the drag payload +includes one, and Dropwheel fetches a favicon in the background when the page +exposes a PNG/JPG/ICO/WEBP icon. + +Telegram web links are converted to desktop deep links where possible, so +clicking a `t.me` tile opens Telegram Desktop when the `tg://` protocol is +registered. Dropping files or selected text onto a Telegram tile copies the +payload to the clipboard, opens the chat or topic, and pastes it once Telegram is +foreground; review and press Send in Telegram. + ## Themes Four themes — chosen in Settings. Each carries a full palette: the wheel, the @@ -176,12 +190,14 @@ are tuned per theme. VirtualFileService, TextDropService, SortService, SortMigration, WatcherService (auto-sort watched folders), FileMeta, PresetService, ShortcutResolver, MouseHook, HotkeyService, LaunchService, - IconService, StartupService, FullscreenDetector + IconService, LinkTargetService, LinkMetadataService, + TelegramDropService, StartupService, FullscreenDetector UI/ OverlayWindow (hub + rim + spokes wheel, partial classes), TargetEditorWindow (+ .Rules master-detail), SettingsWindow, Themes, Palette (per-theme widget colours), MenuTheme.xaml tests/ Dropwheel.Tests (xUnit: SortService, SortMigration, FileMeta, - TextDropService, WatcherService, HotkeyService, VirtualFileService) + TextDropService, WatcherService, HotkeyService, VirtualFileService, + LinkTargetService, LinkMetadataService, TelegramDropService) docs/media/ screenshots and gifs used by this README ## Known limitations diff --git a/cog.toml b/cog.toml index 6eb4716..b1b5615 100644 --- a/cog.toml +++ b/cog.toml @@ -11,7 +11,7 @@ from_latest_tag = true ignore_merge_commits = true tag_prefix = "v" -branch_whitelist = ["main", "master", "feat/**", "fix/**", "docs/**", "hotfix/**", "chore/**", "refactor/**"] +branch_whitelist = ["main", "master", "feat/**", "fix/**", "docs/**", "hotfix/**", "chore/**", "refactor/**", "release/**"] [changelog] path = "CHANGELOG.md" diff --git a/design/mockups/drop-safety-review/APPROVED b/design/mockups/drop-safety-review/APPROVED index 0a017b6..fcfc582 100644 --- a/design/mockups/drop-safety-review/APPROVED +++ b/design/mockups/drop-safety-review/APPROVED @@ -1 +1,2 @@ 2026-07-09 fix/product-safety-review approved 01-minimal-light.html for drop safety and incomplete undo states +2026-07-10 release/v0.12.0 reuses drop-safety-review approval for quick-access drag/drop states diff --git a/harness.config.json b/harness.config.json index e14aadf..8cb4174 100644 --- a/harness.config.json +++ b/harness.config.json @@ -27,8 +27,7 @@ "enabled": true, "base": "main", "soft": false, - "exclude": [ - "hooks/**" - ] + "exclude": [], + "strict": true } } diff --git a/hooks/_lib.js b/hooks/_lib.js index e5684c5..0137167 100644 --- a/hooks/_lib.js +++ b/hooks/_lib.js @@ -1,5 +1,5 @@ -// _lib.js — shared helpers for the harness hooks (guard.js, design-gate.js). -// Single source of truth for: glob→regex, harness.config.json loading, path +// _lib.js - shared helpers for the harness hooks (guard.js, design-gate.js). +// Single source of truth for: glob-to-regex, harness.config.json loading, path // normalization. No CLI, no side effects. const fs = require("fs"); @@ -18,10 +18,11 @@ const DEFAULT_PROTECTED = [ ".github/rulesets/", ".github/workflows/", ".claude/settings.json", ".git/", ]; -// Линт/формат-конфиги ЦЕЛЕВОГО проекта (паттерн ECC config-protection): агенты -// «чинят» красный VERIFY, ослабляя конфиг вместо кода. Блокируем правку -// СУЩЕСТВУЮЩЕГО конфига; создание с нуля — легитимный bootstrap. Смешанные файлы -// (pyproject.toml, package.json, tsconfig.json) намеренно НЕ в списке. +// Lint/format configs of the target project (ECC config-protection pattern): +// agents sometimes "fix" a red VERIFY by weakening config instead of fixing code. +// Block edits to existing configs; creating a config from scratch is legitimate +// bootstrap work. Mixed-purpose files (pyproject.toml, package.json, tsconfig.json) +// are intentionally not listed. const DEFAULT_LINT_CONFIGS = [ ".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yml", ".eslintrc.yaml", "eslint.config.js", "eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts", "eslint.config.mts", "eslint.config.cts", @@ -36,7 +37,7 @@ const DEFAULT_LINT_CONFIGS = [ ".markdownlint.json", ".markdownlint.yaml", ".markdownlintrc", ".shellcheckrc", ]; -// glob → RegExp (supports **, *, literal) +// glob to RegExp (supports **, *, literal) function globToRe(g) { const re = g.replace(/[.+^${}()|[\]\\]/g, "\\$&") .replace(/\*\*\//g, "@@DS@@").replace(/\*\*/g, "@@SS@@").replace(/\*/g, "[^/]*") @@ -44,7 +45,7 @@ function globToRe(g) { return new RegExp("^" + re + "$", "i"); } -// harness.config.json (missing/broken file → defaults; hooks stay fail-open) +// harness.config.json (missing/broken file -> defaults; hooks stay fail-open) function loadConfig(root) { let c = {}; try { c = JSON.parse(fs.readFileSync(path.join(root, "harness.config.json"), "utf8")); } catch {} @@ -57,8 +58,8 @@ function loadConfig(root) { }; } -// Absolute/relative path → normalized repo-relative posix path. -// Collapses ./ and ..; strips the project prefix case-insensitively — WITHOUT +// Absolute/relative path -> normalized repo-relative posix path. +// Collapses ./ and ..; strips the project prefix case-insensitively - WITHOUT // normalization "./lefthook.yml" or "design/../hooks/x" would dodge prefix checks. function normRel(fp, projectDir) { let f = String(fp).replace(/\\/g, "/"); @@ -80,12 +81,12 @@ function isProtectedPath(rel, protectedList) { } // ---------- shell-write detection ---------- -// Covers write/delete/move verbs across POSIX, cmd и PowerShell + sed/perl -i и -// перенаправление > / >>. Windows-глаголы (del/move/Remove-Item/Set-Content…) — -// потому что здесь основная оболочка PowerShell/cmd, а не bash: без них -// `del hooks\x.js` или `Remove-Item lefthook.yml` проходили мимо защиты. -// Все глаголы срабатывают ТОЛЬКО когда цель — защищённый путь (${target}), -// поэтому обычные команды не задеваются. Read-only (cat/ls/node hooks/x.js) — мимо. +// Covers write/delete/move verbs across POSIX, cmd, and PowerShell, plus sed/perl +// in-place edits and > / >> redirection. Windows verbs matter because this repo is +// commonly driven from PowerShell/cmd; without them, commands such as +// `del hooks\x.js` or `Remove-Item lefthook.yml` would bypass protection. +// Verbs match only when the target is a protected path, so ordinary commands and +// read-only calls (cat/ls/node hooks/x.js) pass through. const WRITE_VERBS = "rm|mv|cp|tee|chmod|ln|truncate|touch|" + // POSIX "del|erase|rmdir|rd|move|ren|rename|copy|" + // cmd.exe @@ -103,66 +104,82 @@ function shellWriteHit(scrubbed, alt, lb, dirPrefixInRedirect) { return res.some((re) => re.test(scrubbed)); } -// SEP — разделитель пути: `/` ИЛИ `\` (в PowerShell/cmd путь пишут через backslash, -// поэтому `del hooks\agent\guard.js` должен матчиться так же, как `rm hooks/...`). +function normalizePathFragments(text) { + return String(text || "").replace( + /[A-Za-z0-9_.-]+(?:[\/\\]+[A-Za-z0-9_.-]+)+/g, + (frag) => path.posix.normalize(frag.replace(/\\/g, "/")) + ); +} + +// SEP is a path separator: `/` or `\`. PowerShell/cmd often use backslashes, so +// `del hooks\agent\guard.js` must match the same way as `rm hooks/...`. const SEP = "[\\/\\\\]"; -// Запись в защищённые пути харнесса (префиксы от корня репо). +// Writes to protected harness paths, expressed as repo-root prefixes. function isProtectedShellWrite(scrubbed, protectedList) { - // Порядок важен: сначала слэши → SEP, потом точки → `\.`. Иначе экранирование - // точки вставит `\`, который замена слэшей затрёт (lefthook.yml → lefthook[\/\\]yml). + const scanned = normalizePathFragments(scrubbed); + // Order matters: replace slashes with SEP before escaping dots. Otherwise dot + // escaping inserts backslashes that the slash replacement would later corrupt. const esc = (str) => str.replace(/[\/\\]/g, SEP).replace(/\./g, "\\."); - // "hooks/" защищает и "hooks/x", и голое "rm -rf hooks"; файлы — по границе слова. + // "hooks/" protects both "hooks/x" and a bare "rm -rf hooks"; files use word boundaries. const alt = "(?:\\." + SEP + ")?(?:" + protectedList.map((p) => p.endsWith("/") ? esc(p.slice(0, -1)) + "(?:" + SEP + "|[\\s;&|]|$)" : esc(p) + "\\b" ).join("|") + ")"; - // Lookbehind БЕЗ разделителя: src/hooks/useAuth.ts (React) — не файл харнесса. - return shellWriteHit(scrubbed, alt, "(?<=^|[\\s=:'\"(])", false); + // Lookbehind without a path separator keeps src/hooks/useAuth.ts from matching. + return shellWriteHit(scanned, alt, "(?<=^|[\\s=:'\"(])", false); } -// Запись в lint/format-конфиг (по basename, в любом каталоге — через / или \). +// Writes to lint/format config files by basename, in any directory, with / or \. function isLintConfigShellWrite(scrubbed, lintConfigs) { + const scanned = normalizePathFragments(scrubbed); const names = lintConfigs.map((n) => n.replace(/\./g, "\\.")).join("|"); const alt = `(?:[^\\s;|&<>]*${SEP})?(?:${names})\\b`; - return shellWriteHit(scrubbed, alt, "(?<=^|[\\s=:'\"(/\\\\])", true); + return shellWriteHit(scanned, alt, "(?<=^|[\\s=:'\"(/\\\\])", true); } -// rel — нормализованный путь; сравнение по basename, регистронезависимо. +// rel is normalized; compare by basename, case-insensitively. function isLintConfigPath(rel, lintConfigs) { const base = String(rel).toLowerCase().split("/").pop(); return lintConfigs.some((n) => n.toLowerCase() === base); } -// ---------- запись в защищённый путь через инлайн-eval интерпретатора ---------- -// `node -e "fs.writeFileSync('hooks/x')"`, `python -c "open('lefthook.yml','w')"`, -// `bash -c "rm -rf hooks/"` обходят write-verb-детекцию: глагол/путь спрятаны в -// строке, а scrubQuotes её обнуляет. Работаем по СЫРОЙ команде. Это НОТА, не блок: -// в -e путь может быть безобидной строкой, жёстко блокировать нельзя, но напомнить -// про обход стоит. Триггерим только при совпадении трёх условий: интерпретатор с -// eval-флагом + индикатор записи + литерал защищённого пути (минимум ложных). +// ---------- protected-path writes through inline interpreter eval ---------- +// Commands such as `node -e "fs.writeFileSync('hooks/x')"`, +// `python -c "open('lefthook.yml','w')"`, or `bash -c "rm -rf hooks/"` +// hide the write verb/path inside a quoted program, so scrubbed shell-write +// detection cannot see them. Inspect the raw command and block only when the +// command is eval-like, has a write indicator, and references a protected path. +// Encoded PowerShell is intentionally blocked as opaque eval: the write/path +// cannot be inspected before execution. const INTERP_EVAL_RE = /\b(?:node|nodejs|deno|bun|python|python3|py|perl|ruby|php|pwsh|powershell|bash|sh|zsh)\b[^\n]*?(?:\s-e\b|\s--eval\b|\s-c\b|\seval\b|\s-Command\b|\s-EncodedCommand\b)/i; -const INTERP_WRITE_RE = /writefile|writefilesync|appendfile|createwritestream|fs\.write|\.write\s*\(|open\s*\([^)]*['"][aw]|set-content|add-content|out-file|>{1,2}|\b(?:rm|del|erase|move|mv|remove-item|ren|rename)\b/i; +const INTERP_ENCODED_RE = /\b(?:pwsh|powershell)\b[^\n]*\s-EncodedCommand\b/i; +const INTERP_WRITE_RE = /writefile|writefilesync|appendfile|createwritestream|write_text|write_bytes|unlink\s*\(|rmtree\s*\(|\brm(?:sync)?\s*\(|remove\s*\(|replace\s*\(|rename\s*\(|shutil\.(?:rmtree|move)|os\.(?:remove|unlink|replace|rename)|path\([^)]*\)\.(?:write_text|write_bytes|unlink)|fs\.write|\.write\s*\(|\[\s*['"]write|['"]write['"]\s*\+|\+\s*['"]filesync['"]|open\s*\([^)]*['"][aw]|set-content|add-content|out-file|>{1,2}|\b(?:rm|del|erase|move|mv|remove-item|ren|rename)\b/i; function interpreterProtectedHint(rawCmd, protectedList) { const s = String(rawCmd); - if (!INTERP_EVAL_RE.test(s) || !INTERP_WRITE_RE.test(s)) return null; - const low = s.replace(/\\/g, "/").toLowerCase(); + if (!INTERP_EVAL_RE.test(s)) return null; + if (INTERP_ENCODED_RE.test(s)) return "encoded-command"; + if (!INTERP_WRITE_RE.test(s)) return null; + const low = normalizePathFragments(s.replace(/\\/g, "/").toLowerCase()); for (const p of protectedList) { const pref = p.toLowerCase().replace(/\/$/, "").replace(/[.]/g, "\\."); - if (new RegExp("(?:^|[\\s'\"(/=:])" + pref + "(?:/|\\b)").test(low)) return p; + if (new RegExp("(?:^|[\\s'\"(=:,])(?:\\.\\/)?" + pref + "(?:/|\\b)").test(low)) return p; } return null; } // ---------- changed files (branch/worktree diff) ---------- -// Изменённые файлы ветки относительно базы. Возвращает {files, base} при успешном -// diff (пусть даже ПУСТОМ) или {error} если ни одна база не доступна — это РАЗНЫЕ -// исходы: пустой diff = «изменений нет», ошибка = «не смогли проверить». Молчаливый -// fail-open при ошибке означал бы, что в репо без ожидаемой базы гейт/фильтр просто -// никогда не работает. explicitFiles (тесты/CI) возвращается как есть, без git. -// По умолчанию это branch-only контракт для CI/design-gate. Локальный verify может -// явно добавить dirty/staged/untracked файлы через includeDirty. +// Changed files in the branch relative to a base. Returns {files, base} when diff +// succeeds, even if the diff is empty, or {error} when no base can be used. Those +// outcomes are intentionally distinct: empty diff means "no changes", while error +// means "could not check". Silent fail-open would make gates ineffective in repos +// without the expected base. explicitFiles is normalized and returned without git. +// By default this is a branch-only contract for CI/design-gate; local verify can +// explicitly include dirty/staged/untracked files via includeDirty. function changedFiles(base, root, explicitFiles, opts = {}) { - if (explicitFiles) return { files: explicitFiles }; + if (explicitFiles) { + const files = normalizeChangedFiles(root, explicitFiles); + return { files, explicit: true, branchFiles: files, dirtyFiles: [], includeDirty: false }; + } const remoteFirst = /^origin\//.test(String(base || "")); const fallbacks = remoteFirst ? [base, "origin/HEAD", "main", "master"] @@ -172,20 +189,39 @@ function changedFiles(base, root, explicitFiles, opts = {}) { for (const args of [["diff", "--name-only", `${b}...HEAD`], ["diff", "--name-only", b]]) { try { const out = execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000, killSignal: "SIGKILL" }); - const branchFiles = parseFiles(out); - return { files: opts.includeDirty ? mergeFiles(branchFiles, dirtyFiles(root)) : branchFiles, base: b }; + const branchFiles = parseFiles(out, root); + const worktreeFiles = opts.includeDirty ? dirtyFiles(root) : []; + return { + files: opts.includeDirty ? mergeFiles(root, branchFiles, worktreeFiles) : branchFiles, + base: b, + explicit: false, + branchFiles, + dirtyFiles: worktreeFiles, + includeDirty: !!opts.includeDirty, + }; } catch {} } } - return { error: `git diff не удался ни для одной базы (${bases.join(", ")})` }; + return { error: `git diff failed for every base (${bases.join(", ")})` }; } function workingTreeChangedFiles(base, root, explicitFiles) { return changedFiles(base, root, explicitFiles, { includeDirty: true }); } -function parseFiles(out) { - return String(out || "").split(/\r?\n/).map((s) => s.trim()).filter(Boolean); +function normalizeChangedFile(root, fp) { + const rel = normRel(fp, root); + if (!rel || rel === "." || rel === ".." || rel.startsWith("../")) return ""; + if (/^(?:[A-Za-z]:)?\//.test(rel) || /^[A-Za-z]:\//.test(rel)) return ""; + return rel; +} + +function normalizeChangedFiles(root, files) { + return mergeFiles(root, files); +} + +function parseFiles(out, root) { + return normalizeChangedFiles(root, String(out || "").split(/\r?\n/)); } function gitFiles(root, args) { @@ -193,26 +229,26 @@ function gitFiles(root, args) { return parseFiles(execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000, killSignal: "SIGKILL", - })); + }), root); } catch { return []; } } function dirtyFiles(root) { - return mergeFiles( + return mergeFiles(root, gitFiles(root, ["diff", "--name-only"]), gitFiles(root, ["diff", "--name-only", "--cached"]), gitFiles(root, ["ls-files", "--others", "--exclude-standard"]) ); } -function mergeFiles(...lists) { +function mergeFiles(root, ...lists) { const out = []; const seen = new Set(); for (const list of lists) { for (const f of list || []) { - const rel = String(f).replace(/\\/g, "/"); + const rel = normalizeChangedFile(root, f); if (!rel || seen.has(rel)) continue; seen.add(rel); out.push(rel); diff --git a/hooks/agent/_input.js b/hooks/agent/_input.js index 09b74de..6630762 100644 --- a/hooks/agent/_input.js +++ b/hooks/agent/_input.js @@ -1,4 +1,4 @@ -// _input.js — runtime-agnostic input normalizer for the agent-adapter hooks. +// _input.js - runtime-agnostic input normalizer for the agent-adapter hooks. // // Different LLM harnesses describe a pending tool call with different field names. // These hooks read a JSON payload on stdin and normalize it, so the SAME hook works @@ -14,17 +14,15 @@ // stderr text = human-readable reason (shown by most runners) // // Non-blocking note (see note()): Claude Code reads additionalContext ONLY from -// {"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"…"}} -// — a bare top-level {"additionalContext": …} is ignored. We emit BOTH shapes +// {"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"..."}} +// - a bare top-level {"additionalContext": "..."} is ignored. We emit BOTH shapes // (top-level for simpler runners, hookSpecificOutput for Claude Code) + stderr mirror. function readStdin() { - // Чтение stdin без жёсткого дедлайна. Основной сигнал конца — событие `end`. - // На паузе потока (idle) завершаемся ТОЛЬКО если буфер уже парсится как целый - // JSON: иначе это середина рывкового payload'а (GC-пауза, медленный пайп) — - // ждём остаток до общего капа, а не мис-фреймим на неполных данных (иначе - // легитимный вызов ловил ложный fail-closed блок). Лимит размера → truncated, - // по нему guard решает fail-closed. Пустой idle держим до капа (ручной запуск). + // Read stdin without a hard idle deadline. The main completion signal is `end`. + // On idle, finish only if the buffer already parses as complete JSON; otherwise + // wait for the rest of a bursty payload until the cap. Size cap sets truncated, + // and guard.js decides fail-closed. Empty idle waits until the cap for manual runs. const IDLE_MS = 300, CAP_MS = 5000, MAX_BYTES = 2 * 1024 * 1024; return new Promise((resolve) => { let data = "", done = false, idle = null, truncated = false; @@ -35,10 +33,10 @@ function readStdin() { }; const cap = setTimeout(finish, CAP_MS); const onIdle = () => { - if (truncated) return finish(); // обрезано по лимиту — дальше не ждём - if (!data) return arm(); // ничего не пришло — ждём (до капа) - try { JSON.parse(data); finish(); } // целый JSON — можно завершать - catch { arm(); } // ещё неполный — ждём остаток + if (truncated) return finish(); // size cap hit; do not wait longer + if (!data) return arm(); // no data yet; wait until cap + try { JSON.parse(data); finish(); } // complete JSON; finish + catch { arm(); } // incomplete JSON; wait for more }; const arm = () => { clearTimeout(idle); idle = setTimeout(onIdle, IDLE_MS); }; process.stdin.setEncoding("utf8"); @@ -64,12 +62,31 @@ async function parse() { let raw = {}; let parseError = false; try { raw = JSON.parse(data || "{}"); } - catch { parseError = data.trim().length > 0; } // пустой stdin = ручной запуск, не ошибка + catch { parseError = data.trim().length > 0; } // empty stdin = manual run, not an error const ti = raw.tool_input || raw.toolInput || raw.input || raw.arguments || {}; return { tool: firstString(raw.tool_name, raw.toolName, raw.tool, raw.name), command: firstString(ti.command, raw.command), - filePath: firstString(ti.file_path, ti.path, ti.filePath, raw.file_path, raw.path), + filePath: firstString( + ti.file_path, + ti.path, + ti.filePath, + ti.filename, + ti.file, + ti.target_file, + ti.targetFile, + ti.notebook_path, + ti.notebookPath, + raw.file_path, + raw.path, + raw.filePath, + raw.filename, + raw.file, + raw.target_file, + raw.targetFile, + raw.notebook_path, + raw.notebookPath + ), sessionId: firstString( process.env.HARNESS_SESSION_ID, process.env.CLAUDE_SESSION_ID, @@ -91,7 +108,7 @@ async function parse() { }; } -// Non-blocking note injected into agent context. eventName: "PreToolUse" | "PostToolUse" | … +// Non-blocking note injected into agent context. eventName: "PreToolUse" | "PostToolUse" | ... function note(text, eventName = "PreToolUse") { try { process.stdout.write(JSON.stringify({ diff --git a/hooks/agent/guard.js b/hooks/agent/guard.js index f1dd37c..a312395 100644 --- a/hooks/agent/guard.js +++ b/hooks/agent/guard.js @@ -1,33 +1,26 @@ #!/usr/bin/env node -// guard.js — единый agent-adapter хук (PreToolUse на Bash/Write/Edit/Read и т.п.). +// guard.js - unified agent-adapter hook for PreToolUse shell/file/read calls. // -// Архитектура (паттерн ECC bash-hook-dispatcher): вся логика — в экспортируемой -// синхронной run(ctx, env) -> { exitCode, stdout, stderr }, БЕЗ process.exit и -// побочных эффектов вывода. CLI-обёртка внизу читает stdin и применяет результат. -// Это даёт in-process запуск из тестов/диспетчеров без ~50-100мс спавна на вызов. +// Architecture: all policy lives in exported synchronous +// run(ctx, env) -> { exitCode, stdout, stderr }, with no process.exit and no +// output side effects. The CLI wrapper at the bottom only reads stdin and applies +// the result. Tests and dispatchers can call run() in-process without spawn cost. // -// БЛОК (exit 2): -// • обход харнесса: --no-verify / git commit -n, core.hooksPath (config и -c), -// LEFTHOOK=0, lefthook uninstall, запись в .git/hooks; -// • правка файлов харнесса (hooks/, lefthook.yml, конфиги, workflows) — file-tools -// И shell (POSIX rm/mv/sed -i/tee/редирект + cmd/PowerShell del/move/Remove-Item/ -// Set-Content…); пути нормализуются, разделитель / и \; -// • правка СУЩЕСТВУЮЩЕГО lint/format-конфига проекта (создание нового — можно); -// • дегенеративные циклы; мусор tool-разметки; низкоэнтропийная команда; -// • обрезанный/нечитаемый входной payload (fail-closed, всегда включён). +// BLOCK (exit 2): +// - harness bypass: --no-verify / git commit -n, core.hooksPath, LEFTHOOK=0, +// lefthook uninstall, and writes to .git/hooks; +// - writes to harness files through file tools and shell commands; +// - edits to existing lint/format config files; +// - degenerate loops, leaked tool markup, low-entropy commands; +// - truncated or unreadable payloads. // NOTE (exit 0 + additionalContext): -// • git commit/merge/push или правка файлов на main/master; -// • правка UI-файла — напоминание о DESIGN-стадии; -// • fact-force (паттерн ECC GateGuard): правка существующего файла, который -// в этой сессии ни разу не читали — EXPLORE прежде IMPLEMENT (1 note на файл). +// - git commit/merge/push or file writes on main/master; +// - UI file edits that need the DESIGN stage; +// - fact-force: editing an existing file that was not read in this session. // -// Профили строгости (для ЧЕЛОВЕКА; env задаёт раннер, не команды агента): -// HARNESS_PROFILE=minimal — только анти-обход + защита файлов харнесса; -// HARNESS_PROFILE=standard — всё (default); -// HARNESS_PROFILE=strict — всё + пороги циклов вдвое ниже. -// HARNESS_DISABLED_CHECKS=loops,entropy — точечное отключение проверок. -// Escape одобренного обхода в block-сообщениях агенту не называется (см. AGENTS.md → Env). -// Ошибка самого хука никогда не блокирует работу (fail-open → exit 0). +// Strictness profiles are runner/user environment controls, not agent commands. +// Approved bypass details are not printed in block messages; see AGENTS.md. +// Internal hook errors fail open, but are made visible on stderr. const fs = require("fs"); const os = require("os"); @@ -40,9 +33,9 @@ const { globToRe, loadConfig, normRel, isProtectedPath, isProtectedShellWrite, const TTL_MS = 2 * 60 * 60 * 1000; const SEEN_MAX = 200; -const BYPASS_HINT = "Обход возможен только по явному одобрению пользователя — попроси его (escape описан в AGENTS.md → Env)."; +const BYPASS_HINT = "Bypass requires explicit user approval; ask the user first. The escape hatch is documented in AGENTS.md."; -// ---------- профили ---------- +// ---------- profiles ---------- const ALL_CHECKS = ["bypass", "protected", "lintconfig", "corruption", "entropy", "loops", "main-note", "design-note", "fact-force"]; const PROFILES = { minimal: new Set(["bypass", "protected"]), @@ -62,14 +55,14 @@ function envAllow(env, name) { return ["1", "true", "yes", "on"].includes(String(env[name] || "").trim().toLowerCase()); } -// ---------- результат (вместо process.exit / stdout из логики) ---------- +// ---------- result object, no process.exit inside policy logic ---------- function allowRes(notes) { if (!notes || !notes.length) return { exitCode: 0, stdout: "", stderr: "" }; const text = notes.join("\n"); return { exitCode: 0, stdout: JSON.stringify({ - additionalContext: text, // простые раннеры + additionalContext: text, // simple runners hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: text }, // Claude Code }) + "\n", stderr: text + "\n", @@ -79,7 +72,7 @@ function blockRes(text) { return { exitCode: 2, stdout: "", stderr: text + "\n" }; } -// ---------- state (per session, в tmpdir) ---------- +// ---------- state (per session, in tmpdir) ---------- function stateFile(sessionId, projectDir) { const id = sessionId || "proj-" + crypto.createHash("sha1").update(String(projectDir)).digest("hex").slice(0, 12); return path.join(os.tmpdir(), `harness-guard-${id}.json`); @@ -91,9 +84,8 @@ function readState(p) { return { hist: s.hist || [], streak: s.streak || 0, seen: s.seen || [] }; } catch { return { hist: [], streak: 0, seen: [] }; } } -// Атомарная запись: temp-файл в том же каталоге (tmpdir) + rename. Иначе -// параллельные PreToolUse-хуки могли оставить оборванный JSON или затереть -// историю на полузаписи (гонка read-modify-write). rename атомарен в пределах ФС. +// Atomic write: temp file in the same directory plus rename. Parallel hooks can +// otherwise leave truncated JSON or lose history during read-modify-write races. function writeState(p, s) { const tmp = p + "." + process.pid + ".tmp"; try { @@ -115,9 +107,8 @@ function currentBranch(cwd) { } catch { return ""; } } -// ---------- детекторы ---------- -// Узкий паттерн разметки tool-call'ов: , , это легитимная запись HTML). +// ---------- detectors ---------- +// Narrow tool-call markup pattern; generic HTML such as stays valid. const CORRUPTION_RE = /<\/?(?:tool_?(?:call|use|result)|function_call|invoke|antml)[\s>_:/]|^\s*["']\s*,?\s*\d*\s*= T) { writeState(p, { hist: [], streak: 0, seen: st.seen }); - return blockRes(`🛑 guard: ${rep}× подряд одно и то же действие — похоже на зацикливание.\n` + + return blockRes(`guard: same action repeated ${rep} times; this looks like a loop.\n` + ` ${st.hist[st.hist.length - 1].slice(0, 120)}\n` + - ` Остановись, сверься с планом/TodoWrite, смени подход. Порог: ${T}.`); + ` Stop, compare with the plan/TodoWrite, and change approach. Threshold: ${T}.`); } const alt = tailAlt(st.hist); if (alt >= 2 * T) { writeState(p, { hist: [], streak: 0, seen: st.seen }); - return blockRes(`🛑 guard: чередование двух действий ${alt} шагов подряд (A-B-A-B…) — цикл без прогресса.\n` + - ` Остановись, сверься с планом/TodoWrite, смени подход. Порог: ${2 * T}.`); + return blockRes(`guard: two actions alternated for ${alt} steps (A-B-A-B); this is a no-progress loop.\n` + + ` Stop, compare with the plan/TodoWrite, and change approach. Threshold: ${2 * T}.`); } return null; } -// ---------- обход харнесса (shell) ---------- -// GIT: на Windows `git.exe`/`git.cmd` — валидные вызовы; без вариантов имени -// `git.exe commit --no-verify` проходил мимо блока. Замечание про -n: у git commit -// это --no-verify (обход), у merge/revert — безобидное. +// ---------- harness bypass through shell ---------- +// Windows can call git.exe/git.cmd. For git commit, -n means --no-verify. const GIT = "git(?:\\.exe|\\.cmd)?"; const BYPASS = [ { re: new RegExp(`\\b${GIT}\\s+commit\\b[^\\n]*(?:\\s--no-verify\\b|\\s-[a-z]*n[a-z]*\\b)`, "i"), - why: "--no-verify / -n на git commit — пропускает pre-commit и commit-msg" }, + why: "--no-verify / -n on git commit skips pre-commit and commit-msg hooks" }, { re: new RegExp(`\\b${GIT}\\s+(merge|push)\\b[^\\n]*\\s--no-verify\\b`, "i"), - why: "--no-verify на git merge/push — пропускает хуки" }, + why: "--no-verify on git merge/push skips hooks" }, { re: new RegExp(`\\b${GIT}\\b[^\\n]*\\bcore\\.hookspath\\b`, "i"), - why: "core.hooksPath (git config или git -c) — отключение/подмена git-хуков" }, + why: "core.hooksPath disables or replaces git hooks" }, { re: /\blefthook\s+uninstall\b/i, - why: "lefthook uninstall — снятие всех git-хуков" }, + why: "lefthook uninstall removes git hooks" }, { re: /(^|[\s;&|])LEFTHOOK\s*=\s*(0|false)\b/i, - why: "LEFTHOOK=0 — отключение lefthook-хуков (это escape для человека, не для агента)" }, + why: "LEFTHOOK=0 disables lefthook hooks; this is a human escape hatch, not an agent escape hatch" }, ]; function isGitHooksWrite(scrubbed) { return /\.git[\/\\]hooks\b/i.test(scrubbed) && /(^|[\s;&|])(rm|mv|cp|tee|chmod|ln|truncate|sed|del|erase|rmdir|rd|move|ren|rename|copy|Remove-Item|Move-Item|Rename-Item|Copy-Item|Set-Content|Add-Content|Clear-Content|Out-File|New-Item)\b|>/i.test(scrubbed); } -// Обнулить строки в кавычках, чтобы commit -m "про -n флаг" не считался обходом. +// Blank quoted strings so commit messages mentioning -n do not look like bypasses. function scrubQuotes(cmd) { return cmd.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '""'); } +function scrubGitMessageArgs(cmd) { + return String(cmd).replace( + new RegExp(`(\\b${GIT}\\s+commit\\b[^\\n]*?\\s(?:-m|--message)\\s+)(?:"(?:[^"\\\\]|\\\\.)*"|'(?:[^'\\\\]|\\\\.)*')`, "gi"), + "$1\"\"" + ); +} +function unquoteShellPaths(cmd) { + return String(cmd).replace(/"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'/g, (m, dq, sq) => + (dq !== undefined ? dq : sq).replace(/\\(["'\\])/g, "$1") + ); +} -// ---------- основная логика ---------- +// ---------- main policy ---------- function run(ctx, env = process.env) { try { - // Fail-closed (всегда включён): обрезанный или нечитаемый payload — отказ - // решать вслепую. Пустой stdin (ручной запуск) остаётся fail-open в parse(). + // Fail closed on truncated or unreadable payloads. Empty stdin for manual + // runs remains fail-open in parse(). if (ctx.truncated || ctx.parseError) - return blockRes("🛑 guard: входной payload обрезан или нечитаем — защитные проверки не решают вслепую.\n" + - " Повтори вызов меньшим изменением (или доставь ввод целиком)."); + return blockRes("guard: input payload is truncated or unreadable; policy cannot decide blind.\n" + + " Retry with a smaller change or provide the full payload."); const { tool, command, filePath, sessionId, projectDir } = ctx; const isShell = /^(bash|shell|sh|exec|run|terminal|execute)/i.test(tool) || (!tool && command); const isFile = /^(write|edit|multiedit|applypatch|create|str_replace|notebookedit)/i.test(tool); const isRead = /^read/i.test(tool); - // Пороги циклов; strict — вдвое строже. + // Loop thresholds; strict mode halves them. let T_SH = Number(env.HARNESS_LOOP_THRESHOLD) || 5; let T_FT = Number(env.HARNESS_TOOLLOOP_THRESHOLD) || 12; if (getProfile(env) === "strict") { T_SH = Math.max(2, Math.ceil(T_SH / 2)); T_FT = Math.max(3, Math.ceil(T_FT / 2)); } @@ -220,67 +220,70 @@ function run(ctx, env = process.env) { if (isShell && typeof command === "string" && command) { const scrubbed = scrubQuotes(command); + const pathScan = unquoteShellPaths(scrubGitMessageArgs(command)); - // 1) обход харнесса / запись в защищённые пути и lint-конфиги через shell + // 1) harness bypass or shell writes to protected paths and lint configs const hit = (checkEnabled("bypass", env) && (BYPASS.find((b) => b.re.test(scrubbed)) || - (isGitHooksWrite(scrubbed) ? { why: "прямое вмешательство в .git/hooks" } : null))) || - (checkEnabled("protected", env) && isProtectedShellWrite(scrubbed, cfg.protected) - ? { why: "shell-запись в файлы харнесса (hooks/, конфиги, workflows) — их агент не меняет сам" } : null) || - (checkEnabled("lintconfig", env) && isLintConfigShellWrite(scrubbed, cfg.lintConfigs) - ? { why: "shell-запись в lint/format-конфиг — красный гейт чинится кодом, а не ослаблением конфига" } : null); + (isGitHooksWrite(pathScan) ? { why: "direct write/delete under .git/hooks" } : null))) || + (checkEnabled("protected", env) && isProtectedShellWrite(pathScan, cfg.protected) + ? { why: "shell write to harness files (hooks/, configs, workflows)" } : null) || + (checkEnabled("lintconfig", env) && isLintConfigShellWrite(pathScan, cfg.lintConfigs) + ? { why: "shell write to lint/format config; fix code instead of weakening config" } : null); if (hit) { if (envAllow(env, "HARNESS_ACK_BYPASS")) { - notes.push(`⚠️ guard: обход харнесса разрешён явно пользователем: ${hit.why}. Обоснуй в отчёте.`); + notes.push(`guard: harness bypass was explicitly approved by the user: ${hit.why}. Explain this in the report.`); } else { - return blockRes(`🛑 guard: команда обходит harness — заблокировано.\n Причина: ${hit.why}.\n ${BYPASS_HINT}`); + return blockRes(`guard: command bypasses the harness; blocked.\n Reason: ${hit.why}.\n ${BYPASS_HINT}`); } } - // 1c) запись в файл харнесса через инлайн-eval интерпретатора (node -e/ - // python -c/bash -c…). Write-verb-детекция это не ловит (глагол/путь в - // строке, scrubQuotes их обнулил), поэтому проверяем СЫРУЮ команду и только - // напоминаем — жёстко блокировать нельзя (путь в -e может быть безобиден). + // 1c) writes to harness files through inline interpreter eval. if (checkEnabled("protected", env)) { const ip = interpreterProtectedHint(command, cfg.protected); - if (ip) notes.push(`⚠️ guard: похоже на запись в файл харнесса (${ip}) через инлайн-eval интерпретатора ` + - `(node -e / python -c / bash -c …). Такой обход не ловится жёстким блоком — не меняй файлы харнесса так. ` + - `Если это не файл харнесса, игнорируй.`); + if (ip) { + if (envAllow(env, "HARNESS_ACK_BYPASS")) { + notes.push(`guard: inline-eval write to harness file (${ip}) was explicitly approved by the user. Explain this in the report.`); + } else { + return blockRes(`guard: command looks like an inline-eval write to a harness file (${ip}).\n` + + ` Reason: node -e / python -c / bash -c hides the write from normal shell detection.\n ${BYPASS_HINT}`); + } + } } - // 2) сбой стриминга / мусор + // 2) stream corruption / junk if (checkEnabled("corruption", env) && CORRUPTION_RE.test(scrubbed)) { writeState(p, { hist: [], streak: 0, seen: st.seen }); - return blockRes(`🛑 guard: мусор tool-разметки в команде — верный признак сбоя стриминга/парсинга.\n ${JSON.stringify(command.slice(0, 120))}`); + return blockRes(`guard: leaked tool markup in command; this usually means streaming/parsing corruption.\n ${JSON.stringify(command.slice(0, 120))}`); } if (checkEnabled("entropy", env) && isLowEntropy(command)) { writeState(p, { hist: [], streak: 0, seen: st.seen }); - return blockRes(`🛑 guard: аномально низкая энтропия токенов команды (паттерн «echo a echo a …»).\n ${JSON.stringify(command.slice(0, 120))}`); + return blockRes(`guard: command token entropy is abnormally low.\n ${JSON.stringify(command.slice(0, 120))}`); } - // 3) циклы + // 3) loops if (checkEnabled("loops", env)) { st.streak = isTrivial(command) ? st.streak + 1 : 0; st.hist.push("sh::" + command.trim()); if (st.hist.length > HIST_MAX) st.hist.shift(); if (st.streak >= T_SH) { writeState(p, { hist: [], streak: 0, seen: st.seen }); - return blockRes(`🛑 guard: ${st.streak} тривиальных команд подряд (echo/ls/pwd/…) — дегенеративный паттерн.\n` + - ` Остановись и реши задачу одним осмысленным шагом. Порог: HARNESS_LOOP_THRESHOLD=${T_SH}.`); + return blockRes(`guard: ${st.streak} trivial commands in a row; this is a degenerate pattern.\n` + + ` Stop and make one meaningful step. Threshold: HARNESS_LOOP_THRESHOLD=${T_SH}.`); } const lr = loopCheck(st, p, T_SH); if (lr) return lr; writeState(p, st); } - // 4) ранние подсказки про main + // 4) early notes about protected branches if (checkEnabled("main-note", env)) { const branch = currentBranch(projectDir); if (["main", "master"].includes(branch) && new RegExp(`\\b${GIT}\\s+(commit|merge)\\b`).test(scrubbed)) - notes.push(`⚠️ guard: git commit/merge на «${branch}» — pre-commit отклонит. Перейди на feature-ветку (релиз: HARNESS_ALLOW_MAIN=1).`); + notes.push(`guard: git commit/merge on ${branch} will be rejected by pre-commit. Switch to a feature branch; releases use HARNESS_ALLOW_MAIN=1.`); if (new RegExp(`\\b${GIT}\\s+push\\b`).test(scrubbed) && /\b(main|master)\b/.test(scrubbed) && !/refs\/tags|v\d/.test(scrubbed)) - notes.push(`⚠️ guard: прямой push в main/master отклонит серверный ruleset. main обновляется через PR.`); + notes.push(`guard: direct push to main/master will be rejected by the server ruleset. Update main through a PR.`); } return allowRes(notes); } @@ -289,47 +292,45 @@ function run(ctx, env = process.env) { const rel = normRel(filePath, projectDir); const abs = path.isAbsolute(String(filePath)) ? String(filePath) : path.join(String(projectDir), rel); - // 1) файлы самого харнесса — только с явного разрешения + // 1) harness files require explicit approval if (isFile && checkEnabled("protected", env) && isProtectedPath(rel, cfg.protected)) { if (envAllow(env, "HARNESS_ACK_BYPASS")) { - notes.push(`⚠️ guard: правка файла харнесса (${rel}) разрешена явно пользователем.`); + notes.push(`guard: harness file edit (${rel}) was explicitly approved by the user.`); } else { - return blockRes(`🛑 guard: правка файла харнесса заблокирована: ${rel}\n` + - ` Хуки/конфиги харнесса агент не меняет сам по себе.\n ${BYPASS_HINT}`); + return blockRes(`guard: harness file edit blocked: ${rel}\n` + + ` Agents must not change harness hooks/configs without approval.\n ${BYPASS_HINT}`); } } - // 1b) lint/format-конфиги ЦЕЛЕВОГО проекта: правка существующего — блок - // (агент «чинит» красный VERIFY, ослабляя конфиг); создание нового — можно. + // 1b) existing target lint/format configs: block edits, allow new files. if (isFile && checkEnabled("lintconfig", env) && !isProtectedPath(rel, cfg.protected) && isLintConfigPath(rel, cfg.lintConfigs)) { let exists = true; try { fs.lstatSync(abs); } - catch (e) { if (e && e.code === "ENOENT") exists = false; } // иные ошибки = fail-closed + catch (e) { if (e && e.code === "ENOENT") exists = false; } // other errors fail closed if (exists) { if (envAllow(env, "HARNESS_ACK_BYPASS")) { - notes.push(`⚠️ guard: правка lint-конфига (${rel}) разрешена явно пользователем.`); + notes.push(`guard: lint config edit (${rel}) was explicitly approved by the user.`); } else { - return blockRes(`🛑 guard: правка существующего lint/format-конфига заблокирована: ${rel}\n` + - ` Красный гейт чинится исправлением кода, а не ослаблением конфига.\n` + - ` Создание нового конфига с нуля разрешено. ${BYPASS_HINT}`); + return blockRes(`guard: existing lint/format config edit blocked: ${rel}\n` + + ` Fix failing gates in code instead of weakening config.\n` + + ` Creating a new config file from scratch is allowed. ${BYPASS_HINT}`); } } } - // 2) fact-force (ECC GateGuard, note-only): правка существующего файла, - // который в этой сессии не читали — одна заметка на файл, без спама. + // 2) fact-force note: editing an existing file before reading it. if (isRead) markSeen(st, rel); if (isFile && checkEnabled("fact-force", env) && !st.seen.includes(rel)) { let exists = false; try { fs.lstatSync(abs); exists = true; } catch {} if (exists) - notes.push(`⚠️ guard: правишь ${rel}, не читав его в этой сессии (EXPLORE → IMPLEMENT). ` + - `Прочитай файл или места его использования перед правкой.`); - markSeen(st, rel); // независимо от exists: новый файл дальше «знаком» + notes.push(`guard: editing ${rel} before reading it in this session (EXPLORE -> IMPLEMENT). ` + + `Read the file or its call sites before editing.`); + markSeen(st, rel); // new files are considered known after this point } - // 3) циклы (Read/Write/Edit одного и того же объекта) + // 3) file-tool loops if (checkEnabled("loops", env)) { st.streak = 0; st.hist.push(tool.toLowerCase() + "::" + rel); @@ -339,17 +340,17 @@ function run(ctx, env = process.env) { } writeState(p, st); - // 4) подсказки: DESIGN-стадия и main + // 4) notes: DESIGN stage and protected branches if (isFile) { const mockRoot = cfg.mockups.dir.replace(/\\/g, "/").replace(/\/$/, ""); if (checkEnabled("design-note", env) && !rel.startsWith(mockRoot + "/") && cfg.uiGlobs.map(globToRe).some((re) => re.test(rel))) - notes.push(`⚠️ guard: правка GUI-файла (${rel}). DESIGN-стадия: ≥${cfg.mockups.min} мокапа + APPROVED до кода ` + - `(node hooks/new-mockups.js ). Жёсткий гейт — design-gate.js в pre-push/CI.`); + notes.push(`guard: GUI file edit (${rel}). DESIGN stage requires >=${cfg.mockups.min} mockups plus APPROVED before code ` + + `(node hooks/new-mockups.js ). The hard gate is design-gate.js in pre-push/CI.`); if (checkEnabled("main-note", env)) { const branch = currentBranch(projectDir); if (["main", "master"].includes(branch)) - notes.push(`⚠️ guard: правишь файлы на «${branch}». Нужна feature-ветка: git checkout -b feat/…`); + notes.push(`guard: editing files on ${branch}. Create a feature branch first: git checkout -b feat/...`); } } return allowRes(notes); @@ -357,15 +358,14 @@ function run(ctx, env = process.env) { return { exitCode: 0, stdout: "", stderr: "" }; } catch (e) { - // fail-open: хук не должен вешать сессию. НО делаем поломку видимой в stderr — - // иначе исключение в детекторе тихо отключило бы всю защиту для этого вызова. - return { exitCode: 0, stdout: "", stderr: "⚠️ guard: внутренняя ошибка проверки, пропускаю (fail-open): " + (e && e.message) + "\n" }; + // Fail open so the hook cannot wedge the session, but make the failure visible. + return { exitCode: 0, stdout: "", stderr: "guard: internal policy error, allowing call (fail-open): " + (e && e.message) + "\n" }; } } module.exports = { run }; -// ---------- CLI-обёртка ---------- +// ---------- CLI wrapper ---------- if (require.main === module) { (async () => { const ctx = await parse(); diff --git a/hooks/agent/stop-reminder.js b/hooks/agent/stop-reminder.js index 0d76741..efd09d9 100644 --- a/hooks/agent/stop-reminder.js +++ b/hooks/agent/stop-reminder.js @@ -1,11 +1,10 @@ #!/usr/bin/env node -// stop-reminder.js — Stop-хук. Напоминает про VERIFY / COMMIT / REPORT ТОЛЬКО если -// в рабочем дереве есть незакоммиченные изменения; чистое дерево → молчит. +// stop-reminder.js - Stop hook. Reminds about VERIFY / COMMIT / REPORT only when +// the working tree has uncommitted changes; clean tree stays quiet. // -// Контракт Stop-хука Claude Code: additionalContext на Stop НЕ поддерживается — -// единственный способ донести текст до модели: {"decision":"block","reason":"…"}. -// Защита от зацикливания: если раннер прислал stop_hook_active=true (мы уже -// блокировали этот Stop), выходим молча — иначе агент никогда не остановится. +// Claude Code Stop hooks do not support additionalContext. The only way to +// deliver text is {"decision":"block","reason":"..."}. If the runner sends +// stop_hook_active=true, this hook stays quiet to avoid an infinite stop loop. const { execSync } = require("child_process"); const crypto = require("crypto"); @@ -61,10 +60,10 @@ function lastAssistantTextFromTranscript(file) { function explainedIntentionalDirty(text) { const s = String(text || "").toLowerCase(); if (!s) return false; - const mentionsDirty = /dirty tree|uncommitted|незакоммич|некоммич|рабоч(ем|ее) дерев|оставш/.test(s); - const intentional = /intentional|intentionally|намеренн|осознанн|не трогал|не трогала|оставил|оставила|оставлены/.test(s); - const reportsLoop = /verify|проверен|проверено|self-review|diff|commit|коммит|report|отч[её]т/.test(s); - const reviewOnly = /review-only|только review|только ревью|повторн(ый|ое) review|повторн(ый|ое) ревью|изменения я не правил|изменения не правил|не коммитил|commit\/pr не делал|коммит не делал/.test(s); + const mentionsDirty = /dirty tree|uncommitted|working tree|left over|left intentionally/.test(s); + const intentional = /intentional|intentionally|left on purpose|not touched|left unchanged/.test(s); + const reportsLoop = /verify|verified|self-review|diff|commit|report/.test(s); + const reviewOnly = /review-only|review only|did not edit|did not commit|no commit\/pr|commit\/pr not created/.test(s); return (mentionsDirty && intentional && reportsLoop) || (reviewOnly && reportsLoop); } function isHarnessOrLocalStatus(line) { @@ -83,7 +82,7 @@ function isHarnessOrLocalStatus(line) { stopHookActive = ctx.stopHookActive; raw = ctx.raw || {}; } catch {} - if (stopHookActive) process.exit(0); // уже напоминали в этом же Stop — не зацикливаемся + if (stopHookActive) process.exit(0); // already reminded during this Stop; avoid loops let status = ""; try { @@ -107,16 +106,16 @@ function isHarnessOrLocalStatus(line) { process.exit(0); } } - const shown = lines.slice(0, 20).join("\n") + (lines.length > 20 ? `\n... ещё ${lines.length - 20}` : ""); + const shown = lines.slice(0, 20).join("\n") + (lines.length > 20 ? `\n... ${lines.length - 20} more` : ""); const harnessNote = harnessLike.length - ? "\nПохоже, часть dirty tree — bootstrap/harness/local files. Если они оставлены намеренно, повторный Stop с тем же git status будет разрешён.\n" - : "\nЕсли dirty tree оставлено намеренно и отчёт уже объясняет почему, повторный Stop с тем же git status будет разрешён.\n"; + ? "\nSome dirty files look like bootstrap/harness/local files. If they are intentional, the next Stop with the same git status will be allowed.\n" + : "\nIf the dirty tree is intentional and the report explains why, the next Stop with the same git status will be allowed.\n"; const reason = - "stop-reminder: есть незакоммиченные изменения — закрыты ли шаги loop?\n" + - " 4. VERIFY (node hooks/verify.js + git diff review) -> 5. COMMIT на feature-ветке -> 6. REPORT.\n" + - "Коммит не всегда нужен: можно явно отчитаться, почему изменения остаются uncommitted.\n" + + "stop-reminder: uncommitted changes remain; are the loop steps complete?\n" + + " 4. VERIFY (node hooks/verify.js + git diff review) -> 5. COMMIT on a feature branch -> 6. REPORT.\n" + + "A commit is not always required: explicitly report why changes remain uncommitted.\n" + harnessNote + - "git status (первые строки):\n" + shown; + "git status (first lines):\n" + shown; try { process.stdout.write(JSON.stringify({ decision: "block", reason }) + "\n"); } catch {} process.stderr.write(reason + "\n"); process.exit(0); diff --git a/hooks/apply-ruleset.js b/hooks/apply-ruleset.js index 2d5484e..b70915e 100644 --- a/hooks/apply-ruleset.js +++ b/hooks/apply-ruleset.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -// apply-ruleset.js — install the versioned branch ruleset (the REAL, server-side gate). +// apply-ruleset.js - install the versioned branch ruleset (the REAL, server-side gate). // // Local hooks (lefthook + gitleaks + cocogitto) are HYGIENE: fast, but an agent with // write access can bypass them. Only a GitHub ruleset with a required status check @@ -17,45 +17,112 @@ const fs = require("fs"); const path = require("path"); const { execFileSync } = require("child_process"); -const dryRun = process.argv.includes("--dry-run"); const rulesetPath = path.join(__dirname, "..", ".github", "rulesets", "main.json"); function gh(args, opts = {}) { return execFileSync("gh", args, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], ...opts }).trim(); } +function stable(value) { + if (Array.isArray(value)) return value.map(stable); + if (!value || typeof value !== "object") return value; + return Object.fromEntries(Object.keys(value).sort().map((k) => [k, stable(value[k])])); +} +function same(a, b) { + return JSON.stringify(stable(a)) === JSON.stringify(stable(b)); +} +function parseRulesetList(text) { + const raw = String(text || "").trim(); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.flat() : [parsed]; + } catch {} + const out = []; + for (const line of raw.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)) { + const parsed = JSON.parse(line); + if (Array.isArray(parsed)) out.push(...parsed); + else out.push(parsed); + } + return out; +} +function ruleByType(ruleset, type) { + return ((ruleset && ruleset.rules) || []).find((r) => r.type === type) || null; +} +function requiredCheckKey(check) { + return `${check.context || ""}\u0000${check.integration_id === undefined ? "" : check.integration_id}`; +} +function compareRuleset(expected, actual) { + const mismatches = []; + for (const key of ["name", "target", "enforcement"]) { + if (expected[key] !== actual[key]) mismatches.push(`${key}: expected ${expected[key]}, got ${actual[key]}`); + } + if (!same(expected.conditions || {}, actual.conditions || {})) mismatches.push("conditions differ"); + for (const expRule of expected.rules || []) { + const gotRule = ruleByType(actual, expRule.type); + if (!gotRule) { mismatches.push(`missing rule: ${expRule.type}`); continue; } + if (expRule.type === "required_status_checks") { + const expParams = expRule.parameters || {}; + const gotParams = gotRule.parameters || {}; + if (expParams.strict_required_status_checks_policy !== gotParams.strict_required_status_checks_policy) + mismatches.push("required_status_checks.strict_required_status_checks_policy differs"); + const gotChecks = new Set((gotParams.required_status_checks || []).map(requiredCheckKey)); + for (const check of expParams.required_status_checks || []) { + if (!gotChecks.has(requiredCheckKey(check))) + mismatches.push(`required status check missing: ${check.context} / ${check.integration_id}`); + } + continue; + } + for (const [key, value] of Object.entries(expRule.parameters || {})) { + if (!same(value, ((gotRule.parameters || {})[key]))) + mismatches.push(`${expRule.type}.${key} differs`); + } + } + return mismatches; +} -try { - const raw = JSON.parse(fs.readFileSync(rulesetPath, "utf8")); - delete raw._comment; // strip the doc comment before sending - const body = JSON.stringify(raw); +function main() { + const dryRun = process.argv.includes("--dry-run"); + try { + const raw = JSON.parse(fs.readFileSync(rulesetPath, "utf8")); + delete raw._comment; // strip the doc comment before sending + const body = JSON.stringify(raw); - if (dryRun) { - console.log("would POST this ruleset to repos///rulesets:\n"); - console.log(JSON.stringify(raw, null, 2)); - process.exit(0); - } + if (dryRun) { + console.log("would apply this ruleset to repos///rulesets:\n"); + console.log(JSON.stringify(raw, null, 2)); + process.exit(0); + } - const repo = gh(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]); + const repo = gh(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]); - // Replace an existing "protect-main" ruleset if present (idempotent). - let existingId = ""; - try { - const list = JSON.parse(gh(["api", `repos/${repo}/rulesets`])); - const found = (Array.isArray(list) ? list : []).find((r) => r.name === raw.name); - if (found) existingId = String(found.id); - } catch {} + // Replace an existing "protect-main" ruleset if present (idempotent). + const list = parseRulesetList(gh(["api", "--paginate", `repos/${repo}/rulesets`])); + const found = list.find((r) => r.name === raw.name); + const existingId = found ? String(found.id) : ""; - const apiArgs = existingId - ? ["api", "-X", "PUT", `repos/${repo}/rulesets/${existingId}`, "--input", "-"] - : ["api", "-X", "POST", `repos/${repo}/rulesets`, "--input", "-"]; + const apiArgs = existingId + ? ["api", "-X", "PUT", `repos/${repo}/rulesets/${existingId}`, "--input", "-"] + : ["api", "-X", "POST", `repos/${repo}/rulesets`, "--input", "-"]; - gh(apiArgs, { input: body }); - console.log(`✅ ruleset "${raw.name}" ${existingId ? "updated" : "created"} on ${repo} (branches: main/master).`); - console.log(" Enforced: require PR, required check «verify», block force-push & deletion."); - process.exit(0); -} catch (e) { - console.error("❌ apply-ruleset failed:", e.message); - console.error(" Need: gh CLI (authenticated, repo admin) and a plan that supports rulesets"); - console.error(" (private repos → Pro/Team/Enterprise; or make the repo public). See BACKLOG P0-0."); - process.exit(1); + const written = JSON.parse(gh(apiArgs, { input: body })); + const id = String(written.id || existingId || ""); + const applied = id ? JSON.parse(gh(["api", `repos/${repo}/rulesets/${id}`])) : written; + const mismatches = compareRuleset(raw, applied); + if (mismatches.length) { + console.error(`FAIL ruleset "${raw.name}" was written but readback does not match .github/rulesets/main.json:`); + for (const m of mismatches) console.error(` - ${m}`); + process.exit(1); + } + console.log(`OK ruleset "${raw.name}" ${existingId ? "updated" : "created"} on ${repo} (branches: main/master).`); + console.log(" Readback verified: PR policy, required check \"verify\", force-push/delete protection."); + process.exit(0); + } catch (e) { + console.error("FAIL apply-ruleset failed:", e.message); + console.error(" Need: gh CLI (authenticated, repo admin) and a plan that supports rulesets"); + console.error(" (private repos need Pro/Team/Enterprise; or make the repo public). See BACKLOG P0-0."); + process.exit(1); + } } + +module.exports = { parseRulesetList, compareRuleset }; +if (require.main === module) main(); diff --git a/hooks/design-gate.js b/hooks/design-gate.js index 2465c28..063426a 100644 --- a/hooks/design-gate.js +++ b/hooks/design-gate.js @@ -1,8 +1,8 @@ #!/usr/bin/env node -// design-gate.js — DESIGN-stage gate. +// design-gate.js - DESIGN-stage gate. // // Policy: GUI work must be preceded by design review. If a branch's changes touch UI -// paths, the SAME branch diff must also touch an APPROVED set of >= N mockups — +// paths, the SAME branch diff must also touch an APPROVED set of >= N mockups - // otherwise one old approval would open the gate for all future UI work forever. // // Usage: @@ -13,7 +13,7 @@ // --strict diff errors fail closed (CI/server enforcement) // // Exit 0 = gate satisfied (or no UI change), exit 1 = UI changed without approved mockups. -// Internal error → exit 0 (never wedge unrelated work), но с ГРОМКИМ warning. +// Internal error -> exit 0 locally (never wedge unrelated work), with a loud warning. const fs = require("fs"); const path = require("path"); @@ -50,21 +50,18 @@ function defaultBase(root) { return "origin/main"; } -// ---------- config (общая с guard.js: hooks/_lib.js) ---------- -// changedFiles — общий с verify.js (--changed): единый источник git-diff логики. +// ---------- config shared with guard.js and verify.js ---------- const { globToRe, loadConfig, changedFiles } = require(path.join(__dirname, "_lib.js")); // ---------- mockups scan ---------- -// Одобренный набор засчитывается ТОЛЬКО если он затронут в diff этой же ветки — -// иначе один старый approval навсегда открывал бы гейт для любых будущих UI-правок. -// Повторное использование уже одобренного набора: допиши строку в его APPROVED -// (дата/ветка) — файл попадёт в diff, и связь «этот набор ↔ это изменение» явная. +// An approved set counts only if that same set is touched in this branch diff. +// Otherwise one old approval would unlock future UI work forever. function hasApprovedMockups(root, m, changed) { const base = path.join(root, m.dir); const mockRoot = m.dir.replace(/\\/g, "/").replace(/\/$/, ""); let dirs; try { dirs = fs.readdirSync(base, { withFileTypes: true }).filter((d) => d.isDirectory()); } - catch { return { ok: false, reason: `нет каталога ${m.dir}/` }; } + catch { return { ok: false, reason: `missing directory ${m.dir}/` }; } const stale = []; for (const d of dirs) { @@ -81,9 +78,9 @@ function hasApprovedMockups(root, m, changed) { return { ok: false, reason: stale.length - ? `одобренные наборы (${stale.join(", ")}) не затронуты в diff этой ветки — ` + - `привяжи набор к изменению: допиши строку в ${m.dir}//${m.approvalFile}` - : `нет ${m.dir}// с >=${m.min} мокапами и файлом ${m.approvalFile}, затронутого в этой ветке`, + ? `approved set(s) (${stale.join(", ")}) are not touched in this branch diff; ` + + `touch ${m.dir}//${m.approvalFile} to bind an existing approval to this change` + : `no ${m.dir}// with >=${m.min} mockups and ${m.approvalFile} touched in this branch`, }; } @@ -99,8 +96,8 @@ function hasApprovedMockups(root, m, changed) { const cf = changedFiles(a.base, a.root, a.files); if (cf.base) res.base = cf.base; if (cf.error) { - // local default = fail-open, но ГРОМКО; strict/CI = fail-closed. - const warn = `⚠️ design-gate: ${cf.error} — ${a.strict ? "гейт НЕ МОЖЕТ ПРОВЕРИТЬ UI-изменения" : "гейт ПРОПУЩЕН, UI-изменения не проверены"}. Укажи базу явно: --base .`; + // Local default = fail-open loudly; strict/CI = fail-closed. + const warn = `design-gate: ${cf.error}; ${a.strict ? "gate cannot verify UI changes" : "gate skipped and UI changes were not checked"}. Pass --base .`; if (a.json) console.log(JSON.stringify({ ...res, ok: !a.strict, skipped: true, warn })); else console.error(warn); process.exit(a.strict ? 1 : 0); @@ -112,8 +109,8 @@ function hasApprovedMockups(root, m, changed) { ); if (res.uiChanged.length === 0) { - if (a.json) console.log(JSON.stringify({ ...res, note: "нет изменений в UI-путях" })); - else console.log("OK design-gate: изменений в UI-путях нет — гейт не требуется."); + if (a.json) console.log(JSON.stringify({ ...res, note: "no UI-path changes" })); + else console.log("OK design-gate: no UI-path changes; gate not required."); process.exit(0); } @@ -121,18 +118,18 @@ function hasApprovedMockups(root, m, changed) { res.mockups = mk; if (mk.ok) { if (a.json) console.log(JSON.stringify(res)); - else console.log(`OK design-gate: UI-изменения есть, одобренный набор мокапов затронут в ветке (${mk.feature}, ${mk.count} шт.).`); + else console.log(`OK design-gate: UI changes have an approved mockup set touched in this branch (${mk.feature}, ${mk.count}).`); process.exit(0); } if (a.json) { console.log(JSON.stringify({ ...res, ok: false })); process.exit(1); } console.error( - `BLOCK design-gate: изменения затрагивают GUI, но DESIGN-стадия не выполнена.\n` + - ` UI-файлы: ${res.uiChanged.slice(0, 8).join(", ")}${res.uiChanged.length > 8 ? " ..." : ""}\n` + - ` Требуется: ${mk.reason}.\n` + - ` Новый набор: node hooks/new-mockups.js → approval → создай ${cfg.mockups.dir}//${cfg.mockups.approvalFile}.\n` + - ` Уже одобренный набор: допиши строку в его ${cfg.mockups.approvalFile}, чтобы он попал в diff ветки.\n` + - ` Политика: для нового/изменяемого GUI — >=${cfg.mockups.min} стилистически разных мокапа + approval.` + `BLOCK design-gate: GUI changes require DESIGN approval.\n` + + ` UI files: ${res.uiChanged.slice(0, 8).join(", ")}${res.uiChanged.length > 8 ? " ..." : ""}\n` + + ` Required: ${mk.reason}.\n` + + ` New set: node hooks/new-mockups.js , get approval, then create ${cfg.mockups.dir}//${cfg.mockups.approvalFile}.\n` + + ` Existing set: touch its ${cfg.mockups.approvalFile} so it appears in this branch diff.\n` + + ` Policy: new/changed GUI needs >=${cfg.mockups.min} stylistically distinct mockups plus approval.` ); process.exit(1); })(); diff --git a/hooks/doctor.js b/hooks/doctor.js index 2c17fa2..76e3ffb 100644 --- a/hooks/doctor.js +++ b/hooks/doctor.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -// doctor.js — environment self-check (BACKLOG P2-12). Catches the classes of problem we +// doctor.js - environment self-check (BACKLOG P2-12). Catches the classes of problem we // hit in development: hooks not wired, CRLF, NUL bytes, bad config, missing git identity. // Checks the migrated stack (lefthook + gitleaks + cocogitto). Run: node hooks/doctor.js // @@ -20,28 +20,73 @@ function gitSafe(args) { try { return git(args); } catch { return null; } } function inPath(bin) { try { execFileSync(process.platform === "win32" ? "where" : "which", [bin], { stdio: ["ignore", "pipe", "ignore"], timeout: 5000, killSignal: "SIGKILL" }); return true; } catch { return false; } } function tracked(rel) { return gitSafe(["ls-files", "--error-unmatch", rel]) !== null; } function readText(rel) { try { return fs.readFileSync(path.join(ROOT, rel), "utf8"); } catch { return ""; } } +function escapeRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +function tomlSection(text, name) { + const re = new RegExp(`^\\s*\\[${escapeRe(name)}\\]\\s*$`, "m"); + const m = re.exec(text); + if (!m) return ""; + const rest = text.slice(m.index + m[0].length); + const next = rest.search(/^\s*\[[^\]]+\]\s*$/m); + return next >= 0 ? rest.slice(0, next) : rest; +} +function tomlString(section, key) { + const re = new RegExp(`^\\s*${escapeRe(key)}\\s*=\\s*"([^"]*)"\\s*$`, "m"); + const m = re.exec(section); + return m ? m[1] : null; +} +function githubRepoFromUrl(url) { + const value = String(url || "").trim(); + const m = value.match(/^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/:]+)\/([^/]+?)(?:\.git)?\/?$/i); + return m ? { owner: m[1], repository: m[2] } : null; +} function checkTextFile(rel) { const p = path.join(ROOT, rel); let buf; - try { buf = fs.readFileSync(p); } catch { fail(rel + " отсутствует"); return; } + try { buf = fs.readFileSync(p); } catch { fail(rel + " is missing"); return; } const text = buf.toString("utf8"); - if (!Buffer.from(text, "utf8").equals(buf)) fail(rel + ": невалидный UTF-8 или обрезанный многобайтный символ"); - else if (buf.includes(0)) fail(rel + " содержит NUL-байты"); - else if (buf.includes(13)) fail(rel + ": CRLF/CR line endings (нужен LF)"); - else ok(rel + ": LF, UTF-8, без NUL"); + if (!Buffer.from(text, "utf8").equals(buf)) fail(rel + ": invalid UTF-8 or truncated multibyte character"); + else if (buf.includes(0)) fail(rel + " contains NUL bytes"); + else if (buf.includes(13)) fail(rel + ": CRLF/CR line endings (LF required)"); + else ok(rel + ": LF, UTF-8, no NUL"); } -function workflowJobIds(rel) { +function yamlKeyLine(line) { + const m = String(line).match(/^(\s*)(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))\s*:.*$/); + if (!m) return null; + return { indent: m[1].length, key: m[2] || m[3] || m[4] }; +} +function workflowJobs(rel) { const text = readText(rel); const lines = text.split(/\r?\n/); - const ids = []; - let inJobs = false; + const jobs = []; + let jobsIndent = null, jobIndent = null; + let current = null; for (const line of lines) { - if (/^jobs:\s*$/.test(line)) { inJobs = true; continue; } - if (inJobs && /^\S/.test(line) && !/^jobs:\s*$/.test(line)) break; - const m = inJobs && line.match(/^ ([A-Za-z0-9_-]+):\s*(?:#.*)?$/); - if (m) ids.push(m[1]); + if (/^\s*(?:#.*)?$/.test(line)) { + if (current) current.body.push(line); + continue; + } + const key = yamlKeyLine(line); + if (jobsIndent === null) { + if (key && key.key === "jobs") jobsIndent = key.indent; + continue; + } + if (key && key.indent <= jobsIndent) break; + if (key && (jobIndent === null || key.indent === jobIndent)) { + if (jobIndent === null) jobIndent = key.indent; + current = { id: key.key, body: [line] }; + jobs.push(current); + continue; + } + if (current) current.body.push(line); } - return ids; + return jobs; +} +function workflowJobIds(rel) { + return workflowJobs(rel).map((j) => j.id); +} +function workflowJobBody(rel, id) { + const job = workflowJobs(rel).find((j) => j.id === id); + return job ? job.body.join("\n") : ""; } function rulesetRequiredChecks(rel) { let ruleset = {}; @@ -49,20 +94,81 @@ function rulesetRequiredChecks(rel) { const rsc = (ruleset.rules || []).find((r) => r.type === "required_status_checks"); return (((rsc || {}).parameters || {}).required_status_checks || []).map((c) => c.context).filter(Boolean); } +function codeownersInfo(rel = ".github/CODEOWNERS") { + const abs = path.join(ROOT, rel); + const exists = fs.existsSync(abs); + const text = exists ? readText(rel) : ""; + const ownerLines = text.split(/\r?\n/) + .map((line) => line.replace(/\s+#.*$/, "").trim()) + .filter((line) => line && !line.startsWith("#") && /\s@[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)?\b/.test(line)); + return { rel, exists, hasOwner: ownerLines.length > 0, tracked: !inRepo || tracked(rel) }; +} +function checkRulesetPrReview(rel) { + let ruleset = {}; + try { ruleset = JSON.parse(readText(rel)); } catch { return; } + const pr = (ruleset.rules || []).find((r) => r.type === "pull_request"); + const p = (pr && pr.parameters) || {}; + const codeowners = codeownersInfo(); + if (Number(p.required_approving_review_count || 0) < 1) + warn("ruleset: pull_request does not require approving review; this is only appropriate for a solo-maintainer source repo"); + else ok("ruleset: pull_request requires approving review"); + if (p.require_code_owner_review === true) { + ok("ruleset: code-owner review required"); + if (!codeowners.exists) fail("ruleset: code-owner review requires .github/CODEOWNERS"); + else if (!codeowners.tracked) fail("ruleset: .github/CODEOWNERS must be tracked"); + else if (!codeowners.hasOwner) fail("ruleset: code-owner review is enabled but .github/CODEOWNERS has no owner entries"); + else ok("ruleset: CODEOWNERS has owner entries and is tracked"); + } else { + if (codeowners.exists && codeowners.hasOwner) + warn("ruleset: CODEOWNERS has owner entries but required code-owner review is disabled"); + else + ok("ruleset: code-owner review disabled and CODEOWNERS has no required owner configured"); + } +} +function checkVerifyJobContract(workflowPath, required) { + if (!required.includes("verify")) return; + const body = workflowJobBody(workflowPath, "verify"); + if (!body) { fail("CI job verify is required by ruleset but its workflow body was not found"); return; } + const checks = [ + { name: "doctor", re: /node\s+hooks\/doctor\.js\b/ }, + { name: "verify", re: /node\s+hooks\/verify\.js\b/ }, + { name: "design-gate strict", re: /node\s+hooks\/design-gate\.js\b[^\n]*--strict\b/ }, + { name: "secret scan", re: /gitleaks/i }, + ]; + const missing = checks.filter((c) => !c.re.test(body)).map((c) => c.name); + if (missing.length) fail(`CI job verify does not run required harness step(s): ${missing.join(", ")}`); + else ok("CI job verify runs doctor, verify.js, design-gate --strict and secret scan"); +} +function checkWorkflowSupplyChain(workflowPath) { + const text = readText(workflowPath); + const unpinned = []; + for (const m of text.matchAll(/uses:\s*([^\s#]+)/g)) { + const spec = m[1]; + if (!/@[0-9a-f]{40}$/i.test(spec)) unpinned.push(spec); + } + if (unpinned.length) fail(`CI action(s) not pinned to full SHA: ${unpinned.join(", ")}`); + else ok("CI actions are pinned to full commit SHAs"); + + if (/ecc-agentshield@/.test(text)) { + /AGENTSHIELD_INTEGRITY:\s*["']sha512-/.test(text) && /NPM_CONFIG_IGNORE_SCRIPTS:\s*["']true["']/.test(text) + ? ok("CI AgentShield npm package has integrity pin and install scripts disabled") + : fail("CI AgentShield npm package must pin dist.integrity and set NPM_CONFIG_IGNORE_SCRIPTS=true"); + } +} // node / git ok("node " + process.version); const gv = gitSafe(["--version"]); -gv ? ok(gv) : fail("git не найден в PATH"); +gv ? ok(gv) : fail("git not found in PATH"); // repo const inRepo = gitSafe(["rev-parse", "--is-inside-work-tree"]) === "true"; if (!inRepo) { - fail("не git-репозиторий (запусти внутри репо)"); + fail("not a git repository; run inside a repository"); } else { const name = gitSafe(["config", "--get", "user.name"]); const email = gitSafe(["config", "--get", "user.email"]); - (name && email) ? ok("git identity: " + name + " <" + email + ">") : warn("git user.name/email не заданы"); + (name && email) ? ok("git identity: " + name + " <" + email + ">") : warn("git user.name/email are not configured"); // lefthook wired into .git/hooks? (lefthook install writes a stub referencing lefthook) const hooksDir = gitSafe(["rev-parse", "--git-path", "hooks"]) || ".git/hooks"; @@ -72,13 +178,12 @@ if (!inRepo) { if (/lefthook/i.test(fs.readFileSync(path.join(ROOT, hooksDir, h), "utf8"))) { wired = true; break; } } catch {} } - wired ? ok("lefthook wired into .git/hooks") : warn("хуки не установлены — запусти: lefthook install"); + wired ? ok("lefthook wired into .git/hooks") : warn("hooks are not installed; run: lefthook install"); - // .git должна допускать полный жизненный цикл lock-файла (write + unlink): git - // обновляет index и refs через .lock -> rename/unlink. На FS без удаления - // (некоторые сетевые/контейнерные/FUSE mount'ы) commit/checkout/rebase падают на - // "index.lock: File exists". Проверяем реальной пробой, а не предположением — - // именно этот отказ среды раньше не ловился. + // .git must support the full lock-file lifecycle (write + unlink): git updates + // the index and refs through .lock -> rename/unlink. On filesystems that + // cannot delete files (some network/container/FUSE mounts), commit/checkout/rebase + // fails with "index.lock: File exists". Probe the real behavior instead of guessing. const gitDir = gitSafe(["rev-parse", "--git-dir"]) || ".git"; const gitDirAbs = path.isAbsolute(gitDir) ? gitDir : path.join(ROOT, gitDir); const probe = path.join(gitDirAbs, ".doctor-lock-probe-" + process.pid); @@ -86,32 +191,34 @@ if (!inRepo) { fs.writeFileSync(probe, "x"); try { fs.unlinkSync(probe); - ok(".git допускает атомарные lock-операции (write + unlink)"); + ok(".git supports atomic lock operations (write + unlink)"); } catch { - fail(".git запрещает удаление файлов — git не уберёт *.lock (index.lock/ref.lock); commit/checkout/rebase упадут. Проверь mount (read-delete/FUSE) или права."); + fail(".git cannot delete files; git cannot clean up lock files and commit/checkout/rebase will fail. Check mount or permissions."); } } catch { - fail(".git недоступна для записи — git add/commit/checkout работать не будут. Проверь права/mount."); + fail(".git is not writable; git add/commit/checkout will not work. Check permissions or mount."); } try { if (fs.existsSync(path.join(gitDirAbs, "index.lock"))) - warn("залипший .git/index.lock — удали, если ни один git-процесс не запущен (иначе add/commit блокируются)"); + warn("stale .git/index.lock detected; remove it only if no git process is running"); } catch {} } -// runner + delegated tools in PATH (WARN, not FAIL — CI provides them) +// runner + delegated tools in PATH (WARN, not FAIL; CI provides them) const tools = [ - ["lefthook", "git-hook раннер (lefthook install)"], + ["lefthook", "git hook runner (lefthook install)"], ["gitleaks", "secret scanning (pre-commit + CI)"], ["cog", "cocogitto: conventional commits + release"], ]; for (const t of tools) { - inPath(t[0]) ? ok(t[0] + " найден") : warn(t[0] + " не в PATH — " + t[1]); + inPath(t[0]) ? ok(t[0] + " found") : warn(t[0] + " not in PATH - " + t[1]); } const requiredHarnessFiles = [ "hooks/verify.js", + "hooks/verify-core.js", "hooks/design-gate.js", + "hooks/release-preflight.js", "hooks/new-mockups.js", "hooks/doctor.js", "hooks/apply-ruleset.js", @@ -124,10 +231,13 @@ const requiredHarnessFiles = [ "harness.config.json", "lefthook.yml", "cog.toml", + "CHANGELOG.md", ".gitleaks.toml", "AGENTS.md", "settings.example.json", ".github/rulesets/main.json", + ".github/workflows/ci.yml", + ".github/CODEOWNERS", ]; const missingHarness = []; const untrackedHarness = []; @@ -139,8 +249,8 @@ if (missingHarness.length || untrackedHarness.length) { const parts = []; if (missingHarness.length) parts.push("missing: " + missingHarness.join(", ")); if (untrackedHarness.length) parts.push("untracked: " + untrackedHarness.join(", ")); - fail("harness not bootstrapped into repository main — " + parts.join("; ") + - ". Создай bootstrap PR и закоммить эти файлы перед dev/release loop."); + fail("harness not bootstrapped into repository main - " + parts.join("; ") + + ". Create a bootstrap PR and commit these files before the dev/release loop."); } else { ok("harness bootstrap files present and tracked"); } @@ -163,26 +273,87 @@ for (const f of textCritical) checkTextFile(f); // harness.config.json valid JSON const cfgPath = path.join(ROOT, "harness.config.json"); if (fs.existsSync(cfgPath)) { - try { JSON.parse(fs.readFileSync(cfgPath, "utf8")); ok("harness.config.json — валидный JSON"); } - catch (e) { fail("harness.config.json невалиден: " + e.message); } + try { + const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); + ok("harness.config.json is valid JSON"); + const hasSelfTest = fs.existsSync(path.join(ROOT, "hooks", "test.js")); + const stacks = cfg.verify && Array.isArray(cfg.verify.stacks) ? cfg.verify.stacks : null; + if (hasSelfTest && stacks) { + const harness = stacks.find((s) => s && s.id === "harness"); + const steps = (harness && Array.isArray(harness.steps)) ? harness.steps : []; + const runsSelfTest = steps.some((s) => /node\s+test\.js\b/.test(String(s && s.run || ""))); + runsSelfTest ? ok("harness.config.json: VERIFY includes harness self-test") : + fail("harness.config.json: verify.stacks is set but required harness self-test (node test.js) is missing"); + } + } + catch (e) { fail("harness.config.json is invalid: " + e.message); } } const cogPath = path.join(ROOT, "cog.toml"); if (fs.existsSync(cogPath)) { const cog = fs.readFileSync(cogPath, "utf8"); - /from_latest_tag\s*=\s*true/.test(cog) ? ok("cog.toml: from_latest_tag=true") : fail("cog.toml: нужен from_latest_tag=true для release bump от последнего v* tag"); - /ignore_merge_commits\s*=\s*true/.test(cog) ? ok("cog.toml: ignore_merge_commits=true") : fail("cog.toml: нужен ignore_merge_commits=true"); - /tag_prefix\s*=\s*"v"/.test(cog) ? ok("cog.toml: tag_prefix=\"v\"") : fail("cog.toml: нужен tag_prefix=\"v\""); + const changelogSection = tomlSection(cog, "changelog"); + const changelogTemplate = tomlString(changelogSection, "template"); + const changelogOwner = tomlString(changelogSection, "owner"); + const changelogRepository = tomlString(changelogSection, "repository"); + /from_latest_tag\s*=\s*true/.test(cog) ? ok("cog.toml: from_latest_tag=true") : fail("cog.toml: from_latest_tag=true is required for release bumps from the latest v* tag"); + /ignore_merge_commits\s*=\s*true/.test(cog) ? ok("cog.toml: ignore_merge_commits=true") : fail("cog.toml: ignore_merge_commits=true is required"); + /tag_prefix\s*=\s*"v"/.test(cog) ? ok("cog.toml: tag_prefix=\"v\"") : fail("cog.toml: tag_prefix=\"v\" is required"); + /branch_whitelist\s*=\s*\[[^\]]*"release\/\*\*"/s.test(cog) + ? ok("cog.toml: branch_whitelist includes release/**") + : fail("cog.toml: branch_whitelist must include release/** for release worktrees"); + changelogTemplate === "remote" + ? ok("cog.toml: changelog.template=\"remote\"") + : fail("cog.toml: changelog.template=\"remote\" is required for github.com remote changelog generation"); + changelogOwner + ? ok("cog.toml: changelog.owner set") + : fail("cog.toml: changelog.owner is required for remote changelog generation"); + changelogRepository + ? ok("cog.toml: changelog.repository set") + : fail("cog.toml: changelog.repository is required for remote changelog generation"); + if (changelogTemplate === "remote" && (changelogOwner || changelogRepository)) { + const origin = githubRepoFromUrl(gitSafe(["remote", "get-url", "origin"])); + if (!origin) { + fail("cog.toml: remote changelog generation requires a GitHub origin remote"); + } else { + const ownerMatches = changelogOwner && changelogOwner.toLowerCase() === origin.owner.toLowerCase(); + const repoMatches = changelogRepository && changelogRepository.toLowerCase() === origin.repository.toLowerCase(); + ownerMatches + ? ok("cog.toml: changelog.owner matches origin") + : fail(`cog.toml: changelog.owner must match origin owner (${origin.owner})`); + repoMatches + ? ok("cog.toml: changelog.repository matches origin") + : fail(`cog.toml: changelog.repository must match origin repository (${origin.repository})`); + } + } + const changelogPath = path.join(ROOT, "CHANGELOG.md"); + let changelog = ""; + try { changelog = fs.readFileSync(changelogPath, "utf8"); } catch {} + changelog + ? ok("CHANGELOG.md: present") + : fail("CHANGELOG.md is required for cog bump changelog generation"); + /^- - -\s*$/m.test(changelog) + ? ok("CHANGELOG.md: contains Cocogitto separator - - -") + : fail("CHANGELOG.md must contain Cocogitto separator line - - -"); } const workflowPath = ".github/workflows/ci.yml"; const rulesetPath = ".github/rulesets/main.json"; -if (fs.existsSync(path.join(ROOT, workflowPath)) && fs.existsSync(path.join(ROOT, rulesetPath))) { +if (fs.existsSync(path.join(ROOT, rulesetPath))) { const jobs = workflowJobIds(workflowPath); const required = rulesetRequiredChecks(rulesetPath); - const missing = required.filter((ctx) => !jobs.includes(ctx)); - if (missing.length) fail(`ruleset required check(s) not published by CI workflow: ${missing.join(", ")} (jobs: ${jobs.join(", ") || "none"})`); - else if (required.length) ok("ruleset required checks match CI workflow job ids"); + if (required.length && !fs.existsSync(path.join(ROOT, workflowPath))) { + fail(`ruleset required check(s) cannot run because ${workflowPath} is missing: ${required.join(", ")}`); + } else if (fs.existsSync(path.join(ROOT, workflowPath))) { + const missing = required.filter((ctx) => !jobs.includes(ctx)); + if (missing.length) fail(`ruleset required check(s) not published by CI workflow: ${missing.join(", ")} (jobs: ${jobs.join(", ") || "none"})`); + else if (required.length) { + ok("ruleset required checks match CI workflow job ids"); + checkVerifyJobContract(workflowPath, required); + } + checkWorkflowSupplyChain(workflowPath); + } + checkRulesetPrReview(rulesetPath); } // report @@ -191,8 +362,8 @@ if (process.argv.includes("--json")) { console.log(JSON.stringify({ ok: fails === 0, results })); } else { console.log("harness doctor:"); - const icon = { PASS: "✓", WARN: "⚠", FAIL: "✗" }; + const icon = { PASS: "OK", WARN: "WARN", FAIL: "FAIL" }; for (const r of results) console.log(" " + icon[r.level] + " " + r.msg); - console.log(fails ? "\n❌ doctor: " + fails + " FAIL — почини перед работой." : "\n✅ doctor: окружение в порядке."); + console.log(fails ? "\ndoctor: " + fails + " FAIL - fix before work." : "\ndoctor: environment is ready."); } process.exit(fails ? 1 : 0); diff --git a/hooks/new-mockups.js b/hooks/new-mockups.js index e2784df..f758305 100644 --- a/hooks/new-mockups.js +++ b/hooks/new-mockups.js @@ -1,10 +1,10 @@ #!/usr/bin/env node -// new-mockups.js — scaffold N stylistically-distinct single-file HTML mockups for a +// new-mockups.js - scaffold N stylistically-distinct single-file HTML mockups for a // GUI feature, satisfying the DESIGN stage (BACKLOG P1-5). // // Usage: node hooks/new-mockups.js // Creates design/mockups// with 4 openable HTML mockups (4 different style -// directions) + NOTES.md. Does NOT create APPROVED — you add that after picking a +// directions) + NOTES.md. Does NOT create APPROVED; you add that after picking a // direction, which is what the gate checks. const fs = require("fs"); @@ -22,7 +22,7 @@ const STYLES = [ function mockupHtml(feature, s) { return ` -${feature} — ${s.name} +${feature} - ${s.name} -
${feature}${s.name}MOCKUP · ${s.id}
+
${feature}${s.name}MOCKUP - ${s.id}

Panel A

-

Замени этот скелет реальным макетом экрана «${feature}» в стиле «${s.name}».

+

Replace this skeleton with the real ${feature} screen in the ${s.name} direction.

Panel B

-

Разные стили нужны, чтобы сравнить направления до кода, а не переписывать GUI потом.

+

Use distinct directions to compare the design before implementation.

`; } -const NOTES = (feature, styles) => `# Mockups — ${feature} +const NOTES = (feature, styles) => `# Mockups - ${feature} -DESIGN-стадия для GUI-фичи «${feature}». Правило: >=${styles.length} стилистически разных -мокапа + approval **до** написания GUI-кода (BACKLOG P1-5). +DESIGN stage for the GUI feature "${feature}". Rule: >=${styles.length} stylistically distinct +mockups plus approval before GUI implementation. -## Варианты -${styles.map((s) => `- \`${s.id}.html\` — ${s.name}`).join("\n")} +## Variants +${styles.map((s) => `- \`${s.id}.html\` - ${s.name}`).join("\n")} -## Как закрыть гейт -1. Доведи мокапы до реальных экранов (это скелеты-заглушки). -2. Обсуди/выбери направление с ревьюером. -3. Создай пустой файл \`APPROVED\` в этом каталоге (его проверяет hooks/design-gate.js). -4. Только после этого — реализация GUI. +## Closing the gate +1. Turn the skeletons into real screen mockups. +2. Review and choose a direction. +3. Create an \`APPROVED\` file in this directory. +4. Implement GUI code only after approval. `; function main() { const feature = (process.argv[2] || "").trim().replace(/[^a-zA-Z0-9._-]/g, "-"); if (!feature) { console.error("usage: node hooks/new-mockups.js "); process.exit(1); } const dir = path.join(ROOT, "design", "mockups", feature); - if (fs.existsSync(dir)) { console.error(`❌ уже существует: design/mockups/${feature}/ — не перезаписываю.`); process.exit(1); } + if (fs.existsSync(dir)) { console.error(`already exists: design/mockups/${feature}/; not overwriting.`); process.exit(1); } fs.mkdirSync(dir, { recursive: true }); for (const s of STYLES) fs.writeFileSync(path.join(dir, s.id + ".html"), mockupHtml(feature, s)); fs.writeFileSync(path.join(dir, "NOTES.md"), NOTES(feature, STYLES)); - console.log(`✅ создано ${STYLES.length} мокапа: design/mockups/${feature}/`); + console.log(`created ${STYLES.length} mockups: design/mockups/${feature}/`); STYLES.forEach((s) => console.log(` - ${s.id}.html (${s.name})`)); - console.log(`Дальше: доведи макеты → approval → создай design/mockups/${feature}/APPROVED`); + console.log(`Next: refine mockups, get approval, then create design/mockups/${feature}/APPROVED`); } main(); diff --git a/hooks/release-preflight.js b/hooks/release-preflight.js new file mode 100644 index 0000000..bdeb547 --- /dev/null +++ b/hooks/release-preflight.js @@ -0,0 +1,272 @@ +#!/usr/bin/env node +// release-preflight.js - executable release gate for the harness release loop. +// +// Checks the risky parts that are easy to miss by prose: +// - clean worktree; +// - release HEAD is based on the configured base ref; +// - the local release tag exists and points at HEAD; +// - the remote tag does not already exist; +// - project manifest versions match the release tag. +// +// Usage: +// node hooks/release-preflight.js --tag v1.2.3 [--root ] [--base origin/main] [--json] +// --allow-dirty do not fail on dirty worktree +// --allow-missing-tag do not require a local tag at HEAD yet +// --allow-remote-tag allow the tag to already exist on origin + +const fs = require("fs"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +const SKIP_DIRS = new Set([".git", "node_modules", "target", "bin", "obj", "dist", "build", ".venv", "venv", "__pycache__", ".next"]); +const MAX_DEPTH = 6; + +function parseArgs(argv) { + const a = { + root: process.cwd(), + base: "origin/main", + tag: "", + json: false, + allowDirty: false, + allowMissingTag: false, + allowRemoteTag: false, + }; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--root") a.root = argv[++i]; + else if (argv[i] === "--base") a.base = argv[++i]; + else if (argv[i] === "--tag") a.tag = argv[++i]; + else if (argv[i] === "--json") a.json = true; + else if (argv[i] === "--allow-dirty") a.allowDirty = true; + else if (argv[i] === "--allow-missing-tag") a.allowMissingTag = true; + else if (argv[i] === "--allow-remote-tag") a.allowRemoteTag = true; + } + a.root = path.resolve(a.root); + return a; +} + +function git(root, args) { + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: args[0] === "ls-remote" ? 60000 : 10000, + killSignal: "SIGKILL", + }).trim(); +} +function gitOk(root, args) { + try { git(root, args); return true; } catch { return false; } +} +function gitOut(root, args) { + try { return git(root, args); } catch { return ""; } +} +function gitResult(root, args) { + try { return { ok: true, out: git(root, args) }; } + catch (e) { return { ok: false, out: String((e && e.stderr) || (e && e.message) || "") }; } +} + +function add(res, level, msg, extra = {}) { + res.results.push({ level, msg, ...extra }); +} +function fail(res, msg, extra) { add(res, "FAIL", msg, extra); } +function warn(res, msg, extra) { add(res, "WARN", msg, extra); } +function pass(res, msg, extra) { add(res, "PASS", msg, extra); } + +function semverFromTag(tag) { + const m = String(tag || "").match(/^v(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)$/); + return m ? m[1] : ""; +} + +function walk(root, visit) { + function rec(dir, depth) { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const e of entries) { + const p = path.join(dir, e.name); + if (e.isDirectory()) { + if (depth < MAX_DEPTH && !SKIP_DIRS.has(e.name)) rec(p, depth + 1); + } else if (e.isFile()) { + visit(p, path.relative(root, p).replace(/\\/g, "/")); + } + } + } + rec(root, 0); +} + +function firstMatch(text, re) { + const m = re.exec(text); + return m ? m[1] : ""; +} + +function tomlSection(text, name) { + const lines = String(text || "").split(/\r?\n/); + const out = []; + let inSection = false; + for (const line of lines) { + const sec = line.match(/^\s*\[([^\]]+)\]\s*$/); + if (sec) { + if (inSection) break; + inSection = sec[1] === name; + continue; + } + if (inSection) out.push(line); + } + return out.join("\n"); +} + +function packageJsonVersion(abs, rel) { + if (path.basename(rel) !== "package.json") return null; + try { + const j = JSON.parse(fs.readFileSync(abs, "utf8")); + return typeof j.version === "string" && j.version ? { rel, kind: "package.json", version: j.version } : null; + } catch { return null; } +} + +function csprojVersion(abs, rel) { + if (!rel.toLowerCase().endsWith(".csproj")) return null; + let text = ""; + try { text = fs.readFileSync(abs, "utf8"); } catch { return null; } + const version = firstMatch(text, /\s*([^<\s]+)\s*<\/Version>/i) || + firstMatch(text, /\s*([^<\s]+)\s*<\/VersionPrefix>/i); + return version ? { rel, kind: "csproj", version } : null; +} + +function cargoVersion(abs, rel) { + if (path.basename(rel) !== "Cargo.toml") return null; + let text = ""; + try { text = fs.readFileSync(abs, "utf8"); } catch { return null; } + const version = firstMatch(tomlSection(text, "package"), /^\s*version\s*=\s*"([^"]+)"/m); + return version ? { rel, kind: "Cargo.toml", version } : null; +} + +function pyprojectVersion(abs, rel) { + if (path.basename(rel) !== "pyproject.toml") return null; + let text = ""; + try { text = fs.readFileSync(abs, "utf8"); } catch { return null; } + const version = firstMatch(tomlSection(text, "project"), /^\s*version\s*=\s*"([^"]+)"/m); + return version ? { rel, kind: "pyproject.toml", version } : null; +} + +function collectVersions(root) { + const versions = []; + walk(root, (abs, rel) => { + const hit = csprojVersion(abs, rel) || packageJsonVersion(abs, rel) || cargoVersion(abs, rel) || pyprojectVersion(abs, rel); + if (hit) versions.push(hit); + }); + return versions; +} + +function checkGitState(a, res) { + if (!gitOk(a.root, ["rev-parse", "--is-inside-work-tree"])) { + fail(res, "not a git repository"); + return; + } + pass(res, "git repository detected"); + + const dirty = gitOut(a.root, ["status", "--porcelain"]); + if (dirty && !a.allowDirty) fail(res, "worktree is dirty", { details: dirty.split(/\r?\n/).filter(Boolean).slice(0, 20) }); + else if (dirty) warn(res, "worktree is dirty but allowed", { details: dirty.split(/\r?\n/).filter(Boolean).slice(0, 20) }); + else pass(res, "worktree is clean"); + + if (a.base) { + if (!gitOk(a.root, ["rev-parse", "--verify", "--quiet", a.base])) { + fail(res, `base ref not found: ${a.base}`); + } else if (gitOk(a.root, ["merge-base", "--is-ancestor", a.base, "HEAD"])) { + pass(res, `HEAD is based on ${a.base}`); + } else { + fail(res, `HEAD is not based on ${a.base}`); + } + } +} + +function checkTag(a, res, version) { + if (!a.tag) { + fail(res, "release tag is required; pass --tag vX.Y.Z"); + return; + } + if (!version) { + fail(res, `tag must look like vX.Y.Z: ${a.tag}`); + return; + } + pass(res, `tag format ok: ${a.tag}`); + + const head = gitOut(a.root, ["rev-parse", "HEAD"]); + const localTag = gitOut(a.root, ["rev-parse", "-q", "--verify", `refs/tags/${a.tag}^{}`]); + if (!localTag) { + if (a.allowMissingTag) warn(res, `local tag is missing but allowed: ${a.tag}`); + else fail(res, `local tag is missing: ${a.tag}`); + } else if (localTag !== head) { + fail(res, `local tag ${a.tag} does not point at HEAD`, { tag: localTag, head }); + } else { + pass(res, `local tag ${a.tag} points at HEAD`); + } + const tagType = gitOut(a.root, ["cat-file", "-t", `refs/tags/${a.tag}`]); + if (!tagType) { + if (!a.allowMissingTag) fail(res, `cannot inspect local tag object: ${a.tag}`); + } else if (tagType !== "tag") { + fail(res, `local tag ${a.tag} must be annotated`, { type: tagType }); + } else { + pass(res, `local tag ${a.tag} is annotated`); + } + + const remote = gitResult(a.root, ["remote", "get-url", "origin"]); + if (!remote.ok || !remote.out) { + fail(res, "origin remote is not configured; cannot verify remote tag state"); + return; + } + const remoteTag = gitResult(a.root, ["ls-remote", "--tags", "origin", `refs/tags/${a.tag}`]); + if (!remoteTag.ok) fail(res, `cannot verify remote tag state for ${a.tag}`); + else if (remoteTag.out && !a.allowRemoteTag) fail(res, `remote tag already exists: ${a.tag}`); + else if (remoteTag.out) warn(res, `remote tag already exists but allowed: ${a.tag}`); + else pass(res, `remote tag does not exist yet: ${a.tag}`); +} + +function checkVersions(a, res, version) { + if (!version) return; + const versions = collectVersions(a.root); + if (!versions.length) { + warn(res, "no project version manifests found"); + return; + } + const mismatches = versions.filter((v) => v.version !== version); + if (mismatches.length) { + fail(res, `project version(s) do not match ${a.tag}`, { expected: version, mismatches }); + } else { + pass(res, `project version manifests match ${a.tag}`, { count: versions.length }); + } +} + +function checkChangelog(a, res) { + const p = path.join(a.root, "CHANGELOG.md"); + let text = ""; + try { text = fs.readFileSync(p, "utf8"); } catch { fail(res, "CHANGELOG.md not found"); return; } + if (text.includes(a.tag) || text.includes(semverFromTag(a.tag))) pass(res, "CHANGELOG.md mentions the release version"); + else fail(res, "CHANGELOG.md does not mention the release version"); +} + +function main() { + const a = parseArgs(process.argv.slice(2)); + const res = { ok: true, tag: a.tag, root: a.root, results: [] }; + const version = semverFromTag(a.tag); + + checkGitState(a, res); + checkTag(a, res, version); + checkVersions(a, res, version); + checkChangelog(a, res); + + res.ok = !res.results.some((r) => r.level === "FAIL"); + if (a.json) { + console.log(JSON.stringify(res)); + } else { + const icon = { PASS: "+", WARN: "!", FAIL: "X" }; + console.log(`release preflight: ${a.tag || "(no tag)"}`); + for (const r of res.results) { + console.log(` ${icon[r.level]} ${r.msg}`); + if (r.mismatches) for (const m of r.mismatches) console.log(` ${m.rel}: ${m.version} (${m.kind})`); + if (r.details) for (const d of r.details) console.log(` ${d}`); + } + console.log(res.ok ? "\nrelease preflight passed." : "\nrelease preflight failed."); + } + process.exit(res.ok ? 0 : 1); +} + +if (require.main === module) main(); diff --git a/hooks/verify-core.js b/hooks/verify-core.js new file mode 100644 index 0000000..f1209d0 --- /dev/null +++ b/hooks/verify-core.js @@ -0,0 +1,244 @@ +// verify-core.js - side-effect-light VERIFY planning and audit policy. +// +// verify.js owns the CLI, process.exit calls, and command execution. This module +// owns stack detection, changed-file target selection, and debug-audit policy so +// those rules can be tested or reused without running external commands. + +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); +const { workingTreeChangedFiles, globToRe } = require(path.join(__dirname, "_lib.js")); + +function fileUnder(rel, file) { + const r = String(rel).replace(/\\/g, "/"); + const f = String(file).replace(/\\/g, "/"); + if (r === "." || r === "") return true; + return f === r || f.startsWith(r + "/"); +} + +const DEBUG_HARD = [ + { ext: [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"], re: /(?:^|[^.\w])debugger\s*;/, what: "debugger statement" }, + { ext: [".py"], re: /(?:^|[^.\w])breakpoint\s*\(\s*\)/, what: "breakpoint call" }, + { ext: [".py"], re: /(?:^|[^.\w])i?pdb\.set_trace\s*\(/, what: "pdb set_trace" }, + { ext: [".rs"], re: /(?:^|[^.\w])dbg!\s*\(/, what: "dbg macro" }, + { ext: [".cs"], re: /Debugger\.Break\s*\(/, what: "Debugger Break" }, +]; +const DEBUG_SOFT = [ + { ext: [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"], re: /(?:^|[^.\w])console\.(?:log|debug)\s*\(/, what: "console.log/debug" }, + { ext: [".py"], re: /(?:^|[^.\w])print\s*\(/, what: "print()" }, +]; + +function loadDebugAudit(root) { + try { + const cfg = JSON.parse(fs.readFileSync(path.join(root, "harness.config.json"), "utf8")); + const d = cfg.debugAudit || {}; + return { + enabled: d.enabled !== false, + base: d.base || "main", + soft: d.soft === true, + exclude: Array.isArray(d.exclude) ? d.exclude : [], + strict: d.strict === true, + }; + } catch { + return { enabled: true, base: "main", soft: false, exclude: [], strict: false }; + } +} + +function maskQuotedSegments(line) { + let out = "", quote = null, esc = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (quote) { + if (esc) { esc = false; out += " "; continue; } + if (ch === "\\") { esc = true; out += " "; continue; } + if (ch === quote) { quote = null; out += ch; continue; } + out += " "; + continue; + } + if (ch === "\"" || ch === "'" || ch === "`") { quote = ch; out += ch; continue; } + out += ch; + } + return out; +} + +function stripCommentTail(line, ext) { + if ([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".rs", ".cs"].includes(ext)) return line.replace(/\/\/.*$/, ""); + if (ext === ".py") return line.replace(/#.*$/, ""); + return line; +} + +function scanFileForDebug(abs, rel, soft) { + let text; + try { + const st = fs.statSync(abs); + if (!st.isFile() || st.size > 1024 * 1024) return []; + text = fs.readFileSync(abs, "utf8"); + } catch { return []; } + if (text.includes(String.fromCharCode(0))) return []; + const ext = path.extname(rel).toLowerCase(); + const softSet = new Set(DEBUG_SOFT); + const markers = (soft ? DEBUG_HARD.concat(DEBUG_SOFT) : DEBUG_HARD).filter((m) => m.ext.includes(ext)); + if (!markers.length) return []; + const hits = []; + const lines = text.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = stripCommentTail(maskQuotedSegments(lines[i]), ext); + for (const m of markers) if (m.re.test(line)) hits.push({ rel, line: i + 1, what: m.what, soft: softSet.has(m) }); + } + return hits; +} + +function debugAudit(root, opts, base, explicitFiles) { + if (!opts.enabled) return { hard: [], soft: [], skipped: "disabled in harness.config.json" }; + const cf = workingTreeChangedFiles(base || opts.base, root, explicitFiles); + if (cf.error) return { hard: [], soft: [], skipped: cf.error }; + const excludeRe = (opts.exclude || []).map(globToRe); + const hard = [], soft = []; + for (const f of cf.files) { + const rel = String(f).replace(/\\/g, "/"); + if (excludeRe.some((re) => re.test(rel))) continue; + for (const h of scanFileForDebug(path.join(root, rel), rel, opts.soft)) (h.soft ? soft : hard).push(h); + } + return { hard, soft, skipped: null }; +} + +const DEFAULT_STACKS = [ + { id: "rust", markers: ["Cargo.toml"], steps: [ + { name: "fmt", run: "cargo fmt --all --check" }, + { name: "clippy", run: "cargo clippy --all-targets --all-features -- -D warnings" }, + { name: "test", run: "cargo test --all" }, + ] }, + { id: "dotnet", markers: ["*.sln", "*.csproj"], steps: [ + { name: "format", run: "dotnet format --verify-no-changes", optional: true }, + { name: "build", run: "dotnet build --nologo -warnaserror" }, + { name: "test", run: "dotnet test --nologo" }, + ] }, + { id: "python", markers: ["pyproject.toml", "requirements.txt", "setup.py"], steps: [ + { name: "lint", run: "ruff check ." }, + { name: "format", run: "ruff format --check .", optional: true }, + { name: "test", run: "pytest -q", okCodes: { 5: "pytest: no tests collected; add at least a smoke test" } }, + ] }, + { id: "node", markers: ["package.json"], steps: [ + { name: "lint", run: "npm run lint --if-present" }, + { name: "build", run: "npm run build --if-present" }, + { name: "test", run: "npm test --if-present" }, + ] }, +]; + +const SKIP_DIRS = new Set([".git", ".codex", "node_modules", "target", "bin", "obj", "dist", "build", ".venv", "venv", "__pycache__", ".next", ".idea", ".vscode"]); +const MAX_DEPTH = 6; +const HARNESS_CHANGED = [ + "hooks/", "lefthook.yml", "harness.config.json", ".gitleaks.toml", "cog.toml", + ".github/rulesets/", ".github/workflows/", "settings.example.json", "AGENTS.md", +]; + +function loadStacks(root) { + try { + const cfg = JSON.parse(fs.readFileSync(path.join(root, "harness.config.json"), "utf8")); + if (cfg.verify && Array.isArray(cfg.verify.stacks) && cfg.verify.stacks.length) { + return { stacks: cfg.verify.stacks, failFast: cfg.verify.failFast !== false, explicit: true }; + } + return { stacks: DEFAULT_STACKS, failFast: !(cfg.verify && cfg.verify.failFast === false), explicit: false }; + } catch { + return { stacks: DEFAULT_STACKS, failFast: true, explicit: false }; + } +} + +function isHarnessChangedFile(file) { + const f = String(file || "").replace(/\\/g, "/"); + return HARNESS_CHANGED.some((p) => p.endsWith("/") ? f.startsWith(p) : f === p); +} + +function harnessTarget(root) { + const steps = []; + if (fs.existsSync(path.join(root, "hooks", "test.js"))) steps.push({ name: "self-test", run: "node test.js", cwdRel: "hooks" }); + return { stack: { id: "harness", steps }, dir: root, rel: "." }; +} + +function harnessSyntaxTarget(root) { + return { stack: { id: "harness-syntax", steps: [{ name: "syntax", run: "node hooks/verify.js --check-harness-syntax" }] }, dir: root, rel: "." }; +} + +function ensureHarnessSyntaxTarget(root, targets) { + if (!fs.existsSync(path.join(root, "hooks", "verify.js"))) return targets; + if (targets.some((t) => t.stack && t.stack.id === "harness-syntax")) return targets; + return targets.concat([harnessSyntaxTarget(root)]); +} + +function isGitRepo(root) { + const r = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { + cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + timeout: 5000, killSignal: "SIGKILL", + }); + return r.status === 0 && String(r.stdout || "").trim() === "true"; +} + +function ensureGitHygieneTarget(root, targets) { + if (!isGitRepo(root)) return targets; + if (targets.some((t) => t.stack && t.stack.id === "git-hygiene")) return targets; + return targets.concat([{ stack: { id: "git-hygiene", steps: [ + { name: "diff-check", run: "git diff --check && git diff --cached --check" }, + ] }, dir: root, rel: "." }]); +} + +function maybeAddHarnessTarget(root, targets, files) { + if (!files.some(isHarnessChangedFile)) return targets; + if (!fs.existsSync(path.join(root, "hooks", "verify.js"))) return targets; + targets = ensureHarnessSyntaxTarget(root, targets); + if (!fs.existsSync(path.join(root, "hooks", "test.js"))) return targets; + if (targets.some((t) => t.stack && t.stack.id === "harness")) return targets; + return targets.concat([harnessTarget(root)]); +} + +function fnMatch(pattern, name) { + if (pattern === name) return true; + const re = "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*") + "$"; + return new RegExp(re).test(name); +} + +function detect(root, stacks) { + const found = []; + function walk(dir, depth) { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } + const names = entries.filter((e) => e.isFile()).map((e) => e.name); + for (const s of stacks) { + const markers = s.markers || []; + if (markers.some((m) => names.some((n) => fnMatch(m, n)))) { + found.push({ stack: s, dir, rel: (path.relative(root, dir) || ".").replace(/\\/g, "/") }); + } + } + if (depth >= MAX_DEPTH) return; + for (const e of entries) if (e.isDirectory() && !SKIP_DIRS.has(e.name)) walk(path.join(dir, e.name), depth + 1); + } + walk(root, 0); + return found; +} + +function planVerifyTargets(root, stacks, opts = {}) { + let targets = detect(root, stacks); + if (opts.changed) { + const cf = workingTreeChangedFiles(opts.base, root, opts.files); + if (cf.error) { + return { + targets: maybeAddHarnessTarget(root, targets, ["hooks/verify.js"]), + scope: cf, + warning: `verify --changed: ${cf.error}; filter not applied, checking all detected stacks. Pass --base to choose a base.`, + }; + } + const files = cf.files.map((f) => String(f).replace(/\\/g, "/")); + targets = files.length ? targets.filter((t) => files.some((f) => fileUnder(t.rel, f))) : []; + targets = maybeAddHarnessTarget(root, targets, files); + return { targets, scope: cf, files }; + } + targets = ensureHarnessSyntaxTarget(root, targets); + targets = ensureGitHygieneTarget(root, targets); + return { targets, scope: null, files: null }; +} + +module.exports = { + DEBUG_HARD, DEBUG_SOFT, DEFAULT_STACKS, + fileUnder, loadDebugAudit, scanFileForDebug, debugAudit, + loadStacks, detect, planVerifyTargets, + ensureHarnessSyntaxTarget, ensureGitHygieneTarget, maybeAddHarnessTarget, +}; diff --git a/hooks/verify.js b/hooks/verify.js index d40b8a1..5e6fdaf 100644 --- a/hooks/verify.js +++ b/hooks/verify.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -// verify.js — executable, multi-stack VERIFY runner (BACKLOG P1-8). +// verify.js - executable, multi-stack VERIFY runner (BACKLOG P1-8). // // Makes AGENTS.md step 4 (VERIFY) an actual command instead of prose. Auto-detects // which stacks live in the repo by marker files and runs lint -> build -> test for @@ -9,7 +9,7 @@ // Node (npm lint/build/test). // // Usage: -// node hooks/verify.js [--root ] [--stack ] [--changed [--base ]] [--list] [--json] +// node hooks/verify.js [--root ] [--stack ] [--changed [--base ]] [--list] [--json] [--strict-audit] // --list detect + print the plan, do not run // --stack run only the named stack // --root repo root (default: cwd) @@ -17,150 +17,32 @@ // --base git ref to diff against for --changed (default: main; fallback master/origin/HEAD) // --files explicit comma-separated changed files (tests/CI; bypasses git) // --check-harness-syntax internal lightweight JS syntax check for harness files +// --strict-audit fail VERIFY if the debug-audit cannot compute its changed-file scope // --changed fail-safe: if the diff can't be computed, verify ALL stacks (loud warn), // never silently skip verification; empty diff = nothing to verify. // // Config: harness.config.json -> "verify". If "verify.stacks" is present it REPLACES // the auto-detected defaults (explicit control); otherwise DEFAULT_STACKS are used. -// Per-step: optional (missing tool → skip), okCodes { "": "note" } — non-zero -// exits that are warnings, not failures (e.g. pytest 5 = no tests collected). +// Per-step: optional (missing tool -> skip only), okCodes { "": "note" } +// - explicit non-zero exits that are warnings, not failures (e.g. pytest 5 = no tests collected). // -// Debug-аудит: параллельно со стеками verify сканирует ТОЛЬКО изменённые в diff -// файлы на забытые отладочные строки. hard-маркеры (debugger; / breakpoint() / -// pdb.set_trace() / dbg!()) валят VERIFY; soft (console.log / print) — заметка, -// включается harness.config.json -> debugAudit.soft. Область — diff (база = --base -// или debugAudit.base); без diff аудит пропускается (не сканируем весь репо, иначе -// массовые легитимные console.log/print дали бы шум). exclude-глобы — пропуск путей. +// Debug audit: runs alongside stack checks and scans only changed files for +// leftover debug statements. Hard markers fail VERIFY; soft markers are notes +// only when harness.config.json -> debugAudit.soft is true. // // Exit 0 = all steps passed (or nothing to verify), exit 1 = a required step failed -// ИЛИ debug-аудит нашёл hard-находку. +// or the debug audit found a hard marker. const fs = require("fs"); const os = require("os"); const path = require("path"); const { spawnSync } = require("child_process"); -const { workingTreeChangedFiles, globToRe } = require(path.join(__dirname, "_lib.js")); - -// Файл `file` лежит под каталогом стека `rel`? Корневой стек (rel ".") владеет всем -// деревом → матчит любой изменённый файл; глубокий стек — только свой подкаталог. -function fileUnder(rel, file) { - const r = String(rel).replace(/\\/g, "/"); - const f = String(file).replace(/\\/g, "/"); - if (r === "." || r === "") return true; - return f === r || f.startsWith(r + "/"); -} - -// ---------- debug-leftover audit (только изменённые файлы) ---------- -// Маркеры «никогда не коммить». Точные по синтаксису (требуют `;`/`()`), поэтому -// строковые определения ниже и regex-литералы НЕ матчат сами себя (self-FP). -// `what` намеренно без реального синтаксиса (без точки/скобок) — та же причина. -// Привязка к расширениям: `breakpoint()`/`set_trace` — только .py, `dbg!` — только -// .rs; иначе слово-омоним в чужом языке дал бы ложную тревогу. -const DEBUG_HARD = [ - { ext: [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"], re: /(?:^|[^.\w])debugger\s*;/, what: "debugger statement" }, - { ext: [".py"], re: /(?:^|[^.\w])breakpoint\s*\(\s*\)/, what: "breakpoint call" }, - { ext: [".py"], re: /(?:^|[^.\w])i?pdb\.set_trace\s*\(/, what: "pdb set_trace" }, - { ext: [".rs"], re: /(?:^|[^.\w])dbg!\s*\(/, what: "dbg macro" }, - { ext: [".cs"], re: /Debugger\.Break\s*\(/, what: "Debugger Break" }, -]; -const DEBUG_SOFT = [ - { ext: [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"], re: /(?:^|[^.\w])console\.(?:log|debug)\s*\(/, what: "console.log/debug" }, - { ext: [".py"], re: /(?:^|[^.\w])print\s*\(/, what: "print()" }, -]; - -// harness.config.json → debugAudit (дефолты: включён, база main, soft off, без exclude). -function loadDebugAudit(root) { - try { - const cfg = JSON.parse(fs.readFileSync(path.join(root, "harness.config.json"), "utf8")); - const d = cfg.debugAudit || {}; - return { - enabled: d.enabled !== false, - base: d.base || "main", - soft: d.soft === true, - exclude: Array.isArray(d.exclude) ? d.exclude : [], - }; - } catch { - return { enabled: true, base: "main", soft: false, exclude: [] }; - } -} - -// Один файл → массив находок. Бинарь/крупный/нечитаемый → пропуск (не ошибка). -function scanFileForDebug(abs, rel, soft) { - let text; - try { - const st = fs.statSync(abs); - if (!st.isFile() || st.size > 1024 * 1024) return []; - text = fs.readFileSync(abs, "utf8"); - } catch { return []; } - if (text.includes(String.fromCharCode(0))) return []; - const ext = path.extname(rel).toLowerCase(); - const softSet = new Set(DEBUG_SOFT); - const markers = (soft ? DEBUG_HARD.concat(DEBUG_SOFT) : DEBUG_HARD).filter((m) => m.ext.includes(ext)); - if (!markers.length) return []; - const hits = []; - const lines = text.split(/\r?\n/); - for (let i = 0; i < lines.length; i++) { - for (const m of markers) if (m.re.test(lines[i])) hits.push({ rel, line: i + 1, what: m.what, soft: softSet.has(m) }); - } - return hits; -} - -// Аудит изменённых файлов. Область — ТОЛЬКО diff (в отличие от стеков, где fail-safe -// = проверить всё): без diff НЕ сканируем весь репозиторий, т.к. console.log/print -// массово легитимны и дали бы шум. → { hard:[], soft:[], skipped:<причина>|null }. -function debugAudit(root, opts, base, explicitFiles) { - if (!opts.enabled) return { hard: [], soft: [], skipped: "отключён в harness.config.json" }; - const cf = workingTreeChangedFiles(base || opts.base, root, explicitFiles); - if (cf.error) return { hard: [], soft: [], skipped: cf.error }; - const excludeRe = (opts.exclude || []).map(globToRe); - const hard = [], soft = []; - for (const f of cf.files) { - const rel = String(f).replace(/\\/g, "/"); - if (excludeRe.some((re) => re.test(rel))) continue; - for (const h of scanFileForDebug(path.join(root, rel), rel, opts.soft)) (h.soft ? soft : hard).push(h); - } - return { hard, soft, skipped: null }; -} - -// ---------- defaults (warnings-as-errors baked in) ---------- -const DEFAULT_STACKS = [ - { id: "rust", markers: ["Cargo.toml"], steps: [ - { name: "fmt", run: "cargo fmt --all --check" }, - { name: "clippy", run: "cargo clippy --all-targets --all-features -- -D warnings" }, - { name: "test", run: "cargo test --all" }, - ] }, - { id: "dotnet", markers: ["*.sln", "*.csproj"], steps: [ - // Existing C# repos often need a one-time formatting migration. Keep the - // default bootstrap gate warning-only; target repos can make this required - // by overriding verify.stacks after the format baseline lands. - { name: "format", run: "dotnet format --verify-no-changes", optional: true }, - { name: "build", run: "dotnet build --nologo -warnaserror" }, - { name: "test", run: "dotnet test --nologo" }, - ] }, - { id: "python", markers: ["pyproject.toml", "requirements.txt", "setup.py"], steps: [ - { name: "lint", run: "ruff check ." }, - { name: "format", run: "ruff format --check .", optional: true }, - // pytest exit 5 = «тесты не собраны»: проект без тестов — warning, не вечно-красный VERIFY - { name: "test", run: "pytest -q", okCodes: { 5: "pytest: тесты не найдены — добавь хотя бы smoke-тест" } }, - ] }, - { id: "node", markers: ["package.json"], steps: [ - { name: "lint", run: "npm run lint --if-present" }, - { name: "build", run: "npm run build --if-present" }, - { name: "test", run: "npm test --if-present" }, - ] }, -]; - -const SKIP_DIRS = new Set([".git", "node_modules", "target", "bin", "obj", "dist", "build", ".venv", "venv", "__pycache__", ".next", ".idea", ".vscode"]); -const MAX_DEPTH = 6; +const { loadDebugAudit, debugAudit, loadStacks, planVerifyTargets } = require(path.join(__dirname, "verify-core.js")); const DEFAULT_STEP_TIMEOUT_MS = 15 * 60 * 1000; -const HARNESS_CHANGED = [ - "hooks/", "lefthook.yml", "harness.config.json", ".gitleaks.toml", "cog.toml", - ".github/rulesets/", ".github/workflows/", "settings.example.json", "AGENTS.md", -]; // ---------- args ---------- function parseArgs(argv) { - const a = { root: process.cwd(), stack: null, list: false, json: false, changed: false, base: "main", files: null, checkHarnessSyntax: false }; + const a = { root: process.cwd(), stack: null, list: false, json: false, changed: false, base: "main", files: null, checkHarnessSyntax: false, strictAudit: false }; for (let i = 0; i < argv.length; i++) { if (argv[i] === "--root") a.root = argv[++i]; else if (argv[i] === "--stack") a.stack = argv[++i]; @@ -170,41 +52,11 @@ function parseArgs(argv) { else if (argv[i] === "--base") a.base = argv[++i]; else if (argv[i] === "--files") a.files = (argv[++i] || "").split(",").map((s) => s.trim()).filter(Boolean); else if (argv[i] === "--check-harness-syntax") a.checkHarnessSyntax = true; + else if (argv[i] === "--strict-audit") a.strictAudit = true; } return a; } -// ---------- config ---------- -function loadStacks(root) { - try { - const cfg = JSON.parse(fs.readFileSync(path.join(root, "harness.config.json"), "utf8")); - if (cfg.verify && Array.isArray(cfg.verify.stacks) && cfg.verify.stacks.length) { - return { stacks: cfg.verify.stacks, failFast: cfg.verify.failFast !== false, explicit: true }; - } - return { stacks: DEFAULT_STACKS, failFast: !(cfg.verify && cfg.verify.failFast === false), explicit: false }; - } catch { - return { stacks: DEFAULT_STACKS, failFast: true, explicit: false }; - } -} - -function isHarnessChangedFile(file) { - const f = String(file || "").replace(/\\/g, "/"); - return HARNESS_CHANGED.some((p) => p.endsWith("/") ? f.startsWith(p) : f === p); -} - -function harnessTarget(root) { - const steps = [{ name: "syntax", run: "node hooks/verify.js --check-harness-syntax" }]; - if (fs.existsSync(path.join(root, "hooks", "test.js"))) steps.push({ name: "self-test", run: "node test.js", cwdRel: "hooks" }); - return { stack: { id: "harness", steps }, dir: root, rel: "." }; -} - -function maybeAddHarnessTarget(root, targets, files) { - if (!files.some(isHarnessChangedFile)) return targets; - if (!fs.existsSync(path.join(root, "hooks", "verify.js"))) return targets; - if (targets.some((t) => t.stack && t.stack.id === "harness")) return targets; - return targets.concat([harnessTarget(root)]); -} - function listHarnessJs(root) { const out = []; function walk(dir) { @@ -243,33 +95,6 @@ function checkHarnessSyntax(root) { process.exit(failed ? 1 : 0); } -// ---------- filename glob (within a directory) ---------- -function fnMatch(pattern, name) { - if (pattern === name) return true; - const re = "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*") + "$"; - return new RegExp(re).test(name); -} - -// ---------- detect: dirs that contain a stack's marker ---------- -function detect(root, stacks) { - const found = []; // {stack, dir(abs), rel} - function walk(dir, depth) { - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } - const names = entries.filter((e) => e.isFile()).map((e) => e.name); - for (const s of stacks) { - const markers = s.markers || []; - if (markers.some((m) => names.some((n) => fnMatch(m, n)))) { - found.push({ stack: s, dir, rel: path.relative(root, dir) || "." }); - } - } - if (depth >= MAX_DEPTH) return; - for (const e of entries) if (e.isDirectory() && !SKIP_DIRS.has(e.name)) walk(path.join(dir, e.name), depth + 1); - } - walk(root, 0); - return found; -} - // ---------- run ---------- function stepTimeoutMs(step) { const raw = step.timeoutMs !== undefined ? step.timeoutMs : step.timeout; @@ -326,6 +151,14 @@ function diagnosticExcerpt(res, maxLines = 8) { const lines = text.split(/\r?\n/).map((s) => s.trimEnd()).filter((s) => s.trim()).slice(0, maxLines); return lines.join("\n"); } +function commandNotFound(res) { + if (res.notFound || res.code === 9009 || res.code === 127) return true; + const text = diagnosticExcerpt(res, 8).toLowerCase(); + return /is not recognized as an internal or external command/.test(text) || + /is not recognized as the name of (?:a )?(?:cmdlet|function|script file|operable program)/.test(text) || + /command not found/.test(text) || + /not found:/.test(text); +} function readSmallOutputFile(file, maxBytes = 64 * 1024) { if (!file) return ""; try { @@ -356,53 +189,41 @@ function cleanupStepOutput(dir) { let { stacks, failFast, explicit } = loadStacks(a.root); if (a.stack) stacks = stacks.filter((s) => s.id === a.stack); - let targets = detect(a.root, stacks); - - // --changed: сузить до стеков, чьи каталоги затронуты в diff ветки. Fail-safe — - // при ошибке diff проверяем ВСЕ стеки (громкий warn), а не молча пропускаем. - if (a.changed) { - const cf = workingTreeChangedFiles(a.base, a.root, a.files); - if (cf.error) { - console.error(`⚠️ verify --changed: ${cf.error} — фильтр не применён, проверяю все обнаруженные стеки. Задай базу: --base .`); - targets = maybeAddHarnessTarget(a.root, targets, ["hooks/verify.js"]); - } else { - const files = cf.files.map((f) => f.replace(/\\/g, "/")); - targets = files.length ? targets.filter((t) => files.some((f) => fileUnder(t.rel, f))) : []; - targets = maybeAddHarnessTarget(a.root, targets, files); - } - } + const targetPlan = planVerifyTargets(a.root, stacks, { changed: a.changed, base: a.base, files: a.files }); + if (targetPlan.warning) console.error(targetPlan.warning); + const targets = targetPlan.targets; if (a.list) { const plan = targets.map((t) => ({ stack: t.stack.id, dir: t.rel, steps: (t.stack.steps || []).map((s) => s.name) })); if (a.json) console.log(JSON.stringify({ explicit, plan })); else { console.log(`design of VERIFY (${explicit ? "config" : "auto-detect"}):`); - if (!plan.length) console.log(" (стеки не обнаружены)"); - for (const p of plan) console.log(` • ${p.stack} @ ${p.dir}: ${p.steps.join(" → ")}`); + if (!plan.length) console.log(" (no stacks detected)"); + for (const p of plan) console.log(` - ${p.stack} @ ${p.dir}: ${p.steps.join(" -> ")}`); } process.exit(0); } - // debug-аудит изменённых файлов: до раннего выхода — работает даже без стеков. - // hard-находки валят VERIFY; soft (только при soft=true) — заметка без падения. + // Debug audit runs before the early no-target exit, so it still works without stacks. const da = loadDebugAudit(a.root); const audit = debugAudit(a.root, da, a.base, a.files); let auditFailed = null; if (audit.skipped) { - if (da.enabled) console.log(`\n· debug-аудит пропущен: ${audit.skipped}`); + if (da.enabled) console.log(`\n- debug audit skipped: ${audit.skipped}`); + if (da.enabled && (a.strictAudit || da.strict)) auditFailed = `debug audit strict: ${audit.skipped}`; } else { - for (const h of audit.soft) console.log(` ⚠ debug-строка (soft): ${h.rel}:${h.line} — ${h.what}`); + for (const h of audit.soft) console.log(` debug line (soft): ${h.rel}:${h.line} - ${h.what}`); if (audit.hard.length) { - console.error("\n❌ debug-аудит — забытые отладочные строки в изменённых файлах:"); - for (const h of audit.hard) console.error(` ✗ ${h.rel}:${h.line} — ${h.what}`); - auditFailed = `debug-аудит: ${audit.hard.length} hard-находок (debugger/breakpoint/set_trace/dbg!)`; + console.error("\ndebug audit: leftover debug statements in changed files:"); + for (const h of audit.hard) console.error(` - ${h.rel}:${h.line} - ${h.what}`); + auditFailed = `debug audit: ${audit.hard.length} hard finding(s)`; } } if (!targets.length && !auditFailed) { console.log(a.changed - ? "✅ verify --changed: изменённые файлы не затрагивают ни один стек — проверять нечего." - : "✅ verify: стеки не обнаружены — проверять нечего."); + ? "verify --changed: changed files do not touch any detected stack; nothing to check." + : "verify: no stacks detected; nothing to check."); process.exit(0); } @@ -413,42 +234,38 @@ function cleanupStepOutput(dir) { for (const t of targets) { for (const step of t.stack.steps || []) { const label = `${t.stack.id}/${step.name} @ ${t.rel}`; - console.log(`\n▶ ${label}: ${step.run}`); + console.log(`\n> ${label}: ${step.run}`); const res = runStep(step, t.dir); try { - if (res.ok) { emitStepOutput(res); summary.push(`✓ ${label}`); continue; } + if (res.ok) { emitStepOutput(res); summary.push(`OK ${label}`); continue; } if (res.timedOut) { - summary.push(`✗ ${label} (timeout after ${stepTimeoutMs(step)}ms)`); + summary.push(`FAIL ${label} (timeout after ${stepTimeoutMs(step)}ms)`); emitStepOutput(res); failed = `${label}: timeout after ${stepTimeoutMs(step)}ms`; if (failFast) break outer; else continue; } - if (res.notFound || res.code === 9009 || res.code === 127) { + if (commandNotFound(res)) { if (step.optional) { - warnings.push(`${label}: optional tool not found — step skipped`); - summary.push(`⚠ ${label} (инструмент не найден — пропущено)`); + warnings.push(`${label}: optional tool not found - step skipped`); + summary.push(`${label} (optional tool not found; skipped)`); continue; } - summary.push(`✗ ${label} (инструмент не найден)`); + summary.push(`${label} (tool not found)`); emitStepOutput(res); - failed = `${label}: команда не найдена — установи инструмент или переопредели шаг в harness.config.json`; + failed = `${label}: command not found; install the tool or override the step in harness.config.json`; if (failFast) break outer; else continue; } if (step.okCodes && step.okCodes[res.code] !== undefined) { const detail = diagnosticExcerpt(res); - warnings.push(`${label}: exit ${res.code}: ${step.okCodes[res.code] || "допустимый код"}${detail ? "\n" + detail : ""}`); - summary.push(`⚠ ${label} (exit ${res.code}: ${step.okCodes[res.code] || "допустимый код"})`); - continue; - } - if (step.optional) { - const detail = diagnosticExcerpt(res); - warnings.push(`${label}: optional step exited ${res.code}${detail ? "\n" + detail : "\n(no diagnostics captured)"}`); - summary.push(`⚠ ${label} (optional, exit ${res.code})`); + warnings.push(`${label}: exit ${res.code}: ${step.okCodes[res.code] || "allowed code"}${detail ? "\n" + detail : ""}`); + summary.push(`${label} (exit ${res.code}: ${step.okCodes[res.code] || "allowed code"})`); continue; } - summary.push(`✗ ${label} (exit ${res.code})`); + summary.push(`FAIL ${label} (exit ${res.code})`); emitStepOutput(res); - failed = `${label}: exit ${res.code}`; + failed = step.optional + ? `${label}: optional step ran but failed with exit ${res.code}` + : `${label}: exit ${res.code}`; if (failFast) break outer; } finally { if (res.cleanup) res.cleanup(); @@ -457,17 +274,17 @@ function cleanupStepOutput(dir) { } if (warnings.length) { - console.log("\n— optional warnings —"); - for (const w of warnings) console.log(" ⚠ " + w.replace(/\n/g, "\n ")); + console.log("\n-- optional warnings --"); + for (const w of warnings) console.log(" WARN " + w.replace(/\n/g, "\n ")); } if (summary.length) { - console.log("\n— verify summary —"); + console.log("\n-- verify summary --"); for (const s of summary) console.log(" " + s); } if (failed || auditFailed) { - console.error(`\n❌ VERIFY failed: ${[failed, auditFailed].filter(Boolean).join(" | ")}`); + console.error(`\nVERIFY failed: ${[failed, auditFailed].filter(Boolean).join(" | ")}`); process.exit(1); } - console.log("\n✅ VERIFY passed."); + console.log("\nVERIFY passed."); process.exit(0); })(); diff --git a/settings.example.json b/settings.example.json index 7a30395..8472b87 100644 --- a/settings.example.json +++ b/settings.example.json @@ -4,7 +4,7 @@ "Any runtime that can run a command before a tool call and honor its exit code", "can wire the same scripts: exit 2 = block, exit 0 = allow,", "stdout {\"additionalContext\":\"...\"} = non-blocking note.", - "The git layer is NOT wired here — install it once with `lefthook install`.", + "The git layer is NOT wired here - install it once with `lefthook install`.", "Copy the `hooks` block into .claude/settings.json (project) to activate." ], "hooks": { diff --git a/src/Dropwheel/Dropwheel.csproj b/src/Dropwheel/Dropwheel.csproj index 8ef8fdb..b30f66f 100644 --- a/src/Dropwheel/Dropwheel.csproj +++ b/src/Dropwheel/Dropwheel.csproj @@ -11,7 +11,7 @@ Dropwheel app.manifest dropwheel.ico - 0.11.0 + 0.12.0 $(NoWarn);WPF0001 diff --git a/src/Dropwheel/Models/TargetItem.cs b/src/Dropwheel/Models/TargetItem.cs index 76815d3..86394dc 100644 --- a/src/Dropwheel/Models/TargetItem.cs +++ b/src/Dropwheel/Models/TargetItem.cs @@ -9,6 +9,13 @@ public class TargetItem { public string Name { get; set; } = ""; public string Path { get; set; } = ""; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SourceUrl { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? IconPath { get; set; } + public DropAction Override { get; set; } = DropAction.Inherit; public bool Pinned { get; set; } @@ -47,6 +54,12 @@ public class TargetItem public static bool IsExeExtension(string path) => ExeExtensions.Contains(System.IO.Path.GetExtension(path).ToLowerInvariant()); + public static bool IsLaunchUri(string path) => + Uri.TryCreate(path, UriKind.Absolute, out var uri) + && (uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + || uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + || uri.Scheme.Equals("tg", StringComparison.OrdinalIgnoreCase)); + [JsonIgnore] public bool IsGroup => Children != null; [JsonIgnore] public bool IsSorter => SortRules is { Count: > 0 } || Rules is { Count: > 0 }; [JsonIgnore] public bool IsFolder => !IsGroup && Directory.Exists(Path); @@ -54,8 +67,9 @@ public static bool IsExeExtension(string path) => /// An executable or script by its own extension. A .lnk that points at an executable /// is handled by LaunchService.IsRunTarget, which resolves the shortcut first. [JsonIgnore] public bool IsExecutable => !IsGroup && IsExeExtension(Path); + [JsonIgnore] public bool IsUri => !IsGroup && IsLaunchUri(Path); - [JsonIgnore] public bool Exists => IsGroup || IsFolder || File.Exists(Path); + [JsonIgnore] public bool Exists => IsGroup || IsFolder || File.Exists(Path) || IsUri; } public sealed class LaunchOptions diff --git a/src/Dropwheel/Services/IconService.cs b/src/Dropwheel/Services/IconService.cs index 7b6c873..ef64b4f 100644 --- a/src/Dropwheel/Services/IconService.cs +++ b/src/Dropwheel/Services/IconService.cs @@ -3,6 +3,7 @@ using System.Windows.Interop; using System.Windows.Media; using System.Windows.Media.Imaging; +using Dropwheel.Models; namespace Dropwheel.Services; @@ -28,15 +29,40 @@ private struct SHFILEINFO private static readonly Dictionary _cache = new(); + public static ImageSource? GetIcon(TargetItem target) => + !string.IsNullOrWhiteSpace(target.IconPath) && System.IO.File.Exists(target.IconPath) + ? GetIcon(target.IconPath) + : GetIcon(target.Path); + public static ImageSource? GetIcon(string path) { if (_cache.TryGetValue(path, out var cached)) return cached; - var icon = Extract(path); + var icon = ExtractBitmap(path) ?? ExtractShellIcon(path); _cache[path] = icon; return icon; } - private static ImageSource? Extract(string path) + private static ImageSource? ExtractBitmap(string path) + { + if (!System.IO.File.Exists(path)) return null; + + try + { + var bitmap = new BitmapImage(); + bitmap.BeginInit(); + bitmap.CacheOption = BitmapCacheOption.OnLoad; + bitmap.UriSource = new Uri(path, UriKind.Absolute); + bitmap.EndInit(); + bitmap.Freeze(); + return bitmap; + } + catch + { + return null; + } + } + + private static ImageSource? ExtractShellIcon(string path) { var info = new SHFILEINFO(); SHGetFileInfo(path, 0, ref info, (uint)Marshal.SizeOf(info), SHGFI_ICON | SHGFI_LARGEICON); diff --git a/src/Dropwheel/Services/LinkMetadataService.cs b/src/Dropwheel/Services/LinkMetadataService.cs new file mode 100644 index 0000000..ab24f66 --- /dev/null +++ b/src/Dropwheel/Services/LinkMetadataService.cs @@ -0,0 +1,189 @@ +using System.IO; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using Dropwheel.Models; + +namespace Dropwheel.Services; + +public sealed record LinkMetadataUpdate(string? Title, string? IconPath); + +public static class LinkMetadataService +{ + private const int MaxHtmlBytes = 512 * 1024; + private const int MaxIconBytes = 1024 * 1024; + + private static readonly HttpClient Client = CreateClient(); + + private static HttpClient CreateClient() + { + var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + client.DefaultRequestHeaders.UserAgent.ParseAdd("Dropwheel/1.0"); + return client; + } + + public static async Task FetchAsync(TargetItem target, CancellationToken ct = default) + { + if (SourceUri(target) is not { } pageUri) return null; + + string? html = null; + try { html = await FetchHtmlAsync(pageUri, ct); } + catch (Exception ex) { ErrorLog.Write($"Could not fetch link metadata for '{pageUri}'", ex); } + + var title = html == null ? null : ExtractTitle(html); + var iconUri = html == null ? new Uri(pageUri, "/favicon.ico") : ExtractIconUri(pageUri, html); + var iconPath = iconUri == null ? null : await TryDownloadIconAsync(iconUri, ct); + + return string.IsNullOrWhiteSpace(title) && string.IsNullOrWhiteSpace(iconPath) + ? null + : new LinkMetadataUpdate(title, iconPath); + } + + internal static Uri? SourceUri(TargetItem target) + { + var source = string.IsNullOrWhiteSpace(target.SourceUrl) ? target.Path : target.SourceUrl; + return Uri.TryCreate(source, UriKind.Absolute, out var uri) && IsWebUri(uri) ? uri : null; + } + + internal static string? ExtractTitle(string html) + { + var match = Regex.Match(html, @"(?is)]*>(.*?)"); + if (!match.Success) return null; + + var title = Regex.Replace(match.Groups[1].Value, "<[^>]+>", ""); + title = WebUtility.HtmlDecode(title); + title = Regex.Replace(title, @"\s+", " ").Trim(); + return title.Length == 0 ? null : title; + } + + internal static Uri? ExtractIconUri(Uri pageUri, string html) + { + foreach (Match match in Regex.Matches(html, @"(?is)]*>")) + { + var tag = match.Value; + var rel = AttributeValue(tag, "rel"); + if (rel == null || !rel.Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Any(part => part.Contains("icon", StringComparison.OrdinalIgnoreCase))) + continue; + + var href = AttributeValue(tag, "href"); + if (string.IsNullOrWhiteSpace(href)) continue; + + href = WebUtility.HtmlDecode(href); + if (!Uri.TryCreate(pageUri, href, out var iconUri)) continue; + if (IsUnsupportedIcon(iconUri, contentType: null)) continue; + return iconUri; + } + + return new Uri(pageUri, "/favicon.ico"); + } + + private static async Task FetchHtmlAsync(Uri uri, CancellationToken ct) + { + using var response = await Client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, ct); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(ct); + using var copy = new MemoryStream(); + var buffer = new byte[16 * 1024]; + while (copy.Length <= MaxHtmlBytes) + { + var read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), ct); + if (read == 0) break; + copy.Write(buffer, 0, read); + } + + return Encoding.UTF8.GetString(copy.ToArray()); + } + + private static async Task TryDownloadIconAsync(Uri iconUri, CancellationToken ct) + { + try + { + using var response = await Client.GetAsync(iconUri, HttpCompletionOption.ResponseHeadersRead, ct); + if (!response.IsSuccessStatusCode) return null; + + var contentType = response.Content.Headers.ContentType?.MediaType; + if (IsUnsupportedIcon(iconUri, contentType)) return null; + + var ext = IconExtension(iconUri, contentType); + if (ext == null) return null; + + var path = IconPathFor(iconUri, ext); + if (File.Exists(path)) return path; + + await using var stream = await response.Content.ReadAsStreamAsync(ct); + var bytes = await ReadLimitedAsync(stream, MaxIconBytes, ct); + if (bytes.Length == 0) return null; + + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var tmp = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + await File.WriteAllBytesAsync(tmp, bytes, ct); + File.Move(tmp, path, overwrite: true); + return path; + } + catch (Exception ex) + { + ErrorLog.Write($"Could not download favicon '{iconUri}'", ex); + return null; + } + } + + private static async Task ReadLimitedAsync(Stream stream, int maxBytes, CancellationToken ct) + { + using var copy = new MemoryStream(); + var buffer = new byte[16 * 1024]; + while (copy.Length <= maxBytes) + { + var read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), ct); + if (read == 0) break; + copy.Write(buffer, 0, read); + } + + return copy.Length > maxBytes ? Array.Empty() : copy.ToArray(); + } + + private static string? AttributeValue(string tag, string name) + { + var quoted = Regex.Match(tag, $@"(?is)\b{name}\s*=\s*(['""])(.*?)\1"); + if (quoted.Success) return quoted.Groups[2].Value; + + var unquoted = Regex.Match(tag, $@"(?is)\b{name}\s*=\s*([^\s>]+)"); + return unquoted.Success ? unquoted.Groups[1].Value : null; + } + + private static string IconPathFor(Uri iconUri, string extension) + { + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(iconUri.AbsoluteUri)))[..24] + .ToLowerInvariant(); + return Path.Combine(TargetStore.Dir, "icons", hash + extension); + } + + private static string? IconExtension(Uri iconUri, string? contentType) + { + var byType = contentType?.ToLowerInvariant() switch + { + "image/png" => ".png", + "image/jpeg" => ".jpg", + "image/x-icon" or "image/vnd.microsoft.icon" => ".ico", + "image/webp" => ".webp", + _ => null, + }; + if (byType != null) return byType; + + var ext = Path.GetExtension(iconUri.AbsolutePath).ToLowerInvariant(); + return ext is ".png" or ".jpg" or ".jpeg" or ".ico" or ".webp" ? ext : ".ico"; + } + + private static bool IsUnsupportedIcon(Uri iconUri, string? contentType) + { + if (contentType?.Contains("svg", StringComparison.OrdinalIgnoreCase) == true) return true; + return Path.GetExtension(iconUri.AbsolutePath).Equals(".svg", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsWebUri(Uri uri) => + uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + || uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Dropwheel/Services/LinkTargetService.cs b/src/Dropwheel/Services/LinkTargetService.cs new file mode 100644 index 0000000..11c84da --- /dev/null +++ b/src/Dropwheel/Services/LinkTargetService.cs @@ -0,0 +1,321 @@ +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +using System.Windows; +using Dropwheel.Models; + +namespace Dropwheel.Services; + +/// Creates quick-access targets from dragged links such as tg:// and https://t.me/... +public static class LinkTargetService +{ + private sealed record LinkDropCandidate(string Text, string? Title = null); + + private static readonly Regex LaunchUri = + new(@"\b(?:(?:https?://|tg://)[^\s<>'""]+|(?:t\.me|telegram\.me|telegram\.dog)/[^\s<>'""]+|[a-z0-9_]{2,64}\.t\.me(?:/[^\s<>'""]*)?)", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex SavedMessagesLabel = + new(@"^\s*(?:saved messages?|избранное)\s*$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public static bool HasLaunchUri(IDataObject data) => TryGetLaunchUri(data, out _); + + public static bool HasPotentialLaunchUriData(IDataObject data) => + data.GetDataPresent("UniformResourceLocatorW") + || data.GetDataPresent("UniformResourceLocator") + || data.GetDataPresent("text/x-moz-url") + || data.GetDataPresent(DataFormats.Html) + || TextDropService.HasPotentialText(data); + + public static TargetItem? CreateTarget(IDataObject data) => + TryGetLaunchUri(data, out var candidate) ? CreateTarget(candidate.Text, candidate.Title) : null; + + public static bool HasSavedMessagesLabel(IDataObject data) => + TextCandidates(data).Any(IsSavedMessagesText); + + public static TargetItem? CreateSavedMessagesTarget(string account) + { + account = account.Trim(); + if (account.Length == 0) return null; + + if (CreateTarget(account) is { } linkTarget) + return new TargetItem { Name = "Saved Messages", Path = linkTarget.Path }; + + account = account.TrimStart('@'); + if (account.Length == 0) return null; + + var parameter = account.StartsWith('+') || account.Any(char.IsDigit) && account.All(c => char.IsDigit(c) || c is '+' or '-' or '(' or ')' or ' ') + ? "phone" + : "domain"; + var value = parameter == "phone" + ? new string(account.Where(c => char.IsDigit(c) || c == '+').ToArray()) + : account; + if (value.Length == 0) return null; + + return new TargetItem + { + Name = "Saved Messages", + Path = $"tg://resolve?{parameter}={Uri.EscapeDataString(value)}", + }; + } + + internal static TargetItem? CreateTarget(string text) + { + if (!TryExtractLaunchUri(text, out var uriText)) return null; + return CreateTarget(uriText, titleHint: null); + } + + internal static bool TryExtractLaunchUri(string? text, out string uriText) + { + uriText = ""; + if (string.IsNullOrWhiteSpace(text)) return false; + + var match = LaunchUri.Match(text); + if (!match.Success) return false; + + var candidate = NormalizeUri(TrimUri(match.Value)); + if (!TargetItem.IsLaunchUri(candidate)) return false; + + uriText = candidate; + return true; + } + + internal static bool IsSavedMessagesText(string? text) + { + if (string.IsNullOrWhiteSpace(text)) return false; + return text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Trim()) + .Any(line => SavedMessagesLabel.IsMatch(line)); + } + + private static bool TryGetLaunchUri(IDataObject data, out LinkDropCandidate candidate) + { + LinkDropCandidate? fallback = null; + foreach (var text in LinkDropCandidates(data)) + { + if (!TryExtractLaunchUri(text.Text, out var uriText)) continue; + var current = text with { Text = uriText }; + if (!string.IsNullOrWhiteSpace(current.Title)) + { + candidate = current; + return true; + } + + fallback ??= current; + } + + candidate = fallback ?? new LinkDropCandidate(""); + return fallback != null; + } + + private static IEnumerable TextCandidates(IDataObject data) + => LinkDropCandidates(data).Select(candidate => candidate.Text); + + private static IEnumerable LinkDropCandidates(IDataObject data) + { + if (ReadData(data, "UniformResourceLocatorW", Encoding.Unicode) is { } urlW) + yield return new LinkDropCandidate(urlW); + if (ReadData(data, "UniformResourceLocator", Encoding.Default) is { } url) + yield return new LinkDropCandidate(url); + if (ReadData(data, "text/x-moz-url", Encoding.Unicode) is { } moz) + yield return new LinkDropCandidate(FirstLine(moz), SecondLine(moz)); + if (data.GetData(DataFormats.Html) is string html) + yield return new LinkDropCandidate(html, TitleFromHtml(html)); + if (TextDropService.GetText(data) is { } text) + yield return new LinkDropCandidate(text); + } + + private static string? ReadData(IDataObject data, string format, Encoding encoding) + { + if (!data.GetDataPresent(format)) return null; + return data.GetData(format) switch + { + string text => text, + byte[] bytes => encoding.GetString(bytes), + MemoryStream stream => encoding.GetString(stream.ToArray()), + Stream stream => ReadStream(stream, encoding), + _ => null, + }; + } + + private static string ReadStream(Stream stream, Encoding encoding) + { + if (stream.CanSeek) stream.Position = 0; + using var copy = new MemoryStream(); + stream.CopyTo(copy); + return encoding.GetString(copy.ToArray()); + } + + private static string FirstLine(string text) => + text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)[0]; + + private static string? SecondLine(string text) => + text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None).Skip(1).FirstOrDefault()?.Trim(); + + private static string TrimUri(string value) => + value.Trim().TrimEnd('\0', '.', ',', ';', '!', ')', ']', '}'); + + private static string NormalizeUri(string value) => + IsBareTelegramLink(value) ? "https://" + value : value; + + private static bool IsBareTelegramLink(string value) => + value.StartsWith("t.me/", StringComparison.OrdinalIgnoreCase) + || value.StartsWith("telegram.me/", StringComparison.OrdinalIgnoreCase) + || value.StartsWith("telegram.dog/", StringComparison.OrdinalIgnoreCase) + || Regex.IsMatch(value, @"^[a-z0-9_]{2,64}\.t\.me(?:/|$)", RegexOptions.IgnoreCase); + + private static TargetItem CreateTarget(string uriText, string? titleHint) + { + var path = TargetPathFor(uriText); + return new TargetItem + { + Name = NameFor(uriText, titleHint), + Path = path, + SourceUrl = IsWebUri(uriText) ? uriText : null, + }; + } + + private static string NameFor(string uriText, string? titleHint) + { + if (!Uri.TryCreate(uriText, UriKind.Absolute, out var uri)) return uriText; + if (CleanTitle(titleHint, uriText) is { } title) return title; + if (IsTelegramUri(uri)) return TelegramName(uri); + return string.IsNullOrWhiteSpace(uri.Host) ? uriText : uri.Host; + } + + private static string? CleanTitle(string? title, string uriText) + { + if (string.IsNullOrWhiteSpace(title)) return null; + title = Regex.Replace(title, @"\s+", " ").Trim(); + return title.Length > 0 && !title.Equals(uriText, StringComparison.OrdinalIgnoreCase) + ? title + : null; + } + + private static string? TitleFromHtml(string html) + { + var title = Regex.Match(html, @"(?is)]*>(.*?)"); + if (title.Success) return DecodeHtmlText(title.Groups[1].Value); + + var anchor = Regex.Match(HtmlFragment(html), @"(?is)]*>(.*?)"); + return anchor.Success ? DecodeHtmlText(anchor.Groups[1].Value) : null; + } + + private static string HtmlFragment(string html) + { + const string startMarker = ""; + const string endMarker = ""; + var markerStart = html.IndexOf(startMarker, StringComparison.OrdinalIgnoreCase); + var markerEnd = html.IndexOf(endMarker, StringComparison.OrdinalIgnoreCase); + if (markerStart >= 0 && markerEnd > markerStart) + return html[(markerStart + startMarker.Length)..markerEnd]; + + return html; + } + + private static string DecodeHtmlText(string value) + { + value = Regex.Replace(value, "<[^>]+>", ""); + return System.Net.WebUtility.HtmlDecode(value).Trim(); + } + + private static string TargetPathFor(string uriText) + { + if (!Uri.TryCreate(uriText, UriKind.Absolute, out var uri) || !IsTelegramWebUri(uri)) + return uriText; + + return TelegramWebDeepLink(uri) ?? uriText; + } + + private static bool IsTelegramWebUri(Uri uri) => + uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + || uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); + + private static bool IsWebUri(string uriText) => + Uri.TryCreate(uriText, UriKind.Absolute, out var uri) + && IsTelegramWebUri(uri); + + private static string? TelegramWebDeepLink(Uri uri) + { + if (!IsTelegramUri(uri)) return null; + + if (uri.Host.EndsWith(".t.me", StringComparison.OrdinalIgnoreCase) + && !uri.Host.Equals("t.me", StringComparison.OrdinalIgnoreCase)) + { + var domain = uri.Host[..^".t.me".Length]; + return $"tg://resolve?domain={Uri.EscapeDataString(domain)}"; + } + + var segments = uri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) return null; + + var first = Uri.UnescapeDataString(segments[0]); + if (first.Equals("c", StringComparison.OrdinalIgnoreCase) && segments.Length >= 3) + { + if (segments.Length >= 4) + return $"tg://privatepost?channel={Uri.EscapeDataString(segments[1])}&topic={Uri.EscapeDataString(segments[2])}&post={Uri.EscapeDataString(segments[3])}"; + + return $"tg://privatepost?channel={Uri.EscapeDataString(segments[1])}&post={Uri.EscapeDataString(segments[2])}"; + } + + if (first.StartsWith('+') && first.Length > 1) + return $"tg://join?invite={Uri.EscapeDataString(first[1..])}"; + + if (first.Equals("joinchat", StringComparison.OrdinalIgnoreCase) && segments.Length >= 2) + return $"tg://join?invite={Uri.EscapeDataString(segments[1])}"; + + var resolvedDomain = Uri.EscapeDataString(first.TrimStart('@')); + if (segments.Length >= 3 && int.TryParse(segments[1], out _) && int.TryParse(segments[2], out _)) + return $"tg://resolve?domain={resolvedDomain}&topic={Uri.EscapeDataString(segments[1])}&post={Uri.EscapeDataString(segments[2])}"; + + if (segments.Length >= 2 && int.TryParse(segments[1], out _)) + return $"tg://resolve?domain={resolvedDomain}&post={Uri.EscapeDataString(segments[1])}"; + + return $"tg://resolve?domain={resolvedDomain}"; + } + + private static bool IsTelegramUri(Uri uri) => + uri.Scheme.Equals("tg", StringComparison.OrdinalIgnoreCase) + || (uri.Host.Equals("t.me", StringComparison.OrdinalIgnoreCase) + || uri.Host.Equals("telegram.me", StringComparison.OrdinalIgnoreCase) + || uri.Host.Equals("telegram.dog", StringComparison.OrdinalIgnoreCase) + || uri.Host.EndsWith(".t.me", StringComparison.OrdinalIgnoreCase)); + + private static string TelegramName(Uri uri) + { + if (uri.Scheme.Equals("tg", StringComparison.OrdinalIgnoreCase)) + { + var domain = QueryValue(uri, "domain"); + if (!string.IsNullOrWhiteSpace(domain)) return "Telegram: " + domain.TrimStart('@'); + + var id = QueryValue(uri, "id") ?? QueryValue(uri, "user_id"); + if (!string.IsNullOrWhiteSpace(id)) return "Telegram: " + id; + + return "Telegram"; + } + + var segments = uri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); + var firstSegment = segments.FirstOrDefault(); + if (string.IsNullOrWhiteSpace(firstSegment)) return "Telegram"; + + firstSegment = Uri.UnescapeDataString(firstSegment); + if (firstSegment.StartsWith('+') || firstSegment.Equals("joinchat", StringComparison.OrdinalIgnoreCase)) + return "Telegram invite"; + if (firstSegment.Equals("c", StringComparison.OrdinalIgnoreCase)) + return segments.Length >= 3 ? "Telegram topic" : "Telegram chat"; + + return "Telegram: " + firstSegment.TrimStart('@'); + } + + private static string? QueryValue(Uri uri, string name) + { + foreach (var part in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var pieces = part.Split('=', 2); + var key = Uri.UnescapeDataString(pieces[0]); + if (!key.Equals(name, StringComparison.OrdinalIgnoreCase)) continue; + return pieces.Length == 2 ? Uri.UnescapeDataString(pieces[1]) : ""; + } + + return null; + } +} diff --git a/src/Dropwheel/Services/TargetStore.cs b/src/Dropwheel/Services/TargetStore.cs index a58876c..5e248d5 100644 --- a/src/Dropwheel/Services/TargetStore.cs +++ b/src/Dropwheel/Services/TargetStore.cs @@ -1,6 +1,7 @@ using System.IO; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.Json.Nodes; using Dropwheel.Models; namespace Dropwheel.Services; @@ -38,7 +39,9 @@ public static void Load() { try { - Config = JsonSerializer.Deserialize(File.ReadAllText(FilePath), Opts) ?? new(); + var configText = File.ReadAllText(FilePath); + Config = DeserializeConfig(configText, out var sanitizedInvalidEnums) ?? new(); + if (sanitizedInvalidEnums) Save(); if (Config.Presets == null) { Config.Presets = PresetService.Defaults(); Save(); } return; } @@ -194,6 +197,93 @@ public static void MoveToGroup(TargetItem item, TargetItem? group) return Groups.Select(g => (IList)g.Children!).FirstOrDefault(children => children.Contains(item)); } + private static AppConfig? DeserializeConfig(string json, out bool sanitizedInvalidEnums) + { + try + { + sanitizedInvalidEnums = false; + return JsonSerializer.Deserialize(json, Opts); + } + catch (JsonException ex) when (TrySanitizeInvalidEnums(json, out var sanitizedJson)) + { + ErrorLog.Write("Config contains unknown enum values; falling back only for those fields.", ex); + sanitizedInvalidEnums = true; + return JsonSerializer.Deserialize(sanitizedJson, Opts); + } + } + + private static bool TrySanitizeInvalidEnums(string json, out string sanitizedJson) + { + sanitizedJson = json; + JsonNode? root; + try + { + root = JsonNode.Parse(json); + } + catch (JsonException) + { + return false; + } + + if (root is not JsonObject rootObject) + return false; + + var changed = false; + changed |= RemoveInvalidEnum(rootObject, nameof(AppConfig.GlobalAction)); + changed |= RemoveInvalidEnum(rootObject, nameof(AppConfig.OpenAnimation)); + + if (rootObject[nameof(AppConfig.Targets)] is JsonArray targets) + changed |= SanitizeTargets(targets); + + if (!changed) + return false; + + sanitizedJson = rootObject.ToJsonString(); + return true; + } + + private static bool SanitizeTargets(JsonArray targets) + { + var changed = false; + foreach (var targetNode in targets) + { + if (targetNode is not JsonObject targetObject) + continue; + + changed |= RemoveInvalidEnum(targetObject, nameof(TargetItem.Override)); + + if (targetObject[nameof(TargetItem.Children)] is JsonArray children) + changed |= SanitizeTargets(children); + } + + return changed; + } + + private static bool RemoveInvalidEnum(JsonObject obj, string propertyName) + where TEnum : struct, Enum + { + if (!obj.TryGetPropertyValue(propertyName, out var valueNode) || valueNode is null) + return false; + + if (valueNode is not JsonValue value) + return false; + + if (value.TryGetValue(out var enumToken)) + { + if (Enum.TryParse(enumToken, ignoreCase: true, out _)) + return false; + + obj.Remove(propertyName); + return true; + } + + if (value.TryGetValue(out var enumValue) && Enum.IsDefined(typeof(TEnum), enumValue)) + return false; + + obj.Remove(propertyName); + return true; + } + private static AppConfig Defaults() { static string P(Environment.SpecialFolder f) => Environment.GetFolderPath(f); diff --git a/src/Dropwheel/Services/TelegramDropService.cs b/src/Dropwheel/Services/TelegramDropService.cs new file mode 100644 index 0000000..12dfe43 --- /dev/null +++ b/src/Dropwheel/Services/TelegramDropService.cs @@ -0,0 +1,179 @@ +using System.Collections.Specialized; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Windows; +using Dropwheel.Models; + +namespace Dropwheel.Services; + +public enum TelegramDropKind { Files, Text } + +public sealed class TelegramDropResult +{ + public TelegramDropKind Kind { get; init; } + public int Count { get; init; } +} + +internal sealed class TelegramClipboardPayload +{ + public required TelegramDropKind Kind { get; init; } + public string[] Files { get; init; } = Array.Empty(); + public string? Text { get; init; } + + public int Count => Kind == TelegramDropKind.Files ? Files.Length : 1; + + public void Copy() + { + if (Kind == TelegramDropKind.Files) + { + var list = new StringCollection(); + list.AddRange(Files); + SetClipboard(() => Clipboard.SetFileDropList(list)); + return; + } + + SetClipboard(() => Clipboard.SetText(Text ?? "", TextDataFormat.UnicodeText)); + } + + private static void SetClipboard(Action set) + { + for (int attempt = 0; ; attempt++) + { + try + { + set(); + return; + } + catch (ExternalException) when (attempt < 2) + { + Thread.Sleep(50); + } + } + } +} + +public static class TelegramDropService +{ + private static readonly TimeSpan PasteTimeout = TimeSpan.FromSeconds(4); + private static readonly TimeSpan PastePoll = TimeSpan.FromMilliseconds(100); + + [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out int processId); + + public static bool IsTelegramTarget(TargetItem target) + { + if (target.IsGroup || !Uri.TryCreate(target.Path, UriKind.Absolute, out var uri)) + return false; + + if (uri.Scheme.Equals("tg", StringComparison.OrdinalIgnoreCase)) + return true; + + if (!uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + && !uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + return false; + + return uri.Host.Equals("t.me", StringComparison.OrdinalIgnoreCase) + || uri.Host.Equals("telegram.me", StringComparison.OrdinalIgnoreCase) + || uri.Host.Equals("telegram.dog", StringComparison.OrdinalIgnoreCase) + || uri.Host.EndsWith(".t.me", StringComparison.OrdinalIgnoreCase); + } + + public static bool CanAccept(TargetItem target, IDataObject data) => + IsTelegramTarget(target) && HasSendablePayload(data); + + public static string LaunchPathFor(TargetItem target) => + IsTelegramTarget(target) && LinkTargetService.CreateTarget(target.Path) is { } linkTarget + ? linkTarget.Path + : target.Path; + + public static void PasteIntoTelegramWhenReady() + { + _ = Task.Run(async () => + { + try { await PasteIntoTelegramWhenReady(PasteTimeout, PastePoll); } + catch (Exception ex) { ErrorLog.Write("Could not paste Telegram drop payload", ex); } + }); + } + + public static TelegramDropResult? CopyToClipboard(IDataObject data, string stagingFolder) + { + var payload = CreatePayload(data, stagingFolder); + if (payload == null) return null; + + payload.Copy(); + return new TelegramDropResult { Kind = payload.Kind, Count = payload.Count }; + } + + private static bool HasSendablePayload(IDataObject data) => + RealFiles(data).Length > 0 + || VirtualFileService.HasVirtualFiles(data) + || TextDropService.HasPotentialText(data); + + internal static TelegramClipboardPayload? CreatePayload(IDataObject data, string stagingFolder) + { + var realFiles = RealFiles(data); + if (realFiles.Length > 0) + return new TelegramClipboardPayload { Kind = TelegramDropKind.Files, Files = realFiles }; + + if (VirtualFileService.HasVirtualFiles(data)) + { + Directory.CreateDirectory(stagingFolder); + var saved = VirtualFileService.Extract(data, stagingFolder); + if (saved.Length > 0) + return new TelegramClipboardPayload { Kind = TelegramDropKind.Files, Files = saved }; + } + + var text = TextDropService.GetText(data); + return string.IsNullOrEmpty(text) + ? null + : new TelegramClipboardPayload { Kind = TelegramDropKind.Text, Text = text }; + } + + internal static async Task PasteIntoTelegramWhenReady( + TimeSpan timeout, + TimeSpan poll, + Action? paste = null, + Func? foregroundProcessName = null) + { + var deadline = DateTime.UtcNow + timeout; + do + { + var processName = foregroundProcessName?.Invoke() ?? ForegroundProcessName(); + if (IsTelegramProcessName(processName)) + { + await Task.Delay(250); + if (paste != null) paste(); + else await Application.Current.Dispatcher.InvokeAsync(() => System.Windows.Forms.SendKeys.SendWait("^v")); + return true; + } + + await Task.Delay(poll); + } + while (DateTime.UtcNow <= deadline); + + return false; + } + + internal static bool IsTelegramProcessName(string? processName) => + processName != null + && (processName.Equals("Telegram", StringComparison.OrdinalIgnoreCase) + || processName.Equals("TelegramDesktop", StringComparison.OrdinalIgnoreCase)); + + private static string? ForegroundProcessName() + { + var hWnd = GetForegroundWindow(); + if (hWnd == IntPtr.Zero) return null; + + GetWindowThreadProcessId(hWnd, out var processId); + if (processId == 0) return null; + + try { return Process.GetProcessById(processId).ProcessName; } + catch { return null; } + } + + private static string[] RealFiles(IDataObject data) => + data.GetData(DataFormats.FileDrop) is string[] files + ? files.Where(path => File.Exists(path) || Directory.Exists(path)).ToArray() + : Array.Empty(); +} diff --git a/src/Dropwheel/Services/TextDropService.cs b/src/Dropwheel/Services/TextDropService.cs index 556602d..58802de 100644 --- a/src/Dropwheel/Services/TextDropService.cs +++ b/src/Dropwheel/Services/TextDropService.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Net; using System.Text; using System.Text.RegularExpressions; using System.Windows; @@ -9,11 +10,37 @@ namespace Dropwheel.Services; /// The extension is picked from the content: markdown-looking text becomes .md, otherwise .txt. public static class TextDropService { - public static bool HasText(IDataObject data) => - data.GetDataPresent(DataFormats.UnicodeText) || data.GetDataPresent(DataFormats.Text); + private static readonly string[] PlainTextFormats = + { + DataFormats.UnicodeText, + DataFormats.Text, + DataFormats.StringFormat, + DataFormats.OemText, + "UTF8_STRING", + "text/plain", + "text/plain;charset=utf-8", + }; + + public static bool HasPotentialText(IDataObject data) => + TextFormatCandidates(data).Any(); - public static string? GetText(IDataObject data) => - data.GetData(DataFormats.UnicodeText) as string ?? data.GetData(DataFormats.Text) as string; + public static bool HasText(IDataObject data) => !string.IsNullOrEmpty(GetText(data)); + + public static string? GetText(IDataObject data) + { + foreach (var format in TextFormatCandidates(data)) + { + if (TryGetText(data, format) is { Length: > 0 } text) return text; + } + + return null; + } + + public static string DescribeFormats(IDataObject data) + { + try { return string.Join(", ", data.GetFormats()); } + catch (Exception ex) { return $""; } + } /// Saves dragged text into the folder, or null when the drop carries no text. public static string? SaveFrom(IDataObject data, string folder, DateTime now) @@ -42,16 +69,169 @@ public static string Save(string text, string folder, DateTime now) public static bool LooksLikeMarkdown(string text) => text.Contains("```") || Heading.IsMatch(text) || Link.IsMatch(text); + private static string? TryGetText(IDataObject data, string format) + { + foreach (var autoConvert in new[] { false, true }) + { + try + { + if (!data.GetDataPresent(format, autoConvert)) continue; + if (TextFromFormat(format, data.GetData(format, autoConvert)) is { Length: > 0 } text) + return text; + } + catch + { + // Some delayed-rendered drag formats throw until the final Drop. Try the next mode. + } + } + + return null; + } + + private static string? TextFromFormat(string format, object? data) + { + var text = TextFromData(data); + if (string.IsNullOrEmpty(text)) return null; + if (IsHtmlFormat(format)) return TextFromHtml(text); + if (IsRtfFormat(format)) return TextFromRtf(text); + return text.TrimEnd('\0'); + } + + private static string? TextFromData(object? data) + { + if (data is string text) return text; + if (data is byte[] bytes) return TextFromBytes(bytes); + if (data is not Stream stream) return null; + + if (stream.CanSeek) stream.Position = 0; + using var copy = new MemoryStream(); + stream.CopyTo(copy); + return TextFromBytes(copy.ToArray()); + } + + private static string? TextFromBytes(byte[] bytes) + { + bytes = bytes.TrimTrailingZeros(); + if (bytes.Length == 0) return null; + return LooksLikeUtf16(bytes) + ? Encoding.Unicode.GetString(bytes) + : Encoding.UTF8.GetString(bytes); + } + + private static IEnumerable TextFormatCandidates(IDataObject data) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var formats = SafeGetFormats(data); + + foreach (var format in PlainTextFormats) + { + if ((formats.Contains(format) || SafeGetDataPresent(data, format)) && seen.Add(format)) + yield return format; + } + + foreach (var format in formats) + { + if (LooksLikeTextFormat(format) && seen.Add(format)) + yield return format; + } + } + + private static string[] SafeGetFormats(IDataObject data) + { + try { return data.GetFormats(autoConvert: true); } + catch { return Array.Empty(); } + } + + private static bool SafeGetDataPresent(IDataObject data, string format) + { + try { return data.GetDataPresent(format, autoConvert: true); } + catch { return false; } + } + + private static bool LooksLikeTextFormat(string format) + { + var normalized = format.ToLowerInvariant(); + return normalized.Contains("text") + || normalized.Contains("string") + || normalized.Contains("utf8") + || normalized.Contains("unicode") + || normalized.Contains("html") + || normalized.Contains("rtf") + || normalized.Contains("rich text"); + } + + private static bool IsHtmlFormat(string format) => + format.Contains("html", StringComparison.OrdinalIgnoreCase); + + private static bool IsRtfFormat(string format) => + format.Contains("rtf", StringComparison.OrdinalIgnoreCase) + || format.Contains("rich text", StringComparison.OrdinalIgnoreCase); + + private static string? TextFromHtml(string html) + { + var fragment = HtmlFragment(html); + fragment = Regex.Replace(fragment, @"(?i)", "\n"); + fragment = Regex.Replace(fragment, @"(?i)", "\n"); + fragment = Regex.Replace(fragment, "<[^>]+>", ""); + return WebUtility.HtmlDecode(fragment).Trim(); + } + + private static string HtmlFragment(string html) + { + const string startMarker = ""; + const string endMarker = ""; + var markerStart = html.IndexOf(startMarker, StringComparison.OrdinalIgnoreCase); + var markerEnd = html.IndexOf(endMarker, StringComparison.OrdinalIgnoreCase); + if (markerStart >= 0 && markerEnd > markerStart) + return html[(markerStart + startMarker.Length)..markerEnd]; + + var start = Regex.Match(html, @"StartFragment:(\d+)"); + var end = Regex.Match(html, @"EndFragment:(\d+)"); + if (start.Success + && end.Success + && int.TryParse(start.Groups[1].Value, out var startIndex) + && int.TryParse(end.Groups[1].Value, out var endIndex) + && startIndex >= 0 + && endIndex > startIndex + && endIndex <= html.Length) + return html[startIndex..endIndex]; + + return html; + } + + private static string? TextFromRtf(string rtf) + { + var text = Regex.Replace(rtf, @"\\'[0-9a-fA-F]{2}", ""); + text = Regex.Replace(text, @"\\[a-zA-Z]+\d* ?", ""); + text = text.Replace("{", "").Replace("}", "").Trim(); + return text.Length == 0 ? null : text; + } + + private static bool LooksLikeUtf16(byte[] bytes) => + bytes.Length >= 2 && bytes.Where((_, i) => i % 2 == 1).Take(16).Count(b => b == 0) >= 4; + private static string Unique(string folder, string name) { var path = Path.Combine(folder, name); - if (!File.Exists(path)) return path; + if (!PathExists(path)) return path; var stem = Path.GetFileNameWithoutExtension(name); var ext = Path.GetExtension(name); for (int i = 2; ; i++) { path = Path.Combine(folder, $"{stem} ({i}){ext}"); - if (!File.Exists(path)) return path; + if (!PathExists(path)) return path; } } + + private static bool PathExists(string path) => File.Exists(path) || Directory.Exists(path); +} + +internal static class ByteArrayExtensions +{ + public static byte[] TrimTrailingZeros(this byte[] bytes) + { + var length = bytes.Length; + while (length > 0 && bytes[length - 1] == 0) length--; + return length == bytes.Length ? bytes : bytes[..length]; + } } diff --git a/src/Dropwheel/Services/WatcherService.cs b/src/Dropwheel/Services/WatcherService.cs index bb7d649..099e87e 100644 --- a/src/Dropwheel/Services/WatcherService.cs +++ b/src/Dropwheel/Services/WatcherService.cs @@ -17,7 +17,70 @@ public sealed class WatcherService private sealed class Entry { public required FileSystemWatcher Watcher { get; init; } + public required CancellationTokenSource Lifetime { get; init; } public TargetItem Target { get; set; } = null!; + + private readonly object _gate = new(); + private int _queuedWork; + private bool _stopping; + private bool _lifetimeDisposed; + + public CancellationToken Token => Lifetime.Token; + + public bool TryQueueWork() + { + lock (_gate) + { + if (_stopping) return false; + _queuedWork++; + return true; + } + } + + public void CompleteWork() + { + CancellationTokenSource? dispose = null; + lock (_gate) + { + _queuedWork--; + if (_queuedWork == 0 && _stopping && !_lifetimeDisposed) + { + _lifetimeDisposed = true; + dispose = Lifetime; + } + } + dispose?.Dispose(); + } + + public void Cancel() + { + CancellationTokenSource? dispose = null; + lock (_gate) + { + if (!_stopping) + { + _stopping = true; + Lifetime.Cancel(); + Watcher.Dispose(); + } + if (_queuedWork == 0 && !_lifetimeDisposed) + { + _lifetimeDisposed = true; + dispose = Lifetime; + } + } + dispose?.Dispose(); + } + + public bool TryRunSort(CancellationToken cancellationToken, Action sort) + { + lock (_gate) + { + if (_stopping || cancellationToken.IsCancellationRequested) return false; + sort(); + return true; + } + } } private readonly Dispatcher _ui; @@ -50,12 +113,15 @@ public void Start() public void Stop() { TargetStore.Saved -= Rebuild; - foreach (var e in _entries.Values) e.Watcher.Dispose(); + foreach (var e in _entries.Values) + { + e.Cancel(); + } _entries.Clear(); } /// Re-syncs watchers to the current set of watched sorter folders: adds new folders, - /// drops removed ones, and refreshes the target reference for folders that stay — so rule edits + /// drops removed ones, and refreshes the target reference for folders that stay so rule edits /// take effect without recreating the watcher. private void Rebuild() { @@ -65,25 +131,29 @@ private void Rebuild() if (!t.Watch || !t.IsSorter) continue; string path; try { path = Path.GetFullPath(t.Path); } - catch { continue; } // invalid path in config — skip, do not throw + catch { continue; } // invalid path in config - skip, do not throw if (!Directory.Exists(path)) continue; - desired[path] = t; // rare duplicate (two targets on one folder) — keep the last + desired[path] = t; // rare duplicate (two targets on one folder) - keep the last } foreach (var path in _entries.Keys.Where(p => !desired.ContainsKey(p)).ToList()) { - _entries[path].Watcher.Dispose(); + _entries[path].Cancel(); _entries.Remove(path); } foreach (var (path, target) in desired) { if (_entries.TryGetValue(path, out var existing)) { existing.Target = target; continue; } - // One bad folder (path too long, network glitch) must not break the whole rebuild — it + // One bad folder (path too long, network glitch) must not break the whole rebuild - it // runs from the config-save handler. try { - var entry = new Entry { Watcher = CreateWatcher(path) }; + var entry = new Entry + { + Watcher = CreateWatcher(path), + Lifetime = new CancellationTokenSource(), + }; entry.Target = target; entry.Watcher.Created += (_, e) => OnAppeared(entry, e.FullPath); entry.Watcher.Renamed += (_, e) => OnAppeared(entry, e.FullPath); @@ -100,7 +170,7 @@ private void Rebuild() { IncludeSubdirectories = false, NotifyFilter = NotifyFilters.FileName, - // Larger buffer — fewer lost events when many files are dropped at once (64 KB is the max). + // Larger buffer - fewer lost events when many files are dropped at once (64 KB is the max). InternalBufferSize = 64 * 1024, }; @@ -109,7 +179,7 @@ private void Rebuild() /// it now. The _inFlight dedup keeps a file from being processed twice. private void OnError(Entry entry, string path, Exception ex) { - ErrorLog.Write($"Watch buffer overflow for folder '{path}' — rescanning", ex); + ErrorLog.Write($"Watch buffer overflow for folder '{path}' - rescanning", ex); Sweep(entry); } @@ -127,32 +197,67 @@ private void Sweep(Entry entry) /// Renamed can both fire for one file); then we wait off-thread for it to be released. private void OnAppeared(Entry entry, string fullPath) { - if (!_inFlight.TryAdd(fullPath, 0)) return; - _ = ProcessWhenReady(entry, fullPath); + if (!entry.TryQueueWork()) return; + if (!_inFlight.TryAdd(fullPath, 0)) + { + entry.CompleteWork(); + return; + } + _ = ProcessWhenReady(entry, fullPath, entry.Token); } - private async Task ProcessWhenReady(Entry entry, string file) + private async Task ProcessWhenReady(Entry entry, string file, CancellationToken cancellationToken) { try { - for (int i = 0; i < MaxWaitTicks; i++) + if (!await WaitUntilReadyAsync(file, IsReady, PollMs, MaxWaitTicks, cancellationToken)) return; + await _moveGate.WaitAsync(cancellationToken); + try { - if (Directory.Exists(file)) return; // a folder appeared, not a file - if (!File.Exists(file)) return; // the file vanished while we waited - if (IsReady(file)) break; // the writing process released it — fully written - if (i == MaxWaitTicks - 1) - { - ErrorLog.Write($"File '{file}' stays locked — auto-sort skipped"); - return; - } - await Task.Delay(PollMs); + entry.TryRunSort( + cancellationToken, + () => SortOne(entry, file, cancellationToken)); // off-thread: planning and the silent move never touch the UI } - await _moveGate.WaitAsync(); - try { SortOne(entry, file); } // off-thread: planning and the silent move never touch the UI finally { _moveGate.Release(); } } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } catch (Exception ex) { ErrorLog.Write($"Error waiting for file '{file}'", ex); } - finally { _inFlight.TryRemove(file, out _); } + finally + { + _inFlight.TryRemove(file, out _); + entry.CompleteWork(); + } + } + + internal static async Task WaitUntilReadyAsync( + string file, + Func isReady, + int pollMs, + int maxWaitTicks, + CancellationToken cancellationToken) + { + try + { + for (int i = 0; i < maxWaitTicks; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + if (Directory.Exists(file)) return false; // a folder appeared, not a file + if (!File.Exists(file)) return false; // the file vanished while we waited + if (isReady(file)) return true; // the writing process released it - fully written + if (i == maxWaitTicks - 1) + { + ErrorLog.Write($"File '{file}' stays locked - auto-sort skipped"); + return false; + } + await Task.Delay(pollMs, cancellationToken); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return false; + } + + return false; } /// A file counts as fully written once it can be opened exclusively: while a copy or @@ -168,16 +273,20 @@ private static bool IsReady(string file) catch (UnauthorizedAccessException) { return false; } } - private void SortOne(Entry entry, string file) + private void SortOne(Entry entry, string file, CancellationToken cancellationToken) { try { + cancellationToken.ThrowIfCancellationRequested(); if (!File.Exists(file)) return; var plan = SortService.MovePlan(entry.Target, new[] { file }); foreach (var (folder, files) in plan) { + cancellationToken.ThrowIfCancellationRequested(); + if (SameFolder(folder, file)) continue; // file stays in its own folder - no move, no loop // Create the destination folder first: otherwise SHFileOperation moving a single file // to a non-existent path treats the last segment as a new file name, not a folder. + cancellationToken.ThrowIfCancellationRequested(); Directory.CreateDirectory(folder); var conflicts = FileOps.DestinationConflicts(files, folder); if (conflicts.Length > 0) @@ -185,12 +294,17 @@ private void SortOne(Entry entry, string file) ErrorLog.Write($"Auto-sort skipped '{file}' because destination already exists: '{conflicts[0]}'"); continue; } + cancellationToken.ThrowIfCancellationRequested(); if (FileOps.Execute(files, folder, DropAction.Move, silent: true)) - _ui.InvokeAsync(() => QueueToast(files.Length)); // coalesce the toast on the UI thread + { + if (!cancellationToken.IsCancellationRequested) + _ui.InvokeAsync(() => QueueToast(files.Length)); // coalesce the toast on the UI thread + } else ErrorLog.Write($"Failed to move '{file}' to '{folder}'"); } } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } catch (Exception ex) { ErrorLog.Write($"Auto-sort of '{file}' failed", ex); } } diff --git a/src/Dropwheel/UI/OverlayWindow.Bubble.Wire.cs b/src/Dropwheel/UI/OverlayWindow.Bubble.Wire.cs index 005b9fd..c20d03c 100644 --- a/src/Dropwheel/UI/OverlayWindow.Bubble.Wire.cs +++ b/src/Dropwheel/UI/OverlayWindow.Bubble.Wire.cs @@ -78,7 +78,7 @@ private StackPanel WireBubble(TargetItem t, Border badge, FrameworkElement label if (t.IsGroup) { StartGroupHover(t, back: false); - e.Effects = DragDropEffects.Link; + e.Effects = CanAddTarget(e.Data) ? AddTargetDropEffect(e) : DragDropEffects.None; e.Handled = true; } else OnBubbleDragOver(t, badge, e); diff --git a/src/Dropwheel/UI/OverlayWindow.Bubble.cs b/src/Dropwheel/UI/OverlayWindow.Bubble.cs index 5e3c364..fa5a540 100644 --- a/src/Dropwheel/UI/OverlayWindow.Bubble.cs +++ b/src/Dropwheel/UI/OverlayWindow.Bubble.cs @@ -27,7 +27,7 @@ private FrameworkElement MakeBubble(TargetItem t) { Width = 32, Height = 32, - Source = IconService.GetIcon(t.Path), + Source = IconService.GetIcon(t), HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; diff --git a/src/Dropwheel/UI/OverlayWindow.Dnd.cs b/src/Dropwheel/UI/OverlayWindow.Dnd.cs index 02f7d92..aca81d3 100644 --- a/src/Dropwheel/UI/OverlayWindow.Dnd.cs +++ b/src/Dropwheel/UI/OverlayWindow.Dnd.cs @@ -21,7 +21,27 @@ private void OnBubbleDragOver(TargetItem t, Border badge, DragEventArgs e) { bool real = e.Data.GetDataPresent(DataFormats.FileDrop); bool virt = !real && VirtualFileService.HasVirtualFiles(e.Data); - bool text = !real && !virt && TextDropService.HasText(e.Data); + bool link = !real && !virt && LinkTargetService.HasLaunchUri(e.Data); + bool text = !real && !virt && !link && TextDropService.HasText(e.Data); + + if (TelegramDropService.CanAccept(t, e.Data)) + { + var effect = TelegramDropEffect(e); + if (effect == DragDropEffects.None) + { + e.Effects = DragDropEffects.None; + e.Handled = true; + return; + } + + var telegramText = !real && !virt && TextDropService.HasText(e.Data); + e.Effects = effect; + ((TextBlock)badge.Child).Text = telegramText ? "≡" : "⧉"; + badge.Background = Brushes.CornflowerBlue; + badge.Visibility = Visibility.Visible; + e.Handled = true; + return; + } if (real && LaunchService.IsRunTarget(t)) // drop files on an exe/script → run it (open with) { @@ -32,13 +52,14 @@ private void OnBubbleDragOver(TargetItem t, Border badge, DragEventArgs e) e.Handled = true; return; } - if ((!real && !virt && !text) || !LaunchService.IsFolderTarget(t)) + if ((!real && !virt && !link && !text) || !LaunchService.IsFolderTarget(t)) { e.Effects = DragDropEffects.None; e.Handled = true; return; } var act = virt || text ? DropAction.Copy : Resolve(t, e); // virtual files and text: copy only - e.Effects = act == DropAction.Move ? DragDropEffects.Move : DragDropEffects.Copy; + e.Effects = link ? AddTargetDropEffect(e) : act == DropAction.Move ? DragDropEffects.Move : DragDropEffects.Copy; ((TextBlock)badge.Child).Text = t.IsSorter ? "⇅" : text ? "≡" : act == DropAction.Move ? "➜" : "⧉"; - badge.Background = act == DropAction.Move ? Brushes.Orange : Brushes.MediumSpringGreen; + if (link) ((TextBlock)badge.Child).Text = "+"; + badge.Background = link ? Brushes.CornflowerBlue : act == DropAction.Move ? Brushes.Orange : Brushes.MediumSpringGreen; badge.Visibility = Visibility.Visible; e.Handled = true; } @@ -58,6 +79,31 @@ private void OnBubbleDrop(TargetItem t, Border badge, DragEventArgs e) private void OnBubbleDropCore(TargetItem t, DragEventArgs e) { + if (TelegramDropService.CanAccept(t, e.Data)) + { + var result = TelegramDropService.CopyToClipboard( + e.Data, + System.IO.Path.Combine(TargetStore.Dir, "telegram-drop")); + + if (result == null) + { + ErrorLog.Write( + $"Telegram drop had no extractable payload. AllowedEffects={e.AllowedEffects}; Formats={TextDropService.DescribeFormats(e.Data)}"); + ShowToast("Nothing to send"); + return; + } + + LaunchService.Launch(new TargetItem { Name = t.Name, Path = TelegramDropService.LaunchPathFor(t) }); + TelegramDropService.PasteIntoTelegramWhenReady(); + e.Effects = e.AllowedEffects.HasFlag(DragDropEffects.Copy) + ? DragDropEffects.Copy + : DragDropEffects.None; + ShowToast(result.Kind == TelegramDropKind.Files + ? $"⧉ Copied {result.Count} file(s); pasting in Telegram" + : "≡ Copied text; pasting in Telegram"); + return; + } + // A shortcut target (.lnk) to a folder stores the shortcut's own path — resolve it so files // land in the target folder, not next to the .lnk. var dest = LaunchService.DestPath(t); @@ -96,6 +142,13 @@ private void OnBubbleDropCore(TargetItem t, DragEventArgs e) ? $"⧉ Saved: {saved.Length} item(s) → {t.Name}" : "Nothing to save", saved.Length > 0); } + else if (AddTargetsFromDrop(e.Data, _currentGroup)) + { + } + else if (LinkTargetService.HasSavedMessagesLabel(e.Data)) + { + ShowToast("Saved Messages target was not added"); + } else if (TextDropService.HasText(e.Data)) { var saved = TextDropService.SaveFrom(e.Data, dest, DateTime.Now); @@ -109,4 +162,12 @@ private void OnBubbleDropCore(TargetItem t, DragEventArgs e) : "No text to save", saved != null); } } + + private static DragDropEffects TelegramDropEffect(DragEventArgs e) + { + if (e.AllowedEffects.HasFlag(DragDropEffects.Copy)) return DragDropEffects.Copy; + if (e.AllowedEffects.HasFlag(DragDropEffects.Move)) return DragDropEffects.Move; + if (e.AllowedEffects.HasFlag(DragDropEffects.Link)) return DragDropEffects.Link; + return DragDropEffects.None; + } } diff --git a/src/Dropwheel/UI/OverlayWindow.OrbDrop.cs b/src/Dropwheel/UI/OverlayWindow.OrbDrop.cs index 035cc9b..9049aad 100644 --- a/src/Dropwheel/UI/OverlayWindow.OrbDrop.cs +++ b/src/Dropwheel/UI/OverlayWindow.OrbDrop.cs @@ -11,9 +11,7 @@ public partial class OverlayWindow /// the target goes into it; otherwise into the root. private void OnOrbDrop(object sender, DragEventArgs e) { - if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths || paths.Length == 0) return; - AddTargets(paths, _currentGroup); - e.Handled = true; + if (AddTargetsFromDrop(e.Data, _currentGroup)) e.Handled = true; } /// A quick drop on a group bubble (before hover-expand fires) @@ -21,28 +19,146 @@ private void OnOrbDrop(object sender, DragEventArgs e) private void OnGroupDrop(TargetItem group, DragEventArgs e) { _groupHover?.Stop(); - if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths || paths.Length == 0) return; - AddTargets(paths, group); + if (AddTargetsFromDrop(e.Data, group)) e.Handled = true; + } + + private void OnAddTargetDragOver(object sender, DragEventArgs e) + { + e.Effects = CanAddTarget(e.Data) ? AddTargetDropEffect(e) : DragDropEffects.None; e.Handled = true; } - private void AddTargets(string[] paths, TargetItem? group) + private static bool CanAddTarget(IDataObject data) => + data.GetDataPresent(DataFormats.FileDrop) || LinkTargetService.HasPotentialLaunchUriData(data); + + private static DragDropEffects AddTargetDropEffect(DragEventArgs e) { - var list = group?.Children ?? TargetStore.Config.Targets; - foreach (var p in paths) + if (e.AllowedEffects.HasFlag(DragDropEffects.Copy)) return DragDropEffects.Copy; + if (e.AllowedEffects.HasFlag(DragDropEffects.Link)) return DragDropEffects.Link; + return DragDropEffects.None; + } + + private bool AddTargetsFromDrop(IDataObject data, TargetItem? group) + { + if (data.GetData(DataFormats.FileDrop) is string[] paths && paths.Length > 0) { - // A dropped .lnk becomes a target for what it points at, not the shortcut file. - var target = ShortcutResolver.Resolve(p); - // Keep the shortcut's friendly label (e.g. "Visual Studio Code") over the raw target name. - var name = IOPath.GetFileNameWithoutExtension(p); - if (string.IsNullOrEmpty(name)) name = IOPath.GetFileNameWithoutExtension(target); - if (string.IsNullOrEmpty(name)) name = target; - list.Add(new TargetItem { Name = name, Path = target }); + AddTargets(paths.Select(TargetFromPath), group); + return true; } + + if (LinkTargetService.CreateTarget(data) is { } linkTarget) + { + AddTargets(new[] { linkTarget }, group); + return true; + } + + if (LinkTargetService.HasSavedMessagesLabel(data) + && PromptSavedMessagesTarget() is { } savedMessagesTarget) + { + AddTargets(new[] { savedMessagesTarget }, group); + return true; + } + + return false; + } + + private TargetItem? PromptSavedMessagesTarget() + { + var prompt = new PromptWindow( + "Telegram Saved Messages", + "Enter your Telegram username or phone number:") + { Owner = this }; + + if (prompt.ShowDialog() != true) return null; + if (LinkTargetService.CreateSavedMessagesTarget(prompt.Value) is { } target) return target; + + ShowToast("Saved Messages target needs a username or phone"); + return null; + } + + private static TargetItem TargetFromPath(string path) + { + // A dropped .lnk becomes a target for what it points at, not the shortcut file. + var target = ShortcutResolver.Resolve(path); + // Keep the shortcut's friendly label (e.g. "Visual Studio Code") over the raw target name. + var name = IOPath.GetFileNameWithoutExtension(path); + if (string.IsNullOrEmpty(name)) name = IOPath.GetFileNameWithoutExtension(target); + if (string.IsNullOrEmpty(name)) name = target; + return new TargetItem { Name = name, Path = target }; + } + + private void AddTargets(IEnumerable targets, TargetItem? group) + { + var items = targets.ToArray(); + if (items.Length == 0) return; + + var list = group?.Children ?? TargetStore.Config.Targets; + foreach (var item in items) list.Add(item); + TargetStore.Save(); ShowToast(group == null - ? $"Targets added: {paths.Length}" - : $"Added to {group.Name}: {paths.Length}"); + ? $"Targets added: {items.Length}" + : $"Added to {group.Name}: {items.Length}"); if (_open) BuildCloud(); + RefreshLinkMetadata(items); + } + + private void RefreshLinkMetadata(TargetItem[] items) + { + var pending = items + .Where(item => LinkMetadataService.SourceUri(item) != null) + .Select(item => new PendingLinkMetadata(item, item.Name)) + .ToArray(); + if (pending.Length == 0) return; + + _ = Task.Run(async () => + { + try + { + var updates = new List<(PendingLinkMetadata Pending, LinkMetadataUpdate Update)>(); + foreach (var item in pending) + { + if (await LinkMetadataService.FetchAsync(item.Target) is { } update) + updates.Add((item, update)); + } + + if (updates.Count == 0) return; + + await Dispatcher.InvokeAsync(() => + { + var changed = false; + foreach (var (pendingItem, update) in updates) + { + var target = pendingItem.Target; + if (!TargetStore.AllTargets.Any(item => ReferenceEquals(item, target))) continue; + + if (!string.IsNullOrWhiteSpace(update.Title) + && target.Name == pendingItem.OriginalName + && target.Name != update.Title) + { + target.Name = update.Title; + changed = true; + } + + if (!string.IsNullOrWhiteSpace(update.IconPath) + && target.IconPath != update.IconPath) + { + target.IconPath = update.IconPath; + changed = true; + } + } + + if (!changed) return; + TargetStore.Save(); + if (_open) BuildCloud(); + }); + } + catch (Exception ex) + { + ErrorLog.Write("Could not refresh link metadata", ex); + } + }); } + + private sealed record PendingLinkMetadata(TargetItem Target, string OriginalName); } diff --git a/src/Dropwheel/UI/OverlayWindow.Plus.cs b/src/Dropwheel/UI/OverlayWindow.Plus.cs index ad92bf8..dec75f5 100644 --- a/src/Dropwheel/UI/OverlayWindow.Plus.cs +++ b/src/Dropwheel/UI/OverlayWindow.Plus.cs @@ -52,7 +52,7 @@ private FrameworkElement MakePlusTile() e.Handled = true; return; } - e.Effects = DragDropEffects.Link; + e.Effects = CanAddTarget(e.Data) ? AddTargetDropEffect(e) : DragDropEffects.None; e.Handled = true; }; panel.Drop += (_, e) => diff --git a/src/Dropwheel/UI/OverlayWindow.Sort.cs b/src/Dropwheel/UI/OverlayWindow.Sort.cs index c9dbd00..bd00838 100644 --- a/src/Dropwheel/UI/OverlayWindow.Sort.cs +++ b/src/Dropwheel/UI/OverlayWindow.Sort.cs @@ -7,18 +7,39 @@ namespace Dropwheel.UI; public partial class OverlayWindow { + internal readonly record struct SorterExecutionGroup(string Folder, string[] Sources); + + internal static bool SameNormalizedFolder(string left, string right) => + string.Equals( + IOPath.TrimEndingDirectorySeparator(IOPath.GetFullPath(left)), + IOPath.TrimEndingDirectorySeparator(IOPath.GetFullPath(right)), + StringComparison.OrdinalIgnoreCase); + + internal static IReadOnlyList ExecutableSorterGroups( + Dictionary> plan) + { + var groups = new List(); + foreach (var (folder, group) in plan) + { + var sources = group + .Where(source => !WatcherService.SameFolder(folder, source)) + .ToArray(); + if (sources.Length > 0) groups.Add(new SorterExecutionGroup(folder, sources)); + } + return groups; + } + /// Real files dropped on a sorter target: distribute by the rules. private void DropSorted(TargetItem t, string[] files, DropAction act) { var plan = SortService.Plan(t, files); bool ok = true; var ops = new List(); - foreach (var (folder, group) in plan) + foreach (var group in ExecutableSorterGroups(plan)) { - Directory.CreateDirectory(folder); - var sources = group.ToArray(); - var op = BuildOpBefore(act, sources, folder); - if (FileOps.Execute(sources, folder, act)) ops.Add(op); + Directory.CreateDirectory(group.Folder); + var op = BuildOpBefore(act, group.Sources, group.Folder); + if (FileOps.Execute(group.Sources, group.Folder, act)) ops.Add(op); else ok = false; } if (ops.Count > 0) RememberOps(ops); @@ -33,10 +54,10 @@ private void SortSavedVirtuals(TargetItem t, string[] saved) { var plan = SortService.Plan(t, saved); var ops = new List(); - string root = IOPath.GetFullPath(t.Path).TrimEnd('\\'); + string root = t.Path; foreach (var (folder, group) in plan) { - if (IOPath.GetFullPath(folder).TrimEnd('\\') == root) + if (SameNormalizedFolder(folder, root)) { ops.Add(BuildCreatedCopyOp(group.ToArray(), folder)); continue; } Directory.CreateDirectory(folder); var sources = group.ToArray(); diff --git a/src/Dropwheel/UI/OverlayWindow.xaml.cs b/src/Dropwheel/UI/OverlayWindow.xaml.cs index bf1bb5b..0853ad6 100644 --- a/src/Dropwheel/UI/OverlayWindow.xaml.cs +++ b/src/Dropwheel/UI/OverlayWindow.xaml.cs @@ -44,6 +44,7 @@ public OverlayWindow() Orb.MouseLeave += (_, _) => _hoverTimer.Stop(); Orb.MouseLeftButtonDown += OnOrbMouseDown; Orb.DragEnter += (_, _) => { _closeTimer.Stop(); OpenCloud(); }; + Orb.DragOver += OnAddTargetDragOver; Orb.Drop += OnOrbDrop; // dropping on the orb adds a target var orbMenu = new System.Windows.Controls.ContextMenu(); diff --git a/tests/Dropwheel.Tests/AppConfigTests.cs b/tests/Dropwheel.Tests/AppConfigTests.cs index eea79a2..12d6f7c 100644 --- a/tests/Dropwheel.Tests/AppConfigTests.cs +++ b/tests/Dropwheel.Tests/AppConfigTests.cs @@ -213,4 +213,74 @@ public void Load_does_not_overwrite_config_when_backup_fails() Assert.Equal(original, File.ReadAllText(configPath)); Assert.Empty(Directory.GetFiles(_root, "config.bad.*.json")); } + + [Fact] + public void Load_preserves_config_when_top_level_enum_token_is_unknown() + { + var configPath = Path.Combine(_root, "config.json"); + File.WriteAllText(configPath, + """ + { + "GlobalAction": "Move", + "OpenAnimation": "FutureSpin", + "HoverDelayMs": 900, + "Targets": [ + { + "Name": "Inbox", + "Path": "C:\\Temp\\Inbox", + "Override": "Copy", + "Pinned": true + } + ] + } + """); + + TargetStore.Load(); + + Assert.Equal(DropAction.Move, TargetStore.Config.GlobalAction); + Assert.Equal(OpenAnimation.Pop, TargetStore.Config.OpenAnimation); + Assert.Equal(900, TargetStore.Config.HoverDelayMs); + + var target = Assert.Single(TargetStore.Config.Targets); + Assert.Equal("Inbox", target.Name); + Assert.Equal("C:\\Temp\\Inbox", target.Path); + Assert.Equal(DropAction.Copy, target.Override); + Assert.True(target.Pinned); + + var saved = File.ReadAllText(configPath); + Assert.Contains("\"OpenAnimation\": \"Pop\"", saved); + Assert.Contains("\"HoverDelayMs\": 900", saved); + Assert.Contains("\"Name\": \"Inbox\"", saved); + } + + [Fact] + public void Load_preserves_targets_when_target_override_enum_token_is_unknown() + { + var configPath = Path.Combine(_root, "config.json"); + File.WriteAllText(configPath, + """ + { + "Targets": [ + { + "Name": "Archive", + "Path": "C:\\Temp\\Archive", + "Override": "Teleport", + "Pinned": true + } + ] + } + """); + + TargetStore.Load(); + + var target = Assert.Single(TargetStore.Config.Targets); + Assert.Equal("Archive", target.Name); + Assert.Equal("C:\\Temp\\Archive", target.Path); + Assert.Equal(DropAction.Inherit, target.Override); + Assert.True(target.Pinned); + + var saved = File.ReadAllText(configPath); + Assert.Contains("\"Override\": \"Inherit\"", saved); + Assert.Contains("\"Name\": \"Archive\"", saved); + } } diff --git a/tests/Dropwheel.Tests/ExecutableTargetTests.cs b/tests/Dropwheel.Tests/ExecutableTargetTests.cs index 4d6d0f6..bd464af 100644 --- a/tests/Dropwheel.Tests/ExecutableTargetTests.cs +++ b/tests/Dropwheel.Tests/ExecutableTargetTests.cs @@ -31,6 +31,19 @@ public void Group_is_never_executable() Assert.False(t.IsExecutable); } + [Theory] + [InlineData("tg://resolve?domain=telegram")] + [InlineData("https://t.me/telegram")] + [InlineData("https://example.com/docs")] + public void Uri_targets_count_as_existing_quick_access_targets(string path) + { + var t = new TargetItem { Name = "link", Path = path }; + + Assert.True(t.IsUri); + Assert.True(t.Exists); + Assert.False(t.IsExecutable); + } + [Fact] public void BuildArgs_quotes_and_joins_paths() { diff --git a/tests/Dropwheel.Tests/LinkMetadataServiceTests.cs b/tests/Dropwheel.Tests/LinkMetadataServiceTests.cs new file mode 100644 index 0000000..38a0d5c --- /dev/null +++ b/tests/Dropwheel.Tests/LinkMetadataServiceTests.cs @@ -0,0 +1,53 @@ +using Dropwheel.Models; +using Dropwheel.Services; + +namespace Dropwheel.Tests; + +public sealed class LinkMetadataServiceTests +{ + [Fact] + public void SourceUri_prefers_original_browser_url() + { + var target = new TargetItem + { + Path = "tg://privatepost?channel=1&post=2", + SourceUrl = "https://t.me/c/1/2", + }; + + Assert.Equal("https://t.me/c/1/2", LinkMetadataService.SourceUri(target)?.AbsoluteUri.TrimEnd('/')); + } + + [Fact] + public void ExtractTitle_decodes_and_compacts_page_title() + { + const string html = " Example &\n Docs "; + + Assert.Equal("Example & Docs", LinkMetadataService.ExtractTitle(html)); + } + + [Fact] + public void ExtractIconUri_reads_relative_icon_link() + { + var page = new Uri("https://example.com/docs/page"); + const string html = ""; + + Assert.Equal("https://example.com/assets/favicon.png", LinkMetadataService.ExtractIconUri(page, html)?.AbsoluteUri); + } + + [Fact] + public void ExtractIconUri_falls_back_to_favicon_ico() + { + var page = new Uri("https://example.com/docs/page"); + + Assert.Equal("https://example.com/favicon.ico", LinkMetadataService.ExtractIconUri(page, "")?.AbsoluteUri); + } + + [Fact] + public void ExtractIconUri_skips_svg_icons() + { + var page = new Uri("https://example.com/docs/page"); + const string html = ""; + + Assert.Equal("https://example.com/icon.png", LinkMetadataService.ExtractIconUri(page, html)?.AbsoluteUri); + } +} diff --git a/tests/Dropwheel.Tests/LinkTargetServiceTests.cs b/tests/Dropwheel.Tests/LinkTargetServiceTests.cs new file mode 100644 index 0000000..4bc2d1b --- /dev/null +++ b/tests/Dropwheel.Tests/LinkTargetServiceTests.cs @@ -0,0 +1,146 @@ +using System.Windows; +using Dropwheel.Services; +using WpfDataFormats = System.Windows.DataFormats; +using WpfDataObject = System.Windows.DataObject; + +namespace Dropwheel.Tests; + +public sealed class LinkTargetServiceTests +{ + [Theory] + [InlineData("tg://resolve?domain=telegram", "tg://resolve?domain=telegram", "Telegram: telegram")] + [InlineData("Open https://t.me/telegram.", "tg://resolve?domain=telegram", "Telegram: telegram")] + [InlineData("t.me/c/2669588230/1", "tg://privatepost?channel=2669588230&post=1", "Telegram topic")] + [InlineData("t.me/c/2669588230/1/2", "tg://privatepost?channel=2669588230&topic=1&post=2", "Telegram topic")] + [InlineData("https://telegram.me/durov", "tg://resolve?domain=durov", "Telegram: durov")] + [InlineData("https://t.me/group/1/2", "tg://resolve?domain=group&topic=1&post=2", "Telegram: group")] + [InlineData("durov.t.me", "tg://resolve?domain=durov", "Telegram")] + [InlineData("https://t.me/+abcdef", "tg://join?invite=abcdef", "Telegram invite")] + public void CreateTarget_extracts_telegram_links(string text, string expectedPath, string expectedName) + { + var target = LinkTargetService.CreateTarget(text); + + Assert.NotNull(target); + Assert.Equal(expectedPath, target.Path); + Assert.Equal(expectedName, target.Name); + } + + [Fact] + public void CreateTarget_ignores_plain_text() + { + Assert.Null(LinkTargetService.CreateTarget("not a link")); + } + + [Theory] + [InlineData("Saved Messages")] + [InlineData("Saved message")] + [InlineData("Избранное")] + public void HasSavedMessagesLabel_detects_saved_messages_chat(string text) + { + var data = new WpfDataObject(); + data.SetData(WpfDataFormats.UnicodeText, text); + + Assert.True(LinkTargetService.HasSavedMessagesLabel(data)); + } + + [Theory] + [InlineData("@durov", "tg://resolve?domain=durov")] + [InlineData("durov", "tg://resolve?domain=durov")] + [InlineData("+15555550123", "tg://resolve?phone=%2B15555550123")] + [InlineData("https://t.me/telegram", "tg://resolve?domain=telegram")] + public void CreateSavedMessagesTarget_builds_self_chat_target(string account, string expectedPath) + { + var target = LinkTargetService.CreateSavedMessagesTarget(account); + + Assert.NotNull(target); + Assert.Equal("Saved Messages", target.Name); + Assert.Equal(expectedPath, target.Path); + } + + [Fact] + public void CreateTarget_accepts_unicode_text_drop_data() + { + var data = new WpfDataObject(); + data.SetData(WpfDataFormats.UnicodeText, "t.me/c/2669588230/1"); + + Assert.True(LinkTargetService.HasPotentialLaunchUriData(data)); + + var target = LinkTargetService.CreateTarget(data); + + Assert.NotNull(target); + Assert.Equal("tg://privatepost?channel=2669588230&post=1", target.Path); + Assert.Equal("Telegram topic", target.Name); + } + + [Fact] + public void CreateTarget_accepts_browser_url_drop_format() + { + var data = new WpfDataObject(); + data.SetData("UniformResourceLocatorW", "https://t.me/telegram"); + + Assert.True(LinkTargetService.HasPotentialLaunchUriData(data)); + + var target = LinkTargetService.CreateTarget(data); + + Assert.NotNull(target); + Assert.Equal("tg://resolve?domain=telegram", target.Path); + Assert.Equal("Telegram: telegram", target.Name); + } + + [Fact] + public void CreateTarget_uses_browser_drop_title_when_available() + { + var data = new WpfDataObject(); + data.SetData("text/x-moz-url", "https://example.com/article\r\nReadable Article"); + + var target = LinkTargetService.CreateTarget(data); + + Assert.NotNull(target); + Assert.Equal("https://example.com/article", target.Path); + Assert.Equal("https://example.com/article", target.SourceUrl); + Assert.Equal("Readable Article", target.Name); + } + + [Fact] + public void CreateTarget_keeps_source_url_for_telegram_web_links() + { + var data = new WpfDataObject(); + data.SetData("text/x-moz-url", "https://t.me/c/2669588230/1\r\nGeneral"); + + var target = LinkTargetService.CreateTarget(data); + + Assert.NotNull(target); + Assert.Equal("tg://privatepost?channel=2669588230&post=1", target.Path); + Assert.Equal("https://t.me/c/2669588230/1", target.SourceUrl); + Assert.Equal("General", target.Name); + } + + [Fact] + public void CreateTarget_uses_html_anchor_text_when_title_is_missing() + { + const string html = "Version:0.9\r\nExample & Docs"; + var data = new WpfDataObject(); + data.SetData(WpfDataFormats.Html, html); + + var target = LinkTargetService.CreateTarget(data); + + Assert.NotNull(target); + Assert.Equal("https://example.com/x", target.Path); + Assert.Equal("Example & Docs", target.Name); + } + + [Fact] + public void CreateTarget_prefers_titled_browser_candidate_over_url_only_candidate() + { + const string html = "Version:0.9\r\nExample Docs"; + var data = new WpfDataObject(); + data.SetData("UniformResourceLocatorW", "https://example.com/x"); + data.SetData(WpfDataFormats.Html, html); + + var target = LinkTargetService.CreateTarget(data); + + Assert.NotNull(target); + Assert.Equal("https://example.com/x", target.Path); + Assert.Equal("Example Docs", target.Name); + } +} diff --git a/tests/Dropwheel.Tests/OverlaySortTests.cs b/tests/Dropwheel.Tests/OverlaySortTests.cs new file mode 100644 index 0000000..08b0b0e --- /dev/null +++ b/tests/Dropwheel.Tests/OverlaySortTests.cs @@ -0,0 +1,42 @@ +using System.IO; +using Dropwheel.UI; + +namespace Dropwheel.Tests; + +public sealed class OverlaySortTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "dw_sort_overlay_" + Guid.NewGuid().ToString("N")); + + public OverlaySortTests() => Directory.CreateDirectory(_root); + public void Dispose() { try { Directory.Delete(_root, true); } catch (DirectoryNotFoundException) { } } + + [Fact] + public void ExecutableSorterGroups_skips_sources_already_in_destination_folder() + { + var inRoot = Path.Combine(_root, "already-here.txt"); + var incomingDir = Path.Combine(_root, "incoming"); + var incoming = Path.Combine(incomingDir, "move-me.txt"); + Directory.CreateDirectory(incomingDir); + File.WriteAllText(inRoot, "same-folder"); + File.WriteAllText(incoming, "incoming"); + var plan = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + [_root] = new() { inRoot, incoming }, + }; + + var groups = OverlayWindow.ExecutableSorterGroups(plan); + + var group = Assert.Single(groups); + Assert.Equal(_root, group.Folder); + Assert.Equal(new[] { incoming }, group.Sources); + } + + [Fact] + public void SameNormalizedFolder_ignores_trailing_separator_and_case() + { + var canonical = _root; + var variant = canonical.ToUpperInvariant() + Path.DirectorySeparatorChar; + + Assert.True(OverlayWindow.SameNormalizedFolder(canonical, variant)); + } +} diff --git a/tests/Dropwheel.Tests/TelegramDropServiceTests.cs b/tests/Dropwheel.Tests/TelegramDropServiceTests.cs new file mode 100644 index 0000000..53d31a5 --- /dev/null +++ b/tests/Dropwheel.Tests/TelegramDropServiceTests.cs @@ -0,0 +1,141 @@ +using System.IO; +using System.Windows; +using Dropwheel.Models; +using Dropwheel.Services; +using WpfDataObject = System.Windows.DataObject; +using WpfDataFormats = System.Windows.DataFormats; + +namespace Dropwheel.Tests; + +public sealed class TelegramDropServiceTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "dw_tgdrop_" + Guid.NewGuid().ToString("N")); + + public TelegramDropServiceTests() => Directory.CreateDirectory(_root); + public void Dispose() { try { Directory.Delete(_root, true); } catch (DirectoryNotFoundException) { } } + + [Theory] + [InlineData("tg://privatepost?channel=4379453334&post=1", true)] + [InlineData("tg://resolve?domain=telegram", true)] + [InlineData("https://t.me/c/4379453334/1", true)] + [InlineData("https://durov.t.me", true)] + [InlineData("https://example.com/file", false)] + [InlineData("C:\\Temp\\file.txt", false)] + public void IsTelegramTarget_detects_telegram_uri_targets(string path, bool expected) + { + var target = new TargetItem { Name = "target", Path = path }; + + Assert.Equal(expected, TelegramDropService.IsTelegramTarget(target)); + } + + [Fact] + public void IsTelegramTarget_rejects_groups() + { + var target = new TargetItem + { + Name = "group", + Path = "tg://resolve?domain=telegram", + Children = new(), + }; + + Assert.False(TelegramDropService.IsTelegramTarget(target)); + } + + [Fact] + public void CanAccept_requires_telegram_target_and_payload() + { + var data = new WpfDataObject(); + data.SetData(WpfDataFormats.UnicodeText, "hello"); + + Assert.True(TelegramDropService.CanAccept( + new TargetItem { Name = "topic", Path = "tg://privatepost?channel=4379453334&post=1" }, + data)); + Assert.False(TelegramDropService.CanAccept( + new TargetItem { Name = "web", Path = "https://example.com" }, + data)); + } + + [Fact] + public void LaunchPathFor_converts_web_topic_link_to_desktop_deep_link() + { + var target = new TargetItem { Name = "topic", Path = "https://t.me/c/4379453334/1" }; + + Assert.Equal("tg://privatepost?channel=4379453334&post=1", TelegramDropService.LaunchPathFor(target)); + } + + [Theory] + [InlineData("Telegram", true)] + [InlineData("telegramdesktop", true)] + [InlineData("Dropwheel", false)] + [InlineData(null, false)] + public void IsTelegramProcessName_matches_only_telegram_processes(string? processName, bool expected) => + Assert.Equal(expected, TelegramDropService.IsTelegramProcessName(processName)); + + [Fact] + public async Task PasteIntoTelegramWhenReady_does_not_paste_when_telegram_is_not_foreground() + { + var pasted = false; + + var result = await TelegramDropService.PasteIntoTelegramWhenReady( + TimeSpan.Zero, + TimeSpan.Zero, + () => pasted = true, + () => "Dropwheel"); + + Assert.False(result); + Assert.False(pasted); + } + + [Fact] + public async Task PasteIntoTelegramWhenReady_pastes_when_telegram_is_foreground() + { + var pasted = false; + + var result = await TelegramDropService.PasteIntoTelegramWhenReady( + TimeSpan.Zero, + TimeSpan.Zero, + () => pasted = true, + () => "Telegram"); + + Assert.True(result); + Assert.True(pasted); + } + + [Fact] + public void CreatePayload_prefers_file_drop_list_over_text() + { + var file = Path.Combine(_root, "note.txt"); + File.WriteAllText(file, "hello"); + var data = new WpfDataObject(); + data.SetData(WpfDataFormats.FileDrop, new[] { file }); + data.SetData(WpfDataFormats.UnicodeText, "fallback text"); + + var payload = TelegramDropService.CreatePayload(data, Path.Combine(_root, "staging")); + + Assert.NotNull(payload); + Assert.Equal(TelegramDropKind.Files, payload.Kind); + Assert.Equal(new[] { file }, payload.Files); + } + + [Fact] + public void CreatePayload_uses_text_when_no_files_are_present() + { + var data = new WpfDataObject(); + data.SetData(WpfDataFormats.UnicodeText, "message"); + + var payload = TelegramDropService.CreatePayload(data, Path.Combine(_root, "staging")); + + Assert.NotNull(payload); + Assert.Equal(TelegramDropKind.Text, payload.Kind); + Assert.Equal("message", payload.Text); + } + + [Fact] + public void CreatePayload_ignores_missing_file_paths() + { + var data = new WpfDataObject(); + data.SetData(WpfDataFormats.FileDrop, new[] { Path.Combine(_root, "missing.txt") }); + + Assert.Null(TelegramDropService.CreatePayload(data, Path.Combine(_root, "staging"))); + } +} diff --git a/tests/Dropwheel.Tests/TextDropServiceTests.cs b/tests/Dropwheel.Tests/TextDropServiceTests.cs index c4591bc..075505f 100644 --- a/tests/Dropwheel.Tests/TextDropServiceTests.cs +++ b/tests/Dropwheel.Tests/TextDropServiceTests.cs @@ -1,5 +1,8 @@ using System.IO; +using System.Text; using Dropwheel.Services; +using WpfDataObject = System.Windows.DataObject; +using WpfDataFormats = System.Windows.DataFormats; namespace Dropwheel.Tests; @@ -28,6 +31,75 @@ public void Hash_in_the_middle_is_not_a_heading() Assert.Equal("txt", TextDropService.ExtensionFor("issue #42 was fixed")); } + [Fact] + public void GetText_reads_string_format() + { + var data = new WpfDataObject(); + data.SetData(WpfDataFormats.StringFormat, "from editor"); + + Assert.True(TextDropService.HasText(data)); + Assert.Equal("from editor", TextDropService.GetText(data)); + } + + [Fact] + public void GetText_reads_oem_text_format() + { + var data = new WpfDataObject(); + data.SetData(WpfDataFormats.OemText, "from editor"); + + Assert.True(TextDropService.HasText(data)); + Assert.Equal("from editor", TextDropService.GetText(data)); + } + + [Fact] + public void GetText_reads_utf8_memory_stream_plain_text() + { + var data = new WpfDataObject(); + data.SetData("text/plain", new MemoryStream(Encoding.UTF8.GetBytes("stream text\0"))); + + Assert.Equal("stream text", TextDropService.GetText(data)); + } + + [Fact] + public void GetText_reads_case_variant_text_plain_byte_array() + { + var data = new TestDataObject( + "text/plain;charset=UTF-8", + Encoding.UTF8.GetBytes("byte text\0")); + + Assert.Equal("byte text", TextDropService.GetText(data)); + } + + [Fact] + public void GetText_reads_qt_wrapped_text_plain_stream() + { + var data = new WpfDataObject(); + data.SetData( + "application/x-qt-windows-mime;value=\"text/plain\"", + new MemoryStream(Encoding.UTF8.GetBytes("wrapped text\0"))); + + Assert.Equal("wrapped text", TextDropService.GetText(data)); + } + + [Fact] + public void HasPotentialText_accepts_delayed_text_format() + { + var data = new TestDataObject("text/plain;charset=UTF-8"); + + Assert.True(TextDropService.HasPotentialText(data)); + Assert.False(TextDropService.HasText(data)); + } + + [Fact] + public void GetText_reads_html_fragment_when_plain_text_is_missing() + { + const string html = "Version:0.9\r\nStartHTML:00000097\r\nEndHTML:00000165\r\nStartFragment:00000129\r\nEndFragment:00000133\r\nhi
there"; + var data = new WpfDataObject(); + data.SetData(WpfDataFormats.Html, html); + + Assert.Equal("hi\nthere", TextDropService.GetText(data)); + } + [Fact] public void Save_writes_content_with_timestamped_name() { @@ -53,4 +125,37 @@ public void Save_avoids_collisions_within_the_same_second() Assert.Equal("a", File.ReadAllText(first)); Assert.Equal("b", File.ReadAllText(second)); } + + [Fact] + public void Save_avoids_collisions_with_existing_directory() + { + var occupied = Path.Combine(_root, "text_2026-07-06_23-15-04.txt"); + Directory.CreateDirectory(occupied); + + var path = TextDropService.Save("directory collision", _root, When); + + Assert.Equal("text_2026-07-06_23-15-04 (2).txt", Path.GetFileName(path)); + Assert.True(Directory.Exists(occupied)); + Assert.Equal("directory collision", File.ReadAllText(path)); + } + + private sealed class TestDataObject(string format, object? value = null) : System.Windows.IDataObject + { + public object? GetData(string requestedFormat) => + GetDataPresent(requestedFormat) ? value : null; + + public object? GetData(Type format) => null; + public object? GetData(string requestedFormat, bool autoConvert) => GetData(requestedFormat); + public bool GetDataPresent(string requestedFormat) => + string.Equals(format, requestedFormat, StringComparison.OrdinalIgnoreCase); + + public bool GetDataPresent(Type format) => false; + public bool GetDataPresent(string format, bool autoConvert) => GetDataPresent(format); + public string[] GetFormats() => new[] { format }; + public string[] GetFormats(bool autoConvert) => GetFormats(); + public void SetData(string format, object data) => throw new NotSupportedException(); + public void SetData(Type format, object data) => throw new NotSupportedException(); + public void SetData(string format, object data, bool autoConvert) => throw new NotSupportedException(); + public void SetData(object data) => throw new NotSupportedException(); + } } diff --git a/tests/Dropwheel.Tests/WatcherServiceTests.cs b/tests/Dropwheel.Tests/WatcherServiceTests.cs index 6c8e100..4a69d5b 100644 --- a/tests/Dropwheel.Tests/WatcherServiceTests.cs +++ b/tests/Dropwheel.Tests/WatcherServiceTests.cs @@ -1,4 +1,6 @@ using System.IO; +using System.Reflection; +using System.Windows.Threading; using Dropwheel.Models; using Dropwheel.Services; @@ -48,7 +50,7 @@ public void No_match_resolves_to_own_folder_so_the_file_is_left_in_place() { Field = ConditionField.Extension, Op = CompareOp.In, Value = "jpg" } } } }, }; var folder = SortService.Plan(t, new[] { file }).Keys.Single(); - Assert.True(WatcherService.SameFolder(folder, file)); // stays in its own folder — don't move + Assert.True(WatcherService.SameFolder(folder, file)); // stays in its own folder - don't move } [Fact] @@ -63,7 +65,82 @@ public void Matching_rule_routes_into_a_subfolder_so_the_file_moves() { Field = ConditionField.Extension, Op = CompareOp.In, Value = "jpg" } } } }, }; var folder = SortService.Plan(t, new[] { file }).Keys.Single(); - Assert.False(WatcherService.SameFolder(folder, file)); // routed into a subfolder — move it + Assert.False(WatcherService.SameFolder(folder, file)); // routed into a subfolder - move it + } + + [Fact] + public void Auto_sort_skips_move_when_destination_already_has_a_conflicting_file() + { + var file = Path.Combine(_root, "a.jpg"); + var destFolder = Path.Combine(_root, "Images"); + Directory.CreateDirectory(destFolder); + File.WriteAllText(file, "source"); + File.WriteAllText(Path.Combine(destFolder, "a.jpg"), "existing"); + + var target = new TargetItem + { + Path = _root, + Rules = new() { new SortRule { Dest = "Images", All = { new RuleCondition + { Field = ConditionField.Extension, Op = CompareOp.In, Value = "jpg" } } } }, + }; + + var service = new WatcherService(Dispatcher.CurrentDispatcher, _ => { }); + InvokeSortOne(service, target, file); + + Assert.True(File.Exists(file)); + Assert.Equal("source", File.ReadAllText(file)); + Assert.Equal("existing", File.ReadAllText(Path.Combine(destFolder, "a.jpg"))); + } + + [Fact] + public async Task Wait_until_ready_returns_false_when_cancelled_before_file_is_ready() + { + var file = Path.Combine(_root, "locked.mov"); + File.WriteAllBytes(file, Array.Empty()); + + using var cts = new CancellationTokenSource(); + var waitTask = WatcherService.WaitUntilReadyAsync( + file, + _ => false, + pollMs: 1, + maxWaitTicks: 1000, + cts.Token); + + cts.CancelAfter(TimeSpan.FromMilliseconds(10)); + + Assert.False(await waitTask); + } + + [Fact] + public async Task Wait_until_ready_checks_cancellation_before_readiness() + { + var file = Path.Combine(_root, "ready.mov"); + File.WriteAllBytes(file, Array.Empty()); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var ready = await WatcherService.WaitUntilReadyAsync( + file, + _ => true, + pollMs: 1, + maxWaitTicks: 1, + cts.Token); + + Assert.False(ready); + } + + private static void InvokeSortOne(WatcherService service, TargetItem target, string file) + { + var entryType = typeof(WatcherService).GetNestedType("Entry", BindingFlags.NonPublic) + ?? throw new InvalidOperationException("WatcherService.Entry not found."); + var entry = Activator.CreateInstance(entryType, nonPublic: true) + ?? throw new InvalidOperationException("WatcherService.Entry could not be created."); + entryType.GetProperty("Target", BindingFlags.Instance | BindingFlags.Public)?.SetValue(entry, target); + + var sortOne = typeof(WatcherService).GetMethod("SortOne", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("WatcherService.SortOne not found."); + sortOne.Invoke(service, new[] { entry, file, CancellationToken.None }); } [Fact]