Revision control

++ #!/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