mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:07:04 +01:00
shell splits: add extract_shell_functions.py
Generalises the method proved on fresh-install/main.sh. 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 them into the library where they run at source time and out of order. This walks the file brace-by-brace, lifts only the function blocks, and leaves every other line where it was. Verified by re-deriving the already-committed main.sh split in a throwaway worktree at HEAD~1: both files come out byte-for-byte identical to the hand-made version that passed the shfmt harness, the structural flow comparison, and the source-time side-effect check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
This commit is contained in:
parent
9b30329ca3
commit
94f4a5adb7
38
docs/superpowers/evidence/shell-extract-tool-2026-08-17.json
Normal file
38
docs/superpowers/evidence/shell-extract-tool-2026-08-17.json
Normal file
@ -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."
|
||||
]
|
||||
}
|
||||
104
meta/scripts/extract_shell_functions.py
Executable file
104
meta/scripts/extract_shell_functions.py
Executable file
@ -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()
|
||||
Loading…
Reference in New Issue
Block a user