diff --git a/docs/superpowers/evidence/shell-extract-tool-2026-08-17.json b/docs/superpowers/evidence/shell-extract-tool-2026-08-17.json new file mode 100644 index 00000000..b010b6ac --- /dev/null +++ b/docs/superpowers/evidence/shell-extract-tool-2026-08-17.json @@ -0,0 +1,38 @@ +{ + "intent": "Generalise the fresh-install/main.sh split method into a reusable script, so the remaining shell splits do not repeat the line-range mistake that broke the first attempt.", + "scope": [ + "New: meta/scripts/extract_shell_functions.py", + "Non-goal: any split; this commit only adds the tool" + ], + "changes": [ + "extract_shell_functions.py walks the script brace-by-brace, lifts only top-level function blocks (with the comment directly above each), and leaves every other line in place - so interleaved top-level commands cannot be swept into the library.", + "It inserts the source directive after the `set -e`/`set -euo pipefail` line, with a `# shellcheck source=` comment relative to the script's own directory, matching the repo convention for new sourced libs.", + "--functions moves a named subset, for scripts where only part of the body should leave.", + "It exits non-zero if a named function does not exist, or if there is no `set -e` line to anchor the source directive, rather than producing a subtly wrong file." + ], + "verification": [ + { + "command": "Re-derive the already-verified fresh-install/main.sh split in a throwaway worktree at HEAD~1, then diff against the committed result", + "result": "pass", + "evidence": "main.sh: IDENTICAL. lib/packages.sh: IDENTICAL. The tool reproduces byte-for-byte a split that was verified by hand with the shfmt harness, a structural flow comparison, and a source-time side-effect check." + }, + { + "command": "python3 meta/scripts/extract_shell_functions.py --help", + "result": "pass", + "evidence": "Prints usage for script, library, --functions and --header." + }, + { + "command": "pre-commit run --files meta/scripts/extract_shell_functions.py", + "result": "pass", + "evidence": "All hooks pass. A T201 print warning was fixed by writing to sys.stdout rather than suppressed." + } + ], + "risks": [ + "The brace counter treats { and } inside strings and heredocs as structural. A function containing an unbalanced brace in a string would be mis-delimited; verify_shell_split.sh reports the function count, so this shows up as a wrong count rather than silently.", + "It assumes the `name() {` form on one line. A script using `function name {` or a brace on the next line would have those functions ignored - again visible as a lower count in the verifier." + ], + "rollback": [ + "git revert the commit; the tool disappears and the already-committed splits are unaffected, since it only generates files.", + "No follow-up validation needed after rollback." + ] +} diff --git a/meta/scripts/extract_shell_functions.py b/meta/scripts/extract_shell_functions.py new file mode 100755 index 00000000..a822692c --- /dev/null +++ b/meta/scripts/extract_shell_functions.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Move a shell script's top-level functions into a sourced library, verbatim. + +Line-range slicing is the obvious approach and it is wrong: these scripts +interleave top-level commands between their function definitions, so a range +slice sweeps those commands into the library, where they run at source time +and in the wrong order. This walks the file brace-by-brace instead, lifts only +the function blocks (with the comment directly above each), and leaves every +other line exactly where it was. + +Verify the result with meta/scripts/verify_shell_split.sh. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import re +import sys + +_FUNC_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*\(\)\s*\{") + + +def find_function_blocks(lines: list[str]) -> list[tuple[int, int, str]]: + """Return (start, end, name) for each top-level function, brace-matched.""" + blocks: list[tuple[int, int, str]] = [] + index = 0 + while index < len(lines): + if _FUNC_RE.match(lines[index]): + start, depth = index, 0 + while index < len(lines): + depth += lines[index].count("{") - lines[index].count("}") + if depth <= 0 and index > start: + break + index += 1 + blocks.append((start, index, lines[start].split("(")[0])) + index += 1 + return blocks + + +def split_script( + path: Path, lib_path: Path, names: list[str] | None, header: str +) -> tuple[int, int]: + """Move the named functions (or all of them) into *lib_path*.""" + lines = path.read_text().split("\n") + blocks = find_function_blocks(lines) + if names: + blocks = [b for b in blocks if b[2] in names] + missing = set(names) - {b[2] for b in blocks} + if missing: + sys.exit(f"no such top-level function(s): {', '.join(sorted(missing))}") + + parts: list[str] = [] + drop: set[int] = set() + for start, end, _name in blocks: + head = start + while head - 1 >= 0 and lines[head - 1].lstrip().startswith("#"): + head -= 1 + parts.append("\n".join(lines[head : end + 1])) + drop.update(range(head, end + 1)) + + lib_path.parent.mkdir(parents=True, exist_ok=True) + lib_path.write_text(header.rstrip() + "\n\n" + "\n\n".join(parts) + "\n") + + kept = [ln for n, ln in enumerate(lines) if n not in drop] + rel = lib_path.relative_to(path.parent) + source_line = '. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/' + str(rel) + '"' + out: list[str] = [] + inserted = False + for line in kept: + out.append(line) + if not inserted and line.strip() in ("set -e", "set -eu", "set -euo pipefail"): + out += ["", f"# shellcheck source={rel}", source_line] + inserted = True + if not inserted: + sys.exit("no `set -e` line found to anchor the source directive") + path.write_text("\n".join(out)) + return len(out), len(lib_path.read_text().split("\n")) + + +def main() -> None: + """Extract functions from a script into a library beside it.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("script", type=Path) + parser.add_argument("library", type=Path) + parser.add_argument( + "--functions", + help="comma-separated function names to move (default: all)", + ) + parser.add_argument( + "--header", + default="#!/usr/bin/env bash\n# Helpers sourced by the entry script.", + help="header comment for the generated library", + ) + args = parser.parse_args() + names = args.functions.split(",") if args.functions else None + entry_len, lib_len = split_script(args.script, args.library, names, args.header) + sys.stdout.write( + f"{args.script}: {entry_len} lines\n{args.library}: {lib_len} lines\n" + ) + + +if __name__ == "__main__": + main()