HookStack
Back to catalogue
ValidationPreToolUse· Bash

Enforce build wrapper

Forces ./gradlew / ./mvnw, kills bare-tool drift

Blocks bare gradle/mvn commands when the project ships a wrapper (gradlew/mvnw), keeping the build toolchain version pinned for every contributor and agent.

What does the Enforce build wrapper hook do?

Enforce build wrapper is a Claude Code PreToolUse hook matching Bash. It fires automatically at that lifecycle event — outside the model, so it can't be skipped or forgotten. Forces ./gradlew / ./mvnw, kills bare-tool drift.

As a PreToolUse hook it runs before the action completes, so it can block or adjust what Claude is about to do. Because it is a deterministic Node.js script, it executes on every matching event without relying on the model to remember — the guarantee that makes agentic workflows safe to automate.

Use cases

  • Pin the Gradle/Maven version via the wrapper
  • Avoid toolchain drift between machines
  • Standardize build invocation across a team

Tags

#java#gradle#maven#wrapper#build-tool#validation#workflow

settings.json fragment

{
  "hooks": {
    "PreToolUse": [
      {
        "hooks": [
          {
            "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/enforce-java-build.mjs",
            "type": "command"
          }
        ],
        "matcher": "Bash"
      }
    ]
  }
}

Script · .claude/hooks/enforce-java-build.mjs

#!/usr/bin/env node
// @hookstack pre-bash-enforce-java-build
// Bloque `gradle`/`mvn` nus quand le wrapper (gradlew/mvnw) existe — le wrapper
// épingle la version du toolchain pour toute l'équipe (PreToolUse Bash).
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";

const FORBIDDEN = [
	{ re: /(^|[;&|\s`])gradle(\s|$)/, tool: "gradle", wrapper: "gradlew" },
	{ re: /(^|[;&|\s`])mvn(\s|$)/, tool: "mvn", wrapper: "mvnw" },
];

// Retire le contenu des chaînes entre guillemets pour éviter les faux positifs
// quand gradle/mvn apparaissent comme valeurs d'arguments texte (ex. git commit -m
// "...gradle...") tout en continuant à bloquer les vraies invocations.
function stripQuotedArgs(cmd) {
	return cmd
		.replace(/"(?:[^"\\]|\\.)*"/g, '""')
		.replace(/'(?:[^'\\]|\\.)*'/g, "''");
}

export function run(
	input,
	{ exists = existsSync, projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd() } = {},
) {
	if (input.tool_name !== "Bash") return null;
	const cmd = stripQuotedArgs(input.tool_input?.command ?? "");

	const hit = FORBIDDEN.find(({ re }) => re.test(cmd));
	if (!hit) return null;

	// On n'impose le wrapper que s'il existe réellement dans le projet.
	if (!exists(join(projectDir, hit.wrapper))) return null;

	return {
		decision: "block",
		reason: `Use './${hit.wrapper}' instead of the bare '${hit.tool}' — the wrapper pins this project's ${hit.tool === "gradle" ? "Gradle" : "Maven"} version.`,
	};
}

/* v8 ignore next 5 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
	const input = JSON.parse(readFileSync(0, "utf8"));
	const result = run(input);
	if (result) process.stdout.write(JSON.stringify(result));
}

Learn more

Related hooks