Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
pull_request:

jobs:
build:
verify:
runs-on: windows-latest
steps:
- uses: actions/checkout@v5
Expand Down
53 changes: 48 additions & 5 deletions hooks/_lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
};
5 changes: 3 additions & 2 deletions hooks/agent/stop-reminder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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+/, "");
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 8 additions & 6 deletions hooks/design-gate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ref>] [--root <dir>] [--files a,b,c] [--json]
// node hooks/design-gate.js [--base <ref>] [--root <dir>] [--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.
Expand All @@ -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;
}
Expand Down Expand Up @@ -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 <ref>.`;
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 <ref>.`;
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, "/"));

Expand Down
66 changes: 56 additions & 10 deletions hooks/doctor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand All @@ -96,6 +127,7 @@ const requiredHarnessFiles = [
".gitleaks.toml",
"AGENTS.md",
"settings.example.json",
".github/rulesets/main.json",
];
const missingHarness = [];
const untrackedHarness = [];
Expand All @@ -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");
Expand All @@ -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")) {
Expand Down
Loading