Skip to content

Commit 23c91d5

Browse files
spec-check: a mismatched local openspec is bypassed for npx at the pin, refused only when npx cannot run
Council pass 10 Q3 (seat claude-opus-5.5), adopted over the coordinator's own §5 hold. The refusal's premise — a validator at another release validates a different contract — argues against USING the mismatched binary, not against running the pinned one, and `npx` runs the pinned one in the same command. Refusing outright made `just preflight` fail for a contributor whose global openspec is newer until they downgrade a tool they use elsewhere, which teaches people to skip preflight. New order: local == pin -> use it; else npx at the pin (one stderr line names the ignored version and the pinned one); mismatched local AND npx unavailable -> exit 2 (a wrong-version tool on PATH is never reported as "no tool here"); nothing at all -> SKIPPED locally, exit 1 under CI. The validate exit code is still exec'd, never laundered. RED-first test with a fake binary and a fake npx that logs its arguments; the earlier offline mismatch test still expects exit 2. 15 gate tests; shellcheck clean; `just spec-check` 26/26; docs strict.
1 parent eaa1ae4 commit 23c91d5

4 files changed

Lines changed: 74 additions & 41 deletions

File tree

‎Justfile‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ set shell := ["bash", "-cu"]
66
export PYTHONWARNINGS := "ignore:builtin type swigvarlink has no __module__ attribute:DeprecationWarning"
77

88
# The one OpenSpec CLI release the repository validates against. `scripts/openspec-cli.sh`
9-
# resolves it (a matching local binary, else npx at exactly this version) and refuses any other
10-
# local version; `just spec-check` and the release gate both go through that script. Bumping
9+
# resolves it: a local binary at exactly this version, else npx at exactly this version (a local
10+
# binary at another version is never used — bypassed with a notice, or refused when npx cannot run);
11+
# `just spec-check` and the release gate both go through that script. Bumping
1112
# this is a deliberate commit: a new release can add validation rules (1.14 promoted the
1213
# >500-character requirement finding to a warning, which is how 84 of them appeared at once).
1314
export OPENSPEC_VERSION := "1.14.1"

‎docs/contributor-conventions.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,9 @@ small fix with a test goes straight to a pull request.
7070
`spec-check` until it was added), so the flag is the reviewer's cue that "no delta" was a choice.
7171
3. Validate the structure with `openspec validate --specs --all --strict`: every warning fails, so
7272
the output is a gate and not a list nobody reads. The recipe resolves the CLI through `scripts/openspec-cli.sh` at the one pinned
73-
release (`OPENSPEC_VERSION` in the `Justfile`; a local `openspec` at any other version is
74-
refused, otherwise `npx` fetches the pinned one). Without node or the CLI the recipe prints
73+
release (`OPENSPEC_VERSION` in the `Justfile`): a local `openspec` at that version is used; at
74+
any other version it is never used -- `npx` validates with the pinned one and says so, or the
75+
recipe refuses when `npx` cannot run. Without node or the CLI the recipe prints
7576
`SKIPPED` and exits 0 on your machine -- a skip is not a pass -- and the `spec-check` job in CI,
7677
which runs the same recipe, fails on the same absence:
7778

‎packages/studyloop/tests/test_spec_check_gate.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,3 +267,30 @@ def test_release_check_runs_spec_check() -> None:
267267
line for line in _justfile().splitlines() if line.startswith("release-check:")
268268
)
269269
assert "spec-check" in recipe_line.split()
270+
271+
272+
def test_a_mismatched_local_binary_is_bypassed_for_npx_at_the_pin_when_npx_can_run(
273+
tmp_path: Path,
274+
) -> None:
275+
"""Pass 10 Q3 (seat claude-opus-5.5, adopted over the coordinator's §5 hold): the mismatched
276+
binary is never USED, but the pinned contract is still validated — by npx — with one stderr
277+
line naming both versions. Refusal (exit 2) is kept for the case where npx cannot run, so a
278+
wrong-version tool on PATH is never reported as 'no tool here'."""
279+
fake_bin = _fake_bin(
280+
tmp_path,
281+
"openspec",
282+
'if [ "$1" = --version ]; then echo 9.9.9; exit 0; fi\necho "WRONG BINARY RAN"; exit 0\n',
283+
)
284+
npx_log = tmp_path / "npx-args.txt"
285+
(fake_bin / "npx").write_text(
286+
f'#!/bin/sh\nprintf "%s " "$@" >> "{npx_log}"\n'
287+
'if [ "$3" = --version ]; then echo 1.14.1; exit 0; fi\necho "pinned ran"; exit 0\n',
288+
encoding="utf-8",
289+
)
290+
(fake_bin / "npx").chmod(0o755)
291+
env = {"PATH": f"{fake_bin}:/usr/bin:/bin", "OPENSPEC_VERSION": "1.14.1", "HOME": str(tmp_path)}
292+
proc = _resolver(["validate", "--specs", "--all", "--strict"], env)
293+
assert proc.returncode == 0, proc.stdout + proc.stderr
294+
assert "pinned ran" in proc.stdout and "WRONG BINARY RAN" not in proc.stdout
295+
assert "9.9.9" in proc.stderr and "1.14.1" in proc.stderr
296+
assert "@fission-ai/openspec@1.14.1" in npx_log.read_text(encoding="utf-8")

‎scripts/openspec-cli.sh‎

Lines changed: 41 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,23 @@
33
#
44
# Usage: scripts/openspec-cli.sh <openspec args…>
55
#
6-
# Resolution (plan 7, ARBITRATION-pass9.md §4 Q4/Q6, §8.2 R4):
6+
# Resolution (plan 7, ARBITRATION-pass9.md §4 Q4/Q6, §8.2 R4; order revised by pass 10 Q3):
77
# 1. A local `openspec` on PATH is used ONLY if `openspec --version` equals the pin
8-
# ($OPENSPEC_VERSION, exported by the Justfile). A different version is REFUSED — a validator
9-
# at another release may apply other rules, so its "pass" would be about a different contract.
10-
# 2. Otherwise `npx --yes @fission-ai/openspec@$OPENSPEC_VERSION` (exact top-level pin; the
11-
# package's own dependencies float on caret ranges — the same residual the repo accepts for
12-
# `uv tool run pip-audit@…`).
13-
# 3. If neither can run: on a contributor's machine print SKIPPED and exit 0 — a gate that fails for
14-
# a reason nobody is working on teaches people to ignore it (Justfile, release-check notes) —
15-
# but under CI ($CI set, as GitHub sets it) exit 1: a skip is not a pass, and CI is where the
16-
# gate is enforced.
8+
# ($OPENSPEC_VERSION, exported by the Justfile). A validator at another release applies other
9+
# rules, so its "pass" would be about a different contract: a mismatched binary is never used.
10+
# 2. Otherwise `npx --yes @fission-ai/openspec@$OPENSPEC_VERSION` validates the pinned contract in
11+
# the same command (exact top-level pin; the package's own dependencies float on caret ranges —
12+
# the residual the repo also accepts for `uv tool run pip-audit@…`). When a mismatched local
13+
# binary was bypassed, one stderr line says so, naming both versions.
14+
# 3. npx cannot run AND a mismatched local binary exists: exit 2 — a wrong-version tool on PATH is
15+
# a fact the reader must see, never reported as "no tool here".
16+
# 4. Nothing at all: on a contributor's machine print SKIPPED and exit 0 — a gate that fails for a
17+
# reason nobody is working on teaches people to ignore it — but under CI ($CI set, as GitHub
18+
# sets it) exit 1: a skip is not a pass, and CI is where the gate is enforced.
1719
#
1820
# Fetch is separated from validate: `--version` is run first so an environmental failure (no node,
19-
# npx cannot resolve) is reported as such, and the validation's own exit code is the gate.
21+
# npx cannot resolve) is reported as such, and the validation's own exit code is the gate — it is
22+
# exec'd, never laundered.
2023
#
2124
# OPENSPEC_TELEMETRY=0 is set so an npx-fetched CLI does not start reporting usage from a machine
2225
# whose owner never installed openspec. OPENSPEC_NO_NPX=1 disables step 2 (tests use it to stay
@@ -28,41 +31,42 @@ set -euo pipefail
2831
export OPENSPEC_TELEMETRY=0
2932

3033
pointer="see docs/contributor-conventions.md § OpenSpec"
34+
local_version="" # what a local binary reported, when one is on PATH
35+
local_mismatch="" # non-empty when that version is not the pin
3136

32-
run_local() {
33-
local found
34-
found="$(openspec --version 2>/dev/null | tr -d '[:space:]' || true)"
35-
if [[ -z "$found" ]]; then
37+
if command -v openspec >/dev/null 2>&1; then
38+
local_version="$(openspec --version 2>/dev/null | tr -d '[:space:]' || true)"
39+
if [[ -z "$local_version" ]]; then
3640
echo "openspec-cli: a local openspec is on PATH but printed no version; ignoring it" >&2
37-
return 1
38-
fi
39-
if [[ "$found" != "$OPENSPEC_VERSION" ]]; then
40-
echo "openspec-cli: local openspec is $found but the repository pins $OPENSPEC_VERSION;" >&2
41-
echo "openspec-cli: refusing to use it (uninstall it, or install $OPENSPEC_VERSION); $pointer" >&2
42-
exit 2
41+
elif [[ "$local_version" == "$OPENSPEC_VERSION" ]]; then
42+
exec openspec "$@"
43+
else
44+
local_mismatch=1
4345
fi
44-
exec openspec "$@"
45-
}
46+
fi
4647

47-
run_npx() {
48-
if [[ -n "${OPENSPEC_NO_NPX:-}" ]]; then
49-
return 1
50-
fi
51-
if ! command -v npx >/dev/null 2>&1; then
52-
return 1
53-
fi
48+
npx_ok=""
49+
if [[ -z "${OPENSPEC_NO_NPX:-}" ]] && command -v npx >/dev/null 2>&1; then
5450
# fetch first: an unresolvable package is environmental, not a validation result
55-
if ! npx --yes "@fission-ai/openspec@$OPENSPEC_VERSION" --version >/dev/null 2>&1; then
56-
return 1
51+
if npx --yes "@fission-ai/openspec@$OPENSPEC_VERSION" --version >/dev/null 2>&1; then
52+
npx_ok=1
5753
fi
58-
exec npx --yes "@fission-ai/openspec@$OPENSPEC_VERSION" "$@"
59-
}
54+
fi
6055

61-
if command -v openspec >/dev/null 2>&1; then
62-
run_local "$@" || true # a matching binary execs; a mismatch exits 2; a silent one falls through
56+
if [[ -n "$npx_ok" ]]; then
57+
if [[ -n "$local_mismatch" ]]; then
58+
echo "openspec-cli: local openspec $local_version ignored (the repository pins $OPENSPEC_VERSION);" >&2
59+
echo "openspec-cli: validating with $OPENSPEC_VERSION via npx; $pointer" >&2
60+
fi
61+
exec npx --yes "@fission-ai/openspec@$OPENSPEC_VERSION" "$@"
6362
fi
6463

65-
run_npx "$@" || true
64+
if [[ -n "$local_mismatch" ]]; then
65+
echo "openspec-cli: local openspec is $local_version but the repository pins $OPENSPEC_VERSION," >&2
66+
echo "openspec-cli: and npx cannot supply the pinned release here; refusing to use the local one" >&2
67+
echo "openspec-cli: (install $OPENSPEC_VERSION, or make node available); $pointer" >&2
68+
exit 2
69+
fi
6670

6771
if [[ -n "${CI:-}" ]]; then
6872
echo "openspec-cli: openspec $OPENSPEC_VERSION is not available (no matching local binary, npx unavailable);" >&2

0 commit comments

Comments
 (0)