Skip to content

Commit 8f8bd43

Browse files
committed
fix(cli): cljw repl takes the shared [-cp] [-A:alias…] classpath surface
Smell-audited: 1: the -cp/-A arm was about to be spelled a third time across repl/nrepl/build — folded into consumeCpAliasArg (F-011); all three loops now share one spelling. Closes the D-322 residual (adjacent to Discussion #13's nrepl defect): the repl subcommand silently ignored every trailing arg, so `cljw repl -cp src` dropped the -cp without a diagnostic. It now resolves the classpath by the same ADR-0084 rule as every other eval entry point and rejects unknown arguments; --help documents the surface. scripts/check_entrypoint_surface.sh (new, in the per-commit smoke core) guards the parity contract itself: the cli.zig subcommand set must equal the script's eval∪exempt lists two-sidedly (a new subcommand must be classified here), every eval subcommand's branch must reach the shared classpath resolution, and a vacuous extraction (<4 subcommands) fails loudly. This is the mechanical form of "nREPL was the odd one out" (Discussion #13): a future entry point cannot silently skip the classpath contract. test/e2e/phase16_repl_classpath.sh grows repl×{-cp, -A:alias, unknown-arg-rejection} (3→6 cases).
1 parent 42c6fdf commit 8f8bd43

4 files changed

Lines changed: 190 additions & 30 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
#!/usr/bin/env bash
2+
# scripts/check_entrypoint_surface.sh — CLI entry-point classpath parity guard.
3+
#
4+
# Born from Discussion #13: D-322 landed filesystem-classpath resolution on the
5+
# terminal REPL, and `cljw nrepl` was not carried along — the one entry point
6+
# where the user is CERTAIN to be on a project was the only one that could not
7+
# `(require '[my.lib])`. The bug class is "a new (or overlooked) entry point
8+
# silently misses the shared classpath contract". This guard makes that class
9+
# fail the gate instead of waiting for a field report:
10+
#
11+
# 1. Extract the subcommand set from src/app/cli.zig's dispatch
12+
# (`std.mem.eql(u8, first, "<name>")`).
13+
# 2. Two-sided set equality against the lists below — an EXTRA subcommand
14+
# (someone added one) and a MISSING subcommand (someone renamed/refactored
15+
# the dispatch idiom) both fail, forcing a conscious decision here.
16+
# 3. Every EVAL subcommand's dispatch branch must reach the shared classpath
17+
# resolution (resolveClasspath / resolveDefaultClasspath / splitClasspath).
18+
# 4. The default (non-subcommand) path — dispatchArgsRest — must too.
19+
# 5. A vacuous extraction (< MIN_SUBCOMMANDS) fails: if the dispatch idiom
20+
# changes, this script must be re-taught, not silently pass.
21+
#
22+
# Adding a subcommand: EVAL_SUBCOMMANDS if it evaluates user code (it must then
23+
# call the shared resolver AND join the classpath e2e coverage —
24+
# test/e2e/phase16_repl_classpath.sh / phase14_nrepl_classpath.sh /
25+
# phase14_cljw_build.sh are the per-entry rung owners); EXEMPT_SUBCOMMANDS only
26+
# if it never evaluates user code (say why in the list).
27+
#
28+
# Modes: no args = gate (exit 1 on violation). Runs in <1s; part of the
29+
# per-commit smoke core.
30+
31+
set -euo pipefail
32+
cd "$(dirname "$0")/.."
33+
34+
CLI=src/app/cli.zig
35+
MIN_SUBCOMMANDS=4
36+
37+
# Entry points that evaluate user code — each must resolve the classpath.
38+
EVAL_SUBCOMMANDS=(repl nrepl build)
39+
# render-error: decodes a CLJW_ERROR_LOG EDN event log; never evaluates code.
40+
EXEMPT_SUBCOMMANDS=(render-error)
41+
42+
fail=0
43+
44+
# --- 1+2: extract the dispatch's subcommand set, compare two-sided ---
45+
found=$(grep -oE 'std\.mem\.eql\(u8, first, "[a-z-]+"\)' "$CLI" \
46+
| sed -E 's/.*"([a-z-]+)".*/\1/' | sort -u)
47+
found_n=$(printf '%s\n' "$found" | grep -c . || true)
48+
49+
if [ "$found_n" -lt "$MIN_SUBCOMMANDS" ]; then
50+
echo "check_entrypoint_surface: extracted only $found_n subcommand(s) from $CLI (< $MIN_SUBCOMMANDS)."
51+
echo " The dispatch idiom likely changed — re-teach the extraction in this script."
52+
exit 1
53+
fi
54+
55+
expected=$(printf '%s\n' "${EVAL_SUBCOMMANDS[@]}" "${EXEMPT_SUBCOMMANDS[@]}" | sort -u)
56+
if [ "$found" != "$expected" ]; then
57+
echo "check_entrypoint_surface: subcommand set drift in $CLI."
58+
echo " dispatch has: $(echo $found)"
59+
echo " script expects: $(echo $expected)"
60+
echo " A NEW subcommand must be classified here: EVAL_SUBCOMMANDS (evaluates"
61+
echo " user code -> must call the shared classpath resolver + join the"
62+
echo " classpath e2e coverage) or EXEMPT_SUBCOMMANDS (never evaluates code)."
63+
fail=1
64+
fi
65+
66+
# --- 3: every eval subcommand's branch reaches the shared resolver ---
67+
# A branch spans from its `eql(u8, first, "<name>")` line to the next
68+
# subcommand test (or the dispatchArgsRest fall-through).
69+
for sc in "${EVAL_SUBCOMMANDS[@]}"; do
70+
branch=$(awk -v name="\"$sc\"" '
71+
index($0, "std.mem.eql(u8, first, " name ")") { on = 1 }
72+
on && (index($0, "std.mem.eql(u8, first,") && !index($0, name)) { exit }
73+
on && index($0, "dispatchArgsRest(") { exit }
74+
on { print }
75+
' "$CLI")
76+
if ! printf '%s' "$branch" | grep -qE 'resolveClasspath|resolveDefaultClasspath|splitClasspath'; then
77+
echo "check_entrypoint_surface: subcommand '$sc' does not reach the shared"
78+
echo " classpath resolution (resolveClasspath/resolveDefaultClasspath/splitClasspath)."
79+
echo " This is the Discussion-#13 bug shape (nrepl ignored -cp/\$CLJW_PATH)."
80+
fail=1
81+
fi
82+
done
83+
84+
# --- 4: the default (file / -e / bare) path resolves the classpath too ---
85+
if ! awk '/fn dispatchArgsRest\(/,0' "$CLI" | grep -qE 'resolveClasspath|resolveDefaultClasspath|splitClasspath'; then
86+
echo "check_entrypoint_surface: dispatchArgsRest no longer reaches the shared classpath resolution."
87+
fail=1
88+
fi
89+
90+
if [ "$fail" -eq 0 ]; then
91+
echo "check_entrypoint_surface: $found_n subcommands ($(echo $found)) all classified; eval entries resolve the classpath"
92+
fi
93+
exit "$fail"

‎src/app/cli.zig‎

Lines changed: 55 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,24 @@ pub fn dispatch(init: std.process.Init) !void {
122122
const git_cache_base: ?[]const u8 = init.environ_map.get("CLJW_HOME") orelse
123123
if (init.environ_map.get("HOME")) |h| try std.fmt.allocPrint(arena, "{s}/.cljw", .{h}) else null;
124124

125-
// `cljw repl` subcommand (ADR-0048) — peek the first positional
126-
// and route to the REPL when it matches. The REPL takes no further
127-
// argv; trailing args are not allowed today (a future `--init` /
128-
// `--port` would relax this).
125+
// Subcommand routing (ADR-0048) — peek the first positional and
126+
// route when it matches.
129127
if (args.next()) |first| {
130128
if (std.mem.eql(u8, first, "repl")) {
131-
const load_paths = try resolveDefaultClasspath(io, arena, stderr, cljw_path, git_cache_base);
129+
// Same `[-cp <dirs>] [-A:alias…]` surface as every other eval
130+
// entry point (the D-322 residual): trailing args used to be
131+
// silently ignored, so `cljw repl -cp src` dropped the -cp
132+
// without a diagnostic. Unknown arguments are rejected.
133+
var repl_cp: ?[]const u8 = null;
134+
var repl_aliases: std.ArrayList([]const u8) = .empty;
135+
while (args.next()) |a| {
136+
if (!(try consumeCpAliasArg(arena, stderr, "repl", a, &args, &repl_cp, &repl_aliases))) {
137+
try stderr.print("repl: unknown argument '{s}'\n", .{a});
138+
try stderr.flush();
139+
std.process.exit(1);
140+
}
141+
}
142+
const load_paths = try resolveClasspath(io, arena, stderr, repl_cp, cljw_path, repl_aliases.items, git_cache_base);
132143
return repl.run(io, gpa, arena, stdout, stderr, load_paths, fs_jail_root);
133144
}
134145
if (std.mem.eql(u8, first, "render-error")) {
@@ -157,17 +168,8 @@ pub fn dispatch(init: std.process.Init) !void {
157168
try stderr.flush();
158169
std.process.exit(1);
159170
};
160-
} else if (std.mem.eql(u8, a, "-cp") or std.mem.eql(u8, a, "--classpath")) {
161-
nrepl_cp = args.next() orelse {
162-
try stderr.print("nrepl: -cp requires an argument\n", .{});
163-
try stderr.flush();
164-
std.process.exit(1);
165-
};
166-
} else if (std.mem.startsWith(u8, a, "-A")) {
167-
var it = std.mem.splitScalar(u8, a[2..], ':');
168-
while (it.next()) |name| {
169-
if (name.len > 0) try nrepl_aliases.append(arena, name);
170-
}
171+
} else if (try consumeCpAliasArg(arena, stderr, "nrepl", a, &args, &nrepl_cp, &nrepl_aliases)) {
172+
// consumed by the shared classpath-surface arm
171173
} else {
172174
try stderr.print("nrepl: unknown argument '{s}'\n", .{a});
173175
try stderr.flush();
@@ -202,17 +204,8 @@ pub fn dispatch(init: std.process.Init) !void {
202204
try stderr.flush();
203205
std.process.exit(1);
204206
};
205-
} else if (std.mem.eql(u8, a, "-cp") or std.mem.eql(u8, a, "--classpath")) {
206-
build_cp = args.next() orelse {
207-
try stderr.writeAll("build: -cp requires an argument\n");
208-
try stderr.flush();
209-
std.process.exit(1);
210-
};
211-
} else if (std.mem.startsWith(u8, a, "-A")) {
212-
var it = std.mem.splitScalar(u8, a[2..], ':');
213-
while (it.next()) |name| {
214-
if (name.len > 0) try build_aliases.append(arena, name);
215-
}
207+
} else if (try consumeCpAliasArg(arena, stderr, "build", a, &args, &build_cp, &build_aliases)) {
208+
// consumed by the shared classpath-surface arm
216209
} else if (std.mem.startsWith(u8, a, "-") and a.len > 1) {
217210
try stderr.print("build: unknown option '{s}'\n", .{a});
218211
try stderr.flush();
@@ -355,7 +348,9 @@ fn dispatchArgsRest(
355348
\\ -h, --help Show this help.
356349
\\
357350
\\Subcommands:
358-
\\ repl Start a terminal REPL.
351+
\\ repl [-cp <dirs>] [-A:alias…]
352+
\\ Start a terminal REPL. Classpath resolves as
353+
\\ for a run: `-cp` wins, else $CLJW_PATH, else ".".
359354
\\ nrepl [--port N] [-cp <dirs>] [-A:alias…]
360355
\\ Start an nREPL server (default port 7888) for
361356
\\ CIDER and other editors. Classpath resolves as
@@ -514,6 +509,38 @@ fn loadDepsEdn(io: std.Io, arena: std.mem.Allocator, stderr: *std.Io.Writer, bas
514509
return .{ .load_paths = try merged.toOwnedSlice(arena), .cfg = cfg };
515510
}
516511

512+
/// Shared `-cp <dirs>` / `-A:alias…` arm for the subcommand arg loops
513+
/// (`repl` / `nrepl` / `build`): the classpath surface is one contract
514+
/// (ADR-0084 / D-322), so its spelling lives once. Returns true when `a`
515+
/// was consumed; the caller keeps its own flags and its unknown-arg
516+
/// diagnostic.
517+
fn consumeCpAliasArg(
518+
arena: std.mem.Allocator,
519+
stderr: *std.Io.Writer,
520+
subcmd: []const u8,
521+
a: []const u8,
522+
args: anytype,
523+
cp: *?[]const u8,
524+
aliases: *std.ArrayList([]const u8),
525+
) !bool {
526+
if (std.mem.eql(u8, a, "-cp") or std.mem.eql(u8, a, "--classpath")) {
527+
cp.* = args.next() orelse {
528+
try stderr.print("{s}: -cp requires an argument\n", .{subcmd});
529+
try stderr.flush();
530+
std.process.exit(1);
531+
};
532+
return true;
533+
}
534+
if (std.mem.startsWith(u8, a, "-A")) {
535+
var it = std.mem.splitScalar(u8, a[2..], ':');
536+
while (it.next()) |name| {
537+
if (name.len > 0) try aliases.append(arena, name);
538+
}
539+
return true;
540+
}
541+
return false;
542+
}
543+
517544
/// The classpath for a REPL-shaped entry (`repl`, `nrepl`, the bare no-args
518545
/// fallback), resolved by the ADR-0084 rule: `-cp` wins, else `$CLJW_PATH`,
519546
/// else cwd ".", split into roots with a `./deps.edn` `:paths`/source-deps

‎test/e2e/phase16_repl_classpath.sh‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@
55
# `cljw repl` + $CLJW_PATH (subcommand), and bare `cljw` + $CLJW_PATH (no-args).
66
# Before this fix none of the three threaded load_paths into repl.run, so a REPL
77
# require only saw the embedded resolver — no filesystem classpath.
8+
#
9+
# Cases (d)-(f) pin the `repl` subcommand's own arg surface (the D-322
10+
# residual, closed after Discussion #13 landed the same surface on nrepl):
11+
# `repl [-cp <dirs>] [-A:alias…]`, with unknown args REJECTED — before, every
12+
# trailing arg was silently ignored, so `cljw repl -cp src` dropped the -cp
13+
# without a diagnostic. scripts/check_entrypoint_surface.sh guards the
14+
# per-entry-point parity contract this file exercises for repl.
815
set -euo pipefail
916
cd "$(dirname "$0")/../.."
1017
BIN="zig-out/bin/cljw"
@@ -39,4 +46,36 @@ got=$(printf '%s\n' "$PROG" | CLJW_PATH="$CP" "$BIN" 2>/dev/null)
3946
[[ "$got" == *hello-from-classpath* ]] || fail "noargs_classpath: got '$got'"
4047
echo "PASS noargs_classpath"
4148

42-
echo "OK — phase16_repl_classpath (3 cases) green"
49+
# (d) `cljw repl -cp DIR` — the -cp flag on the subcommand itself.
50+
got=$(printf '%s\n' "$PROG" | "$BIN" repl -cp "$CP" 2>/dev/null)
51+
[[ "$got" == *hello-from-classpath* ]] || fail "repl_cp_flag: got '$got'"
52+
echo "PASS repl_cp_flag"
53+
54+
# (e) `cljw repl -A:extra` — a deps.edn alias's :extra-paths joins the
55+
# classpath, same as the run/build path.
56+
PROJ=$(mktemp -d)
57+
trap 'rm -rf "$CP" "$PROJ"' EXIT
58+
mkdir -p "$PROJ/xsrc/my2"
59+
cat > "$PROJ/xsrc/my2/lib2.clj" <<'CLJ'
60+
(ns my2.lib2)
61+
(defn greet [] "hello-from-alias-path")
62+
CLJ
63+
cat > "$PROJ/deps.edn" <<'EOF'
64+
{:aliases {:extra {:extra-paths ["xsrc"]}}}
65+
EOF
66+
PROG2='(require (quote my2.lib2)) (println (my2.lib2/greet))'
67+
ABS_BIN="$(pwd)/$BIN"
68+
got=$(cd "$PROJ" && printf '%s\n' "$PROG2" | "$ABS_BIN" repl -A:extra 2>/dev/null)
69+
[[ "$got" == *hello-from-alias-path* ]] || fail "repl_alias_extra_paths: got '$got'"
70+
echo "PASS repl_alias_extra_paths"
71+
72+
# (f) unknown args are rejected, not silently ignored.
73+
if printf '' | "$BIN" repl --bogus >/tmp/repl_bogus.$$ 2>&1; then
74+
rm -f /tmp/repl_bogus.$$
75+
fail "repl_unknown_arg: --bogus was accepted"
76+
fi
77+
grep -q "unknown argument" /tmp/repl_bogus.$$ || { cat /tmp/repl_bogus.$$; rm -f /tmp/repl_bogus.$$; fail "repl_unknown_arg: no diagnostic"; }
78+
rm -f /tmp/repl_bogus.$$
79+
echo "PASS repl_unknown_arg -> rejected"
80+
81+
echo "OK — phase16_repl_classpath (6 cases) green"

‎test/run_all.sh‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ SMOKE_E2E=""
5555
# unregistered on 2026-08-05 (the full gate caught it, one push too late) —
5656
# a green smoke should not be able to say "the tests pass" about a test that
5757
# is not wired in.
58-
SMOKE_CORE="zig_fmt_check,zig_build_test_vm,zig_build_test_tree_walk,zlinter,build_cljw,lazy_ns_replay,corpus_regression,test_reach,e2e_reach"
58+
SMOKE_CORE="zig_fmt_check,zig_build_test_vm,zig_build_test_tree_walk,zlinter,build_cljw,lazy_ns_replay,corpus_regression,test_reach,e2e_reach,entrypoint_surface"
5959

6060
while [[ $# -gt 0 ]]; do
6161
case "$1" in
@@ -329,6 +329,7 @@ resume_done() { (( RESUME )) && grep -qxF "$GATE_FP $1" "$GATE_LEDGER" 2>/dev/nu
329329
run_step "zig_build_test_vm" "zig build test -Dwasm -Doptimize=ReleaseSafe"
330330
run_step "zig_build_test_tree_walk" "zig build test -Dwasm -Dbackend=tree_walk -Doptimize=ReleaseSafe"
331331
run_step "zone_check" "bash scripts/zone_check.sh --gate"
332+
run_step "entrypoint_surface" "bash scripts/check_entrypoint_surface.sh"
332333
run_step "surface_marker" "bash scripts/check_surface_marker.sh --gate"
333334
run_step "feature_keyword" "bash scripts/check_feature_keyword.sh --gate"
334335
run_step "host_interface" "bash scripts/check_host_interface.sh --gate"

0 commit comments

Comments
 (0)