diff --git a/packages/mosaic/framework/tools/git/push-guard.sh b/packages/mosaic/framework/tools/git/push-guard.sh index bfa66fe9..adf3ed4d 100644 --- a/packages/mosaic/framework/tools/git/push-guard.sh +++ b/packages/mosaic/framework/tools/git/push-guard.sh @@ -32,6 +32,7 @@ # 3 staged JSON does not parse # 4 push verification failed (remote did not move, or moved elsewhere) # 5 nothing staged / empty commit — the null case, refused +# 6 NO JSON DECISION — no --json-path and no usable .push-guard.json # 64 usage error # # Dependencies: bash 4.4+, git, python3. @@ -43,8 +44,13 @@ readonly EX_CONFLICT=2 readonly EX_JSON=3 readonly EX_PUSH=4 readonly EX_NULL=5 +readonly EX_CONFIG=6 readonly EX_USAGE=64 +# Repo-level configuration. Its ABSENCE is refused, not defaulted — see +# resolve_json_config() for why. +readonly CONFIG_FILE=".push-guard.json" + # Conflict-marker detection. # # We match `<<<<<<<`, `>>>>>>>` and the diff3 `|||||||` marker, each anchored at @@ -86,9 +92,10 @@ Usage: Subcommands: check-staged Run pre-commit content guards against the STAGED index: (a) no unmerged index paths, no conflict markers - (c) staged JSON under --json-path parses + (c) staged JSON under the nominated paths parses Refuses to pass when nothing is staged (exit 5). - Check (c) is opt-in by path — see --json-path. + Check (c) requires an EXPLICIT decision — either --json-path, + or .push-guard.json. Saying nothing is refused (exit 6). push Perform a push and prove it HAPPENED: - HEAD is a non-empty commit (differs from its parent) @@ -103,8 +110,9 @@ Options: parse-check, e.g. 'data/**/*.json'. Repeatable. Opt-in on purpose: many real .json files are JSONC (tsconfig) or templates and do NOT parse - strictly. Nominate the generated ones. With no - --json-path the check is announced as skipped. + strictly. Nominate the generated ones. Satisfies + the decision requirement on its own; overrides a + config opt-out (loudly). --allow-invalid-json Exempt a path from the JSON parse check (for deliberate malformed-input fixtures). Exemptions are printed, never silent. @@ -113,6 +121,16 @@ Options: --remote Remote to push to. --branch Branch to push. +Repo config (.push-guard.json, at the repository root): + {"json_paths": ["data/**/*.json"], "allow_invalid_json": ["fixtures/*"]} + — nominate generated JSON for parse-checking. + {"json_check": "none", "reason": "no generated JSON in this repo"} + — opt out. The reason is REQUIRED and is printed on every run. + +An opt-out is only accepted from this file, never from a command-line flag: a +committed reason can be read in a diff, an absent flag cannot be reviewed at all. +An invalid config is refused outright rather than treated as absent. + There is no flag to downgrade a failure to a warning. That is intentional. EOF } @@ -191,19 +209,197 @@ check_conflict_markers() { # worse than no guard, because the bypass then also covers the true positives. # # So the caller nominates the generated/serialized paths this check is FOR, as -# git pathspecs (git, not bash, expands them — '**' works correctly). When no -# path is nominated the check says so on every run rather than contributing a -# silent green. That is an accepted, stated limitation: a repo that needs the -# check and never wires it up is unprotected, and the printed line is the only -# thing standing between that state and going unnoticed. +# git pathspecs (git, not bash, expands them — '**' works correctly). +# +# WHAT SAYING NOTHING NOW COSTS YOU +# --------------------------------- +# The first release announced "JSON check NOT REQUESTED" and continued. That was +# visible rather than silent, which is better — but it is still an ABSENCE, and +# an absence is not reviewable. Nobody reads a line that has printed correctly +# ten thousand times. A repo that needed the check and never wired it up stayed +# unprotected forever, and nothing ever failed. +# +# The decision is now MANDATORY and must come from one of two places: +# * turning the check ON — --json-path on the command line, or "json_paths" +# in .push-guard.json +# * turning the check OFF — ONLY "json_check": "none" in .push-guard.json, +# which REQUIRES a non-empty "reason" +# Saying nothing at all is refused (exit 6). +# +# The asymmetry is deliberate. Turning a check on is safe from anywhere. Turning +# one OFF is confined to a committed file because that is the only form a human +# can review: you can read a reason in a diff, and you cannot review the fact +# that nobody typed a flag. An opt-out that lives in an ad-hoc command line is +# just the old fail-open with extra steps. +# The config parser, held as a string rather than an inline heredoc. +# +# A `<<'PY'` heredoc INSIDE a $( ) command substitution makes bash emit +# warning: command substitution: 1 unterminated here-document +# on every single run. It still executed, every needle stayed green and the +# static linter stayed clean — the warning goes to stderr and broke no +# assertion. It surfaced only when the guard was run against a real +# repository. A tool built to refuse quiet output was quietly polluting +# stderr; needle 'z1' now asserts the guard emits no warnings at all. +# +# `read -d ''` returns non-zero at EOF without finding a NUL, which is the +# NORMAL path when slurping a heredoc — hence `|| true`. That is the one +# shape where it does not mask a real error, unlike the `git grep || true` +# this codebase already removed once. +IFS='' read -r -d '' CONFIG_PARSER <<'PY' || true +import json, sys + +path = sys.argv[1] +try: + with open(path) as fh: + cfg = json.load(fh) +except Exception as exc: + sys.stderr.write("does not parse: %s" % exc) + sys.exit(2) + +if not isinstance(cfg, dict): + sys.stderr.write("must contain a JSON object, got %s" % type(cfg).__name__) + sys.exit(2) + +def clean_list(name, value): + if not isinstance(value, list): + sys.stderr.write('"%s" must be an array' % name) + sys.exit(2) + for item in value: + if not isinstance(item, str) or not item.strip(): + sys.stderr.write('"%s" must contain only non-empty strings' % name) + sys.exit(2) + # Tabs/newlines would be mangled by the tab-delimited handoff below. + # Reject them explicitly rather than silently truncating a pathspec. + if "\t" in item or "\n" in item: + sys.stderr.write('"%s" entry contains a tab or newline: %r' % (name, item)) + sys.exit(2) + return value + +mode = cfg.get("json_check") +paths = cfg.get("json_paths") +allow = cfg.get("allow_invalid_json", []) + +if mode is not None and mode != "none": + sys.stderr.write('"json_check" must be "none" if present, got %r' % (mode,)) + sys.exit(2) + +if mode == "none": + if paths: + sys.stderr.write('"json_check": "none" and "json_paths" are mutually exclusive') + sys.exit(2) + reason = cfg.get("reason") + if not isinstance(reason, str) or not reason.strip(): + sys.stderr.write('"json_check": "none" REQUIRES a non-empty "reason"') + sys.exit(2) + print("MODE\tnone") + print("REASON\t%s" % reason.strip().replace("\t", " ").replace("\n", " ")) + sys.exit(0) + +if paths is None: + sys.stderr.write( + 'states no decision — needs "json_paths", ' + 'or "json_check": "none" with a "reason"' + ) + sys.exit(2) + +clean_list("json_paths", paths) +if not paths: + sys.stderr.write('"json_paths" must not be empty') + sys.exit(2) +clean_list("allow_invalid_json", allow) + +print("MODE\tcheck") +for item in paths: + print("JPATH\t%s" % item) +for item in allow: + print("ALLOW\t%s" % item) +PY + +resolve_json_config() { + local cfg out status=0 + cfg="$(git rev-parse --show-toplevel)/$CONFIG_FILE" + + if [[ ! -f "$cfg" ]]; then + CFG_MODE="absent" + return 0 + fi + + # A malformed config must REFUSE, never degrade to "treat as absent". That + # fallback would rebuild precisely the fail-open this gate closes: a typo in + # the config would silently disable the check it was written to enable. + out="$(python3 -c "$CONFIG_PARSER" "$cfg" 2>&1)" || status=$? + + if (( status != 0 )); then + fail "$EX_CONFIG" \ + "$CONFIG_FILE is INVALID — $out" \ + "" \ + "Refusing to run. A config that does not parse is not the same as no" \ + "config: treating it as absent would silently disable the very check" \ + "this file was written to enable." + fi + + local key val + while IFS=$'\t' read -r key val; do + case "$key" in + MODE) CFG_MODE="$val" ;; + REASON) CFG_REASON="$val" ;; + JPATH) CFG_PATHS+=("$val") ;; + ALLOW) CFG_ALLOW+=("$val") ;; + esac + done <<<"$out" +} + check_staged_json() { local -a checked=() skipped=() local f err local -a pathspec=() + resolve_json_config + if (( ${#JSON_PATHS[@]} == 0 )); then - log " -- JSON check NOT REQUESTED (no --json-path) — no JSON was validated" - return 0 + case "$CFG_MODE" in + absent) + fail "$EX_CONFIG" \ + "NO JSON DECISION — no --json-path, and no $CONFIG_FILE" \ + "" \ + "This guard will not run without an explicit decision about" \ + "staged-JSON validation. Create $CONFIG_FILE with EITHER:" \ + "" \ + ' {"json_paths": ["data/**/*.json"]}' \ + "" \ + "or, if this repo genuinely has no generated JSON:" \ + "" \ + ' {"json_check": "none", "reason": ""}' \ + "" \ + "The reason is mandatory so the opt-out is recorded and" \ + "reviewable in the diff, instead of being an absence nobody sees." + ;; + none) + log " -- JSON check OPTED OUT in $CONFIG_FILE — recorded reason: $CFG_REASON" + return 0 + ;; + check) + JSON_PATHS=(${CFG_PATHS[@]+"${CFG_PATHS[@]}"}) + ALLOW_INVALID_JSON+=(${CFG_ALLOW[@]+"${CFG_ALLOW[@]}"}) + log " .. JSON paths from $CONFIG_FILE: ${JSON_PATHS[*]}" + ;; + esac + else + # An explicit --json-path turns the check ON, so it satisfies the decision + # requirement by itself. If the config opted out, the nomination WINS and + # says so loudly — resolving a contradiction toward more checking is the + # only safe direction, but silently ignoring a committed opt-out would + # hide a real disagreement. + case "$CFG_MODE" in + none) + log " !! $CONFIG_FILE opts out of the JSON check, but --json-path was" + log " !! given — honouring the nomination and checking anyway." + ;; + check) + JSON_PATHS+=(${CFG_PATHS[@]+"${CFG_PATHS[@]}"}) + ALLOW_INVALID_JSON+=(${CFG_ALLOW[@]+"${CFG_ALLOW[@]}"}) + ;; + esac fi # Normalize to :(glob) magic. WITHOUT it, git's default pathspec matching @@ -391,6 +587,11 @@ cmd_push() { ALLOW_INVALID_JSON=() JSON_PATHS=() +CFG_MODE="absent" +CFG_REASON="" +CFG_PATHS=() +CFG_ALLOW=() + main() { (( $# )) || { usage; exit "$EX_USAGE"; } local sub="$1"; shift