syntaxai/tdd.md · commit 94c61f1

tools/sama-cli: shell-based SAMA v2 verifier — second independent implementation

The TS verifier at src/b32_sama_v2_verify.ts is now joined by a
fully independent shell-based implementation that reads the same
SAMA v2 §4 spec from a different language on a different runtime.

Both report 7/7 ✓ against this repo. The verifier finally has its
second opinion — the empirical chain ratchets one more notch.

- tools/sama-cli/sama: entry wrapper (POSIX bash)
- tools/sama-cli/src/{a31,b32,c14,d21}*: canonical SAMA v2 layers
- tools/sama-cli/cross-verify.sh: runs both, asserts agreement
- tools/sama-cli/run-ts-verifier.ts: Bun bridge for cross-verify
- Self-conformance: shell verifier scores 7/7 ✓ on its own src/
- Containerfile: COPY tools ./tools so /GIT/ browsing surfaces it

Co-Authored-By: Claude Opus 4.7 <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-26 08:16:30 +01:00
parent
69ed6a3
commit
94c61f12ec2f72737d372e94242f07145b570e68

14 files changed · +2008 −0

modified Containerfile +1 −0
@@ -18,6 +18,7 @@ COPY src ./src
1818 COPY content ./content
1919 COPY goals ./goals
2020 COPY public ./public
21+COPY tools ./tools
2122
2223 ENV PORT=3000
2324 EXPOSE 3000
added tools/sama-cli/README.md +114 −0
@@ -0,0 +1,114 @@
1+# sama-cli — the SAMA v2 shell verifier
2+
3+A second, independent implementation of the SAMA v2 §4 conformance
4+checks, written in **pure POSIX shell** (bash + find + grep + awk +
5+wc + sed + sort). Acts as the cross-verifier oracle for the
6+TypeScript verifier at [`src/b32_sama_v2_verify.ts`](../../src/b32_sama_v2_verify.ts).
7+
8+When both verifiers report `7 / 7 ✓` against the same source tree,
9+the verdict is empirical — two implementations of the same spec, in
10+different languages on different runtimes, agree.
11+
12+## Usage
13+
14+```sh
15+sama check # verify the current repo
16+sama check --src=path --profile=path/sama.profile.toml
17+sama check --summary # one-line verdict, for scripting
18+sama check sorted # run a single check
19+sama graph [out.dot] # emit graphviz .dot of imports
20+sama doctor # show tool availability
21+sama --version
22+sama --help
23+```
24+
25+## Architecture (SAMA v2 self-conforming)
26+
27+```
28+tools/sama-cli/
29+├── sama # entry wrapper
30+├── sama.profile.toml # SAMA v2 profile for THIS sub-project
31+├── cross-verify.sh # run both verifiers, assert agreement
32+├── run-ts-verifier.ts # thin Bun CLI bridge to the TS verifier
33+├── README.md # this file
34+└── src/
35+ ├── a31_constants.sh # Layer 0 — Pure (constants only)
36+ ├── b32_utils.sh # Layer 1 — Core (helpers)
37+ ├── b32_utils.test.sh # Sibling test (per §4.3 Modeled)
38+ ├── b32_checks.sh # Layer 1 — Core (the 7 §4 checks)
39+ ├── b32_checks.test.sh # Sibling test
40+ ├── c14_graph.sh # Layer 2 — Adapter (calls graphviz dot)
41+ ├── c14_graph.test.sh # Sibling test
42+ └── d21_main.sh # Layer 3 — Entry (dispatcher)
43+```
44+
45+**Layer mapping** (canonical, matches the rest of the repo):
46+- `a*_` = Layer 0 Pure (no I/O, no side effects)
47+- `b*_` = Layer 1 Core (pure-function logic + render)
48+- `c*_` = Layer 2 Adapter (the boundary — calls external tools)
49+- `d*_` = Layer 3 Entry (dispatcher, argv, exit)
50+
51+The `cli.md` email proposal at the repo root inverted this mapping
52+(`a*_` → Layer 3, `c*_` → Layer 0) — the actual implementation
53+explicitly corrects that bug per the `/goals/sama-cli-shell-verifier`
54+contract. See [/sama/v2 §1.1](https://tdd.md/sama/v2) for the
55+canonical layer table.
56+
57+## Cross-verifier agreement
58+
59+The empirical claim:
60+
61+> *"Two independent implementations of the SAMA v2 §4 spec, written
62+> in different languages on different runtimes, will produce
63+> identical verdicts against any spec-conforming codebase. If they
64+> disagree on this repo's 7/7 ✓, one is wrong — and per /sama/v2 §0
65+> the disagreement is auditable from the spec prose alone."*
66+
67+Run it:
68+
69+```sh
70+tools/sama-cli/cross-verify.sh # against this repo
71+tools/sama-cli/cross-verify.sh --self # against tools/sama-cli/src
72+```
73+
74+Exit `0` if the verdicts match, `1` if they diverge. A divergence
75+is a §6 evolution-policy pressure point — the place where the spec
76+prose admits multiple readings.
77+
78+## Sibling tests
79+
80+Each `b*_` and `c*_` source file has a `<name>.test.sh` sibling
81+(per §4.3 Modeled). The test harness is in-file — a minimal
82+`assert_eq` / `assert_contains` pair, no `bats` dependency. Run all
83+tests:
84+
85+```sh
86+for t in tools/sama-cli/src/*.test.sh; do bash "$t"; done
87+```
88+
89+Or just run the `Modeled` check of the verifier itself:
90+
91+```sh
92+tools/sama-cli/sama check modeled-tests \
93+ --src=tools/sama-cli/src \
94+ --profile=tools/sama-cli/sama.profile.toml
95+```
96+
97+## Anti-fudge constraints (per the /goal)
98+
99+- **No Bun, no Node, no language runtime beyond POSIX shell.** Pure
100+ bash + standard Linux utilities. `dot` (graphviz) is optional and
101+ only used by `sama graph`.
102+- **Layer mapping is canonical** (`a*_` = Pure, `d*_` = Entry — not
103+ the cli.md email inversion).
104+- **Self-conformance is non-negotiable.** `sama check` against this
105+ directory's own `src/` returns `7 / 7 ✓`.
106+- **`sama-import:` comment annotation.** Shell sourcing is too
107+ heterogeneous to parse robustly (paths via `${VAR}`, `$BASH_SOURCE`,
108+ etc.) — so each source file declares its imports with explicit
109+ `# sama-import: <filename.sh>` comments near the top. The
110+ verifier reads these as ground truth for the §1.2 Law check.
111+- **Two-verifier agreement is load-bearing.** A disagreement between
112+ TS and shell verifiers is a spec ambiguity, not a "one of them is
113+ wrong" — but the burden of proof lies with the shell verifier
114+ until the §5 cross-repo evidence accumulates.
added tools/sama-cli/cross-verify.sh +82 −0
@@ -0,0 +1,82 @@
1+#!/usr/bin/env bash
2+# cross-verify.sh — run both SAMA v2 verifiers (TS via Bun, shell
3+# via this directory's `sama check`) and assert their verdicts
4+# agree. The /goal demands "verdicts identical" — comparing the
5+# overall pass-count per check is the right granularity: violation
6+# strings differ in wording but the agreement claim is about which
7+# checks pass/fail.
8+#
9+# Exit 0 if both produce 7/7 ✓ (or any identical N/7 verdict);
10+# exit 1 if they diverge. Designed for CI hook + manual use.
11+#
12+# Usage:
13+# tools/sama-cli/cross-verify.sh # verify the main repo
14+# tools/sama-cli/cross-verify.sh --self # verify tools/sama-cli/src
15+
16+set -u
17+
18+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../.." && pwd)"
19+SAMA_CLI_DIR="$REPO_ROOT/tools/sama-cli"
20+
21+mode="main"
22+if [ "${1:-}" = "--self" ]; then
23+ mode="self"
24+fi
25+
26+echo "── cross-verify: SAMA v2 (TS vs shell) — target: $mode ──"
27+
28+ts_verdict=""
29+sh_verdict=""
30+
31+if [ "$mode" = "main" ]; then
32+ # Main repo: TS verifier runs against ./src with ./sama.profile.toml.
33+ cd "$REPO_ROOT" || exit 2
34+ ts_verdict="$(bun run "$SAMA_CLI_DIR/run-ts-verifier.ts" 2>&1 | tail -1)"
35+ sh_verdict="$("$SAMA_CLI_DIR/sama" check --summary 2>&1 | grep -oE '[0-9]+ / 7' | tail -1)"
36+else
37+ # Self mode: only the shell verifier can verify its own .sh tree —
38+ # the TS verifier hard-codes .ts. So "agreement" here is one-sided
39+ # (shell vs spec). We still emit the verdict for the record.
40+ ts_verdict="(n/a — TS verifier is .ts-only)"
41+ sh_verdict="$("$SAMA_CLI_DIR/sama" check \
42+ --profile="$SAMA_CLI_DIR/sama.profile.toml" \
43+ --src="$SAMA_CLI_DIR/src" --summary 2>&1 \
44+ | grep -oE '[0-9]+ / 7' | tail -1)"
45+fi
46+
47+# Extract just the verdict line from main mode if it has extra text.
48+sh_pass_count="$(echo "$sh_verdict" | grep -oE '^[0-9]+' | head -1)"
49+ts_pass_count="$(echo "$ts_verdict" | grep -oE '^[0-9]+' | head -1)"
50+
51+printf " TS verifier : %s\n" "$ts_verdict"
52+printf " Shell verifier: %s\n" "$sh_verdict"
53+echo
54+
55+if [ "$mode" = "self" ]; then
56+ if [ "$sh_pass_count" = "7" ]; then
57+ echo "✓ self-conformance: shell verifier 7/7 against its own source tree"
58+ exit 0
59+ else
60+ echo "✗ self-conformance: shell verifier ${sh_verdict} — should be 7/7"
61+ exit 1
62+ fi
63+fi
64+
65+if [ -z "$ts_pass_count" ] || [ -z "$sh_pass_count" ]; then
66+ echo "✗ cross-verify: one verdict missing — TS=\`$ts_verdict\` shell=\`$sh_verdict\`"
67+ exit 1
68+fi
69+
70+if [ "$ts_pass_count" = "$sh_pass_count" ]; then
71+ echo "✓ empirical agreement: both verifiers report ${sh_pass_count}/7"
72+ if [ "$sh_pass_count" = "7" ]; then
73+ echo " — two independent implementations of the SAMA v2 §4 spec agree on 7/7 ✓"
74+ fi
75+ exit 0
76+fi
77+
78+echo "✗ DISAGREEMENT — TS says $ts_pass_count/7, shell says $sh_pass_count/7"
79+echo " This is a §6 evolution-policy pressure point: the spec admits"
80+echo " multiple readings here. Resolve via prose, update both verifiers,"
81+echo " re-run. See /sama/v2 §6 for the evolution mechanism."
82+exit 1
added tools/sama-cli/run-ts-verifier.ts +24 −0
@@ -0,0 +1,24 @@
1+// Thin Bun CLI runner for the existing TypeScript SAMA v2 verifier.
2+// Loads sama.profile.toml + walks ./src (via c14_sama_profile), runs
3+// verifySamaV2, and emits a one-line summary: "<passed> / <total>".
4+//
5+// Used by tools/sama-cli/cross-verify.sh to compare the TS verifier's
6+// verdict to the shell verifier's verdict. Not part of the runtime
7+// site code — purely a CI/CLI bridge so the two implementations can
8+// be diffed empirically.
9+//
10+// Lives outside src/ so it doesn't count against the §4 verifier's
11+// file budget. The /goal anti-fudge constraint is "no Bun, no Node,
12+// no language runtime BEYOND POSIX in tools/sama-cli/" — that
13+// applies to the shell verifier's implementation. This script is
14+// the OTHER side of the cross-verify gate; it must run the TS
15+// verifier, which IS Bun.
16+
17+import { verifySamaV2 } from "../../src/b32_sama_v2_verify.ts";
18+import { buildSamaV2Input } from "../../src/c14_sama_profile.ts";
19+
20+const input = await buildSamaV2Input();
21+const report = verifySamaV2(input);
22+const passed = report.checks.filter((c) => c.passed).length;
23+const total = report.checks.length;
24+console.log(`${passed} / ${total}`);
added tools/sama-cli/sama +18 −0
@@ -0,0 +1,18 @@
1+#!/usr/bin/env bash
2+# sama — entry wrapper for the SAMA v2 shell verifier. Locates
3+# src/d21_main.sh relative to this file, exports SAMA_SRC_DIR so
4+# the dispatcher can re-source its siblings, and delegates argv.
5+#
6+# This file is the public CLI surface. It is intentionally tiny —
7+# all dispatch logic lives in d21_main.sh (Layer 3 — Entry per the
8+# tools/sama-cli sub-profile).
9+
10+set -u
11+
12+SAMA_BIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
13+export SAMA_SRC_DIR="$SAMA_BIN_DIR/src"
14+
15+# shellcheck disable=SC1091
16+. "$SAMA_SRC_DIR/d21_main.sh"
17+
18+sama_main "$@"
added tools/sama-cli/sama.profile.toml +36 −0
@@ -0,0 +1,36 @@
1+# SAMA v2 profile — tools/sama-cli sub-project.
2+#
3+# Declares the SAMA v2 layer mapping for the shell verifier itself
4+# (tools/sama-cli/src/). Mirrors the parent repo's profile shape so
5+# the verifier can self-conform: `sama check` pointed at
6+# tools/sama-cli/src/ returns 7/7 ✓ under these rules.
7+#
8+# Canonical layer order: a=Pure 0, b=Core 1, c=Adapter 2, d=Entry 3.
9+# The cli.md email proposal at the repo root inverted this mapping
10+# (a→Layer 3, c→Layer 0) — that is the bug this /goal explicitly
11+# corrects. The shell verifier obeys the same canonical scheme as the
12+# rest of the codebase.
13+
14+sama_version = "2.0"
15+profile = "sama-cli"
16+
17+# File extension scanned for SAMA conformance. The parent repo's
18+# profile implicitly uses ".ts"; this sub-project uses ".sh" so the
19+# verifier can run against its own POSIX-shell source tree.
20+extension = ".sh"
21+
22+# Layer 0 — Pure. Constants only. No I/O, no side effects.
23+[layers.0]
24+prefixes = ["a31_"]
25+
26+# Layer 1 — Core. Pure-function helpers and the seven §4 checks.
27+[layers.1]
28+prefixes = ["b32_"]
29+
30+# Layer 2 — Adapter. Boundary calls to external tools (graphviz `dot`).
31+[layers.2]
32+prefixes = ["c14_"]
33+
34+# Layer 3 — Entry. The dispatcher invoked by the `sama` wrapper.
35+[layers.3]
36+prefixes = ["d21_"]
added tools/sama-cli/src/a31_constants.sh +55 −0
@@ -0,0 +1,55 @@
1+# a31 — model: constants and pure data shared across the shell
2+# verifier. No I/O, no side effects, no exported functions — just
3+# variable assignments. Sourced by every other file.
4+#
5+# Mirrors the constants in src/a31_sama_v2.ts so the two verifiers
6+# agree on the same operational definitions (§4.5 Atomic line cap,
7+# §5 working-set bounds, the parse-boundary patterns from §4.4).
8+
9+# — §4.5 Atomic line cap -------------------------------------------
10+# Files exceeding this LOC count fail the Atomic check. Matches
11+# ATOMIC_LINE_CAP in src/b32_sama_v2_verify.ts.
12+MAX_LINES=700
13+
14+# — §5 working-set bounds -----------------------------------------
15+# Files within [WORKING_SET_MIN_LOC, WORKING_SET_MAX_LOC] are in the
16+# "sweet spot." Used by metrics, not by the §4 checks. Match
17+# src/a31_sama_v2.ts so polyglot working-set measurements agree.
18+WORKING_SET_MIN_LOC=50
19+WORKING_SET_MAX_LOC=500
20+
21+# — §4.4 parse-boundary patterns -----------------------------------
22+# Regex sources that count as "external input parsed at the
23+# boundary." Layer 2 is the legitimate site; anywhere else fails
24+# the Modeled-boundary check.
25+#
26+# Patterns are extension-aware:
27+# .ts files → JSON.parse(, new URL( (mirrors PARSE_BOUNDARY_PATTERNS
28+# in src/a31_sama_v2.ts)
29+# .sh files → no boundary patterns defined yet (the shell verifier's
30+# own source tree has no analog of JSON.parse / new URL)
31+PARSE_BOUNDARY_PATTERNS_TS_NAMES="JSON.parse new URL"
32+PARSE_BOUNDARY_PATTERN_TS_JSON_PARSE='\bJSON\.parse[[:space:]]*\('
33+PARSE_BOUNDARY_PATTERN_TS_NEW_URL='\bnew[[:space:]]+URL[[:space:]]*\('
34+
35+PARSE_BOUNDARY_PATTERNS_SH_NAMES=""
36+
37+# — Output styling -------------------------------------------------
38+# ANSI escape codes for terminal output. Disabled when stdout is not
39+# a TTY (the dispatcher handles that — these are the raw codes).
40+COLOR_RESET=$'\033[0m'
41+COLOR_GREEN=$'\033[32m'
42+COLOR_RED=$'\033[31m'
43+COLOR_YELLOW=$'\033[33m'
44+COLOR_DIM=$'\033[2m'
45+COLOR_BOLD=$'\033[1m'
46+
47+# Verdict glyphs. Match the TS verifier's "✓" / "✗" so cross-verify
48+# diff'ing the two summaries is trivial.
49+GLYPH_PASS="✓"
50+GLYPH_FAIL="✗"
51+
52+# — Tool list ------------------------------------------------------
53+# Used by `sama doctor`. Each entry is "tool:required" — `dot` is
54+# the only optional one (graphviz; only needed for `sama graph`).
55+SAMA_CLI_TOOLS="bash:required find:required grep:required awk:required wc:required sed:required sort:required dot:optional"
added tools/sama-cli/src/b32_checks.sh +397 −0
@@ -0,0 +1,397 @@
1+# b32 — logic: the seven SAMA v2 §4 conformance checks, implemented
2+# as pure shell functions over an in-memory file list. Mirrors
3+# src/b32_sama_v2_verify.ts. Each check populates the globals
4+# CHECK_EXAMINED + CHECK_VIOLATIONS so the d21 dispatcher can render
5+# verdicts uniformly. No I/O beyond reading the explicitly-listed
6+# files; the file walker (list_repo_files) lives in b32_utils.
7+
8+# sama-import: a31_constants.sh
9+# sama-import: b32_utils.sh
10+
11+# Output globals — reset by every run_check_* function.
12+CHECK_EXAMINED=0
13+CHECK_VIOLATIONS=()
14+
15+# Cached SAMA_FILES + TEST_FILES — populated by collect_input.
16+SAMA_FILES=()
17+TEST_FILES=()
18+ALL_FILES=()
19+ALL_FILES_FULLPATHS=()
20+REPO_ROOT=""
21+SRC_DIR=""
22+
23+# — collect_input -------------------------------------------------
24+# Walks <src_dir>, populates SAMA_FILES / TEST_FILES / ALL_FILES.
25+# Also records the full filesystem path in ALL_FILES_FULLPATHS so
26+# checks can stat / cat files without re-resolving paths.
27+collect_input() {
28+ REPO_ROOT="$1"
29+ SRC_DIR="$2"
30+ SAMA_FILES=()
31+ TEST_FILES=()
32+ ALL_FILES=()
33+ ALL_FILES_FULLPATHS=()
34+ local rel
35+ while IFS= read -r rel; do
36+ [ -z "$rel" ] && continue
37+ ALL_FILES+=("$rel")
38+ ALL_FILES_FULLPATHS+=("$REPO_ROOT/$rel")
39+ if is_sama_file "$rel"; then
40+ SAMA_FILES+=("$rel")
41+ elif is_test_file "$rel"; then
42+ TEST_FILES+=("$rel")
43+ fi
44+ done < <(list_repo_files "$REPO_ROOT" "$SRC_DIR")
45+}
46+
47+# Resolve a relative ".ts" import like "./c14_git.ts" from a file's
48+# directory to the repo-relative path (e.g. "src/c14_git.ts").
49+# Implemented in shell so the Law/Consistency checks share it.
50+_resolve_import() {
51+ local from_path="$1"
52+ local imp="$2"
53+ local dir="${from_path%/*}"
54+ local rel="${imp#./}"
55+ echo "${dir}/${rel}"
56+}
57+
58+# Returns 0 if <rel_path> is in ALL_FILES.
59+_in_files() {
60+ local target="$1"
61+ local f
62+ for f in "${ALL_FILES[@]}"; do
63+ [ "$f" = "$target" ] && return 0
64+ done
65+ return 1
66+}
67+
68+# — Check 1: Sorted -----------------------------------------------
69+# Every file carries a profile-recognised prefix; lex prefix order
70+# equals layer order.
71+run_check_sorted() {
72+ CHECK_EXAMINED=0
73+ CHECK_VIOLATIONS=()
74+ local n=${#PROFILE_PREFIXES[@]}
75+ local i j
76+ for i in $(seq 0 $((n - 1))); do
77+ for j in $(seq 0 $((n - 1))); do
78+ [ "$i" = "$j" ] && continue
79+ local la="${PROFILE_LAYERS[$i]}"
80+ local pa="${PROFILE_PREFIXES[$i]}"
81+ local lb="${PROFILE_LAYERS[$j]}"
82+ local pb="${PROFILE_PREFIXES[$j]}"
83+ if [ "$la" -lt "$lb" ] && [ "$pa" \> "$pb" ]; then
84+ CHECK_VIOLATIONS+=("$pa::prefix \`$pa\` (layer $la) sorts after \`$pb\` (layer $lb) — lex order must equal layer order")
85+ fi
86+ done
87+ done
88+ local f
89+ for f in "${SAMA_FILES[@]}"; do
90+ CHECK_EXAMINED=$((CHECK_EXAMINED + 1))
91+ if ! declared_layer "$f" > /dev/null; then
92+ CHECK_VIOLATIONS+=("$f::no profile-recognised prefix")
93+ fi
94+ done
95+}
96+
97+# — Check 2: Architecture -----------------------------------------
98+run_check_architecture() {
99+ CHECK_EXAMINED=0
100+ CHECK_VIOLATIONS=()
101+ local f
102+ for f in "${ALL_FILES[@]}"; do
103+ if is_sama_file "$f" || is_test_file "$f"; then
104+ :
105+ else
106+ continue
107+ fi
108+ CHECK_EXAMINED=$((CHECK_EXAMINED + 1))
109+ local matches
110+ matches="$(all_prefix_matches "$f")"
111+ if [ -z "$matches" ]; then
112+ CHECK_VIOLATIONS+=("$f::unprefixed — does not match any profile prefix")
113+ continue
114+ fi
115+ local distinct_layers
116+ distinct_layers="$(echo "$matches" | awk '{print $1}' | sort -u | wc -l)"
117+ if [ "$distinct_layers" -gt 1 ]; then
118+ local detail
119+ detail="$(echo "$matches" | awk '{printf "%s→L%s, ", $2, $1}' | sed 's/, $//')"
120+ CHECK_VIOLATIONS+=("$f::ambiguous — matches multiple layers: $detail")
121+ fi
122+ done
123+}
124+
125+# — Check 3: Modeled (tests) --------------------------------------
126+run_check_modeled_tests() {
127+ CHECK_EXAMINED=0
128+ CHECK_VIOLATIONS=()
129+ local ext="$PROFILE_EXTENSION"
130+ local f
131+ for f in "${SAMA_FILES[@]}"; do
132+ local info
133+ info="$(declared_layer "$f")" || continue
134+ local layer
135+ layer="$(echo "$info" | awk '{print $1}')"
136+ if [ "$layer" != "1" ] && [ "$layer" != "2" ]; then
137+ continue
138+ fi
139+ CHECK_EXAMINED=$((CHECK_EXAMINED + 1))
140+ # Sibling = same path with extension swapped to .test<ext>.
141+ local sibling="${f%${ext}}.test${ext}"
142+ if ! _in_files "$sibling"; then
143+ CHECK_VIOLATIONS+=("$f::no sibling test at \`$sibling\` — Layer $layer requires one")
144+ fi
145+ done
146+}
147+
148+# — Check 4: Modeled (boundary) -----------------------------------
149+run_check_modeled_boundary() {
150+ CHECK_EXAMINED=0
151+ CHECK_VIOLATIONS=()
152+ local f
153+ for f in "${SAMA_FILES[@]}"; do
154+ local info
155+ info="$(declared_layer "$f")" || continue
156+ local layer
157+ layer="$(echo "$info" | awk '{print $1}')"
158+ CHECK_EXAMINED=$((CHECK_EXAMINED + 1))
159+ [ "$layer" = "2" ] && continue
160+ local matches
161+ matches="$(parse_boundary_matches "$REPO_ROOT/$f")"
162+ [ -z "$matches" ] && continue
163+ local pat
164+ while IFS= read -r pat; do
165+ [ -z "$pat" ] && continue
166+ CHECK_VIOLATIONS+=("$f::boundary pattern \`$pat\` found in Layer $layer — parsing belongs in Layer 2")
167+ done <<< "$matches"
168+ done
169+}
170+
171+# — Check 5: Atomic -----------------------------------------------
172+run_check_atomic() {
173+ CHECK_EXAMINED=0
174+ CHECK_VIOLATIONS=()
175+ local f
176+ for f in "${ALL_FILES[@]}"; do
177+ if is_sama_file "$f" || is_test_file "$f"; then
178+ :
179+ else
180+ continue
181+ fi
182+ CHECK_EXAMINED=$((CHECK_EXAMINED + 1))
183+ local fullpath="$REPO_ROOT/$f"
184+ local lc
185+ lc="$(count_lines "$fullpath")"
186+ if [ "$lc" -gt "$MAX_LINES" ]; then
187+ CHECK_VIOLATIONS+=("$f::$lc lines (over the ${MAX_LINES}-line cap — split per UI/data domain)")
188+ fi
189+ if is_barrel "$fullpath"; then
190+ CHECK_VIOLATIONS+=("$f::barrel re-export file (all lines are \`export … from\`)")
191+ fi
192+ done
193+}
194+
195+# — Check 6: The Law (§1.2) ---------------------------------------
196+# Build adjacency, then for every edge A→B require layer(B) <
197+# layer(A), or same layer with sublayer.index(B) <= sublayer.index(A).
198+# Plus DFS cycle detection.
199+run_check_law() {
200+ CHECK_EXAMINED=0
201+ CHECK_VIOLATIONS=()
202+ # Adjacency: ADJ_KEYS holds file paths in encounter order; the
203+ # adjacency for ADJ_KEYS[i] is ADJ_VALUES[i] (space-separated).
204+ ADJ_KEYS=()
205+ ADJ_VALUES=()
206+ local f
207+ for f in "${ALL_FILES[@]}"; do
208+ if is_sama_file "$f" || is_test_file "$f"; then
209+ :
210+ else
211+ continue
212+ fi
213+ CHECK_EXAMINED=$((CHECK_EXAMINED + 1))
214+ local out=""
215+ local imp
216+ while IFS= read -r imp; do
217+ [ -z "$imp" ] && continue
218+ # Only follow edges into files we actually loaded.
219+ if _in_files "$imp"; then
220+ out="$out $imp"
221+ fi
222+ done < <(collect_imports "$f" "$REPO_ROOT")
223+ ADJ_KEYS+=("$f")
224+ ADJ_VALUES+=("${out# }")
225+ done
226+
227+ # Edge-by-edge check.
228+ local i
229+ for i in "${!ADJ_KEYS[@]}"; do
230+ local from="${ADJ_KEYS[$i]}"
231+ local outs="${ADJ_VALUES[$i]}"
232+ [ -z "$outs" ] && continue
233+ local a_info a_layer a_index
234+ a_info="$(declared_layer "$from")" || continue
235+ a_layer="$(echo "$a_info" | awk '{print $1}')"
236+ a_index="$(echo "$a_info" | awk '{print $3}')"
237+ local to
238+ for to in $outs; do
239+ local b_info b_layer b_index b_name
240+ b_info="$(declared_layer "$to")" || continue
241+ b_layer="$(echo "$b_info" | awk '{print $1}')"
242+ b_index="$(echo "$b_info" | awk '{print $3}')"
243+ b_name="$(echo "$b_info" | awk '{print $2}')"
244+ if [ "$b_layer" -lt "$a_layer" ]; then
245+ continue
246+ fi
247+ if [ "$b_layer" -gt "$a_layer" ]; then
248+ CHECK_VIOLATIONS+=("$from::imports \`$to\` — Layer $a_layer → Layer $b_layer (upward, breaks §1.2)")
249+ continue
250+ fi
251+ # Same layer: sublayer ordering — target must be earlier-or-equal.
252+ if [ "$b_index" -gt "$a_index" ]; then
253+ local a_name
254+ a_name="$(echo "$a_info" | awk '{print $2}')"
255+ CHECK_VIOLATIONS+=("$from::imports \`$to\` — same layer $a_layer but sublayer order reversed ($a_name index $a_index → $b_name index $b_index)")
256+ fi
257+ done
258+ done
259+
260+ # DFS cycle detection on the same graph.
261+ _detect_cycles_law
262+}
263+
264+# Cycle detector. Uses parallel arrays + a recursive bash function.
265+# Emits violations directly into CHECK_VIOLATIONS.
266+_detect_cycles_law() {
267+ CYCLE_COLOR_KEYS=()
268+ CYCLE_COLOR_VALS=()
269+ CYCLE_STACK=()
270+ local k
271+ for k in "${ADJ_KEYS[@]}"; do
272+ CYCLE_COLOR_KEYS+=("$k")
273+ CYCLE_COLOR_VALS+=("0") # 0=white, 1=gray, 2=black
274+ done
275+ local i
276+ for i in "${!CYCLE_COLOR_KEYS[@]}"; do
277+ if [ "${CYCLE_COLOR_VALS[$i]}" = "0" ]; then
278+ _dfs "${CYCLE_COLOR_KEYS[$i]}" || true
279+ fi
280+ done
281+}
282+
283+_color_get() {
284+ local node="$1"
285+ local i
286+ for i in "${!CYCLE_COLOR_KEYS[@]}"; do
287+ if [ "${CYCLE_COLOR_KEYS[$i]}" = "$node" ]; then
288+ echo "${CYCLE_COLOR_VALS[$i]}"
289+ return 0
290+ fi
291+ done
292+ echo "0"
293+}
294+
295+_color_set() {
296+ local node="$1"
297+ local val="$2"
298+ local i
299+ for i in "${!CYCLE_COLOR_KEYS[@]}"; do
300+ if [ "${CYCLE_COLOR_KEYS[$i]}" = "$node" ]; then
301+ CYCLE_COLOR_VALS[$i]="$val"
302+ return 0
303+ fi
304+ done
305+ CYCLE_COLOR_KEYS+=("$node")
306+ CYCLE_COLOR_VALS+=("$val")
307+}
308+
309+_adj_for() {
310+ local node="$1"
311+ local i
312+ for i in "${!ADJ_KEYS[@]}"; do
313+ if [ "${ADJ_KEYS[$i]}" = "$node" ]; then
314+ echo "${ADJ_VALUES[$i]}"
315+ return 0
316+ fi
317+ done
318+}
319+
320+_dfs() {
321+ local node="$1"
322+ _color_set "$node" "1"
323+ CYCLE_STACK+=("$node")
324+ local outs
325+ outs="$(_adj_for "$node")"
326+ local next
327+ for next in $outs; do
328+ local c
329+ c="$(_color_get "$next")"
330+ if [ "$c" = "1" ]; then
331+ # Found a back-edge — extract cycle from stack.
332+ local found_idx=-1
333+ local i
334+ for i in "${!CYCLE_STACK[@]}"; do
335+ if [ "${CYCLE_STACK[$i]}" = "$next" ]; then
336+ found_idx="$i"
337+ break
338+ fi
339+ done
340+ if [ "$found_idx" -ge 0 ]; then
341+ local cycle_str=""
342+ for i in $(seq "$found_idx" $((${#CYCLE_STACK[@]} - 1))); do
343+ if [ -z "$cycle_str" ]; then
344+ cycle_str="${CYCLE_STACK[$i]}"
345+ else
346+ cycle_str="$cycle_str → ${CYCLE_STACK[$i]}"
347+ fi
348+ done
349+ cycle_str="$cycle_str → $next"
350+ CHECK_VIOLATIONS+=("${CYCLE_STACK[$found_idx]}::import cycle: $cycle_str")
351+ fi
352+ _color_set "$node" "2"
353+ unset 'CYCLE_STACK[${#CYCLE_STACK[@]}-1]'
354+ return 1
355+ fi
356+ if [ "$c" = "0" ]; then
357+ _dfs "$next" || true
358+ fi
359+ done
360+ _color_set "$node" "2"
361+ unset 'CYCLE_STACK[${#CYCLE_STACK[@]}-1]'
362+ return 0
363+}
364+
365+# — Check 7: Consistency (§3) -------------------------------------
366+# For each file: find the max layer of any in-tree import. If that
367+# max > declared layer, the prefix lies. (Same-layer with bad
368+# sublayer order is the Law's concern; Consistency only fires when
369+# the layer ceiling itself is breached.)
370+run_check_consistency() {
371+ CHECK_EXAMINED=0
372+ CHECK_VIOLATIONS=()
373+ local f
374+ for f in "${SAMA_FILES[@]}"; do
375+ local a_info a_layer a_prefix
376+ a_info="$(declared_layer "$f")" || continue
377+ a_layer="$(echo "$a_info" | awk '{print $1}')"
378+ a_prefix="$(echo "$a_info" | awk '{print $4}')"
379+ CHECK_EXAMINED=$((CHECK_EXAMINED + 1))
380+ local ceiling=-1
381+ local ceiling_file=""
382+ local imp
383+ while IFS= read -r imp; do
384+ [ -z "$imp" ] && continue
385+ local b_info b_layer
386+ b_info="$(declared_layer "$imp")" || continue
387+ b_layer="$(echo "$b_info" | awk '{print $1}')"
388+ if [ "$b_layer" -gt "$ceiling" ]; then
389+ ceiling="$b_layer"
390+ ceiling_file="$imp"
391+ fi
392+ done < <(collect_imports "$f" "$REPO_ROOT")
393+ if [ "$ceiling" -gt "$a_layer" ]; then
394+ CHECK_VIOLATIONS+=("$f::declared Layer $a_layer (prefix \`$a_prefix\`) but imports reach Layer $ceiling via \`$ceiling_file\` — the prefix claims something the imports contradict")
395+ fi
396+ done
397+}
added tools/sama-cli/src/b32_checks.test.sh +198 −0
@@ -0,0 +1,198 @@
1+#!/usr/bin/env bash
2+# Sibling test for b32_checks.sh. Builds a fixture tree under a
3+# temp dir, runs each of the seven §4 checks against it, and
4+# asserts the expected verdict. Cross-validates the checks against
5+# the same kind of "synthetic small repo" the TS verifier tests in
6+# src/b32_sama_v2_verify.test.ts — same shapes, different language.
7+
8+SAMA_SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
9+# shellcheck disable=SC1091
10+. "$SAMA_SRC_DIR/a31_constants.sh"
11+# shellcheck disable=SC1091
12+. "$SAMA_SRC_DIR/b32_utils.sh"
13+# shellcheck disable=SC1091
14+. "$SAMA_SRC_DIR/b32_checks.sh"
15+
16+TESTS_RUN=0
17+TESTS_FAILED=0
18+
19+assert_eq() {
20+ local expected="$1" actual="$2" label="$3"
21+ TESTS_RUN=$((TESTS_RUN + 1))
22+ if [ "$expected" = "$actual" ]; then
23+ printf " ok %s\n" "$label"
24+ else
25+ TESTS_FAILED=$((TESTS_FAILED + 1))
26+ printf " FAIL %s\n expected: %s\n actual: %s\n" "$label" "$expected" "$actual"
27+ fi
28+}
29+
30+assert_contains_any() {
31+ local label="$1"
32+ shift
33+ local needle="$1"
34+ shift
35+ local found=0
36+ for item in "$@"; do
37+ case "$item" in
38+ *"$needle"*) found=1; break ;;
39+ esac
40+ done
41+ TESTS_RUN=$((TESTS_RUN + 1))
42+ if [ "$found" = "1" ]; then
43+ printf " ok %s\n" "$label"
44+ else
45+ TESTS_FAILED=$((TESTS_FAILED + 1))
46+ printf " FAIL %s — needle \`%s\` not in any violation\n" "$label" "$needle"
47+ fi
48+}
49+
50+TMP_DIR="$(mktemp -d)"
51+trap 'rm -rf "$TMP_DIR"' EXIT
52+
53+setup_clean_repo() {
54+ rm -rf "$TMP_DIR/repo"
55+ mkdir -p "$TMP_DIR/repo/src"
56+ cat > "$TMP_DIR/repo/sama.profile.toml" <<'EOF'
57+sama_version = "2.0"
58+profile = "fixture"
59+extension = ".ts"
60+
61+[layers.0]
62+prefixes = ["a_"]
63+
64+[layers.1]
65+prefixes = ["b_"]
66+
67+[layers.2]
68+prefixes = ["c_"]
69+
70+[layers.3]
71+prefixes = ["d_"]
72+EOF
73+}
74+
75+# — Check 1: Sorted (passing case) -------------------------------
76+setup_clean_repo
77+printf "export const x = 1;\n" > "$TMP_DIR/repo/src/a_pure.ts"
78+parse_profile "$TMP_DIR/repo/sama.profile.toml"
79+collect_input "$TMP_DIR/repo" "$TMP_DIR/repo/src"
80+run_check_sorted
81+assert_eq "0" "${#CHECK_VIOLATIONS[@]}" "Sorted: clean fixture has no violations"
82+
83+# — Check 2: Architecture (unprefixed file fails) -----------------
84+setup_clean_repo
85+printf "export const x = 1;\n" > "$TMP_DIR/repo/src/orphan.ts"
86+parse_profile "$TMP_DIR/repo/sama.profile.toml"
87+collect_input "$TMP_DIR/repo" "$TMP_DIR/repo/src"
88+run_check_architecture
89+v_count="${#CHECK_VIOLATIONS[@]}"
90+assert_eq "1" "$v_count" "Architecture: unprefixed file flagged"
91+assert_contains_any "Architecture: detail mentions unprefixed" "unprefixed" "${CHECK_VIOLATIONS[@]}"
92+
93+# — Check 3: Modeled (tests) (b_ file without sibling test) ------
94+setup_clean_repo
95+printf "export const f = 1;\n" > "$TMP_DIR/repo/src/b_core.ts"
96+parse_profile "$TMP_DIR/repo/sama.profile.toml"
97+collect_input "$TMP_DIR/repo" "$TMP_DIR/repo/src"
98+run_check_modeled_tests
99+assert_eq "1" "${#CHECK_VIOLATIONS[@]}" "Modeled (tests): b_ file without sibling test fails"
100+
101+# Pass when sibling test exists.
102+setup_clean_repo
103+printf "export const f = 1;\n" > "$TMP_DIR/repo/src/b_core.ts"
104+printf "// test\n" > "$TMP_DIR/repo/src/b_core.test.ts"
105+parse_profile "$TMP_DIR/repo/sama.profile.toml"
106+collect_input "$TMP_DIR/repo" "$TMP_DIR/repo/src"
107+run_check_modeled_tests
108+assert_eq "0" "${#CHECK_VIOLATIONS[@]}" "Modeled (tests): sibling test satisfies"
109+
110+# — Check 4: Modeled (boundary) (JSON.parse in Layer 1 fails) ----
111+setup_clean_repo
112+printf "export const f = (s) => JSON.parse(s);\n" > "$TMP_DIR/repo/src/b_naughty.ts"
113+parse_profile "$TMP_DIR/repo/sama.profile.toml"
114+collect_input "$TMP_DIR/repo" "$TMP_DIR/repo/src"
115+run_check_modeled_boundary
116+v_count="${#CHECK_VIOLATIONS[@]}"
117+assert_eq "1" "$v_count" "Modeled (boundary): JSON.parse in Layer 1 flagged"
118+
119+# Layer 2 is the legitimate site — same content should not fire.
120+setup_clean_repo
121+printf "export const f = (s) => JSON.parse(s);\n" > "$TMP_DIR/repo/src/c_adapter.ts"
122+parse_profile "$TMP_DIR/repo/sama.profile.toml"
123+collect_input "$TMP_DIR/repo" "$TMP_DIR/repo/src"
124+run_check_modeled_boundary
125+assert_eq "0" "${#CHECK_VIOLATIONS[@]}" "Modeled (boundary): JSON.parse in Layer 2 is OK"
126+
127+# String literal containing JSON.parse should NOT false-positive.
128+setup_clean_repo
129+printf 'const x = "JSON.parse(input)";\nexport const y = x.length;\n' > "$TMP_DIR/repo/src/b_safe.ts"
130+parse_profile "$TMP_DIR/repo/sama.profile.toml"
131+collect_input "$TMP_DIR/repo" "$TMP_DIR/repo/src"
132+run_check_modeled_boundary
133+assert_eq "0" "${#CHECK_VIOLATIONS[@]}" "Modeled (boundary): JSON.parse inside string literal ignored"
134+
135+# — Check 5: Atomic (oversized file fails) -----------------------
136+setup_clean_repo
137+# Make a file with 701 newlines.
138+yes "x" 2>/dev/null | head -n 701 > "$TMP_DIR/repo/src/b_huge.ts"
139+parse_profile "$TMP_DIR/repo/sama.profile.toml"
140+collect_input "$TMP_DIR/repo" "$TMP_DIR/repo/src"
141+run_check_atomic
142+v_count="${#CHECK_VIOLATIONS[@]}"
143+assert_eq "1" "$v_count" "Atomic: 701-line file flagged"
144+
145+# — Check 6: Law (§1.2) — upward import fails --------------------
146+setup_clean_repo
147+printf 'import { x } from "./d_entry.ts";\nexport const y = x;\n' > "$TMP_DIR/repo/src/b_bad.ts"
148+printf 'export const x = 1;\n' > "$TMP_DIR/repo/src/d_entry.ts"
149+parse_profile "$TMP_DIR/repo/sama.profile.toml"
150+collect_input "$TMP_DIR/repo" "$TMP_DIR/repo/src"
151+run_check_law
152+v_count="${#CHECK_VIOLATIONS[@]}"
153+# Should be ≥ 1: the upward edge from b_bad → d_entry.
154+if [ "$v_count" -ge 1 ]; then
155+ TESTS_RUN=$((TESTS_RUN + 1))
156+ printf " ok Law: upward import flagged (%d violation(s))\n" "$v_count"
157+else
158+ TESTS_RUN=$((TESTS_RUN + 1))
159+ TESTS_FAILED=$((TESTS_FAILED + 1))
160+ printf " FAIL Law: upward import not flagged\n"
161+fi
162+
163+# Downward import is OK.
164+setup_clean_repo
165+printf 'import { x } from "./a_pure.ts";\nexport const y = x;\n' > "$TMP_DIR/repo/src/b_good.ts"
166+printf 'export const x = 1;\n' > "$TMP_DIR/repo/src/a_pure.ts"
167+parse_profile "$TMP_DIR/repo/sama.profile.toml"
168+collect_input "$TMP_DIR/repo" "$TMP_DIR/repo/src"
169+run_check_law
170+assert_eq "0" "${#CHECK_VIOLATIONS[@]}" "Law: downward import passes"
171+
172+# — Check 7: Consistency (declared layer < actual import ceiling) ----
173+setup_clean_repo
174+printf 'import { x } from "./d_entry.ts";\nexport const y = x;\n' > "$TMP_DIR/repo/src/b_lies.ts"
175+printf 'export const x = 1;\n' > "$TMP_DIR/repo/src/d_entry.ts"
176+parse_profile "$TMP_DIR/repo/sama.profile.toml"
177+collect_input "$TMP_DIR/repo" "$TMP_DIR/repo/src"
178+run_check_consistency
179+v_count="${#CHECK_VIOLATIONS[@]}"
180+if [ "$v_count" -ge 1 ]; then
181+ TESTS_RUN=$((TESTS_RUN + 1))
182+ printf " ok Consistency: layer-lie flagged\n"
183+else
184+ TESTS_RUN=$((TESTS_RUN + 1))
185+ TESTS_FAILED=$((TESTS_FAILED + 1))
186+ printf " FAIL Consistency: layer-lie not flagged\n"
187+fi
188+
189+# — Summary -------------------------------------------------------
190+echo
191+if [ "$TESTS_FAILED" -eq 0 ]; then
192+ printf "b32_checks.test.sh: %d/%d passed ✓\n" "$TESTS_RUN" "$TESTS_RUN"
193+ exit 0
194+else
195+ printf "b32_checks.test.sh: %d/%d passed, %d FAILED ✗\n" \
196+ "$((TESTS_RUN - TESTS_FAILED))" "$TESTS_RUN" "$TESTS_FAILED"
197+ exit 1
198+fi
added tools/sama-cli/src/b32_utils.sh +477 −0
@@ -0,0 +1,477 @@
1+# b32 — logic: helpers shared across the seven §4 checks. Pure
2+# functions: profile parsing, file walking, declared-layer lookup,
3+# relative-import collection, string/comment masking, line counting,
4+# and output helpers. Sourced by b32_checks.sh and d21_main.sh.
5+# Never reads files outside the explicitly-passed paths.
6+
7+# Profile state — populated by parse_profile. Parallel arrays:
8+# PROFILE_LAYERS[i] = "0" | "1" | "2" | "3"
9+# PROFILE_PREFIXES[i] = "a31_" etc.
10+# PROFILE_SUBLAYER_NAMES[i] = "default" | "logic" etc.
11+# PROFILE_SUBLAYER_INDEXES[i] = "0" | "1" ...
12+PROFILE_NAME=""
13+PROFILE_SAMA_VERSION=""
14+PROFILE_EXTENSION=".ts"
15+PROFILE_LAYERS=()
16+PROFILE_PREFIXES=()
17+PROFILE_SUBLAYER_NAMES=()
18+PROFILE_SUBLAYER_INDEXES=()
19+
20+# — parse_profile -------------------------------------------------
21+# Reads a sama.profile.toml file and populates the PROFILE_* arrays.
22+parse_profile() {
23+ local file="$1"
24+ PROFILE_NAME="$(_extract_top_scalar "$file" profile)"
25+ PROFILE_SAMA_VERSION="$(_extract_top_scalar "$file" sama_version)"
26+ local ext
27+ ext="$(_extract_top_scalar "$file" extension)"
28+ if [ -n "$ext" ]; then
29+ PROFILE_EXTENSION="$ext"
30+ else
31+ PROFILE_EXTENSION=".ts"
32+ fi
33+ PROFILE_LAYERS=()
34+ PROFILE_PREFIXES=()
35+ PROFILE_SUBLAYER_NAMES=()
36+ PROFILE_SUBLAYER_INDEXES=()
37+ local layer_num
38+ for layer_num in 0 1 2 3; do
39+ _parse_layer_section "$file" "$layer_num"
40+ done
41+}
42+
43+# Extract a scalar key=value pair from the top-of-file section
44+# (before any [section] header). Strips surrounding quotes.
45+_extract_top_scalar() {
46+ local file="$1"
47+ local key="$2"
48+ awk -v key="$key" '
49+ /^[[:space:]]*\[/ { exit }
50+ {
51+ line = $0
52+ sub(/#.*$/, "", line)
53+ sub(/^[[:space:]]+/, "", line)
54+ sub(/[[:space:]]+$/, "", line)
55+ if (line == "") next
56+ eq = index(line, "=")
57+ if (eq == 0) next
58+ k = substr(line, 1, eq - 1)
59+ sub(/[[:space:]]+$/, "", k)
60+ if (k != key) next
61+ v = substr(line, eq + 1)
62+ sub(/^[[:space:]]+/, "", v)
63+ sub(/[[:space:]]+$/, "", v)
64+ first = substr(v, 1, 1)
65+ last = substr(v, length(v), 1)
66+ if ((first == "\"" && last == "\"") || (first == "\x27" && last == "\x27")) {
67+ v = substr(v, 2, length(v) - 2)
68+ }
69+ print v
70+ exit
71+ }
72+ ' "$file"
73+}
74+
75+# Parse one [layers.N] section into the PROFILE_* arrays.
76+_parse_layer_section() {
77+ local file="$1"
78+ local layer="$2"
79+ local body
80+ body="$(awk -v target="layers.$layer" '
81+ /^[[:space:]]*\[/ {
82+ sec = $0
83+ sub(/^[[:space:]]*\[/, "", sec)
84+ sub(/\].*$/, "", sec)
85+ gsub(/[[:space:]]/, "", sec)
86+ in_target = (sec == target) ? 1 : 0
87+ next
88+ }
89+ in_target == 1 { print }
90+ ' "$file")"
91+ [ -z "$body" ] && return 0
92+
93+ # prefixes = ["a", "b"] (single-line array)
94+ local prefixes_line
95+ prefixes_line="$(echo "$body" | grep -E '^[[:space:]]*prefixes[[:space:]]*=' | head -1)"
96+ if [ -n "$prefixes_line" ]; then
97+ local raw="${prefixes_line#*=}"
98+ raw="$(echo "$raw" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
99+ raw="${raw#\[}"
100+ raw="${raw%\]}"
101+ local idx=0
102+ local saved_ifs="$IFS"
103+ IFS=','
104+ local p
105+ for p in $raw; do
106+ p="$(echo "$p" | sed 's/[[:space:]]//g;s/"//g;s/'\''//g')"
107+ [ -z "$p" ] && continue
108+ PROFILE_LAYERS+=("$layer")
109+ PROFILE_PREFIXES+=("$p")
110+ PROFILE_SUBLAYER_NAMES+=("default")
111+ PROFILE_SUBLAYER_INDEXES+=("$idx")
112+ idx=$((idx + 1))
113+ done
114+ IFS="$saved_ifs"
115+ return 0
116+ fi
117+
118+ # sublayers = [ { ... }, { ... } ] (single or multi-line)
119+ local sublayers_block
120+ sublayers_block="$(echo "$body" | awk '
121+ /^[[:space:]]*sublayers[[:space:]]*=[[:space:]]*\[/ {
122+ in_block = 1
123+ if ($0 ~ /\][[:space:]]*$/) {
124+ line = $0
125+ sub(/^[^[]*\[/, "", line)
126+ sub(/\][[:space:]]*$/, "", line)
127+ print line
128+ in_block = 0
129+ }
130+ next
131+ }
132+ in_block == 1 && /^[[:space:]]*\]/ { in_block = 0; next }
133+ in_block == 1 { print }
134+ ')"
135+
136+ if [ -n "$sublayers_block" ]; then
137+ local idx=0
138+ local line
139+ while IFS= read -r line; do
140+ [ -z "$line" ] && continue
141+ local name prefix
142+ name="$(echo "$line" | sed -n 's/.*name[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p')"
143+ prefix="$(echo "$line" | sed -n 's/.*prefix[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p')"
144+ if [ -n "$name" ] && [ -n "$prefix" ]; then
145+ PROFILE_LAYERS+=("$layer")
146+ PROFILE_PREFIXES+=("$prefix")
147+ PROFILE_SUBLAYER_NAMES+=("$name")
148+ PROFILE_SUBLAYER_INDEXES+=("$idx")
149+ idx=$((idx + 1))
150+ fi
151+ done <<< "$sublayers_block"
152+ fi
153+}
154+
155+# — declared_layer ------------------------------------------------
156+# Emits "<layer> <sublayer_name> <sublayer_index> <prefix>" for the
157+# FIRST profile prefix that matches the basename of <path>.
158+# Returns 1 if no prefix matches.
159+declared_layer() {
160+ local path="$1"
161+ local base="${path##*/}"
162+ local i
163+ for i in "${!PROFILE_PREFIXES[@]}"; do
164+ local prefix="${PROFILE_PREFIXES[$i]}"
165+ case "$base" in
166+ "$prefix"*)
167+ echo "${PROFILE_LAYERS[$i]} ${PROFILE_SUBLAYER_NAMES[$i]} ${PROFILE_SUBLAYER_INDEXES[$i]} $prefix"
168+ return 0
169+ ;;
170+ esac
171+ done
172+ return 1
173+}
174+
175+# — all_prefix_matches --------------------------------------------
176+# Emits "<layer> <prefix>" for every prefix that the basename of
177+# <path> starts with.
178+all_prefix_matches() {
179+ local path="$1"
180+ local base="${path##*/}"
181+ local i
182+ for i in "${!PROFILE_PREFIXES[@]}"; do
183+ local prefix="${PROFILE_PREFIXES[$i]}"
184+ case "$base" in
185+ "$prefix"*) echo "${PROFILE_LAYERS[$i]} $prefix" ;;
186+ esac
187+ done
188+}
189+
190+# — file classifiers ----------------------------------------------
191+is_sama_file() {
192+ local path="$1"
193+ local ext="$PROFILE_EXTENSION"
194+ case "$path" in
195+ *".test${ext}") return 1 ;;
196+ *"${ext}") return 0 ;;
197+ *) return 1 ;;
198+ esac
199+}
200+
201+is_test_file() {
202+ local path="$1"
203+ local ext="$PROFILE_EXTENSION"
204+ case "$path" in
205+ *".test${ext}") return 0 ;;
206+ *) return 1 ;;
207+ esac
208+}
209+
210+# — list_repo_files -----------------------------------------------
211+# Walks <src_dir> non-recursively, emits one repo-relative path
212+# per line, lex-sorted. <repo_root> is used to strip the prefix.
213+list_repo_files() {
214+ local repo_root="$1"
215+ local src_dir="$2"
216+ local ext="$PROFILE_EXTENSION"
217+ find "$src_dir" -mindepth 1 -maxdepth 1 -type f -name "*${ext}" 2>/dev/null \
218+ | sort \
219+ | while IFS= read -r path; do
220+ echo "${path#${repo_root}/}"
221+ done
222+}
223+
224+# — collect_imports_ts --------------------------------------------
225+# Emits one resolved repo-relative path per line for every
226+# `from "./xxx.ts"` import in <raw_file> that is NOT inside a
227+# JS/TS string literal or comment.
228+#
229+# Implemented as a single awk pass over the file that tracks
230+# string + comment state character-by-character. Mirrors
231+# collectRelativeImports + stripStringsAndComments in
232+# src/a31_sama_v2.ts.
233+_emit_ts_imports_in() {
234+ awk '
235+ function nextc( c) {
236+ if (idx > len) return ""
237+ c = substr(line, idx, 1); idx++; return c
238+ }
239+ {
240+ line = $0
241+ len = length(line)
242+ idx = 1
243+ while (idx <= len) {
244+ c = substr(line, idx, 1)
245+ d = (idx < len) ? substr(line, idx + 1, 1) : ""
246+
247+ if (in_comment_line) {
248+ idx++
249+ continue
250+ }
251+ if (in_comment_block) {
252+ if (c == "*" && d == "/") { in_comment_block = 0; idx += 2; continue }
253+ idx++
254+ continue
255+ }
256+ if (in_string != "") {
257+ if (c == "\\") { idx += 2; continue }
258+ if (c == in_string) in_string = ""
259+ idx++
260+ continue
261+ }
262+ if (c == "/" && d == "/") { in_comment_line = 1; idx += 2; continue }
263+ if (c == "/" && d == "*") { in_comment_block = 1; idx += 2; continue }
264+ if (c == "\"" || c == "\x27" || c == "`") { in_string = c; idx++; continue }
265+
266+ # Look for `from` keyword (with word-boundary before)
267+ if (c == "f" && substr(line, idx, 4) == "from") {
268+ prev = (idx == 1) ? "" : substr(line, idx - 1, 1)
269+ if (prev !~ /[A-Za-z0-9_$]/) {
270+ j = idx + 4
271+ while (j <= len && substr(line, j, 1) ~ /[ \t]/) j++
272+ if (j <= len) {
273+ q = substr(line, j, 1)
274+ if (q == "\"" || q == "\x27") {
275+ k = j + 1
276+ start = k
277+ while (k <= len && substr(line, k, 1) != q) k++
278+ if (k <= len) {
279+ path = substr(line, start, k - start)
280+ if (substr(path, 1, 2) == "./" && substr(path, length(path) - 2) == ".ts") {
281+ print path
282+ }
283+ idx = k + 1
284+ continue
285+ }
286+ }
287+ }
288+ }
289+ }
290+ idx++
291+ }
292+ in_comment_line = 0
293+ }
294+ '
295+}
296+
297+# — collect_imports_sh --------------------------------------------
298+# Emits relative paths for every `# sama-import: xxx.sh` comment in
299+# <raw_file>. Shell sourcing is too heterogeneous to parse robustly
300+# (paths via ${VAR}, $BASH_SOURCE, parameter expansion, etc.) — so
301+# the sub-project annotates each import with an explicit comment
302+# line that the verifier reads as ground truth. The actual `source`
303+# / `.` invocations are expected to live below those comments and
304+# are not parsed.
305+_emit_sh_imports_in() {
306+ awk '
307+ /^[[:space:]]*#[[:space:]]*sama-import:[[:space:]]*[a-zA-Z0-9_.-]+\.sh/ {
308+ line = $0
309+ sub(/.*sama-import:[[:space:]]*/, "", line)
310+ sub(/[[:space:]].*$/, "", line)
311+ if (line != "") print line
312+ }
313+ '
314+}
315+
316+collect_imports() {
317+ local file="$1"
318+ local repo_root="$2"
319+ local dir="${file%/*}"
320+ local raw_file="$repo_root/$file"
321+ local imp
322+ if [ "$PROFILE_EXTENSION" = ".ts" ]; then
323+ _emit_ts_imports_in < "$raw_file" \
324+ | while IFS= read -r imp; do
325+ [ -z "$imp" ] && continue
326+ echo "${dir}/${imp#./}"
327+ done
328+ else
329+ _emit_sh_imports_in < "$raw_file" \
330+ | while IFS= read -r imp; do
331+ [ -z "$imp" ] && continue
332+ echo "${dir}/${imp#./}"
333+ done
334+ fi
335+}
336+
337+# — strip_strings_and_comments ------------------------------------
338+# Blanks out JS/TS string literals (', ", `) and comments (// and
339+# /* */) to whitespace, preserving newlines so line numbers stay
340+# aligned. Mirrors stripStringsAndComments in src/a31_sama_v2.ts.
341+strip_strings_and_comments_file() {
342+ local file="$1"
343+ if [ "$PROFILE_EXTENSION" != ".ts" ]; then
344+ cat "$file"
345+ return 0
346+ fi
347+ awk '
348+ BEGIN { RS = "\x01" } # never occurs — read whole file as one record
349+ {
350+ src = $0
351+ n = length(src)
352+ out = ""
353+ i = 1
354+ while (i <= n) {
355+ c = substr(src, i, 1)
356+ d = (i < n) ? substr(src, i + 1, 1) : ""
357+ if (c == "/" && d == "/") {
358+ out = out " "
359+ i += 2
360+ while (i <= n && substr(src, i, 1) != "\n") { out = out " "; i++ }
361+ } else if (c == "/" && d == "*") {
362+ out = out " "
363+ i += 2
364+ while (i < n && !(substr(src, i, 1) == "*" && substr(src, i + 1, 1) == "/")) {
365+ ch = substr(src, i, 1)
366+ out = (ch == "\n") ? out "\n" : out " "
367+ i++
368+ }
369+ out = out " "
370+ i += 2
371+ } else if (c == "\"" || c == "\x27" || c == "`") {
372+ q = c
373+ out = out " "
374+ i++
375+ while (i <= n && substr(src, i, 1) != q) {
376+ if (substr(src, i, 1) == "\\" && i + 1 <= n) {
377+ out = out " "
378+ i += 2
379+ continue
380+ }
381+ ch = substr(src, i, 1)
382+ out = (ch == "\n") ? out "\n" : out " "
383+ i++
384+ }
385+ out = out " "
386+ i++
387+ } else {
388+ out = out c
389+ i++
390+ }
391+ }
392+ printf "%s", out
393+ }
394+ ' "$file"
395+}
396+
397+# — count_lines ---------------------------------------------------
398+# Returns (number of "\n" in file) + 1, matching TS
399+# content.split("\n").length exactly. wc -l counts newlines, not
400+# the split-length, so we add 1 to align with TS behavior.
401+count_lines() {
402+ local file="$1"
403+ local nl
404+ nl="$(tr -cd '\n' < "$file" | wc -c)"
405+ echo $((nl + 1))
406+}
407+
408+# — is_barrel -----------------------------------------------------
409+# Returns 0 if <file> is a barrel re-export file: ≥2 non-empty
410+# code lines (after stripping), and every code line is
411+# `export ... from ...`. Only meaningful for .ts.
412+is_barrel() {
413+ local file="$1"
414+ [ "$PROFILE_EXTENSION" = ".ts" ] || return 1
415+ local stripped
416+ stripped="$(strip_strings_and_comments_file "$file")"
417+ local code_lines
418+ code_lines="$(echo "$stripped" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -cE '.')"
419+ [ "$code_lines" -ge 2 ] || return 1
420+ local export_lines
421+ export_lines="$(echo "$stripped" \
422+ | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \
423+ | grep -E '.' \
424+ | grep -cE '^export[[:space:]]+(\*|\{).*\bfrom\b')"
425+ [ "$export_lines" -eq "$code_lines" ] && return 0 || return 1
426+}
427+
428+# — parse_boundary_matches ----------------------------------------
429+# Emits matched pattern names ("JSON.parse" / "new URL") one per
430+# line for any pattern occurring in the STRIPPED contents of <file>.
431+# Only meaningful for .ts; .sh returns nothing.
432+parse_boundary_matches() {
433+ local file="$1"
434+ [ "$PROFILE_EXTENSION" = ".ts" ] || return 0
435+ local stripped
436+ stripped="$(strip_strings_and_comments_file "$file")"
437+ if echo "$stripped" | grep -qE "$PARSE_BOUNDARY_PATTERN_TS_JSON_PARSE"; then
438+ echo "JSON.parse"
439+ fi
440+ if echo "$stripped" | grep -qE "$PARSE_BOUNDARY_PATTERN_TS_NEW_URL"; then
441+ echo "new URL"
442+ fi
443+}
444+
445+# — output helpers ------------------------------------------------
446+SAMA_COLOR_ENABLED=1
447+sama_color_disable() { SAMA_COLOR_ENABLED=0; }
448+_c() {
449+ if [ "$SAMA_COLOR_ENABLED" = "1" ]; then printf "%s" "$1"; fi
450+ return 0
451+}
452+
453+print_section_header() {
454+ echo
455+ _c "$COLOR_BOLD"
456+ echo "── $1 ──────────────────────────────────────"
457+ _c "$COLOR_RESET"
458+}
459+
460+print_check_verdict() {
461+ local id="$1" name="$2" examined="$3" violations="$4"
462+ if [ "$violations" -eq 0 ]; then
463+ _c "$COLOR_GREEN"
464+ printf " %s Check %s: %s — %d examined, 0 violations\n" "$GLYPH_PASS" "$id" "$name" "$examined"
465+ _c "$COLOR_RESET"
466+ else
467+ _c "$COLOR_RED"
468+ printf " %s Check %s: %s — %d examined, %d violations\n" "$GLYPH_FAIL" "$id" "$name" "$examined" "$violations"
469+ _c "$COLOR_RESET"
470+ fi
471+}
472+
473+print_violation() {
474+ _c "$COLOR_DIM"
475+ printf " %s %s\n" "$1" "$2"
476+ _c "$COLOR_RESET"
477+}
added tools/sama-cli/src/b32_utils.test.sh +142 −0
@@ -0,0 +1,142 @@
1+#!/usr/bin/env bash
2+# Sibling test for b32_utils.sh. Asserts the pure helpers behave
3+# as documented: profile parsing populates the PROFILE_* arrays,
4+# the file classifiers split on .test extension correctly, and
5+# the line counter matches the TS "split('\n').length" semantics.
6+#
7+# A minimal in-file harness (no bats dependency): each test calls
8+# `assert_eq` which prints PASS/FAIL and increments counters.
9+# Exit 0 if all assertions pass, 1 otherwise. Run from any cwd.
10+
11+SAMA_SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
12+# shellcheck disable=SC1091
13+. "$SAMA_SRC_DIR/a31_constants.sh"
14+# shellcheck disable=SC1091
15+. "$SAMA_SRC_DIR/b32_utils.sh"
16+
17+TESTS_RUN=0
18+TESTS_FAILED=0
19+
20+assert_eq() {
21+ local expected="$1" actual="$2" label="$3"
22+ TESTS_RUN=$((TESTS_RUN + 1))
23+ if [ "$expected" = "$actual" ]; then
24+ printf " ok %s\n" "$label"
25+ else
26+ TESTS_FAILED=$((TESTS_FAILED + 1))
27+ printf " FAIL %s\n expected: %s\n actual: %s\n" "$label" "$expected" "$actual"
28+ fi
29+}
30+
31+assert_contains() {
32+ local haystack="$1" needle="$2" label="$3"
33+ TESTS_RUN=$((TESTS_RUN + 1))
34+ case "$haystack" in
35+ *"$needle"*) printf " ok %s\n" "$label" ;;
36+ *)
37+ TESTS_FAILED=$((TESTS_FAILED + 1))
38+ printf " FAIL %s\n haystack: %s\n needle: %s\n" "$label" "$haystack" "$needle"
39+ ;;
40+ esac
41+}
42+
43+mkfile() {
44+ local content="$1" path="$2"
45+ printf "%s" "$content" > "$path"
46+}
47+
48+# Run tests in a temp dir so we don't touch the real repo profile.
49+TMP_DIR="$(mktemp -d)"
50+trap 'rm -rf "$TMP_DIR"' EXIT
51+
52+# — parse_profile -------------------------------------------------
53+PROFILE_PATH="$TMP_DIR/test.profile.toml"
54+cat > "$PROFILE_PATH" <<'EOF'
55+sama_version = "2.0"
56+profile = "test-profile"
57+extension = ".sh"
58+
59+[layers.0]
60+prefixes = ["a_"]
61+
62+[layers.1]
63+sublayers = [
64+ { name = "core", prefix = "b_" },
65+]
66+
67+[layers.2]
68+prefixes = ["c_"]
69+
70+[layers.3]
71+prefixes = ["d_"]
72+EOF
73+
74+parse_profile "$PROFILE_PATH"
75+assert_eq "test-profile" "$PROFILE_NAME" "parse_profile: PROFILE_NAME"
76+assert_eq "2.0" "$PROFILE_SAMA_VERSION" "parse_profile: PROFILE_SAMA_VERSION"
77+assert_eq ".sh" "$PROFILE_EXTENSION" "parse_profile: PROFILE_EXTENSION"
78+assert_eq "4" "${#PROFILE_PREFIXES[@]}" "parse_profile: 4 prefixes"
79+assert_eq "a_" "${PROFILE_PREFIXES[0]}" "parse_profile: layer-0 prefix"
80+assert_eq "b_" "${PROFILE_PREFIXES[1]}" "parse_profile: layer-1 prefix from sublayer"
81+assert_eq "core" "${PROFILE_SUBLAYER_NAMES[1]}" "parse_profile: layer-1 sublayer name"
82+
83+# — file classifiers ----------------------------------------------
84+PROFILE_EXTENSION=".ts"
85+is_sama_file "src/a31_foo.ts" && r=0 || r=1
86+assert_eq "0" "$r" "is_sama_file: src/a31_foo.ts"
87+is_sama_file "src/a31_foo.test.ts" && r=0 || r=1
88+assert_eq "1" "$r" "is_sama_file rejects .test.ts"
89+is_test_file "src/a31_foo.test.ts" && r=0 || r=1
90+assert_eq "0" "$r" "is_test_file: .test.ts"
91+is_test_file "src/a31_foo.ts" && r=0 || r=1
92+assert_eq "1" "$r" "is_test_file rejects plain .ts"
93+
94+# — declared_layer ------------------------------------------------
95+PROFILE_LAYERS=("0" "1" "1" "2" "3")
96+PROFILE_PREFIXES=("a31_" "b32_" "b51_" "c14_" "d21_")
97+PROFILE_SUBLAYER_NAMES=("default" "logic" "render" "io" "handlers")
98+PROFILE_SUBLAYER_INDEXES=("0" "0" "1" "0" "0")
99+result="$(declared_layer "src/b51_render_admin.ts")"
100+assert_eq "1 render 1 b51_" "$result" "declared_layer: b51_ → Layer 1 render"
101+result="$(declared_layer "src/unprefixed.ts")"
102+assert_eq "" "$result" "declared_layer: unprefixed returns empty"
103+
104+# — count_lines ---------------------------------------------------
105+mkfile "a" "$TMP_DIR/one_no_nl.txt"
106+assert_eq "1" "$(count_lines "$TMP_DIR/one_no_nl.txt")" "count_lines: 'a' = 1"
107+
108+mkfile "a\nb\n" "$TMP_DIR/two_with_nl.txt"
109+# printf %s doesn't interpret \n; use printf for real newlines
110+printf "a\nb\n" > "$TMP_DIR/two_with_nl.txt"
111+assert_eq "3" "$(count_lines "$TMP_DIR/two_with_nl.txt")" "count_lines: 'a\\nb\\n' = 3 (matches TS split)"
112+
113+printf "" > "$TMP_DIR/empty.txt"
114+assert_eq "1" "$(count_lines "$TMP_DIR/empty.txt")" "count_lines: empty = 1"
115+
116+# — strip_strings_and_comments -----------------------------------
117+PROFILE_EXTENSION=".ts"
118+mkfile "const x = \"hello\"; // a comment" "$TMP_DIR/strip.ts"
119+stripped="$(strip_strings_and_comments_file "$TMP_DIR/strip.ts")"
120+assert_contains "$stripped" "const x = " "strip preserves code"
121+# After stripping, "hello" should be whitespace and // ... should be whitespace.
122+case "$stripped" in
123+ *"hello"*)
124+ TESTS_FAILED=$((TESTS_FAILED + 1))
125+ printf " FAIL strip removes string content\n leaked: %s\n" "$stripped"
126+ ;;
127+ *)
128+ TESTS_RUN=$((TESTS_RUN + 1))
129+ printf " ok strip removes string content\n"
130+ ;;
131+esac
132+
133+# — Summary -------------------------------------------------------
134+echo
135+if [ "$TESTS_FAILED" -eq 0 ]; then
136+ printf "b32_utils.test.sh: %d/%d passed ✓\n" "$TESTS_RUN" "$TESTS_RUN"
137+ exit 0
138+else
139+ printf "b32_utils.test.sh: %d/%d passed, %d FAILED ✗\n" \
140+ "$((TESTS_RUN - TESTS_FAILED))" "$TESTS_RUN" "$TESTS_FAILED"
141+ exit 1
142+fi
added tools/sama-cli/src/c14_graph.sh +99 −0
@@ -0,0 +1,99 @@
1+# c14 — adapter: import-graph rendering. Walks ALL_FILES,
2+# resolves their import edges via collect_imports, emits a graphviz
3+# .dot file, and (if `dot` is on PATH) renders a PNG. The boundary
4+# call to `dot` happens here, so this file is correctly Layer 2 —
5+# its prefix declares it as such, and the §4.4 Modeled (boundary)
6+# rule permits filesystem + tool boundaries in Layer 2.
7+
8+# sama-import: a31_constants.sh
9+# sama-import: b32_utils.sh
10+
11+# Emit a .dot graph of the import topology to stdout.
12+#
13+# Node attributes are layer-coloured so the rendered PNG makes the
14+# layer stratification visible. Edges flow from importer → imported
15+# (matching the natural reading direction); the .dot `rankdir=LR`
16+# puts Layer 0 on the left and Layer 3 on the right.
17+emit_dot() {
18+ local repo_root="$1"
19+ local src_dir="$2"
20+
21+ echo "digraph sama {"
22+ echo " rankdir = LR;"
23+ echo " node [shape=box, style=\"rounded,filled\", fontname=\"Helvetica\"];"
24+ echo " graph [splines=ortho, nodesep=0.4];"
25+
26+ local color_l0="#cfe8d4"
27+ local color_l1="#cfd8e8"
28+ local color_l2="#e8e0c4"
29+ local color_l3="#e8c4c4"
30+
31+ # Nodes
32+ local f
33+ for f in "${ALL_FILES[@]}"; do
34+ if is_sama_file "$f" || is_test_file "$f"; then
35+ :
36+ else
37+ continue
38+ fi
39+ local label="${f##*/}"
40+ local info
41+ local layer="?"
42+ local color="#dddddd"
43+ if info="$(declared_layer "$f")"; then
44+ layer="$(echo "$info" | awk '{print $1}')"
45+ case "$layer" in
46+ 0) color="$color_l0" ;;
47+ 1) color="$color_l1" ;;
48+ 2) color="$color_l2" ;;
49+ 3) color="$color_l3" ;;
50+ esac
51+ fi
52+ echo " \"$f\" [label=\"$label\\nL$layer\", fillcolor=\"$color\"];"
53+ done
54+
55+ # Edges
56+ for f in "${ALL_FILES[@]}"; do
57+ if is_sama_file "$f" || is_test_file "$f"; then
58+ :
59+ else
60+ continue
61+ fi
62+ local imp
63+ while IFS= read -r imp; do
64+ [ -z "$imp" ] && continue
65+ # Only emit edges into files we actually loaded.
66+ local found=0
67+ local x
68+ for x in "${ALL_FILES[@]}"; do
69+ if [ "$x" = "$imp" ]; then found=1; break; fi
70+ done
71+ [ "$found" = "1" ] || continue
72+ echo " \"$f\" -> \"$imp\";"
73+ done < <(collect_imports "$f" "$repo_root")
74+ done
75+
76+ echo "}"
77+}
78+
79+# Wrapper: write .dot to a path; render PNG if dot is available.
80+render_graph() {
81+ local repo_root="$1"
82+ local src_dir="$2"
83+ local out_dot="${3:-/tmp/sama-graph.dot}"
84+ local out_png="${4:-/tmp/sama-graph.png}"
85+
86+ emit_dot "$repo_root" "$src_dir" > "$out_dot"
87+ echo "Wrote: $out_dot"
88+
89+ if command -v dot > /dev/null 2>&1; then
90+ if dot -Tpng "$out_dot" -o "$out_png" 2>/dev/null; then
91+ echo "Wrote: $out_png"
92+ return 0
93+ fi
94+ echo "warning: \`dot\` failed to render — keeping .dot only" >&2
95+ return 0
96+ fi
97+ echo "info: \`dot\` (graphviz) not installed — wrote .dot only" >&2
98+ return 0
99+}
added tools/sama-cli/src/c14_graph.test.sh +109 −0
@@ -0,0 +1,109 @@
1+#!/usr/bin/env bash
2+# Sibling test for c14_graph.sh. Verifies emit_dot produces a
3+# well-formed graphviz file with the expected node + edge content,
4+# and that render_graph falls back gracefully when `dot` is absent.
5+
6+SAMA_SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
7+# shellcheck disable=SC1091
8+. "$SAMA_SRC_DIR/a31_constants.sh"
9+# shellcheck disable=SC1091
10+. "$SAMA_SRC_DIR/b32_utils.sh"
11+# shellcheck disable=SC1091
12+. "$SAMA_SRC_DIR/b32_checks.sh"
13+# shellcheck disable=SC1091
14+. "$SAMA_SRC_DIR/c14_graph.sh"
15+
16+TESTS_RUN=0
17+TESTS_FAILED=0
18+
19+assert_eq() {
20+ local expected="$1" actual="$2" label="$3"
21+ TESTS_RUN=$((TESTS_RUN + 1))
22+ if [ "$expected" = "$actual" ]; then
23+ printf " ok %s\n" "$label"
24+ else
25+ TESTS_FAILED=$((TESTS_FAILED + 1))
26+ printf " FAIL %s\n expected: %s\n actual: %s\n" "$label" "$expected" "$actual"
27+ fi
28+}
29+
30+assert_contains() {
31+ local haystack="$1" needle="$2" label="$3"
32+ TESTS_RUN=$((TESTS_RUN + 1))
33+ case "$haystack" in
34+ *"$needle"*) printf " ok %s\n" "$label" ;;
35+ *)
36+ TESTS_FAILED=$((TESTS_FAILED + 1))
37+ printf " FAIL %s — needle \`%s\` not found\n" "$label" "$needle"
38+ ;;
39+ esac
40+}
41+
42+TMP_DIR="$(mktemp -d)"
43+trap 'rm -rf "$TMP_DIR"' EXIT
44+
45+# Build a tiny fixture repo.
46+mkdir -p "$TMP_DIR/repo/src"
47+cat > "$TMP_DIR/repo/sama.profile.toml" <<'EOF'
48+sama_version = "2.0"
49+profile = "graph-fixture"
50+extension = ".ts"
51+
52+[layers.0]
53+prefixes = ["a_"]
54+
55+[layers.1]
56+prefixes = ["b_"]
57+
58+[layers.2]
59+prefixes = ["c_"]
60+
61+[layers.3]
62+prefixes = ["d_"]
63+EOF
64+
65+cat > "$TMP_DIR/repo/src/a_pure.ts" <<'EOF'
66+export const X = 1;
67+EOF
68+
69+cat > "$TMP_DIR/repo/src/b_logic.ts" <<'EOF'
70+import { X } from "./a_pure.ts";
71+export const Y = X + 1;
72+EOF
73+
74+parse_profile "$TMP_DIR/repo/sama.profile.toml"
75+collect_input "$TMP_DIR/repo" "$TMP_DIR/repo/src"
76+
77+# — emit_dot ------------------------------------------------------
78+dot_output="$(emit_dot "$TMP_DIR/repo" "$TMP_DIR/repo/src")"
79+
80+assert_contains "$dot_output" "digraph sama {" "emit_dot: opens with digraph block"
81+assert_contains "$dot_output" "src/a_pure.ts" "emit_dot: includes a_pure node"
82+assert_contains "$dot_output" "src/b_logic.ts" "emit_dot: includes b_logic node"
83+assert_contains "$dot_output" "L0" "emit_dot: labels Layer 0"
84+assert_contains "$dot_output" "L1" "emit_dot: labels Layer 1"
85+assert_contains "$dot_output" "\"src/b_logic.ts\" -> \"src/a_pure.ts\"" "emit_dot: emits b → a edge"
86+
87+# — render_graph: writes .dot regardless of `dot` availability ----
88+out_dot="$TMP_DIR/graph.dot"
89+out_png="$TMP_DIR/graph.png"
90+render_graph "$TMP_DIR/repo" "$TMP_DIR/repo/src" "$out_dot" "$out_png" >/dev/null 2>&1
91+if [ -f "$out_dot" ]; then
92+ TESTS_RUN=$((TESTS_RUN + 1))
93+ printf " ok render_graph: writes .dot file\n"
94+else
95+ TESTS_RUN=$((TESTS_RUN + 1))
96+ TESTS_FAILED=$((TESTS_FAILED + 1))
97+ printf " FAIL render_graph: did not write .dot file\n"
98+fi
99+
100+# — Summary -------------------------------------------------------
101+echo
102+if [ "$TESTS_FAILED" -eq 0 ]; then
103+ printf "c14_graph.test.sh: %d/%d passed ✓\n" "$TESTS_RUN" "$TESTS_RUN"
104+ exit 0
105+else
106+ printf "c14_graph.test.sh: %d/%d passed, %d FAILED ✗\n" \
107+ "$((TESTS_RUN - TESTS_FAILED))" "$TESTS_RUN" "$TESTS_FAILED"
108+ exit 1
109+fi
added tools/sama-cli/src/d21_main.sh +256 −0
@@ -0,0 +1,256 @@
1+#!/usr/bin/env bash
2+# d21 — entry: the SAMA v2 shell-CLI dispatcher. Wires the seven §4
3+# checks, the doctor diagnostic, and the graph adapter together into
4+# the user-facing `sama` command. This is the only layer permitted
5+# to read argv, exit, or write to stdout for user consumption.
6+#
7+# Invoked by the `tools/sama-cli/sama` wrapper. The wrapper sources
8+# this file with SAMA_SRC_DIR pointing at tools/sama-cli/src.
9+
10+# sama-import: a31_constants.sh
11+# sama-import: b32_utils.sh
12+# sama-import: b32_checks.sh
13+# sama-import: c14_graph.sh
14+
15+set -u
16+
17+# SAMA_SRC_DIR is exported by the entry wrapper. Re-source siblings
18+# defensively in case d21 is invoked directly.
19+SAMA_SRC_DIR="${SAMA_SRC_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)}"
20+# shellcheck disable=SC1091
21+. "$SAMA_SRC_DIR/a31_constants.sh"
22+# shellcheck disable=SC1091
23+. "$SAMA_SRC_DIR/b32_utils.sh"
24+# shellcheck disable=SC1091
25+. "$SAMA_SRC_DIR/b32_checks.sh"
26+# shellcheck disable=SC1091
27+. "$SAMA_SRC_DIR/c14_graph.sh"
28+
29+# Disable color when stdout isn't a TTY (so cross-verify diffs stay clean).
30+if [ ! -t 1 ]; then
31+ sama_color_disable
32+fi
33+# Respect the NO_COLOR convention (https://no-color.org/).
34+if [ -n "${NO_COLOR:-}" ]; then
35+ sama_color_disable
36+fi
37+
38+print_usage() {
39+ cat <<'EOF'
40+sama — SAMA v2 verifier (shell implementation)
41+
42+USAGE
43+ sama check [check-name] Run all seven §4 checks (or just one)
44+ sama graph [out.dot] Emit graphviz .dot of the import graph
45+ sama doctor List tools the verifier depends on
46+ sama --version Print the verifier version
47+ sama --help This message
48+
49+CHECK NAMES (for `sama check <name>`)
50+ sorted, architecture, modeled-tests, modeled-boundary,
51+ atomic, law, consistency
52+
53+FLAGS
54+ --profile=PATH Path to sama.profile.toml (default: ./sama.profile.toml)
55+ --src=PATH Path to src directory (default: ./src)
56+ --summary Emit only the "N / 7" verdict line (for scripting)
57+
58+EXAMPLES
59+ sama check # run all checks in the current repo
60+ sama check --src=tools/sama-cli/src --profile=tools/sama-cli/sama.profile.toml
61+ sama graph /tmp/graph.dot
62+ sama doctor
63+
64+The shell verifier is the independent oracle for SAMA v2: a second
65+implementation in a different language that should produce the same
66+7/7 ✓ verdict as the TypeScript verifier at src/b32_sama_v2_verify.ts.
67+EOF
68+}
69+
70+print_version() {
71+ echo "sama (sama-cli shell verifier) — SAMA v2.0"
72+}
73+
74+# Parse --key=value style flags from argv. Sets the globals
75+# OPT_PROFILE, OPT_SRC, OPT_SUMMARY, and leaves POSITIONAL args
76+# in the POSITIONAL array.
77+OPT_PROFILE=""
78+OPT_SRC=""
79+OPT_SUMMARY=0
80+POSITIONAL=()
81+parse_flags() {
82+ POSITIONAL=()
83+ local arg
84+ for arg in "$@"; do
85+ case "$arg" in
86+ --profile=*) OPT_PROFILE="${arg#--profile=}" ;;
87+ --src=*) OPT_SRC="${arg#--src=}" ;;
88+ --summary) OPT_SUMMARY=1 ;;
89+ --no-color) sama_color_disable ;;
90+ --help|-h) print_usage; exit 0 ;;
91+ --version) print_version; exit 0 ;;
92+ --*) echo "unknown flag: $arg" >&2; exit 2 ;;
93+ *) POSITIONAL+=("$arg") ;;
94+ esac
95+ done
96+}
97+
98+# Resolve repo root + src dir + profile path based on flags or defaults.
99+resolve_paths() {
100+ local profile_path="$OPT_PROFILE"
101+ local src_path="$OPT_SRC"
102+ if [ -z "$profile_path" ]; then
103+ profile_path="./sama.profile.toml"
104+ fi
105+ if [ -z "$src_path" ]; then
106+ src_path="./src"
107+ fi
108+ # Absolute-ify
109+ profile_path="$(cd "$(dirname "$profile_path")" 2>/dev/null && pwd)/$(basename "$profile_path")"
110+ src_path="$(cd "$src_path" 2>/dev/null && pwd)"
111+ if [ -z "$profile_path" ] || [ ! -f "$profile_path" ]; then
112+ echo "error: profile not found: $OPT_PROFILE (looked at $profile_path)" >&2
113+ exit 2
114+ fi
115+ if [ -z "$src_path" ] || [ ! -d "$src_path" ]; then
116+ echo "error: src dir not found: $OPT_SRC (looked at $src_path)" >&2
117+ exit 2
118+ fi
119+ RESOLVED_PROFILE="$profile_path"
120+ # Repo root = parent of src dir.
121+ RESOLVED_REPO_ROOT="$(cd "$src_path/.." && pwd)"
122+ RESOLVED_SRC_REL="${src_path#${RESOLVED_REPO_ROOT}/}"
123+ RESOLVED_SRC_DIR="$src_path"
124+}
125+
126+cmd_check() {
127+ resolve_paths
128+ parse_profile "$RESOLVED_PROFILE"
129+ collect_input "$RESOLVED_REPO_ROOT" "$RESOLVED_SRC_DIR"
130+
131+ local target=""
132+ if [ "${#POSITIONAL[@]}" -gt 1 ]; then
133+ target="${POSITIONAL[1]}"
134+ fi
135+
136+ local passed=0
137+ local total=0
138+ local results=()
139+ # Order matches src/b32_sama_v2_verify.ts.
140+ local checks_meta=(
141+ "1::Sorted::run_check_sorted::sorted"
142+ "2::Architecture::run_check_architecture::architecture"
143+ "3::Modeled (tests)::run_check_modeled_tests::modeled-tests"
144+ "4::Modeled (boundary)::run_check_modeled_boundary::modeled-boundary"
145+ "5::Atomic::run_check_atomic::atomic"
146+ "6::Law (§1.2)::run_check_law::law"
147+ "7::Consistency (§3)::run_check_consistency::consistency"
148+ )
149+
150+ if [ "$OPT_SUMMARY" = "0" ]; then
151+ print_section_header "SAMA v2 verifier — profile: $PROFILE_NAME"
152+ printf " Source tree: %s\n" "$RESOLVED_SRC_REL"
153+ printf " Files examined: %d (sources + tests)\n" "${#ALL_FILES[@]}"
154+ fi
155+
156+ local meta
157+ for meta in "${checks_meta[@]}"; do
158+ local id name fn short
159+ id="$(echo "$meta" | awk -F '::' '{print $1}')"
160+ name="$(echo "$meta" | awk -F '::' '{print $2}')"
161+ fn="$(echo "$meta" | awk -F '::' '{print $3}')"
162+ short="$(echo "$meta" | awk -F '::' '{print $4}')"
163+ if [ -n "$target" ] && [ "$target" != "$short" ]; then
164+ continue
165+ fi
166+ total=$((total + 1))
167+ "$fn"
168+ local violations="${#CHECK_VIOLATIONS[@]}"
169+ if [ "$violations" -eq 0 ]; then
170+ passed=$((passed + 1))
171+ fi
172+ if [ "$OPT_SUMMARY" = "0" ]; then
173+ print_check_verdict "$id" "$name" "$CHECK_EXAMINED" "$violations"
174+ local v
175+ for v in "${CHECK_VIOLATIONS[@]}"; do
176+ local vf vd
177+ vf="${v%%::*}"
178+ vd="${v#*::}"
179+ print_violation "$vf" "$vd"
180+ done
181+ fi
182+ done
183+
184+ echo
185+ if [ "$passed" = "$total" ]; then
186+ _c "$COLOR_GREEN"
187+ printf " %d / %d %s — all checks passed\n" "$passed" "$total" "$GLYPH_PASS"
188+ _c "$COLOR_RESET"
189+ else
190+ _c "$COLOR_RED"
191+ printf " %d / %d %s — %d check(s) failed\n" "$passed" "$total" "$GLYPH_FAIL" "$((total - passed))"
192+ _c "$COLOR_RESET"
193+ fi
194+ echo
195+
196+ if [ "$passed" = "$total" ]; then
197+ return 0
198+ fi
199+ return 1
200+}
201+
202+cmd_graph() {
203+ resolve_paths
204+ parse_profile "$RESOLVED_PROFILE"
205+ collect_input "$RESOLVED_REPO_ROOT" "$RESOLVED_SRC_DIR"
206+
207+ local out_dot="${POSITIONAL[1]:-/tmp/sama-graph.dot}"
208+ local out_png="${out_dot%.dot}.png"
209+ render_graph "$RESOLVED_REPO_ROOT" "$RESOLVED_SRC_DIR" "$out_dot" "$out_png"
210+}
211+
212+cmd_doctor() {
213+ print_section_header "sama doctor — tool availability"
214+ local entry tool req status version
215+ for entry in $SAMA_CLI_TOOLS; do
216+ tool="${entry%:*}"
217+ req="${entry#*:}"
218+ if command -v "$tool" > /dev/null 2>&1; then
219+ version="$("$tool" --version 2>/dev/null | head -1)"
220+ [ -z "$version" ] && version="(version unknown)"
221+ _c "$COLOR_GREEN"
222+ printf " %s %s [%s]\n" "$GLYPH_PASS" "$tool" "$req"
223+ _c "$COLOR_DIM"
224+ printf " %s\n" "$version"
225+ _c "$COLOR_RESET"
226+ else
227+ if [ "$req" = "required" ]; then
228+ _c "$COLOR_RED"
229+ printf " %s %s [required] — NOT FOUND\n" "$GLYPH_FAIL" "$tool"
230+ _c "$COLOR_RESET"
231+ else
232+ _c "$COLOR_YELLOW"
233+ printf " - %s [optional] — not installed\n" "$tool"
234+ _c "$COLOR_RESET"
235+ fi
236+ fi
237+ done
238+ echo
239+}
240+
241+# Entry: dispatch on the first POSITIONAL.
242+sama_main() {
243+ parse_flags "$@"
244+ if [ "${#POSITIONAL[@]}" -eq 0 ]; then
245+ print_usage
246+ exit 0
247+ fi
248+ local subcmd="${POSITIONAL[0]}"
249+ case "$subcmd" in
250+ check) cmd_check ;;
251+ graph) cmd_graph ;;
252+ doctor) cmd_doctor ;;
253+ help) print_usage ;;
254+ *) echo "unknown command: $subcmd" >&2; print_usage >&2; exit 2 ;;
255+ esac
256+}