{"slug": "shellcheck-passed-every-file-the-pipeline-was-still-broken", "title": "ShellCheck Passed Every File. The Pipeline Was Still Broken.", "summary": "A developer built script-chain-walk, a zero-dependency Python CLI (stdlib only, Python 3.9+) that traces cross-file script call chains to catch failures single-file linters miss. Pointed at a directory, it builds a dependency graph and flags broken chains (calls to files that don't exist) and call cycles, using a bash extractor and Python's ast module to detect subprocess, os.system, exec and runpy invocations. It offers JSON output, single-chain tracing via --entry, and a --check mode that exits 1 on broken or cyclic chains for CI pipelines and agent guardrails.", "body_md": "ShellCheck is great at what it does. So is Pylint. So is every linter you've\n\never run in CI. They all share one blind spot: they look at **one file at a time**.\n\nHere's the failure mode that keeps biting me. A deploy script calls\n\n`./cleanup.sh`. The linter passes. The pipeline runs. And it fails — because\n\n`cleanup.sh` was renamed two sprints ago and nobody updated the caller. No\n\nlinter on earth flags a call to a file that doesn't exist, because no linter\n\nis looking at the *relationships between files*.\n\nThe same blind spot shows up with agents. An AI agent writes a tidy little\n\n`setup.sh`, then a `run.py` that calls it via `subprocess`, then a wrapper\n\nthat calls `run.py`… and somewhere in the middle there's a loop, or a dead\n\nreference, and you only find out at 2 AM. What you want in that moment isn't\n\na per-file report. It's a **map**: which script calls which, where the chain\n\ndead-ends, and where it loops back on itself.\n\nI built that map.\n\n**script-chain-walk** — a zero-dependency Python CLI (stdlib only, Python ≥\n\n3.9) that traces cross-file script call chains. Point it at a directory and\n\nit builds the full dependency graph, then flags two things linters can't\n\nsee: **broken chains** (calls to files that don't exist) and **call cycles**.\n\n```\npip install script-chain-walk\nwalk ./scripts\n```\n\nExample output on a messy deploy directory:\n\n```\ndeploy.sh\n|-- migrate.py  (line 3, exec)\n|   `-- seed.sh  (line 12, exec)\n`-- notify.sh  (line 5, exec)\n\n========================================\nscripts: 4   edges: 3   broken: 1   cycles: 0\n\nBROKEN CHAINS (call target not found):\n  notify.sh:8 -> ./cleanup.sh (exec, missing)\n```\n\nThere's also `--format json` for machine-readable output, `--entry` to trace one chain, and \n\ndeploy.sh`--check`, which exits 1 if anything is\n\nbroken or cyclic — made for CI pipelines and agent guardrails.\n\nTwo extractors, no regex soup on the Python side:\n\n`bash foo.sh`, `./foo.sh`, `sh -x foo.sh`,\n`source lib.sh`, `. lib.sh`, `python3 bar.py`, `exec ./foo.sh`.` ast` module. Detects\n`subprocess.run(...)`, `os.system(...)` / `os.popen(...)`,\n`os.execv(...)` and friends, `exec(open(\"other.py\").read())`, and\n`runpy.run_path(\"other.py\")`.\nEvery target is resolved against the directory tree — relative to the\n\ncaller first, then by unique basename match elsewhere in the tree, with\n\nabsolute paths honored. Anything unresolvable is reported as broken (or\n\nambiguous, when several files share the name). Cycles come from a DFS over\n\nthe graph; entry points are scripts nothing calls.\n\nBecause that's a different job. ShellCheck and Pylint answer \"is this file\n\ncorrect?\" script-chain-walk answers \"does this *system of scripts* hold\n\ntogether?\" A single-file tool will never tell you that `a.sh` calls `b.py`\n\ncalls `c.sh` calls `a.sh`. If you run coding agents that generate scripts,\n\n`walk --check` on their output directory is a thirty-second audit of what\n\nthe agent actually wired up — dead scripts it keeps \"calling\" and accidental\n\nrecursion included.\n\n`pip install script-chain-walk` — stdlib only, no dependencies, MIT licensed.\n\nIf it finds a broken chain in your repo, I'd genuinely like to hear about it.", "url": "https://wpnews.pro/news/shellcheck-passed-every-file-the-pipeline-was-still-broken", "canonical_source": "https://dev.to/haoli/shellcheck-passed-every-file-the-pipeline-was-still-broken-4l54", "published_at": "2026-10-21 08:01:39+00:00", "updated_at": "2026-10-21 08:10:24.755949+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-safety"], "entities": ["script-chain-walk", "ShellCheck", "Pylint", "Python"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/shellcheck-passed-every-file-the-pipeline-was-still-broken", "markdown": "https://wpnews.pro/news/shellcheck-passed-every-file-the-pipeline-was-still-broken.md", "text": "https://wpnews.pro/news/shellcheck-passed-every-file-the-pipeline-was-still-broken.txt", "jsonld": "https://wpnews.pro/news/shellcheck-passed-every-file-the-pipeline-was-still-broken.jsonld"}}