#!/usr/bin/env bash # shellcheck shell=bash # Scribe plugin — the pieces the two write-path hooks share (#2901). # # scribe_prior_art.sh fires BEFORE a Write/Edit tool call; scribe_after_write.sh # fires AFTER a Bash tool call and diffs the working tree, so code written by # sed/heredocs/scripts gets the same prior-art and ledger checks. Both need the # same three things, kept here so they cannot drift apart: # # scribe_skip_path PATH formats that hold prose or data, not shapes # scribe_defs stdin code → "kindname" per definition # scribe_local_dups ROOT REL "kindname" lines on stdin → the by-name # local-duplicate lines (ARM 1, #2280) # scribe_unreached STATE SID SECS REL the "Scribe didn't answer" line, once # per outage (#2932) — or nothing, if said lately # scribe_reached STATE SID the server answered: the next outage speaks again # # Sourced, not executed: `. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"`. # Skip formats that hold prose or data rather than reusable code. Purely to # avoid a pointless round-trip — the server would return nothing for these # anyway. Config formats are NOT skipped: a CI workflow or a compose file is # often exactly the thing worth reusing. scribe_skip_path() { case "$1" in *.md|*.mdx|*.txt|*.rst|*.json|*.lock|*.log|*.csv|*.tsv|*.svg|*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf) return 0 ;; esac return 1 } # --------------------------------------------------------------------------- # kindname for each thing a piece of code DEFINES, in source order. One # program, two consumers: the local duplicate arm (every definition in the # payload) and the ledger feed (#2791, below: the definitions being written, # or the one enclosing an Edit). Rule-for-rule mirrored by the server's # services/coverage.py extract_shapes — ledger rows are keyed by what THAT # sees, so the two must agree on what counts as a definition. scribe_defs() { awk ' { # CSS class definition: .name { or .name, if (match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/)) { t = $0; sub(/^[[:space:]]*\./, "", t); sub(/[[:space:]]*[,{].*$/, "", t) if (t != "") print "css\t" t; next } line = $0; sub(/^[[:space:]]+/, "", line) # Strip leading declaration modifiers so the definition keyword is the # first word regardless of language (export/pub/private/suspend/...). sub(/^((pub(\([a-z]+\))?|export|default|private|internal|protected|public|static|suspend|async|open|sealed|data|abstract|final|inline|unsafe|extern|override)[[:space:]]+)*/, "", line) # Go method with receiver: func (r *T) Name( if (match(line, /^func[[:space:]]*\([^)]*\)[[:space:]]*[A-Za-z_]/)) { t = line; sub(/^func[[:space:]]*\([^)]*\)[[:space:]]*/, "", t) sub(/[^A-Za-z0-9_].*$/, "", t) if (t != "") print "sym\t" t; next } # Keyword-announced definitions, functions and named types alike. # Dunders are skipped: every class defines __init__, so "already defined # in N other files" is guaranteed noise for them — and noise is what # teaches sessions to skip the hint. if (match(line, /^(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+[A-Za-z_$]/)) { t = line; sub(/^[a-z]+[[:space:]]+/, "", t) sub(/[^A-Za-z0-9_$].*$/, "", t) # `type` defines only when something follows the name (= or {); an # import specifier `type Foo,` is the same two words and defines # nothing (mirror of coverage.py, #2904). if (line ~ /^type[[:space:]]/) { rest = line; sub(/^type[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/, "", rest) if (rest !~ /[={]/) next } if (t != "" && t !~ /^__.*__$/) print "sym\t" t; next } # Arrow/expression assignment: const name = (…) / let name = async ( if (match(line, /^(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/)) { t = line; sub(/^(const|let)[[:space:]]+/, "", t) sub(/[^A-Za-z0-9_$].*$/, "", t) if (t != "") print "sym\t" t; next } } ' 2>/dev/null } # --------------------------------------------------------------------------- # ARM 1 — BY NAME, LOCALLY (#2280). Does a definition of this already exist? # # The recorded arms ask Scribe what was RECORDED; the ledger arm (#2900) asks # what a BOUND repo's ledger knows. A helper nobody recorded, in a repo nobody # bound, is invisible to both — which is how `.btn-primary` came to be defined # four times, already diverged. This arm asks the one question only the # developer's machine can answer, inside the repo, holding the code about to # be written: no index, no storage, no server — it runs even on an install # that has never configured Scribe. # # Definition-shaped patterns only. Grepping for bare occurrences would match # every CALL site and drown the real finding — and a hint that is mostly noise # is one people learn to skip, which is worse than none. ALL code, not a # language shortlist (#2682): the same keyword family scribe_defs announces. # # $1 repo root, $2 repo-relative path of the file being written (excluded from # the grep — it would always match itself on an Edit). Definitions on stdin. # Prints one "> - `name` is already defined in N other file(s): …" per hit. scribe_local_dups() { local root="$1" rel="$2" kind name pat hits count label files while IFS=$'\t' read -r kind name; do [ -n "${name:-}" ] || continue case "$kind" in css) pat="^[[:space:]]*\.${name}[[:space:]]*[,{]" ;; *) pat="(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+${name}[^A-Za-z0-9_]|func[[:space:]]*\([^)]*\)[[:space:]]*${name}[[:space:]]*\(|(const|let)[[:space:]]+${name}[[:space:]]*=" ;; esac # -I skips binaries; :(exclude) drops the file being written. hits=$(git -C "$root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel}" 2>/dev/null | head -4) || hits="" [ -n "$hits" ] || continue count=$(printf '%s\n' "$hits" | grep -c . 2>/dev/null || echo 0) label=$([ "$kind" = css ] && printf '.%s' "$name" || printf '%s' "$name") files=$(printf '%s' "$hits" | tr '\n' ' ' | sed 's/ $//') printf '> - `%s` is already defined in %s other file(s): %s\n' "$label" "$count" "$files" done } # --------------------------------------------------------------------------- # The blind spot made visible (#2932). Both write-path hooks fail OPEN when the # instance is slow or down — right for noise, wrong for silence: a session # cannot tell "the ledger checked and found nothing" from "the ledger never # answered", and a self-surfacing system cannot afford an invisible miss (the # first write after a redeploy lost its derive line to a 4s cold start and # nobody knew). So a failed call says so — ONCE per outage: the marker holds # the time it last spoke; within ten minutes of that it stays quiet, and a # successful call clears it so the next outage announces itself afresh. # Unconfigured installs never reach this: no URL/token means no call was owed. _SCRIBE_UNREACHED_QUIET=600 scribe_unreached() { local marker="$1/$2.unreached" now last now=$(date +%s 2>/dev/null) || now=0 if [ -f "$marker" ]; then last=$(cat "$marker" 2>/dev/null) || last=0 case "$last" in ''|*[!0-9]*) last=0 ;; esac [ $((now - last)) -lt "$_SCRIBE_UNREACHED_QUIET" ] && return 0 fi printf '%s' "$now" > "$marker" 2>/dev/null || true printf '> Scribe did not answer the prior-art check for `%s` within %ss — this write went UNCHECKED against the record and the shape ledger (the local by-name arm, if it spoke above, needed no server). If the name matters, check it yourself: `search` for the concept, `list_shapes(project_id, path=…)` for the ledger. Said once per outage; if it keeps happening the instance is slow or down.' "$4" "$3" } scribe_reached() { rm -f "$1/$2.unreached" 2>/dev/null || true }