Revision control
Copy as Markdown
#!/bin/sh
# vale-pre-commit — lint only the changed column ranges in staged QDoc-aware
# source files.
#
# For .qdoc / .qdocinc / .qml files: runs Vale directly on the file.
# For .cpp / .h / .cc / .cxx files: extracts /*!...*/ doc-comment blocks that
# contain changed lines, lints those blocks via a temp .qdoc file (with
# blank-line padding to preserve original line numbers), and reports issues
# only in changed column ranges.
#
# Single-line changes are filtered at the column level: a pre-existing warning
# at the start of a line is not reported if only the end of that line changed.
# Multi-line hunks and pure additions treat all columns as new.
#
# Exit codes and severity:
#
# error, warning -> reported, exit 1 (commit blocked)
# suggestion -> reported, exit 0 (commit proceeds)
#
# Suggestions are advisory by the style's own choice of `level:`, so they are
# shown under a separate heading and never block. Vale itself is invoked with
# --output=JSON, not =line, because the line format omits severity and the
# hook cannot make this split without it. Reported alerts are printed as:
#
# <path>:<line>:<col>:<severity>:<Style.Rule>:<message>
#
# If Vale fails to run at all (unloadable config, missing StylesPath), the
# hook prints the reason and exits 0. A broken linter must not block commits.
#
# Requires a QDoc-aware Vale build: an upstream vale-cli/vale release, a
# veshivas/vale fork release, or a build of vale-cli/vale branch v3. See
# skill-vale-qdoc-lint for how to obtain and verify one. A stock Vale does
# not understand .qdoc/.qdocinc files and produces incorrect results.
#
# Requires `vale sync` to have been run against the config:
#
# vale --config=<vale_linter_config>/.vale-qdoc.ini sync
#
# Vale runs here with --no-global so that only .vale-qdoc.ini governs the
# result and a developer's personal config cannot gate other people's
# commits. That also drops the machine-global StylesPath from the search,
# so every style the config names must be present under
# vale_linter_config/styles/.
#
# styles/Microsoft/ is a sync download and is not committed. Without sync,
# the hook does NOT fail: because rules are enabled individually below
# (Style.Rule = YES) rather than through BasedOnStyles, every unresolvable
# Microsoft.* rule is skipped in silence. The hook still exits 0/1 on the
# Qt.* rules alone, so a fresh clone under-lints with no visible warning.
# Check the download is present before trusting a clean run:
#
# ls <vale_linter_config>/styles/Microsoft/ >/dev/null || echo 'run sync'
#
# `ls-config` does not answer this — it echoes the rules named in the ini
# whether or not their .yml files resolve, so it reports the same count
# either way.
#
# Installation (run once from the repository root):
#
# ln -sf ../../vale_linter_config/vale-pre-commit .git/hooks/pre-commit-vale
#
# To chain with the existing clang-format pre-commit hook, add this line at
# the top of .git/hooks/pre-commit (before any early exits):
#
# "$(git rev-parse --show-toplevel)/vale_linter_config/vale-pre-commit" || exit 1
#
# Environment variables (all optional if ~/.vale-qdoc-agent.json is present):
# VALE_QDOC_BIN Path to a Vale build with QDoc parser support.
# VALE_CONFIG_PATH Path to the .vale-qdoc.ini config file.
#
# Resolution order for each value:
# 1. ~/.vale-qdoc-agent.json (vale_qdoc_bin / vale_config_path fields)
# 2. Environment variable (VALE_QDOC_BIN / VALE_CONFIG_PATH)
# 3. Error — warn and exit 1
REPO_ROOT=$(git rev-parse --show-toplevel)
if command -v cygpath >/dev/null 2>&1; then
REPO_ROOT=$(cygpath -w "$REPO_ROOT")
fi
# Read fields from ~/.vale-qdoc-agent.json if python3 is available
_JSON_BIN=""
_JSON_CONFIG=""
if command -v python3 >/dev/null 2>&1; then
_JSON_BIN=$(python3 -c "
import json, os
try:
d = json.load(open(os.path.expanduser('~/.vale-qdoc-agent.json')))
print(d.get('vale_qdoc_bin', ''))
except Exception:
pass
" 2>/dev/null)
_JSON_CONFIG=$(python3 -c "
import json, os
try:
d = json.load(open(os.path.expanduser('~/.vale-qdoc-agent.json')))
print(d.get('vale_config_path', ''))
except Exception:
pass
" 2>/dev/null)
fi
# Resolve Vale binary
if [ -n "$_JSON_BIN" ] && [ -x "$_JSON_BIN" ]; then
VALE_BIN="$_JSON_BIN"
elif [ -n "$VALE_QDOC_BIN" ] && [ -x "$VALE_QDOC_BIN" ]; then
VALE_BIN="$VALE_QDOC_BIN"
else
VALE_BIN=$(command -v vale 2>/dev/null)
fi
if [ ! -x "$VALE_BIN" ]; then
printf 'vale-pre-commit: Vale binary not found.\n' >&2
printf ' Set VALE_QDOC_BIN or add vale_qdoc_bin to ~/.vale-qdoc-agent.json.\n' >&2
exit 1
fi
# Resolve config path
if [ -n "$_JSON_CONFIG" ] && [ -f "$_JSON_CONFIG" ]; then
VALE_CONFIG="$_JSON_CONFIG"
elif [ -n "$VALE_CONFIG_PATH" ] && [ -f "$VALE_CONFIG_PATH" ]; then
VALE_CONFIG="$VALE_CONFIG_PATH"
else
printf 'vale-pre-commit: Config not found.\n' >&2
printf ' Set VALE_CONFIG_PATH or add vale_config_path to ~/.vale-qdoc-agent.json.\n' >&2
exit 1
fi
QDOC_STAGED=$(git diff --cached --name-only --diff-filter=ACM \
| grep -E '\.(qdoc|qdocinc|qml)$')
CPP_STAGED=$(git diff --cached --name-only --diff-filter=ACM \
| grep -E '\.(cpp|h|cc|cxx)$')
[ -z "$QDOC_STAGED" ] && [ -z "$CPP_STAGED" ] && exit 0
# Iterate and join by newline only, and disable globbing, so staged
# filenames containing spaces or glob metacharacters (e.g. "release
# notes.qdoc") are handled as single, correct paths instead of being
# word-split or glob-expanded — both here and where $QDOC_ABS/$CPP_ABS
# are expanded below. Restored once that expansion is done.
_OLD_IFS=$IFS
IFS='
'
set -f
QDOC_ABS=""
for f in $QDOC_STAGED; do QDOC_ABS="$QDOC_ABS
$REPO_ROOT/$f"; done
CPP_ABS=""
for f in $CPP_STAGED; do CPP_ABS="$CPP_ABS
$REPO_ROOT/$f"; done
PY_SCRIPT=$(mktemp)
trap 'rm -f "$PY_SCRIPT"' EXIT INT TERM
cat > "$PY_SCRIPT" << 'PYEOF'
"""
vale-pre-commit helper.
Usage:
python3 script.py repo_root vale_bin vale_config [qdoc_files...] -- [cpp_files...]
"""
import sys, os, re, json, subprocess, tempfile, pathlib
class ValeError(Exception):
"""Vale itself failed — distinct from Vale reporting lint alerts."""
# ── Diff parsing ──────────────────────────────────────────────────────────────
def get_changed_regions(filepath):
"""
Return a dict mapping line_number (1-indexed) -> (col_start, col_end).
Uses --word-diff=porcelain so that each output line ending in ~ corresponds
to one new-file line. A line is recorded as (1, None) — all columns — when
it contains at least one added word token (a line starting with '+' that is
not the diff file header). Pure deletions produce no new-file line and are
ignored.
"""
result = subprocess.run(
['git', 'diff', '--cached', '--word-diff=porcelain', '-U0', '--', filepath],
capture_output=True, text=True
)
regions = {}
current_line = None
line_has_change = False
for line in result.stdout.splitlines():
if line.startswith('@@'):
m = re.search(r'\+(\d+)(?:,(\d+))?', line)
if m:
count = int(m.group(2)) if m.group(2) is not None else 1
current_line = int(m.group(1)) if count > 0 else None
else:
current_line = None
line_has_change = False
elif line == '~':
if current_line is not None and line_has_change:
regions[current_line] = (1, None)
if current_line is not None:
current_line += 1
line_has_change = False
elif line.startswith('+') and not line.startswith('+++'):
line_has_change = True
return regions
def in_changed_region(lineno, colno, regions):
if lineno not in regions:
return False
col_start, col_end = regions[lineno]
if col_end is not None and col_start > col_end:
return False # empty range
return colno >= col_start and (col_end is None or colno <= col_end)
# ── Vale output parsing ───────────────────────────────────────────────────────
# Severities that block the commit. Vale emits these lowercase in JSON.
BLOCKING = ('error', 'warning')
def run_vale(vale_bin, vale_config, files):
"""
Run Vale and return {path_as_passed: [alert_dict, ...]}.
--output=JSON rather than =line because the hook has to tell a warning
from a suggestion, and the line format omits severity entirely.
--no-global keeps the machine-global config out of the merge. Without
it, --config selects vale_config but Vale still layers the global
config underneath (macOS: ~/Library/Application Support/vale/.vale.ini),
so a developer's personal rules would gate other people's commits.
"""
if not files:
return {}
result = subprocess.run(
[vale_bin, '--no-global', '--config', vale_config,
'--output=JSON'] + list(files),
capture_output=True, text=True
)
try:
data = json.loads(result.stdout)
except ValueError:
# A config Vale cannot load is reported as a JSON error object on
# stderr, leaving stdout empty. Pull out its Text field rather than
# echoing the whole object at the user.
raise ValeError(vale_error_text(result.stderr, result.stdout))
if not isinstance(data, dict):
return {}
# An error object is also a dict; alert reports map path -> list.
if data and not any(isinstance(v, list) for v in data.values()):
raise ValeError(vale_error_text(result.stdout, result.stderr))
return data
def vale_error_text(*streams):
"""Best-effort human-readable message from Vale's error output."""
for stream in streams:
text = (stream or '').strip()
if not text:
continue
try:
obj = json.loads(text)
except ValueError:
return text.splitlines()[0]
if isinstance(obj, dict) and obj.get('Text'):
first = str(obj['Text']).strip().splitlines()
code = obj.get('Code', '')
detail = next((s for s in first[1:] if s.strip()), first[0])
return ('%s: %s' % (code, detail.strip())).strip(': ')
return text.splitlines()[0]
return 'Vale produced no parsable output'
def alert_fields(alert):
"""(lineno, colno, severity, check, message) from a Vale JSON alert."""
span = alert.get('Span') or [1]
return (alert.get('Line', 0), span[0],
(alert.get('Severity') or '').lower(),
alert.get('Check', ''), alert.get('Message', ''))
def format_alert(display_path, lineno, colno, severity, check, message):
return '%s:%d:%d:%s:%s:%s' % (display_path, lineno, colno,
severity, check, message)
def filter_alerts(vale_data, file_regions):
"""Return [(severity, display_line), ...] for alerts on changed columns."""
results = []
for filepath, alerts in vale_data.items():
canon = str(pathlib.Path(filepath).resolve())
regions = file_regions.get(canon, {})
for alert in alerts or []:
lineno, colno, severity, check, message = alert_fields(alert)
if in_changed_region(lineno, colno, regions):
results.append((severity,
format_alert(filepath, lineno, colno,
severity, check, message)))
return results
# ── File processors ───────────────────────────────────────────────────────────
def process_qdoc_files(vale_bin, vale_config, qdoc_files):
if not qdoc_files:
return []
file_regions = {str(pathlib.Path(f).resolve()): get_changed_regions(f)
for f in qdoc_files}
vale_output = run_vale(vale_bin, vale_config, qdoc_files)
return filter_alerts(vale_output, file_regions)
def find_qdoc_blocks(file_lines):
blocks = []
in_block = False
block_start = None
for i, line in enumerate(file_lines, 1):
if not in_block and '/*!' in line:
in_block = True
block_start = i
if in_block and '*/' in line:
blocks.append((block_start, i))
in_block = False
return blocks
def process_cpp_files(vale_bin, vale_config, cpp_files):
results = []
for cpp_file in cpp_files:
regions = get_changed_regions(cpp_file)
if not regions:
continue
with open(cpp_file, errors='replace') as fh:
file_lines = fh.read().splitlines()
blocks = find_qdoc_blocks(file_lines)
if not blocks:
continue
changed_line_nums = set(regions)
relevant = [(s, e) for s, e in blocks
if any(s <= ln <= e for ln in changed_line_nums)]
if not relevant:
continue
block_line_set = set()
for bs, be in relevant:
block_line_set.update(range(bs, be + 1))
target_lines = block_line_set & changed_line_nums
if not target_lines:
continue
tmp_fd, tmp_path = tempfile.mkstemp(suffix='.qdoc')
try:
with os.fdopen(tmp_fd, 'w') as tmp:
for i, line in enumerate(file_lines, 1):
tmp.write((line if i in block_line_set else '') + '\n')
vale_data = run_vale(vale_bin, vale_config, [tmp_path])
tmp_regions = {ln: regions[ln] for ln in target_lines}
# Blank-line padding kept line numbers, so an alert's line maps
# straight back onto cpp_file; only the reported path changes.
for alerts in vale_data.values():
for alert in alerts or []:
lineno, colno, severity, check, message = alert_fields(alert)
if in_changed_region(lineno, colno, tmp_regions):
results.append((severity,
format_alert(cpp_file, lineno, colno,
severity, check, message)))
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
return results
# ── Main ─────────────────────────────────────────────────────────────────────
def main():
args = sys.argv[1:]
repo_root, vale_bin, vale_config = args[0], args[1], args[2]
rest = args[3:]
try:
sep = rest.index('--')
qdoc_files = rest[:sep]
cpp_files = rest[sep + 1:]
except ValueError:
qdoc_files = rest
cpp_files = []
try:
results = (process_qdoc_files(vale_bin, vale_config, qdoc_files) +
process_cpp_files(vale_bin, vale_config, cpp_files))
except ValeError as exc:
print('vale-pre-commit: Vale failed to run: %s' % exc)
print('vale-pre-commit: Not blocking the commit on a linter failure.')
sys.exit(0)
blocking = [line for sev, line in results if sev in BLOCKING]
advisory = [line for sev, line in results if sev not in BLOCKING]
if blocking:
print('vale-pre-commit: Errors and warnings in changed lines:\n')
for line in blocking:
print(line)
if advisory:
if blocking:
print('')
print('vale-pre-commit: Suggestions in changed lines '
'(not blocking):\n')
for line in advisory:
print(line)
# Only errors and warnings block. Suggestions are advisory by the style's
# own choice of level:, so they are shown and the commit proceeds.
if blocking:
print("\nvale-pre-commit: Fix the errors and warnings above, or use "
"'git commit --no-verify' to skip.")
sys.exit(1)
sys.exit(0)
main()
PYEOF
# Probe each candidate by actually running it rather than relying on command -v
# alone. On Windows, the Python Launcher (py) is the reliable entry point, but
# python3 appears to exist on PATH as a Windows Store stub that exits non-zero
# without running anything. command -v cannot distinguish a stub from a real
# interpreter, so we verify by executing a trivial Python 3 import.
PYTHON=""
for _candidate in python3 py python; do
if command -v "$_candidate" >/dev/null 2>&1; then
if "$_candidate" -c "import sys; sys.exit(0 if sys.version_info[0] >= 3 else 1)" 2>/dev/null; then
PYTHON=$(command -v "$_candidate")
break
fi
fi
done
if [ -z "$PYTHON" ]; then
printf 'vale-pre-commit: Python 3 not found. Skipping.\n' >&2
exit 0
fi
"$PYTHON" "$PY_SCRIPT" "$REPO_ROOT" "$VALE_BIN" "$VALE_CONFIG" $QDOC_ABS -- $CPP_ABS
STATUS=$?
IFS=$_OLD_IFS
set +f
exit $STATUS