diff --git a/.ai/conventions/engineering.md b/.ai/conventions/engineering.md index d9d197b..aa0cafa 100644 --- a/.ai/conventions/engineering.md +++ b/.ai/conventions/engineering.md @@ -71,6 +71,7 @@ Peer-конфликт решай совместимыми версиями бе | Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. | | Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. | | Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. | +| Pre-commit | Hook не скачивает tools: вызывай установленные бинарники из `node_modules/.bin` (не `npx`). Full graph не входит в hook; он выполняется в `npm run verify` и CI. | Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши проверенные входы, команду, exit code, результат и ограничения. Повторяй diff --git a/.husky/pre-commit b/.husky/pre-commit index 73788eb..82496ba 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +1,6 @@ gitleaks git --pre-commit --staged --redact=100 --no-banner || exit $? -npx lint-staged -npm run deps +if [ ! -x node_modules/.bin/lint-staged ]; then + echo "lint-staged is not installed; run npm ci" >&2 + exit 1 +fi +node_modules/.bin/lint-staged diff --git a/README.md b/README.md index 6ddefbf..1aa7cfd 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,10 @@ npm run dev Для commits установите [Gitleaks](https://github.com/gitleaks/gitleaks/releases/tag/v8.30.1) 8.30.1 в `PATH` (macOS: `brew install gitleaks`). Pre-commit сначала проверяет -staged diff с полной редакцией секретов, затем запускает formatting и FSD checks. -Находка или отсутствие Gitleaks блокирует commit. +staged diff с полной редакцией секретов, затем запускает установленный +`node_modules/.bin/lint-staged` только для staged JS/TS. Hook не скачивает tools и +не запускает full graph: полный `npm run verify` выполняется в CI и перед передачей. +Находка или отсутствие Gitleaks либо lint-staged блокирует commit. Проверка этого поведения: `node --test scripts/test-pre-commit.mjs`. ## Контейнерный image diff --git a/scripts/test-pre-commit.mjs b/scripts/test-pre-commit.mjs index c2246d0..3d74f8b 100644 --- a/scripts/test-pre-commit.mjs +++ b/scripts/test-pre-commit.mjs @@ -47,6 +47,12 @@ test("pre-commit scans the index, redacts findings and fails closed", () => { } git("init", "--quiet"); + // The hook must run the installed lint-staged binary, never download one. + const localBin = nodePath.join(cwd, "node_modules", ".bin"); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. + mkdirSync(localBin, { recursive: true }); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. + writeFileSync(nodePath.join(localBin, "lint-staged"), "#!/bin/sh\necho ran >> downstream\n", { mode: 0o755 }); // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. writeFileSync(nodePath.join(cwd, "sample.txt"), "safe staged content\n"); git("add", "sample.txt"); @@ -77,3 +83,37 @@ test("pre-commit scans the index, redacts findings and fails closed", () => { rmSync(cwd, { force: true, recursive: true }); } }); + +test("pre-commit never downloads tools and blocks when lint-staged is missing", () => { + const hook = nodePath.resolve(".husky/pre-commit"); + const cwd = mkdtempSync(nodePath.join(tmpdir(), "frontend-hook-")); + const bin = nodePath.join(cwd, "bin"); + const marker = nodePath.join(cwd, "downstream"); + + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. + mkdirSync(bin); + // Gitleaks passes; npx/npm must never be reached, even if present on PATH. + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. + writeFileSync(nodePath.join(bin, "gitleaks"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + + for (const command of ["npx", "npm"]) { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. + writeFileSync(nodePath.join(bin, command), "#!/bin/sh\necho ran >> downstream\n", { mode: 0o755 }); + } + + const result = spawnSync("/bin/sh", ["-e", hook], { + cwd, + encoding: "utf8", + env: { ...process.env, PATH: `${bin}${nodePath.delimiter}${process.env.PATH}` }, + }); + + assert.notEqual(result.status, 0, "missing lint-staged must block commit"); + assert.match(result.stderr, /lint-staged is not installed/); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. + assert.ok(!existsSync(marker), "hook must not invoke npx or npm"); + } + finally { + rmSync(cwd, { force: true, recursive: true }); + } +});