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]>
14 files changed · +2008 −0
Containerfile
+1
−0
| @@ -18,6 +18,7 @@ COPY src ./src | ||
| 18 | 18 | COPY content ./content |
| 19 | 19 | COPY goals ./goals |
| 20 | 20 | COPY public ./public |
| 21 | +COPY tools ./tools | |
| 21 | 22 | |
| 22 | 23 | ENV PORT=3000 |
| 23 | 24 | EXPOSE 3000 |
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. | |
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 | |
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}`); | |
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 "$@" | |
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_"] | |
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" | |
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 | +} | |
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 | |
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 | +} | |
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 | |
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 | +} | |
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 | |
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 | +} | |