#!/usr/bin/env bash # # Create the documentation tree a project is expected to have, copied from the # template's own copy of it, without ever overwriting a document that is # already there. # # bash scripts/scaffold.sh # into the current directory # bash scripts/scaffold.sh --dry-run # list everything, write nothing # bash scripts/scaffold.sh --into ../new-app # bash scripts/scaffold.sh --force # overwrite; needs a clean git tree # SCAFFOLD_TEMPLATE_ROOT=/path/to/Template bash scripts/scaffold.sh # # =========================================================================== # TEMPLATE COPY — configure this before the first run # =========================================================================== # # Copy to `scripts/scaffold.sh` and set TEMPLATE_ROOT in the CONFIGURATION # block below, or pass it as SCAFFOLD_TEMPLATE_ROOT. It is empty and it stays # empty: the template sits at a different absolute path on every machine, and # a plausible-looking default would quietly resolve to whatever checkout # happens to be there. That is not a "file not found" — it is a whole # documentation tree arriving with another project's Owner lines, another # project's review dates and another project's branding, each document reading # as this project's own the moment it lands. # # Assumes: bash and coreutils. git is used only by --force, and only to read. # # ## Why this exists # # The convention this tree encodes is cheap to state and, on the evidence, # almost never adopted whole. Eight repositories were surveyed before the # template was last rewritten: exactly one kept the ledger the template asked # for, three had invented their own dialects, and one had pushed nothing but a # README. Nobody refused the convention. They started projects, and a # convention that has to be re-typed from memory at the start of each one is a # convention that decays into four different ones. # # So the first act of a new project is a command, not a reading exercise. What # is left afterwards — the placeholders, the tracker, the branding — is work # that genuinely needs a person, and it is visible precisely because the # skeleton around it is already correct. # # ## Never overwriting is the whole safety property # # Every other rule here follows from one: a scaffold that clobbers a written # document destroys work that nothing would notice was gone. There is no test # for "the architecture notes used to say more than this", no build failure, no # diff to read if the file was never committed. It is silent at the moment it # happens and silent forever afterwards, and the natural time to re-run a # scaffold — partway into a project, to pick up a file that was missed — is # exactly the time the tree is full of real writing. # # So an existing file is reported as "kept" and left untouched, always. --force # exists for the one honest case (a tree scaffolded from a stale template, not # yet written into) and it refuses twice over: once for the tree, which must be # clean, and again for each file, which git must actually track. A clean tree # alone is not enough — it says nothing about ignored files — so the worst # --force can do is something `git checkout` undoes. # # ## What it deliberately does not do # # It does not touch git. No init, no add, no commit — creating files is enough, # and committing them is a judgement about what belongs in the history of this # project, made by whoever will answer for that history. # # It does not fill anything in. The angle-bracket placeholders are left # standing because a document whose header says it was reviewed on a date # nobody reviewed it is worse than one that visibly has not been filled in: the # first is trusted at the exact moment it should not be. An unfilled header # keeps asking; an invented one stops. # # It does not copy branding. See the docs/data/img note near the bottom. # # It does not copy the scripts that live in this folder. They are in the # template's docs tree for the template's own reasons, and each one needs # configuring before its first run — an unconfigured release.sh landing in # every new repository is a loaded gun, not a head start. Copy those one at a # time, having read them. # # It does not create the tracker labels, the first milestone or the project's # README, and it does not check conformance. It reports what it did. Whether # the project passes is a different question, asked by a different thing. set -uo pipefail # --------------------------------------------------------------------------- # CONFIGURATION — set this one, then delete this banner. # # Empty on purpose. See the note at the top: a default here does not fail, it # copies the wrong project's documents and says "created" for every one. # --------------------------------------------------------------------------- # Absolute path to the template checkout — the directory holding `docs/`. TEMPLATE_ROOT="${SCAFFOLD_TEMPLATE_ROOT:-}" # --------------------------------------------------------------------------- # The tree, listed explicitly. # # A recursive copy would be shorter and would drag in whatever else is sitting # in the template's docs tree today, including this script. An explicit list is # also the only thing that can tell the difference between "the template does # not have that file" and "the copy quietly produced twelve of thirteen" — the # second looks identical to success from the outside. # --------------------------------------------------------------------------- DOCS=( docs/DOC_TRUST_MAP.md docs/WORK_CYCLE.md docs/OPERATIONS.md docs/TOOLS.md docs/planning/PROJECT_PLAN.md docs/qa/ClaudeQAPlan.md docs/qa/ClaudeQACoverage.md docs/qa/ClaudeReport.md docs/history/HISTORY.md docs/history/DEVELOPMENT_LOG.md docs/history/BATCH_LEDGER.md docs/security/SECURITY.md docs/security/SECURITY_CHECKLIST.md docs/architecture/README.md docs/design/README.md docs/data/README.md ) # Directories made empty, holding nothing this script is willing to invent. DIRS=(docs/data/img) # The three names the conformance check looks for, by name, in docs/data/img. BRANDING=(icon.webp logo.webp banner.webp) # The placeholders the template leaves for a person to replace. Used only to # count what is still outstanding at the end — if the template's placeholder # style changes this finds nothing, which is why finding nothing prints nothing # rather than an all-clear. PLACEHOLDER_RE='<(Project|YYYY-MM-DD|who maintains this)>' say() { printf '\033[1mscaffold:\033[0m %s\n' "$*" >&2; } die() { printf '\033[1mscaffold:\033[0m %s\n' "$*" >&2; exit 1; } usage() { cat >&2 <<'EOF' scaffold: create the documentation tree a project is expected to have. bash scripts/scaffold.sh into the current directory bash scripts/scaffold.sh --into into another project root bash scripts/scaffold.sh --dry-run list what would happen, write nothing bash scripts/scaffold.sh --force overwrite; refuses on a dirty git tree SCAFFOLD_TEMPLATE_ROOT path to the template checkout (required, no default) SCAFFOLD_INTO same as --into An existing file is never overwritten without --force. Exits non-zero if any path was skipped, including one the template itself is missing. EOF } DRY_RUN="" FORCE="" TARGET="${SCAFFOLD_INTO:-}" # Set only when --force resolves a repository. Declared here so the copy loop # can test it under `set -u` whether or not --force was passed. force_top="" # Whether the operator named the target, as opposed to inheriting the current # directory. The guard below is only for the inherited case. TARGET_NAMED="" [ -n "$TARGET" ] && TARGET_NAMED="yes" while [ "$#" -gt 0 ]; do case "$1" in --dry-run) DRY_RUN="yes" ;; --force) FORCE="yes" ;; --into) # A bare --into would otherwise swallow the next flag as a path and # scaffold into a directory called "--dry-run". [ "$#" -ge 2 ] || die "--into needs a path." case "$2" in -*) die "--into needs a path, got '$2'." ;; esac # An empty string is not "here". Without this it becomes "." while still # counting as named, which silently disables the project-root guard below # — the one case where the guard is most likely to be right. [ -n "$2" ] || die "--into was given an empty path. Use --into . to mean the current directory." TARGET="$2"; TARGET_NAMED="yes"; shift ;; --into=*) TARGET="${1#--into=}"; TARGET_NAMED="yes" [ -n "$TARGET" ] || die "--into needs a path." ;; -h|--help) usage; exit 0 ;; *) die "unknown argument '$1'. Run --help for usage." ;; esac shift done # --------------------------------------------------------------------------- # Resolve both ends before touching either. Everything checkable is checked # before the first file is written. # --------------------------------------------------------------------------- [ -n "$TEMPLATE_ROOT" ] || die "set TEMPLATE_ROOT (or SCAFFOLD_TEMPLATE_ROOT) — the path to the template checkout holding docs/. See the CONFIGURATION block." # A leading ~ inside a variable is a literal character, not $HOME. The failure # is otherwise "no such directory" naming a path the operator can see exists, # which sends them looking in the wrong place. case "$TEMPLATE_ROOT" in '~'*) die "TEMPLATE_ROOT is '$TEMPLATE_ROOT' — a leading ~ is not expanded inside a variable. Write the path out, or use \"\$HOME/...\"." ;; esac [ -d "$TEMPLATE_ROOT" ] || die "TEMPLATE_ROOT '$TEMPLATE_ROOT' is not a directory." TEMPLATE_ABS=$(cd "$TEMPLATE_ROOT" 2>/dev/null && pwd -P) \ || die "cannot enter TEMPLATE_ROOT '$TEMPLATE_ROOT' — check permissions." [ -d "$TEMPLATE_ABS/docs" ] || die "'$TEMPLATE_ABS' has no docs/ directory, so it is not the template root. TEMPLATE_ROOT is the checkout, not the docs folder inside it." # Same trap as TEMPLATE_ROOT above, and worse here: the bare "does not exist" # message names a path the operator can see does exist. case "$TARGET" in '~'*) die "target is '$TARGET' — a leading ~ is not expanded inside a variable or inside quotes. Write the path out, or use \"\$HOME/...\"." ;; esac [ -n "$TARGET" ] || TARGET="." # Refused rather than created. `mkdir -p` on a typo succeeds, and the result is # a complete, correct documentation tree in a directory that should not exist — # which reads as success and is found weeks later, if at all. [ -d "$TARGET" ] || die "target '$TARGET' does not exist. Create it first — scaffolding into a mistyped path produces a tree that looks entirely correct." TARGET_ABS=$(cd "$TARGET" 2>/dev/null && pwd -P) \ || die "cannot enter target '$TARGET' — check permissions." [ "$TARGET_ABS" != "$TEMPLATE_ABS" ] \ || die "the target is the template root itself. There is nothing to scaffold." # --------------------------------------------------------------------------- # An inherited current directory is the one input nobody typed. # # Run from src/ or from docs/ itself — both of which are where you are when you # think to run this — the default would build a second, wrong-place tree that # every convention here then fails to find. Only guessed targets are checked; # --into is an instruction and is obeyed. # --------------------------------------------------------------------------- if [ -z "$TARGET_NAMED" ]; then if command -v git >/dev/null 2>&1; then guess_top=$(git -C "$TARGET_ABS" rev-parse --show-toplevel 2>/dev/null) if [ -n "$guess_top" ]; then guess_top=$(cd "$guess_top" 2>/dev/null && pwd -P) if [ -n "$guess_top" ] && [ "$guess_top" != "$TARGET_ABS" ]; then die "you are in a subdirectory of '$guess_top'. docs/ belongs at the project root. Re-run with --into '$guess_top', or --into . to mean here." fi fi else # Said out loud rather than passed over. Without git this check cannot run # at all, and a silent skip reads exactly like a check that passed. say "note: git is not installed, so the \"is this the project root?\" check did" say " not run. '$TARGET_ABS' is being taken as the root on trust." fi fi # --------------------------------------------------------------------------- # --force may only run where the damage is undoable. # # Overwriting is recoverable exactly when git already holds the current # contents. A dirty tree means it does not, and an untracked file is the worst # case of all — scaffolded an hour ago, written into since, never committed, # and nothing anywhere remembers what it said. So untracked counts as dirty. # # This is the tree-wide half of the check, and on its own it is not enough: # `git status --porcelain` is silent about ignored files, so the per-file # `ls-files` test in the copy loop below is what actually closes the gap. # # Every way of failing to answer the question is refused, not passed over. # --------------------------------------------------------------------------- if [ -n "$FORCE" ]; then command -v git >/dev/null 2>&1 \ || die "--force needs git, so an overwrite can be undone with 'git checkout'." force_top=$(git -C "$TARGET_ABS" rev-parse --show-toplevel 2>/dev/null) \ || force_top="" [ -n "$force_top" ] || die "--force refused: '$TARGET_ABS' is not inside a git repository, so an overwrite would be permanent. Commit the tree somewhere first, or drop --force and let existing files be kept." if ! dirty=$(git -C "$force_top" status --porcelain 2>/dev/null); then die "--force refused: could not read git status in '$force_top'. Not knowing whether the tree is clean is a reason to stop, not to continue." fi if [ -n "$dirty" ]; then say "--force refused: '$force_top' has uncommitted or untracked changes:" printf '%s\n' "$dirty" | head -n 10 >&2 die "commit or stash them first. --force is only safe when git already holds what it is about to replace." fi fi say "template $TEMPLATE_ABS" say "target $TARGET_ABS" [ -n "$DRY_RUN" ] && say "--dry-run: nothing will be written." [ -n "$FORCE" ] && say "--force: existing files WILL be replaced (git tree is clean)." say "" # --------------------------------------------------------------------------- # The copy. # --------------------------------------------------------------------------- CREATED=0 KEPT=0 OVERWRITTEN=0 MISSING_TEMPLATE=0 UNREADABLE=0 CONFLICTED=0 FAILED=0 HEADERLESS=0 MISSING_LIST=() PLACEHOLDER_LIST=() report() { printf ' %-13s %s%s\n' "$1" "$2" "${3:+ ($3)}" >&2; } # Every document opens with an H1 and then a fenced status header. Checked on # the template's copy rather than the result, because a header missing here # means the template drifted and every project scaffolded from it starts # non-conformant — a fact about the template, reported as one. has_status_header() { awk 'NR==1 { if ($0 !~ /^# /) { bad=1; exit } } NR>1 && NR<=4 { if ($0 ~ /^```/) { ok=1; exit } } NR>4 { exit } END { exit (ok && !bad) ? 0 : 1 }' "$1" 2>/dev/null } for rel in "${DIRS[@]}"; do dst="$TARGET_ABS/$rel" if [ -d "$dst" ]; then report "kept" "$rel/" "directory exists" KEPT=$((KEPT + 1)) continue fi if [ -e "$dst" ] || [ -L "$dst" ]; then report "SKIPPED" "$rel/" "exists and is not a directory" CONFLICTED=$((CONFLICTED + 1)) continue fi if [ -n "$DRY_RUN" ]; then report "would create" "$rel/" "empty directory" CREATED=$((CREATED + 1)) continue fi if mkdir -p "$dst" 2>/dev/null; then report "created" "$rel/" "empty directory" CREATED=$((CREATED + 1)) else report "FAILED" "$rel/" "could not create" FAILED=$((FAILED + 1)) fi done for rel in "${DOCS[@]}"; do src="$TEMPLATE_ABS/$rel" dst="$TARGET_ABS/$rel" # Reported, counted and returned in the exit status. A template missing a # file it is supposed to carry produces a tree that is wrong in a way only # this run can see — by the next run, "not in the template" and "already # here" look the same from the target's side. if [ ! -e "$src" ]; then report "SKIPPED" "$rel" "missing from the template" MISSING_TEMPLATE=$((MISSING_TEMPLATE + 1)) MISSING_LIST+=("$rel") continue fi # Present but unreadable is a different fact with a different fix — a mode # bit here, a missing document there. Reporting both as "missing from the # template" sends someone to write a file that is already sitting in front # of them. if [ ! -f "$src" ] || [ ! -r "$src" ]; then report "SKIPPED" "$rel" "in the template but not a readable regular file" UNREADABLE=$((UNREADABLE + 1)) continue fi has_status_header "$src" || HEADERLESS=$((HEADERLESS + 1)) # A symlink is followed by cp, so "writing into docs/" could land anywhere on # the disk, including on top of the file it points at. Never written through, # in either direction, --force included. if [ -L "$dst" ]; then report "SKIPPED" "$rel" "is a symlink — never written through" CONFLICTED=$((CONFLICTED + 1)) continue fi if [ -e "$dst" ] && [ ! -f "$dst" ]; then report "SKIPPED" "$rel" "exists and is not a regular file" CONFLICTED=$((CONFLICTED + 1)) continue fi if [ -f "$dst" ]; then if [ -z "$FORCE" ]; then report "kept" "$rel" "already here, not touched" KEPT=$((KEPT + 1)) continue fi # An identical file is not an overwrite worth announcing, and calling it # one inflates the number that is supposed to mean "work was replaced". if cmp -s "$src" "$dst"; then report "kept" "$rel" "identical to the template" KEPT=$((KEPT + 1)) continue fi # The clean-tree check above is necessary and not sufficient. `git status # --porcelain` says nothing about ignored files, so a repository with # docs/qa/ClaudeReport.md in .gitignore reports clean while git holds no # copy of it at all. Overwriting that is exactly the permanent, silent loss # the whole script is built to refuse, announced as "(git tree is clean)". # # So the question is asked per file, of git, in the only form that answers # it: does git track this path. Anything else — ignored, untracked, or an # unreadable index — is refused and counted, never overwritten. if ! git -C "$force_top" ls-files --error-unmatch -- "$dst" >/dev/null 2>&1; then report "SKIPPED" "$rel" "--force: git does not track it (ignored?) — overwrite would be permanent" CONFLICTED=$((CONFLICTED + 1)) continue fi if [ -n "$DRY_RUN" ]; then report "would REPLACE" "$rel" "--force, differs from the template" OVERWRITTEN=$((OVERWRITTEN + 1)) continue fi elif [ -n "$DRY_RUN" ]; then report "would create" "$rel" CREATED=$((CREATED + 1)) continue fi if ! mkdir -p "$(dirname "$dst")" 2>/dev/null; then report "FAILED" "$rel" "could not create its directory" FAILED=$((FAILED + 1)) continue fi existed="" [ -f "$dst" ] && existed="yes" if ! cp -- "$src" "$dst" 2>/dev/null; then report "FAILED" "$rel" "copy failed" FAILED=$((FAILED + 1)) continue fi # Verified rather than assumed. A short write on a full disk leaves a file # that exists, reports as created, and is a truncated document — which is the # one outcome here that is worse than not copying at all. if ! cmp -s "$src" "$dst"; then report "FAILED" "$rel" "copied file does not match the template — check disk space" FAILED=$((FAILED + 1)) continue fi if [ -n "$existed" ]; then report "REPLACED" "$rel" "--force" OVERWRITTEN=$((OVERWRITTEN + 1)) else report "created" "$rel" CREATED=$((CREATED + 1)) fi grep -Eq "$PLACEHOLDER_RE" "$dst" 2>/dev/null && PLACEHOLDER_LIST+=("$rel") done SKIPPED=$((MISSING_TEMPLATE + UNREADABLE + CONFLICTED + FAILED)) say "" if [ -n "$DRY_RUN" ]; then say "--dry-run: nothing was written. It would have made" say " ${CREATED} created, ${KEPT} kept, ${OVERWRITTEN} replaced, ${SKIPPED} skipped." else say "${CREATED} created, ${KEPT} kept, ${OVERWRITTEN} replaced, ${SKIPPED} skipped." fi say " skipped breaks down as ${MISSING_TEMPLATE} missing from the template," say " ${UNREADABLE} present in the template but unreadable, ${CONFLICTED} conflicting" say " with something already at that path, ${FAILED} failed." if [ "$MISSING_TEMPLATE" -gt 0 ]; then say "" say "WARNING: the template at $TEMPLATE_ABS does not have:" printf ' %s\n' ${MISSING_LIST[@]+"${MISSING_LIST[@]}"} >&2 say " This tree is incomplete and nothing downstream will say so." say " Fix the template, or write those files by hand." fi if [ "$HEADERLESS" -gt 0 ]; then say "" say "WARNING: ${HEADERLESS} template document(s) do not open with an H1 followed by" say " a fenced block within the first four lines — the shape a status" say " header is written in. Only that shape was checked: the field" say " names (Status / Owner / Last reviewed / Governs / Review" say " trigger) were not read, so a passing document is not a" say " conformant one. Every project scaffolded from this template" say " inherits whatever is there. Fix it in the template." fi # Counted only among files this run created, and silent when there are none — # an "all placeholders filled in" line would be a claim about documents this # run never looked at, and about a placeholder style that may simply have # changed underneath the pattern above. if [ "${#PLACEHOLDER_LIST[@]}" -gt 0 ]; then say "" say "${#PLACEHOLDER_LIST[@]} file(s) written by this run still carry placeholders. Every status" say "header needs a real Owner, a real Last reviewed date and a real Review" say "trigger — that last line is what stops a document going quietly stale." say "Find them with:" say " grep -rnE '$PLACEHOLDER_RE' docs/" fi # --------------------------------------------------------------------------- # Branding: named, measured, and never invented. # # The template's own docs/data/img is not a source. When this was written it # held one zero-byte logo.webp, and copying that in would satisfy a check that # looks for the name while producing a project card with nothing on it. The # deeper reason is the one docs/data/README.md gives: a placeholder that looks # deliberate outlives the issue that would have replaced it, because nobody # files a ticket against an image that appears finished. # # So the directory is created empty and the three names are reported by what is # actually there — including the difference between absent and present-but- # empty, which the check itself distinguishes and a file listing does not. # --------------------------------------------------------------------------- img_dir="$TARGET_ABS/docs/data/img" say "" say "docs/data/img/ needs this project's own branding. The conformance check" say "looks for exactly these three names, and reads webp by magic bytes rather" say "than by extension — a renamed PNG does not pass:" if [ -d "$img_dir" ]; then for name in "${BRANDING[@]}"; do if [ -s "$img_dir/$name" ]; then report "present" "docs/data/img/$name" elif [ -e "$img_dir/$name" ]; then report "EMPTY" "docs/data/img/$name" "0 bytes — fails the magic-byte check" else report "absent" "docs/data/img/$name" fi done else # Not "none present". The directory does not exist yet, so nothing about # these three files has been measured and no count would be honest. say " not checked — $img_dir does not exist yet." printf ' %s\n' ${BRANDING[@]+"${BRANDING[@]}"} >&2 fi say "" say "Nothing here can draw them. When one is absent, file an issue — title it" say "for the asset, label it P2, and end the body with its Verify: line — rather" say "than committing a placeholder." say "" say "note: no git command was run. Nothing is staged and nothing is committed;" say " what belongs in this project's history is your call." # Non-zero when the tree that came out is not the tree that was asked for. The # caller — often another script, or an agent — needs that distinction to survive # past the last line of output. if [ "$SKIPPED" -gt 0 ]; then say "" die "${SKIPPED} path(s) were skipped, so this project is not fully scaffolded. Resolve each one listed above and run this again — re-running is safe, because everything already here is kept." fi exit 0