diff --git a/.gitattributes b/.gitattributes index 77130dc..d65fbf8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,6 +12,11 @@ install.js text eol=lf *.yml text eol=lf *.yaml text eol=lf *.json text eol=lf +*.md text eol=lf +*.cmd text eol=lf +.gitignore text eol=lf +.gitattributes text eol=lf +.github/CODEOWNERS text eol=lf # Default: normalize text, let git decide line endings on checkout for the rest. * text=auto diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48fb4aa..0ecdc2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: pull_request: jobs: - build: + verify: runs-on: windows-latest steps: - uses: actions/checkout@v5 diff --git a/hooks/_lib.js b/hooks/_lib.js index 02d4b5d..e5684c5 100644 --- a/hooks/_lib.js +++ b/hooks/_lib.js @@ -153,14 +153,15 @@ function interpreterProtectedHint(rawCmd, protectedList) { return null; } -// ---------- changed files (branch diff) ---------- +// ---------- changed files (branch/worktree diff) ---------- // Изменённые файлы ветки относительно базы. Возвращает {files, base} при успешном // diff (пусть даже ПУСТОМ) или {error} если ни одна база не доступна — это РАЗНЫЕ // исходы: пустой diff = «изменений нет», ошибка = «не смогли проверить». Молчаливый // fail-open при ошибке означал бы, что в репо без ожидаемой базы гейт/фильтр просто // никогда не работает. explicitFiles (тесты/CI) возвращается как есть, без git. -// Общий источник для design-gate.js (гейт) и verify.js (--changed фильтр стеков). -function changedFiles(base, root, explicitFiles) { +// По умолчанию это branch-only контракт для CI/design-gate. Локальный verify может +// явно добавить dirty/staged/untracked файлы через includeDirty. +function changedFiles(base, root, explicitFiles, opts = {}) { if (explicitFiles) return { files: explicitFiles }; const remoteFirst = /^origin\//.test(String(base || "")); const fallbacks = remoteFirst @@ -171,17 +172,59 @@ function changedFiles(base, root, explicitFiles) { 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" }); - return { files: out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean), base: b }; + const branchFiles = parseFiles(out); + return { files: opts.includeDirty ? mergeFiles(branchFiles, dirtyFiles(root)) : branchFiles, base: b }; } catch {} } } return { error: `git diff не удался ни для одной базы (${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 gitFiles(root, args) { + try { + return parseFiles(execFileSync("git", args, { + cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + timeout: 5000, killSignal: "SIGKILL", + })); + } catch { + return []; + } +} + +function dirtyFiles(root) { + return mergeFiles( + gitFiles(root, ["diff", "--name-only"]), + gitFiles(root, ["diff", "--name-only", "--cached"]), + gitFiles(root, ["ls-files", "--others", "--exclude-standard"]) + ); +} + +function mergeFiles(...lists) { + const out = []; + const seen = new Set(); + for (const list of lists) { + for (const f of list || []) { + const rel = String(f).replace(/\\/g, "/"); + if (!rel || seen.has(rel)) continue; + seen.add(rel); + out.push(rel); + } + } + return out; +} + module.exports = { DEFAULT_UI_GLOBS, DEFAULT_MOCKUPS, DEFAULT_PROTECTED, DEFAULT_LINT_CONFIGS, globToRe, loadConfig, normRel, isProtectedPath, isProtectedShellWrite, isLintConfigShellWrite, isLintConfigPath, interpreterProtectedHint, - changedFiles, + changedFiles, workingTreeChangedFiles, }; diff --git a/hooks/agent/stop-reminder.js b/hooks/agent/stop-reminder.js index 205d4f9..0d76741 100644 --- a/hooks/agent/stop-reminder.js +++ b/hooks/agent/stop-reminder.js @@ -64,7 +64,8 @@ function explainedIntentionalDirty(text) { const mentionsDirty = /dirty tree|uncommitted|незакоммич|некоммич|рабоч(ем|ее) дерев|оставш/.test(s); const intentional = /intentional|intentionally|намеренн|осознанн|не трогал|не трогала|оставил|оставила|оставлены/.test(s); const reportsLoop = /verify|проверен|проверено|self-review|diff|commit|коммит|report|отч[её]т/.test(s); - return mentionsDirty && intentional && reportsLoop; + const reviewOnly = /review-only|только review|только ревью|повторн(ый|ое) review|повторн(ый|ое) ревью|изменения я не правил|изменения не правил|не коммитил|commit\/pr не делал|коммит не делал/.test(s); + return (mentionsDirty && intentional && reportsLoop) || (reviewOnly && reportsLoop); } function isHarnessOrLocalStatus(line) { const p = line.replace(/\\/g, "/").replace(/^\S\S\s+/, ""); @@ -115,7 +116,7 @@ function isHarnessOrLocalStatus(line) { " 4. VERIFY (node hooks/verify.js + git diff review) -> 5. COMMIT на feature-ветке -> 6. REPORT.\n" + "Коммит не всегда нужен: можно явно отчитаться, почему изменения остаются uncommitted.\n" + harnessNote + - "git status:\n" + shown; + "git status (первые строки):\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/design-gate.js b/hooks/design-gate.js index e4e3283..2465c28 100644 --- a/hooks/design-gate.js +++ b/hooks/design-gate.js @@ -6,10 +6,11 @@ // otherwise one old approval would open the gate for all future UI work forever. // // Usage: -// node hooks/design-gate.js [--base ] [--root ] [--files a,b,c] [--json] +// node hooks/design-gate.js [--base ] [--root ] [--files a,b,c] [--json] [--strict] // --base git ref to diff against (default: origin/main if available) [CI/local] // --files explicit comma-separated changed files [tests/CI] // --root repo root to resolve config + mockups (default: cwd) +// --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. @@ -20,12 +21,13 @@ const { execFileSync } = require("child_process"); // ---------- args ---------- function parseArgs(argv) { - const a = { base: null, root: process.cwd(), files: null, json: false }; + const a = { base: null, root: process.cwd(), files: null, json: false, strict: false }; for (let i = 0; i < argv.length; i++) { if (argv[i] === "--base") a.base = argv[++i]; else if (argv[i] === "--root") a.root = argv[++i]; else if (argv[i] === "--files") a.files = (argv[++i] || "").split(",").map((s) => s.trim()).filter(Boolean); else if (argv[i] === "--json") a.json = true; + else if (argv[i] === "--strict") a.strict = true; } return a; } @@ -97,11 +99,11 @@ function hasApprovedMockups(root, m, changed) { const cf = changedFiles(a.base, a.root, a.files); if (cf.base) res.base = cf.base; if (cf.error) { - // fail-open, но ГРОМКО: молчаливый пропуск = гейта нет. - const warn = `⚠️ design-gate: ${cf.error} — гейт ПРОПУЩЕН, UI-изменения не проверены. Укажи базу явно: --base .`; - if (a.json) console.log(JSON.stringify({ ...res, skipped: true, warn })); + // local default = fail-open, но ГРОМКО; strict/CI = fail-closed. + const warn = `⚠️ design-gate: ${cf.error} — ${a.strict ? "гейт НЕ МОЖЕТ ПРОВЕРИТЬ UI-изменения" : "гейт ПРОПУЩЕН, UI-изменения не проверены"}. Укажи базу явно: --base .`; + if (a.json) console.log(JSON.stringify({ ...res, ok: !a.strict, skipped: true, warn })); else console.error(warn); - process.exit(0); + process.exit(a.strict ? 1 : 0); } const files = cf.files.map((f) => f.replace(/\\/g, "/")); diff --git a/hooks/doctor.js b/hooks/doctor.js index 35730f5..2c17fa2 100644 --- a/hooks/doctor.js +++ b/hooks/doctor.js @@ -19,6 +19,36 @@ function git(args) { return execFileSync("git", args, { cwd: ROOT, encoding: "ut 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 checkTextFile(rel) { + const p = path.join(ROOT, rel); + let buf; + try { buf = fs.readFileSync(p); } catch { fail(rel + " отсутствует"); 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"); +} +function workflowJobIds(rel) { + const text = readText(rel); + const lines = text.split(/\r?\n/); + const ids = []; + let inJobs = false; + 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]); + } + return ids; +} +function rulesetRequiredChecks(rel) { + let ruleset = {}; + try { ruleset = JSON.parse(readText(rel)); } catch { return []; } + const rsc = (ruleset.rules || []).find((r) => r.type === "required_status_checks"); + return (((rsc || {}).parameters || {}).required_status_checks || []).map((c) => c.context).filter(Boolean); +} // node / git ok("node " + process.version); @@ -84,6 +114,7 @@ const requiredHarnessFiles = [ "hooks/design-gate.js", "hooks/new-mockups.js", "hooks/doctor.js", + "hooks/apply-ruleset.js", "hooks/_lib.js", "hooks/branch-guard.js", "hooks/no-coauthor.js", @@ -96,6 +127,7 @@ const requiredHarnessFiles = [ ".gitleaks.toml", "AGENTS.md", "settings.example.json", + ".github/rulesets/main.json", ]; const missingHarness = []; const untrackedHarness = []; @@ -113,16 +145,20 @@ if (missingHarness.length || untrackedHarness.length) { ok("harness bootstrap files present and tracked"); } -// config files present, LF, no NUL -for (const f of ["lefthook.yml", "cog.toml", ".gitleaks.toml"]) { - const p = path.join(ROOT, f); - let buf; - try { buf = fs.readFileSync(p); } catch { fail(f + " отсутствует"); continue; } - const nl = buf.indexOf(10); - if (buf.includes(0)) fail(f + " содержит NUL-байты"); - else if (buf.slice(0, nl >= 0 ? nl : buf.length).includes(13)) fail(f + ": CRLF (нужен LF)"); - else ok(f + ": LF, без NUL"); -} +// Critical harness files must be portable across Windows/macOS/Linux checkouts. +const textCritical = requiredHarnessFiles.concat([ + ".gitattributes", + ".gitignore", + "README.md", + "CLAUDE.md", + "BACKLOG.md", + ".github/workflows/ci.yml", + ".github/CODEOWNERS", + ".github/dependabot.yml", + "install.cmd", + "install.sh", +]).filter((f, i, a) => a.indexOf(f) === i && fs.existsSync(path.join(ROOT, f))); +for (const f of textCritical) checkTextFile(f); // harness.config.json valid JSON const cfgPath = path.join(ROOT, "harness.config.json"); @@ -139,6 +175,16 @@ if (fs.existsSync(cogPath)) { /tag_prefix\s*=\s*"v"/.test(cog) ? ok("cog.toml: tag_prefix=\"v\"") : fail("cog.toml: нужен tag_prefix=\"v\""); } +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))) { + 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"); +} + // report const fails = results.filter((r) => r.level === "FAIL").length; if (process.argv.includes("--json")) { diff --git a/hooks/verify.js b/hooks/verify.js index 034ddbe..d40b8a1 100644 --- a/hooks/verify.js +++ b/hooks/verify.js @@ -16,6 +16,7 @@ // --changed verify only stacks whose dir is touched in the branch diff (faster inner loop) // --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 // --changed fail-safe: if the diff can't be computed, verify ALL stacks (loud warn), // never silently skip verification; empty diff = nothing to verify. // @@ -35,9 +36,10 @@ // ИЛИ debug-аудит нашёл hard-находку. const fs = require("fs"); +const os = require("os"); const path = require("path"); const { spawnSync } = require("child_process"); -const { changedFiles, globToRe } = require(path.join(__dirname, "_lib.js")); +const { workingTreeChangedFiles, globToRe } = require(path.join(__dirname, "_lib.js")); // Файл `file` лежит под каталогом стека `rel`? Корневой стек (rel ".") владеет всем // деревом → матчит любой изменённый файл; глубокий стек — только свой подкаталог. @@ -108,7 +110,7 @@ function scanFileForDebug(abs, rel, soft) { // массово легитимны и дали бы шум. → { hard:[], soft:[], skipped:<причина>|null }. function debugAudit(root, opts, base, explicitFiles) { if (!opts.enabled) return { hard: [], soft: [], skipped: "отключён в harness.config.json" }; - const cf = changedFiles(base || opts.base, root, explicitFiles); + 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 = []; @@ -128,6 +130,9 @@ const DEFAULT_STACKS = [ { 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" }, @@ -147,10 +152,15 @@ const DEFAULT_STACKS = [ const SKIP_DIRS = new Set([".git", "node_modules", "target", "bin", "obj", "dist", "build", ".venv", "venv", "__pycache__", ".next", ".idea", ".vscode"]); const MAX_DEPTH = 6; +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 }; + const a = { root: process.cwd(), stack: null, list: false, json: false, changed: false, base: "main", files: null, checkHarnessSyntax: 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]; @@ -159,6 +169,7 @@ function parseArgs(argv) { else if (argv[i] === "--changed") a.changed = true; 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; } return a; } @@ -176,6 +187,62 @@ function loadStacks(root) { } } +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) { + 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()) walk(p); + else if (e.isFile() && e.name.endsWith(".js")) out.push(p); + } + } + walk(path.join(root, "hooks")); + for (const rel of ["install.js"]) { + const p = path.join(root, rel); + try { if (fs.statSync(p).isFile()) out.push(p); } catch {} + } + return out; +} + +function checkHarnessSyntax(root) { + const files = listHarnessJs(root); + let failed = false; + for (const file of files) { + const r = spawnSync(process.execPath, ["--check", file], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30 * 1000, killSignal: "SIGKILL" }); + if (r.status !== 0 || r.error) { + failed = true; + const rel = path.relative(root, file) || file; + process.stderr.write(`syntax failed: ${rel}\n`); + if (r.stdout) process.stdout.write(r.stdout); + if (r.stderr) process.stderr.write(r.stderr); + if (r.error) process.stderr.write(String(r.error.message || r.error) + "\n"); + } + } + if (!files.length) console.log("harness syntax: no JS files found"); + else console.log(`harness syntax: checked ${files.length} JS file(s)`); + process.exit(failed ? 1 : 0); +} + // ---------- filename glob (within a directory) ---------- function fnMatch(pattern, name) { if (pattern === name) return true; @@ -204,25 +271,88 @@ function detect(root, stacks) { } // ---------- run ---------- +function stepTimeoutMs(step) { + const raw = step.timeoutMs !== undefined ? step.timeoutMs : step.timeout; + if (raw === undefined) return DEFAULT_STEP_TIMEOUT_MS; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? n : DEFAULT_STEP_TIMEOUT_MS; +} +function stepCwd(step, cwd) { + if (!step.cwdRel) return cwd; + return path.resolve(cwd, step.cwdRel); +} function runStep(step, cwd) { - const r = spawnSync(step.run, { cwd, shell: true, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); - if (r.error) return { ok: false, code: -1, notFound: r.error.code === "ENOENT" }; - return { ok: r.status === 0, code: r.status, stdout: String(r.stdout || ""), stderr: String(r.stderr || "") }; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "harness-verify-step-")); + const stdoutPath = path.join(dir, "stdout.log"); + const stderrPath = path.join(dir, "stderr.log"); + const outFd = fs.openSync(stdoutPath, "w"); + const errFd = fs.openSync(stderrPath, "w"); + let r; + try { + r = spawnSync(step.run, { cwd: stepCwd(step, cwd), shell: true, stdio: ["ignore", outFd, errFd], timeout: stepTimeoutMs(step), killSignal: "SIGKILL" }); + } finally { + try { fs.closeSync(outFd); } catch {} + try { fs.closeSync(errFd); } catch {} + } + if (r.error) return { ok: false, code: -1, notFound: r.error.code === "ENOENT", timedOut: r.error.code === "ETIMEDOUT", stdoutPath, stderrPath, cleanup: () => cleanupStepOutput(dir) }; + return { ok: r.status === 0, code: r.status, stdoutPath, stderrPath, cleanup: () => cleanupStepOutput(dir) }; } function emitStepOutput(res) { - if (res.stdout) process.stdout.write(res.stdout); - if (res.stderr) process.stderr.write(res.stderr); + for (const [file, stream] of [[res.stdoutPath, process.stdout], [res.stderrPath, process.stderr]]) { + emitOutputFile(file, stream); + } +} +function emitOutputFile(file, stream) { + if (!file) return; + let fd; + try { + const st = fs.statSync(file); + if (!st.isFile() || st.size === 0) return; + fd = fs.openSync(file, "r"); + const buf = Buffer.alloc(64 * 1024); + for (;;) { + const n = fs.readSync(fd, buf, 0, buf.length, null); + if (!n) break; + stream.write(buf.subarray(0, n)); + } + } catch { + } finally { + if (fd !== undefined) try { fs.closeSync(fd); } catch {} + } } function diagnosticExcerpt(res, maxLines = 8) { - const text = [res.stderr, res.stdout].filter(Boolean).join("\n").trim(); + const text = [res.stderrPath, res.stdoutPath].map((file) => readSmallOutputFile(file)).filter(Boolean).join("\n").trim(); if (!text) return ""; const lines = text.split(/\r?\n/).map((s) => s.trimEnd()).filter((s) => s.trim()).slice(0, maxLines); return lines.join("\n"); } +function readSmallOutputFile(file, maxBytes = 64 * 1024) { + if (!file) return ""; + try { + const st = fs.statSync(file); + if (!st.isFile() || st.size === 0) return ""; + if (st.size <= maxBytes) return fs.readFileSync(file, "utf8"); + const start = Math.max(0, st.size - maxBytes); + const fd = fs.openSync(file, "r"); + try { + const buf = Buffer.alloc(st.size - start); + fs.readSync(fd, buf, 0, buf.length, start); + return (start > 0 ? "[output truncated]\n" : "") + buf.toString("utf8"); + } finally { + fs.closeSync(fd); + } + } catch { + return ""; + } +} +function cleanupStepOutput(dir) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} +} // ---------- main ---------- (function main() { const a = parseArgs(process.argv.slice(2)); + if (a.checkHarnessSyntax) checkHarnessSyntax(a.root); let { stacks, failFast, explicit } = loadStacks(a.root); if (a.stack) stacks = stacks.filter((s) => s.id === a.stack); @@ -231,12 +361,14 @@ function diagnosticExcerpt(res, maxLines = 8) { // --changed: сузить до стеков, чьи каталоги затронуты в diff ветки. Fail-safe — // при ошибке diff проверяем ВСЕ стеки (громкий warn), а не молча пропускаем. if (a.changed) { - const cf = changedFiles(a.base, a.root, a.files); + 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); } } @@ -283,34 +415,44 @@ function diagnosticExcerpt(res, maxLines = 8) { const label = `${t.stack.id}/${step.name} @ ${t.rel}`; console.log(`\n▶ ${label}: ${step.run}`); const res = runStep(step, t.dir); - if (res.ok) { emitStepOutput(res); summary.push(`✓ ${label}`); continue; } - if (res.notFound || res.code === 9009 || res.code === 127) { + try { + if (res.ok) { emitStepOutput(res); summary.push(`✓ ${label}`); continue; } + if (res.timedOut) { + summary.push(`✗ ${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 (step.optional) { + warnings.push(`${label}: optional tool not found — step skipped`); + summary.push(`⚠ ${label} (инструмент не найден — пропущено)`); + continue; + } + summary.push(`✗ ${label} (инструмент не найден)`); + emitStepOutput(res); + failed = `${label}: команда не найдена — установи инструмент или переопредели шаг в 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) { - warnings.push(`${label}: optional tool not found — step skipped`); - summary.push(`⚠ ${label} (инструмент не найден — пропущено)`); + 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})`); continue; } - summary.push(`✗ ${label} (инструмент не найден)`); + summary.push(`✗ ${label} (exit ${res.code})`); emitStepOutput(res); - failed = `${label}: команда не найдена — установи инструмент или переопредели шаг в 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})`); - continue; + failed = `${label}: exit ${res.code}`; + if (failFast) break outer; + } finally { + if (res.cleanup) res.cleanup(); } - summary.push(`✗ ${label} (exit ${res.code})`); - emitStepOutput(res); - failed = `${label}: exit ${res.code}`; - if (failFast) break outer; } }