forgedock 1.18.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/bin/engine/outcome-codes.mjs +13 -0
  2. package/bin/engine/phases.mjs +54 -4
  3. package/bin/engine/runlog.mjs +10 -2
  4. package/bin/engine/telemetry-seam.mjs +3 -0
  5. package/bin/engine/telemetry.test.mjs +3 -2
  6. package/bin/engine-cli.mjs +32 -2
  7. package/bin/engine.mjs +139 -7
  8. package/bin/runner.mjs +69 -9
  9. package/bin/stats/aggregate.mjs +7 -3
  10. package/bin/stats/cache.mjs +7 -3
  11. package/bin/stats/github-source.mjs +136 -28
  12. package/bin/stats/index.mjs +46 -9
  13. package/bin/stats/render.mjs +1 -1
  14. package/bin/stats/stats.test.mjs +201 -40
  15. package/commands/review-pr-staging.md +4 -4
  16. package/commands/review-pr.md +25 -15
  17. package/commands/work-on/build/architect.md +53 -0
  18. package/commands/work-on/build/implement.md +19 -0
  19. package/commands/work-on/build/validate.md +320 -28
  20. package/commands/work-on/build.md +66 -9
  21. package/commands/work-on/investigate.md +7 -0
  22. package/commands/work-on/remediate.md +32 -13
  23. package/commands/work-on/review.md +32 -19
  24. package/docs/CONFIG.md +89 -2
  25. package/docs/OUTCOME-CODES.md +5 -1
  26. package/docs/STATS.md +11 -4
  27. package/docs/TELEMETRY.md +5 -2
  28. package/docs/WORK-ON-RUNTIME.md +18 -0
  29. package/package.json +2 -2
  30. package/packages/protocol/src/types.js +1 -1
  31. package/scripts/acceptance-red-green.sh +90 -0
  32. package/scripts/acceptance-red-green.test.sh +74 -0
  33. package/scripts/cap-findings.test.sh +2 -0
  34. package/scripts/check-contract-scope.test.sh +2 -0
  35. package/scripts/check-edge-cases.sh +143 -0
  36. package/scripts/check-edge-cases.test.sh +175 -0
  37. package/scripts/check-spec-markers.sh +2 -0
  38. package/scripts/check-test-tmp-cleanup.sh +56 -0
  39. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  40. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  41. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  42. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  43. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  44. package/scripts/ci-local/adapters/common.mjs +115 -0
  45. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  46. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  47. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  48. package/scripts/ci-local/adapters/portability.mjs +104 -0
  49. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  50. package/scripts/ci-local/cli.mjs +50 -0
  51. package/scripts/ci-local/lib.mjs +371 -0
  52. package/scripts/ci-local/plan.mjs +186 -0
  53. package/scripts/ci-local/run.mjs +345 -0
  54. package/scripts/ci-local/scoping.mjs +508 -0
  55. package/scripts/ci-local.sh +97 -0
  56. package/scripts/ci-local.test.sh +919 -0
  57. package/scripts/classify-finding.sh +32 -6
  58. package/scripts/classify-finding.test.sh +23 -0
  59. package/scripts/classify-test-run.sh +69 -0
  60. package/scripts/classify-test-run.test.sh +90 -0
  61. package/scripts/close-parent-c3.test.sh +2 -0
  62. package/scripts/consumer-sweep.sh +569 -0
  63. package/scripts/consumer-sweep.test.sh +357 -0
  64. package/scripts/detect-append-registries.sh +106 -0
  65. package/scripts/detect-append-registries.test.sh +64 -0
  66. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  67. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  68. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  69. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  70. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  71. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  72. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  73. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  74. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  75. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  76. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  77. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  78. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  79. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  80. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  81. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  82. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  83. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  84. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  85. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  86. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  87. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  88. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  89. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  90. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  91. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  92. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  93. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  94. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  95. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  96. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  97. package/scripts/forge-root.test.sh +1 -1
  98. package/scripts/lib/telemetry.sh +1 -0
  99. package/scripts/remediate-approved-head.test.sh +2 -0
  100. package/scripts/remediate-basesync.test.sh +1 -1
  101. package/scripts/review-acceptance-gate.sh +2 -2
  102. package/scripts/review-acceptance-gate.test.sh +20 -0
  103. package/scripts/review-delta.sh +8 -1
  104. package/scripts/review-delta.test.sh +19 -0
  105. package/scripts/review-finalize.sh +193 -41
  106. package/scripts/review-finalize.test.sh +211 -4
  107. package/scripts/review-pr-delta-wiring.test.sh +13 -0
  108. package/scripts/review-prepare.sh +5 -3
  109. package/scripts/review-prepare.test.sh +18 -2
  110. package/scripts/run-shell-tests.sh +5 -0
  111. package/scripts/telemetry.test.sh +2 -0
  112. package/scripts/trusted-comments.test.sh +2 -1
  113. package/scripts/verify-phase-trail.test.sh +8 -0
  114. package/scripts/wait-ci-green.sh +61 -9
  115. package/scripts/wait-ci-green.test.sh +41 -3
  116. package/scripts/with-run-tmpdir.sh +20 -0
@@ -0,0 +1,569 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # consumer-sweep.sh — deterministic consumer sweep: every reference, outside the change set, to an identifier the
6
+ # diff removed, renamed or re-typed. Language-agnostic (fixed-string git grep); no ctags or tree-sitter needed.
7
+ #
8
+ # Usage:
9
+ # consumer-sweep.sh --range <base>..<head> [--repo P] [--exclude-glob G]... [--cap N] [--consumers FILE]
10
+ # [--budget-seconds N] [--mode advisory|blocking]
11
+ # consumer-sweep.sh --ident NAME [--ident NAME]... [--repo P] [--exclude-glob G]... [--cap N] [--budget-seconds N]
12
+ # consumer-sweep.sh --eval-history N [--repo P] [--exclude-glob G]... [--cap N] [--budget-seconds N]
13
+ #
14
+ # --range A..B diff mode (validate). A..B is a plain `git diff A B`. When B is the word WORKTREE the diff is
15
+ # merge-base(A, HEAD) against the working tree, so commits, staged, unstaged and untracked work all
16
+ # count as the change set (validate runs before the commit).
17
+ # --ident NAME identifier mode (architect, before any diff exists): sweep the named identifiers, empty change set.
18
+ # --consumers F gate mode (diff mode only): F is a FORGE:BUILDER body (or any text). A referencing site is
19
+ # addressed when its file carries a line `- <path>[:<line>] unaffected: <reason>` in F (file-level
20
+ # granularity; the line number is ignored because lines shift). The path is everything before the
21
+ # literal ` unaffected: ` token minus one trailing `:<digits>`, so any path characters work
22
+ # (`app/[id]/page.tsx`, `app/(auth)/login.tsx`, spaces). Files in the change set are never sites. An
23
+ # `updated` or `unresolved` line is informational: it never justifies a site.
24
+ # --budget-seconds N wall-clock budget (default 60). When it runs out the sweep stops, reports the partial result
25
+ # and prints BUDGET_EXCEEDED. A budget stop is advisory: it never produces exit 1.
26
+ # --mode M gate-mode strictness. `blocking` (default at script level): unaddressed sites exit 1. `advisory`:
27
+ # sites are printed as `ADVISORY:` lines and the exit is never 1. Callers pass the configured value
28
+ # (verification.consumer_sweep.mode).
29
+ # --eval-history N precision measurement: run the diff-mode sweep over the last N first-parent merges of HEAD and
30
+ # print flagged sites per merge plus the mean and max. Read-only; mutually exclusive with
31
+ # --range, --ident and --consumers.
32
+ #
33
+ # Extraction (diff mode) is a per-file survival filter over the diff. A token is swept only when it was REMOVED or
34
+ # RENAMED: it appears on a removed line and NOT on any added line of the same file, or it is the name on a
35
+ # definition head (function/class/const/def/fn/func/export ... or an assignment head) whose line changed. Tokens
36
+ # that survive the edit, prose words, numbers, versions and semver are never swept. Prose files (*.md, *.rst, *.txt,
37
+ # *.adoc) contribute only backticked tokens (fenced blocks are examples and spec-internal helpers). UPPER_CASE names count only where defined as constants. Kinds: function, class, const, env, key, flag, literal
38
+ # (7+ hex), path. Flags are swept without their leading dashes. Identifiers must match ^[A-Za-z0-9_.:/-]{4,80}$,
39
+ # must not start with "-", must contain a letter, must not be a version, are filtered through a stop-list, and are
40
+ # written to a pattern file consumed by ONE `git grep -n -w -F -f` as data only: diff text is never evaluated as
41
+ # shell or as a regex. Hits in generated/vendored paths are skipped: the same default globs as scripts/diff-size.sh
42
+ # (keep in sync) plus --exclude-glob (callers pass build.scope.exclude_globs).
43
+ #
44
+ # Output (sweep mode): TSV `identifier<TAB>kind<TAB>file:line`, at most --cap rows per identifier (default 25),
45
+ # applied before any per-hit work; a further `identifier<TAB>truncated<TAB>+N` row marks the cut (advisory, never a
46
+ # gate failure). A removed function/class whose name is still defined outside the change set prints
47
+ # `identifier<TAB>ambiguous<TAB>still defined at file:line` instead of sites (informational). An over-budget run adds `*<TAB>BUDGET_EXCEEDED<TAB>detail`. Gate mode prints
48
+ # `UNADDRESSED: <identifier> <file:line>` per unaddressed site (`ADVISORY:` in advisory mode), or `OK: ...`.
49
+ #
50
+ # Exit: 0 swept / all addressed (including no sites, advisory mode, and BUDGET_EXCEEDED); 1 unaddressed sites (gate
51
+ # mode, blocking); 2 usage error, unresolvable ref or any git failure — NEVER a pass; callers report DEGRADED.
52
+ #
53
+ # Offline: no network, no gh. Bash 3.2 portable: no mapfile, no associative arrays in the shell part.
54
+
55
+ set -uo pipefail
56
+
57
+ MAX_IDS=150
58
+ RANGE=""; REPO="."; CAP=25; CONSUMERS=""; IDENTS=""; EXTRA=""; BUDGET=60; MODE=blocking; EVAL=""
59
+ NL='
60
+ '
61
+ TAB=$(printf '\t')
62
+ die2() { echo "consumer-sweep: $*" >&2; exit 2; }
63
+
64
+ while [ "$#" -gt 0 ]; do
65
+ case "$1" in
66
+ --range) [ "$#" -ge 2 ] || die2 "--range needs a value"; RANGE="$2"; shift 2 ;;
67
+ --repo) [ "$#" -ge 2 ] || die2 "--repo needs a value"; REPO="$2"; shift 2 ;;
68
+ --cap) [ "$#" -ge 2 ] || die2 "--cap needs a value"; CAP="$2"; shift 2 ;;
69
+ --consumers) [ "$#" -ge 2 ] || die2 "--consumers needs a value"; CONSUMERS="$2"; shift 2 ;;
70
+ --ident) [ "$#" -ge 2 ] || die2 "--ident needs a value"; IDENTS="${IDENTS}${2}${NL}"; shift 2 ;;
71
+ --exclude-glob) [ "$#" -ge 2 ] || die2 "--exclude-glob needs a value"; [ -z "$2" ] || EXTRA="${EXTRA}${2}${NL}"; shift 2 ;;
72
+ --budget-seconds) [ "$#" -ge 2 ] || die2 "--budget-seconds needs a value"; BUDGET="$2"; shift 2 ;;
73
+ --mode) [ "$#" -ge 2 ] || die2 "--mode needs a value"; MODE="$2"; shift 2 ;;
74
+ --eval-history) [ "$#" -ge 2 ] || die2 "--eval-history needs a value"; EVAL="$2"; shift 2 ;;
75
+ *) die2 "usage: consumer-sweep.sh (--range <base>..<head> [--consumers FILE] | --ident NAME... | --eval-history N) [--repo P] [--exclude-glob G]... [--cap N] [--budget-seconds N] [--mode advisory|blocking]" ;;
76
+ esac
77
+ done
78
+ case "$CAP" in ""|0|*[!0-9]*) die2 "--cap must be a positive integer" ;; esac
79
+ case "$BUDGET" in ""|*[!0-9]*) die2 "--budget-seconds must be a non-negative integer" ;; esac
80
+ case "$MODE" in advisory|blocking) ;; *) die2 "--mode must be advisory or blocking" ;; esac
81
+ if [ -n "$EVAL" ]; then
82
+ case "$EVAL" in 0|*[!0-9]*) die2 "--eval-history must be a positive integer" ;; esac
83
+ if [ -n "$RANGE$IDENTS$CONSUMERS" ]; then die2 "--eval-history is mutually exclusive with --range, --ident and --consumers"; fi
84
+ else
85
+ if [ -n "$RANGE" ] && [ -n "$IDENTS" ]; then die2 "--range and --ident are mutually exclusive"; fi
86
+ if [ -z "$RANGE" ] && [ -z "$IDENTS" ]; then die2 "one of --range, --ident or --eval-history is required"; fi
87
+ fi
88
+ if [ -n "$CONSUMERS" ]; then
89
+ [ -n "$RANGE" ] || die2 "--consumers requires --range"
90
+ [ -f "$CONSUMERS" ] || die2 "consumers file not found: $CONSUMERS"
91
+ fi
92
+ git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1 || die2 "not a git work tree: $REPO"
93
+ SECONDS=0
94
+
95
+ # ---- precision evaluation over merge history ------------------------------------------------------------
96
+ if [ -n "$EVAL" ]; then
97
+ SELF="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
98
+ MERGES=$(git -C "$REPO" log --merges --first-parent -n "$EVAL" --format=%H HEAD 2>/dev/null) || die2 "git log failed"
99
+ [ -n "$MERGES" ] || die2 "no merge commits found for --eval-history"
100
+ SUMMARY=""
101
+ for m in $MERGES; do
102
+ set -- --repo "$REPO" --range "${m}^1..${m}" --budget-seconds "$BUDGET" --cap "$CAP"
103
+ while IFS= read -r g; do [ -z "$g" ] || set -- "$@" --exclude-glob "$g"; done <<EOF
104
+ $EXTRA
105
+ EOF
106
+ out=$(bash "$SELF" "$@" 2>/dev/null); rcx=$?
107
+ subj=$(git -C "$REPO" log -n 1 --format=%s "$m" 2>/dev/null | cut -c1-70)
108
+ if [ "$rcx" -ne 0 ]; then
109
+ printf 'eval\t%s\terror(rc=%s)\t%s\n' "$(printf '%s' "$m" | cut -c1-10)" "$rcx" "$subj"
110
+ continue
111
+ fi
112
+ stat=$(printf '%s\n' "$out" | awk -F'\t' '
113
+ $2 == "BUDGET_EXCEEDED" { b = 1; next }
114
+ $2 == "ambiguous" { next }
115
+ $2 == "truncated" { v = $3; sub(/^\+/, "", v); sites += v + 0; next }
116
+ NF >= 3 { sites++; ids[$1] = 1 }
117
+ END { n = 0; for (k in ids) n++; print sites + 0 "\t" n "\t" (b ? "budget" : "") }')
118
+ sites=${stat%%"$TAB"*}
119
+ printf 'eval\t%s\tsites=%s\t%s\t%s\n' "$(printf '%s' "$m" | cut -c1-10)" "$sites" "$(printf '%s' "$stat" | cut -f3)" "$subj"
120
+ SUMMARY="${SUMMARY}${sites}${NL}"
121
+ done
122
+ printf '%s' "$SUMMARY" | awk '
123
+ NF { n++; t += $1; if ($1 + 0 > mx) mx = $1 + 0 }
124
+ END {
125
+ if (n == 0) { print "mean=0.00 max=0 prs=0 guard=UNKNOWN"; exit }
126
+ mean = t / n
127
+ printf "mean=%.2f max=%d prs=%d guard=%s\n", mean, mx, n, (mean <= 2 && mx <= 10) ? "PASS" : "FAIL"
128
+ }'
129
+ exit 0
130
+ fi
131
+
132
+ TMP=$(mktemp -d "${TMPDIR:-/tmp}/consumer-sweep.XXXXXX") || die2 "mktemp failed"
133
+ trap 'rm -rf "$TMP"' EXIT
134
+
135
+ # ---- exclude globs (mirror of scripts/diff-size.sh: keep in sync) ---------------------------------------
136
+ DEFAULTS="package-lock.json yarn.lock pnpm-lock.yaml Cargo.lock poetry.lock uv.lock go.sum Gemfile.lock composer.lock *.min.* *.snap __generated__/ __snapshots__/"
137
+ ROOT_DIRS="dist/ build/ generated/ fixtures/ vendor/"
138
+ GLOBS="${DEFAULTS}${NL}${EXTRA}"
139
+ excluded() { # path -> 0 when excluded (globs are data: matched by case, never evaluated)
140
+ local p="$1" b="${1##*/}" g
141
+ for g in $ROOT_DIRS; do case "$p" in "$g"*) return 0 ;; esac; done
142
+ set -f
143
+ for g in $GLOBS; do
144
+ case "$g" in
145
+ */) case "/$p" in *"/$g"*) set +f; return 0 ;; esac ;;
146
+ *) case "$b" in $g) set +f; return 0 ;; esac; case "$p" in $g) set +f; return 0 ;; esac ;;
147
+ esac
148
+ done
149
+ set +f
150
+ return 1
151
+ }
152
+ # the same rules, one per line, for the awk-side filter: R:<root prefix> D:<dir glob> G:<name/path glob>
153
+ : > "$TMP/globs"
154
+ for g in $ROOT_DIRS; do printf 'R:%s\n' "$g" >> "$TMP/globs"; done
155
+ set -f
156
+ for g in $GLOBS; do
157
+ case "$g" in */) printf 'D:%s\n' "$g" >> "$TMP/globs" ;; *) printf 'G:%s\n' "$g" >> "$TMP/globs" ;; esac
158
+ done
159
+ set +f
160
+
161
+ : > "$TMP/changed"
162
+ : > "$TMP/excl"
163
+ : > "$TMP/raw"
164
+ OVER=0; OVERWHY=""
165
+
166
+ # ---- diff mode: change set and per-file survival extraction ---------------------------------------------
167
+ if [ -n "$RANGE" ]; then
168
+ case "$RANGE" in *..*) ;; *) die2 "--range must look like <base>..<head>" ;; esac
169
+ A="${RANGE%%..*}"; B="${RANGE#*..}"; B="${B#.}"
170
+ case "$A" in ""|-*) die2 "invalid base in --range: '$A'" ;; esac
171
+ case "$B" in ""|-*) die2 "invalid head in --range: '$B'" ;; esac
172
+ git -C "$REPO" rev-parse --verify --quiet "${A}^{commit}" >/dev/null 2>&1 || die2 "cannot resolve base ref '$A'"
173
+ if [ "$B" = WORKTREE ]; then
174
+ MB=$(git -C "$REPO" merge-base "$A" HEAD 2>/dev/null) && [ -n "$MB" ] || die2 "cannot compute merge base of '$A' and HEAD"
175
+ DIFFARGS="$MB"
176
+ git -C "$REPO" diff -z --name-only --no-renames "$MB" -- > "$TMP/c1" 2>/dev/null || die2 "git diff --name-only failed"
177
+ git -C "$REPO" ls-files -z --others --exclude-standard > "$TMP/c2" 2>/dev/null || die2 "git ls-files failed"
178
+ else
179
+ git -C "$REPO" rev-parse --verify --quiet "${B}^{commit}" >/dev/null 2>&1 || die2 "cannot resolve head ref '$B'"
180
+ DIFFARGS="$A $B"
181
+ # shellcheck disable=SC2086
182
+ git -C "$REPO" diff -z --name-only --no-renames $DIFFARGS -- > "$TMP/c1" 2>/dev/null || die2 "git diff --name-only failed"
183
+ : > "$TMP/c2"
184
+ fi
185
+ cat "$TMP/c1" "$TMP/c2" | tr '\0' '\n' | sed '/^$/d' | sort -u > "$TMP/changed"
186
+ # files whose lines are not extracted (generated/vendored)
187
+ while IFS= read -r f; do
188
+ [ -n "$f" ] || continue
189
+ if excluded "$f"; then printf '%s\n' "$f" >> "$TMP/excl"; fi
190
+ done < "$TMP/changed"
191
+ # shellcheck disable=SC2086
192
+ git -C "$REPO" -c core.quotepath=off diff -U0 --no-color --no-ext-diff --no-renames $DIFFARGS -- > "$TMP/diff" 2>/dev/null \
193
+ || die2 "git diff failed"
194
+
195
+ # One awk pass: removed and added lines are collected per file (a "--- a/<path>" / "+++ b/<path>" header is read
196
+ # only before the first @@ of a file, so removed "-- " lines are never mistaken for headers), then every
197
+ # candidate token of a removed line is kept only when it did not survive in that file's added lines.
198
+ cat > "$TMP/extract.awk" <<'AWK'
199
+ function trim(s) { gsub(/^[ \t]+|[ \t]+$/, "", s); gsub(/[ \t]+/, " ", s); return s }
200
+ function isprose(f) { f = tolower(f); return f ~ /\.(md|markdown|mdx|rst|txt|adoc|org)$/ }
201
+ function addl(F, s, n, i, parts) {
202
+ anl[F]++; al[F, anl[F]] = s
203
+ gl[trim(s)] = 1
204
+ n = split(s, parts, /[^A-Za-z0-9_]+/)
205
+ for (i = 1; i <= n; i++) if (parts[i] != "") aw[F, parts[i]] = 1
206
+ }
207
+ function spans(s, out) {
208
+ out = "" # spans are joined with a separator so two spans never read as one "keyword NAME" declaration
209
+ while (match(s, /`[^`]+`/)) { out = out " \001 " substr(s, RSTART + 1, RLENGTH - 2); s = substr(s, RSTART + RLENGTH) }
210
+ return out
211
+ }
212
+ # every maximal word-bounded run of identifier characters in one file's added lines, built once per file on first use
213
+ # (O(added chars x 80)), so a survival test is one hash lookup instead of a scan of every added line per candidate
214
+ function build(F, k, line, n, i, j, c, pc, nc2) {
215
+ built[F] = 1
216
+ for (k = 1; k <= anl[F]; k++) {
217
+ line = al[F, k]; n = length(line)
218
+ for (i = 1; i <= n; i++) {
219
+ c = substr(line, i, 1)
220
+ if (c !~ /[A-Za-z0-9_.:\/-]/) continue
221
+ pc = (i > 1) ? substr(line, i - 1, 1) : ""
222
+ if (pc ~ /[A-Za-z0-9_]/) continue
223
+ for (j = i; j <= n && j < i + 80; j++) {
224
+ if (substr(line, j, 1) !~ /[A-Za-z0-9_.:\/-]/) break
225
+ nc2 = (j < n) ? substr(line, j + 1, 1) : ""
226
+ if (nc2 !~ /[A-Za-z0-9_]/) sx[F, substr(line, i, j - i + 1)] = 1
227
+ }
228
+ }
229
+ }
230
+ }
231
+ function survives(F, id) {
232
+ if (id ~ /^[A-Za-z0-9_]+$/) return ((F SUBSEP id) in aw)
233
+ if (!(F in built)) build(F)
234
+ return ((F SUBSEP id) in sx)
235
+ }
236
+ # a candidate must be code-like: charset, 4..80 chars, a letter, not a version/number, not a stop word
237
+ function cand(F, id, kind, head, norm) {
238
+ if (id !~ /^[A-Za-z0-9_.:\/-]+$/) return
239
+ if (length(id) < 4 || length(id) > 80 || substr(id, 1, 1) == "-") return
240
+ if (id !~ /[A-Za-z]/) return
241
+ if (id ~ /^[vV]?[0-9][0-9.]*([-+][A-Za-z0-9.+-]*)?$/) return
242
+ if (id ~ /^[vV]?[0-9]+\.[0-9]+\.[0-9]+/) return
243
+ if (tolower(id) in st) return
244
+ nc++; cF[nc] = F; cid[nc] = id; ckind[nc] = kind; chead[nc] = head; cnorm[nc] = norm
245
+ }
246
+ function scan(F, t, norm, prose, pmode, s, m, kw, nm, k, tok, ok, ext, comment, n, sp) {
247
+ comment = (!prose && t ~ /^[ \t]*(#|\/\/|\*|\/\*|--[ \t]|;)/)
248
+ # declarations: keyword [receiver] NAME -> kind by keyword (a definition head: the whole line is compared)
249
+ if (!comment) {
250
+ s = t
251
+ while (match(s, /(^|[^A-Za-z0-9_])(def|function|func|fn|fun|sub|proc|class|struct|interface|enum|trait|type|const|val)[ \t]+(\([^)]*\)[ \t]*)?[A-Za-z_][A-Za-z0-9_]*/)) {
252
+ m = substr(s, RSTART, RLENGTH); s = substr(s, RSTART + RLENGTH)
253
+ kw = m; sub(/^[^A-Za-z]/, "", kw); sub(/[ \t].*$/, "", kw)
254
+ nm = m; sub(/^.*[ \t)]/, "", nm)
255
+ if (substr(s, 1, 1) !~ /[({<:=\[]/ && s !~ /^[ \t]*([({<:=\[]|$)/) continue
256
+ k = "const"
257
+ if (kw == "def" || kw == "function" || kw == "func" || kw == "fn" || kw == "fun" || kw == "sub" || kw == "proc") k = "function"
258
+ else if (kw == "class" || kw == "struct" || kw == "interface" || kw == "enum" || kw == "trait" || kw == "type") k = "class"
259
+ # a const/val is an API symbol only at top level or with an access modifier; an indented one is a local variable
260
+ if (kw == "const" || kw == "val") {
261
+ if (t !~ /^(const|val)[ \t]/ && t !~ /^[ \t]*((export|pub|public|private|protected|static|final|readonly|default|declare)[ \t]+)+(const|val)[ \t]/) continue
262
+ }
263
+ # only a function/class head is a signature: a changed const/val value does not invalidate its consumers
264
+ cand(F, nm, k, (k == "const") ? 0 : 1, norm)
265
+ }
266
+ }
267
+ # shell-style name() {
268
+ if (match(t, /^[ \t]*[A-Za-z_][A-Za-z0-9_]*\(\)[ \t]*\{/)) {
269
+ nm = substr(t, RSTART, RLENGTH); sub(/^[ \t]*/, "", nm); sub(/\(\).*$/, "", nm)
270
+ cand(F, nm, "function", 1, norm)
271
+ }
272
+ # env vars: a quoted UPPER_CASE name, or process.env.NAME, on a line that reads the environment
273
+ if (t ~ /getenv|environ|process\.env|os\.Getenv|ENV\[|env:|\.env/) {
274
+ s = t
275
+ while (match(s, /["'][A-Z][A-Z0-9_]*["']|process\.env\.[A-Z][A-Z0-9_]*/)) {
276
+ tok = substr(s, RSTART, RLENGTH); s = substr(s, RSTART + RLENGTH)
277
+ sub(/^process\.env\./, "", tok); gsub(/["']/, "", tok)
278
+ cand(F, tok, "env", 0, norm)
279
+ }
280
+ }
281
+ # UPPER_CASE constants: only where the token is DEFINED as a constant. A modifier head (export/const/static/final/
282
+ # readonly/#define ...), a column-0 assignment in a non-shell file, or an assignment in a key/value config file. A
283
+ # shell variable (shell file or fenced shell in prose) is a local, never a constant. A backticked UPPER_SNAKE
284
+ # mention in prose is a documented constant.
285
+ s = t
286
+ while (match(s, /(^|[^A-Za-z0-9_])[A-Z][A-Z0-9_]*([^A-Za-z0-9_]|$)/)) {
287
+ tok = substr(s, RSTART, RLENGTH); s = substr(s, RSTART + RLENGTH)
288
+ sub(/^[^A-Z]/, "", tok); sub(/[^A-Z0-9_]$/, "", tok)
289
+ if (length(tok) < 4) continue
290
+ ok = 0
291
+ if (t ~ ("^[ \t]*((export|const|static|final|readonly|public|private|protected|declare|pub|let|var)[ \t]+)+" tok "([^A-Za-z0-9_]|$)") || t ~ ("^[ \t]*#[ \t]*define[ \t]+" tok "([^A-Za-z0-9_]|$)")) ok = 1
292
+ else if (pmode == 1) ok = (index(tok, "_") > 0)
293
+ else if (pmode == 0 && t !~ ("^[ \t]*" tok "[ \t]*==") && t ~ ("^" tok "[ \t]*[:=]") && F !~ /\.(sh|bash|zsh|ksh)$/) ok = 1
294
+ else if (pmode == 0 && t ~ ("^[ \t]*" tok "[ \t]*[:=]") && F ~ /\.(ya?ml|toml|ini|env|properties|cfg|conf)$/) ok = 1
295
+ if (ok) cand(F, tok, "const", 0, norm)
296
+ }
297
+ # CLI flags (swept without the leading dashes)
298
+ s = t
299
+ while (match(s, /(^|[^A-Za-z0-9_-])--[A-Za-z][A-Za-z0-9_-]*/)) {
300
+ nm = substr(s, RSTART, RLENGTH); s = substr(s, RSTART + RLENGTH)
301
+ sub(/^[^-]?--/, "", nm); sub(/-+$/, "", nm)
302
+ # a flag is swept where the changed file DEFINES it (parser registration, case arm, or a documented option), never
303
+ # where it merely passes the flag to another tool
304
+ if (length(nm) >= 4 && (t ~ /add_argument|add_option|\.option\(|\.flag\(|Flag\(|flag\.[A-Z][A-Za-z]*\(|getopt|argparse|click\.option|\.Var\(/ \
305
+ || t ~ ("^[ \t]*(-[A-Za-z][ \t]*\\|[ \t]*)?--" nm "(=[^ \t|)]*)?[ \t]*(\\||\\))") \
306
+ || (prose && t ~ ("^[ \t]*--" nm "([^A-Za-z0-9_-]|$)")))) cand(F, nm, "flag", 0, norm)
307
+ }
308
+ # literals: 7+ hex that mixes digits and a-f (a pinned hash); semver and plain numbers are never swept
309
+ s = t
310
+ while (match(s, /(^|[^A-Za-z0-9_])[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]+([^A-Za-z0-9_]|$)/)) {
311
+ tok = substr(s, RSTART, RLENGTH); s = substr(s, RSTART + RLENGTH)
312
+ sub(/^[^0-9a-f]/, "", tok); sub(/[^0-9a-f]$/, "", tok)
313
+ if (length(tok) <= 64 && tok ~ /[0-9]/ && tok ~ /[a-f]/) cand(F, tok, "literal", 0, norm)
314
+ }
315
+ # quoted config/context keys
316
+ s = t
317
+ while (match(s, /["'][A-Za-z_][A-Za-z0-9_.-]*["']/)) {
318
+ tok = substr(s, RSTART + 1, RLENGTH - 2); s = substr(s, RSTART + RLENGTH)
319
+ # a quoted string is a key only in key position (followed by a colon); other strings are messages, values or uses
320
+ if (substr(s, 1, 1) == ":" || s ~ /^[ \t]*:/) cand(F, tok, "key", 0, norm)
321
+ }
322
+ # prose: the specific identifier a list item or table row DOCUMENTS (a leading backticked span such as
323
+ # "- `legacy_option` ..." or "| `legacy_option` |"); a mid-sentence mention is a use, not a definition
324
+ if (pmode == 1 && match(norm, /^([-*+]|\|)[ \t]*`[A-Za-z_][A-Za-z0-9_.-]*`/)) {
325
+ tok = substr(norm, RSTART, RLENGTH); sub(/^[^`]*`/, "", tok); sub(/`$/, "", tok)
326
+ if (tok ~ /[_.-]/ || tok ~ /.[A-Z]/) cand(F, tok, "ident", 0, norm)
327
+ }
328
+ # yaml-style keys (never from prose files)
329
+ if (!prose && match(t, /^[ \t]*(-[ \t]+)?[A-Za-z_][A-Za-z0-9_.-]*:([ \t]|$)/)) {
330
+ tok = substr(t, RSTART, RLENGTH); sub(/^[ \t]*(-[ \t]+)?/, "", tok); sub(/:[ \t]?$/, "", tok)
331
+ cand(F, tok, "key", 0, norm)
332
+ }
333
+ }
334
+ BEGIN {
335
+ while ((getline l < excl) > 0) skip[l] = 1
336
+ n = split(xstop, sw, " ")
337
+ for (i = 1; i <= n; i++) st[tolower(sw[i])] = 1
338
+ }
339
+ /^diff --git / { inhunk = 0; next }
340
+ /^@@ / { inhunk = 1; hunk++; next }
341
+ !inhunk && /^--- / { p = substr($0, 5); sub(/\t.*$/, "", p); sub(/^a\//, "", p); cur = p; next }
342
+ !inhunk && /^\+\+\+ / {
343
+ p = substr($0, 5); sub(/\t.*$/, "", p); sub(/^b\//, "", p); curb = p
344
+ # a deleted or moved-away file: its own path is an identifier whose references go stale
345
+ if (p == "/dev/null" && cur != "/dev/null" && !(cur in skip)) cand(cur, cur, "path", 0, "")
346
+ next
347
+ }
348
+ inhunk && /^-/ { if (!(cur in skip) && cur != "/dev/null") { nr++; rf[nr] = cur; rt[nr] = substr($0, 2); rh[nr] = hunk } next }
349
+ inhunk && /^\+/ { if (curb != "/dev/null" && !(curb in skip)) addl(curb, substr($0, 2)); next }
350
+ END {
351
+ lasth = -1
352
+ for (i = 1; i <= nr; i++) {
353
+ F = rf[i]; line = rt[i]
354
+ if (rh[i] != lasth) { fence = 0; lasth = rh[i] }
355
+ prose = isprose(F); text = line
356
+ if (prose) {
357
+ if (line ~ /^[ \t]*(```|~~~)/) { fence = !fence; continue }
358
+ # fenced blocks in prose are examples and spec-internal helpers (shell locals, sample JSON): not definitions
359
+ if (fence) continue
360
+ text = spans(line)
361
+ if (text == "") continue
362
+ }
363
+ scan(F, text, trim(line), prose, prose ? 1 : 0)
364
+ }
365
+ for (c = 1; c <= nc; c++) {
366
+ # a definition head whose exact line still exists in the added side (same file or moved) did not change
367
+ if (chead[c] && (cnorm[c] in gl)) continue
368
+ # a token that survives in the file's added lines was edited around, not removed; only a changed head counts
369
+ if (survives(cF[c], cid[c]) && !chead[c]) continue
370
+ print cid[c] "\t" ckind[c]
371
+ }
372
+ }
373
+ AWK
374
+ XSTOP="that this with from have will your into than then them they were been being their there these those which while where when what would could should about after before because between both each other such only also more most some many much very just like make made used uses using need needs needed must may might shall can cannot does done doing not nor yet but and for are the any all one two use via per set get put add see run runs also else elif try catch finally case switch default return returns import export from yield await async static public private protected final override begin end then fi done esac function class struct enum type none null true false undefined void version versions note notes todo fixme hack example examples see below above following here step steps stage phase section sections item items line lines word words text name names title label labels body bodies content contents message messages description summary details detail info information option options param params parameter parameters arg args argument arguments key keys value values field fields row rows column columns table tables list lists array arrays map maps string strings number numbers boolean object objects element elements node nodes child children parent parents root base head tail first last next prev previous current latest main master release releases branch branches commit commits change changes changed file files path paths dir dirs directory folder folders link links url urls href src dest target targets source sources input inputs output outputs result results error errors warn warning warnings debug trace level levels mode modes state states status user users admin id ids uid uuid time date datetime timestamp when once always never true false enabled disabled enable disable active inactive required optional allow deny allowed denied yes no on off"
375
+ # the extraction runs inside the same --budget-seconds deadline as the grep: over budget is advisory, never a failure
376
+ REM=$((BUDGET - SECONDS)); TO=""
377
+ if command -v timeout >/dev/null 2>&1; then TO="timeout"; elif command -v gtimeout >/dev/null 2>&1; then TO="gtimeout"; fi
378
+ if [ "$REM" -le 0 ]; then
379
+ OVER=1; OVERWHY="budget ${BUDGET}s spent before the extraction started"
380
+ else
381
+ ${TO:+"$TO" "$REM"} awk -v excl="$TMP/excl" -v xstop="$XSTOP" -f "$TMP/extract.awk" "$TMP/diff" 2>/dev/null > "$TMP/raw"
382
+ xrc=$?
383
+ if [ "$xrc" -eq 124 ]; then
384
+ : > "$TMP/raw"; OVER=1; OVERWHY="budget ${BUDGET}s elapsed during the extraction; results are partial"
385
+ elif [ "$xrc" -ne 0 ]; then die2 "extraction failed"; fi
386
+ fi
387
+ else
388
+ # ---- identifier mode ------------------------------------------------------------------------------------
389
+ printf '%s' "$IDENTS" | sed '/^$/d' | awk '{ print $0 "\tident" }' > "$TMP/raw"
390
+ fi
391
+
392
+ # ---- filter: first-seen per identifier, charset, length, stop-list -------------------------------------
393
+ STOP="true false null none self this main init test tests data name type types value values list dict item items result results error errors string default config options args kwargs size path file files text node nodes index count state status user users TODO FIXME NOTE HACK NULL TRUE FALSE NONE UTF8 JSON YAML HTML HTTP HTTPS GET POST PUT PATCH DELETE ERROR WARN INFO DEBUG EOF LINE MAIN version VERSION"
394
+ awk -F'\t' -v stop="$STOP" '
395
+ BEGIN { n = split(stop, s, " "); for (i = 1; i <= n; i++) st[tolower(s[i])] = 1 }
396
+ $1 ~ /^[A-Za-z0-9_.:\/-]+$/ && length($1) >= 4 && length($1) <= 80 && substr($1, 1, 1) != "-" && $1 ~ /[A-Za-z]/ \
397
+ && $1 !~ /^[vV]?[0-9][0-9.]*([-+][A-Za-z0-9.+-]*)?$/ \
398
+ && !(tolower($1) in st) && !seen[$1]++ { print $1 "\t" $2 }
399
+ ' "$TMP/raw" > "$TMP/ids"
400
+ if [ -z "$RANGE" ]; then
401
+ # an explicit --ident that fails validation is a usage error, never silently skipped
402
+ want=$(printf '%s' "$IDENTS" | sed '/^$/d' | wc -l | tr -d ' ')
403
+ have=$(wc -l < "$TMP/ids" | tr -d ' ')
404
+ [ "$want" = "$have" ] || die2 "invalid --ident (want ^[A-Za-z0-9_.:/-]{4,80}\$, not a stop-list word, no duplicates)"
405
+ fi
406
+
407
+ # ---- sweep: ONE bounded git grep -----------------------------------------------------------------------
408
+ : > "$TMP/sites" # identifier<TAB>kind<TAB>file:line
409
+ NIDS=$(wc -l < "$TMP/ids" | tr -d ' ')
410
+ if [ "$NIDS" -gt "$MAX_IDS" ]; then
411
+ head -n "$MAX_IDS" "$TMP/ids" > "$TMP/ids.cut"; mv "$TMP/ids.cut" "$TMP/ids"
412
+ fi
413
+ if [ -s "$TMP/ids" ]; then
414
+ cut -f1 "$TMP/ids" > "$TMP/idents"
415
+ REM=$((BUDGET - SECONDS))
416
+ if [ "$REM" -le 0 ]; then
417
+ OVER=1; OVERWHY="budget ${BUDGET}s spent before the grep started"
418
+ else
419
+ TO=""
420
+ if command -v timeout >/dev/null 2>&1; then TO="timeout"; elif command -v gtimeout >/dev/null 2>&1; then TO="gtimeout"; fi
421
+ if [ -n "$TO" ]; then
422
+ "$TO" "$REM" git -C "$REPO" grep -n -z -I -w -F -f "$TMP/idents" -- . > "$TMP/g" 2> "$TMP/gerr"
423
+ else
424
+ git -C "$REPO" grep -n -z -I -w -F -f "$TMP/idents" -- . > "$TMP/g" 2> "$TMP/gerr"
425
+ fi
426
+ grc=$?
427
+ if [ "$grc" -eq 124 ]; then
428
+ OVER=1; OVERWHY="budget ${BUDGET}s elapsed during the grep; results are partial"
429
+ elif [ "$grc" -gt 1 ]; then
430
+ die2 "git grep failed (rc=$grc): $(head -n 1 "$TMP/gerr")"
431
+ fi
432
+ if [ -s "$TMP/g" ]; then
433
+ # group, drop the change set and excluded paths, cap per identifier before any per-hit work: one awk, no forks
434
+ tr '\0' '\001' < "$TMP/g" | awk -v cap="$CAP" -v plainprose="$([ -n "$RANGE" ] && echo 1 || echo 0)" -v ids="$TMP/ids" -v changed="$TMP/changed" -v globs="$TMP/globs" '
435
+ function globre(g, i, c, r) {
436
+ r = "^"
437
+ for (i = 1; i <= length(g); i++) {
438
+ c = substr(g, i, 1)
439
+ if (c == "*") r = r ".*"
440
+ else if (c == "?") r = r "."
441
+ else if (c ~ /[.+(){}|^$\\\[\]]/) r = r "\\" c
442
+ else r = r c
443
+ }
444
+ return r "$"
445
+ }
446
+ function isexcl(p, i, b) {
447
+ for (i = 1; i <= nroot; i++) if (index(p, roots[i]) == 1) return 1
448
+ for (i = 1; i <= ndir; i++) if (index("/" p, "/" dirs[i]) > 0) return 1
449
+ b = p; sub(/^.*\//, "", b)
450
+ for (i = 1; i <= ng; i++) if (b ~ gre[i] || p ~ gre[i]) return 1
451
+ return 0
452
+ }
453
+ function hasword(text, id, start, p, abs, prev, nx) {
454
+ start = 1
455
+ while ((p = index(substr(text, start), id)) > 0) {
456
+ abs = start + p - 1
457
+ prev = (abs > 1) ? substr(text, abs - 1, 1) : ""
458
+ nx = substr(text, abs + length(id), 1)
459
+ if (prev !~ /[A-Za-z0-9_]/ && nx !~ /[A-Za-z0-9_]/) return 1
460
+ start = abs + 1
461
+ }
462
+ return 0
463
+ }
464
+ # the name a hit line DEFINES (keyword NAME, or shell name() {), or ""
465
+ function defname(text, m, nm, r) {
466
+ if (match(text, /^[ \t]*[A-Za-z_][A-Za-z0-9_]*\(\)[ \t]*\{/)) {
467
+ nm = substr(text, RSTART, RLENGTH); sub(/^[ \t]*/, "", nm); sub(/\(\).*$/, "", nm); return nm
468
+ }
469
+ if (match(text, /(^|[^A-Za-z0-9_])(def|function|func|fn|fun|sub|proc|class|struct|interface|enum|trait|type)[ \t]+(\([^)]*\)[ \t]*)?[A-Za-z_][A-Za-z0-9_]*/)) {
470
+ nm = substr(text, RSTART, RLENGTH); sub(/^.*[ \t)]/, "", nm); return nm
471
+ }
472
+ return ""
473
+ }
474
+ BEGIN {
475
+ FS = "\001"
476
+ while ((getline l < ids) > 0) {
477
+ split(l, a, "\t"); N++; iid[N] = a[1]; ikind[N] = a[2]
478
+ if (a[1] ~ /^[A-Za-z0-9_]+$/) wid[a[1]] = N; else sid[++ns] = N
479
+ # a plain lowercase word is generic English in prose: it is swept in code files only (diff mode)
480
+ if (plainprose && a[1] ~ /^[a-z]+$/) plain[N] = 1
481
+ }
482
+ while ((getline l < changed) > 0) chg[l] = 1
483
+ while ((getline l < globs) > 0) {
484
+ v = substr(l, 3)
485
+ if (substr(l, 1, 2) == "R:") roots[++nroot] = v
486
+ else if (substr(l, 1, 2) == "D:") dirs[++ndir] = v
487
+ else gre[++ng] = globre(v)
488
+ }
489
+ }
490
+ NF >= 3 {
491
+ f = $1; ln = $2
492
+ if (f in chg) next
493
+ if (!(f in exc)) exc[f] = isexcl(f)
494
+ if (exc[f]) next
495
+ if (!(f in pro)) pro[f] = (tolower(f) ~ /\.(md|markdown|mdx|rst|txt|adoc|org)$/)
496
+ text = $3
497
+ for (j = 4; j <= NF; j++) text = text "\001" $j
498
+ hit++
499
+ dn = defname(text)
500
+ if (dn != "" && (dn in wid)) { dk = wid[dn]; if ((ikind[dk] == "function" || ikind[dk] == "class") && !(dk in defat)) defat[dk] = f ":" ln }
501
+ nw = split(text, w, /[^A-Za-z0-9_]+/)
502
+ for (j = 1; j <= nw; j++) if (w[j] in wid) { k = wid[w[j]]; if (seen[k] != hit) { seen[k] = hit; take(k, f, ln) } }
503
+ for (j = 1; j <= ns; j++) { k = sid[j]; if (seen[k] != hit && index(text, iid[k]) > 0 && hasword(text, iid[k])) { seen[k] = hit; take(k, f, ln) } }
504
+ }
505
+ function take(k, f, ln) {
506
+ if (plain[k] && pro[f]) return
507
+ cnt[k]++
508
+ if (cnt[k] <= cap) rows[k] = rows[k] iid[k] "\t" ikind[k] "\t" f ":" ln "\n"
509
+ else extra[k]++
510
+ }
511
+ END {
512
+ for (k = 1; k <= N; k++) {
513
+ # a removed function/class whose name is still DEFINED outside the change set is not a unique symbol:
514
+ # its references resolve to the other definition, so they cannot be attributed to the removed one
515
+ if (k in defat) { printf "%s\tambiguous\tstill defined at %s\n", iid[k], defat[k]; continue }
516
+ if (k in rows) printf "%s", rows[k]
517
+ if (extra[k] > 0) printf "%s\ttruncated\t+%d\n", iid[k], extra[k]
518
+ }
519
+ }
520
+ ' > "$TMP/sites" || die2 "sweep grouping failed"
521
+ fi
522
+ fi
523
+ fi
524
+ if [ "$NIDS" -gt "$MAX_IDS" ]; then printf '*\ttruncated\tidentifiers beyond %s not swept\n' "$MAX_IDS" >> "$TMP/sites"; fi
525
+ if [ "$OVER" -eq 1 ]; then printf '*\tBUDGET_EXCEEDED\t%s\n' "$OVERWHY" >> "$TMP/sites"; fi
526
+
527
+ if [ -z "$CONSUMERS" ]; then
528
+ cat "$TMP/sites"
529
+ exit 0
530
+ fi
531
+
532
+ # ---- gate mode -------------------------------------------------------------------------------------------
533
+ # Justified files: lines like "- path[:line] unaffected: reason" (leading bullet and backticks tolerated). The
534
+ # path is everything before the literal " unaffected: " token minus one trailing :<digits>, so any path characters
535
+ # work; a line with an empty reason never justifies.
536
+ sed -E 's/\r$//; s/`//g; s/^[[:space:]]*([-*][[:space:]]+)?//' "$CONSUMERS" \
537
+ | awk '{
538
+ sub(/[ \t]+$/, "")
539
+ i = index($0, " unaffected: ")
540
+ if (i > 1) {
541
+ p = substr($0, 1, i - 1); r = substr($0, i + 13)
542
+ gsub(/^[ \t]+|[ \t]+$/, "", p); gsub(/^[ \t]+|[ \t]+$/, "", r)
543
+ sub(/:[0-9]+$/, "", p)
544
+ if (p != "" && r != "") print p
545
+ }
546
+ }' \
547
+ | sort -u > "$TMP/justified"
548
+ RC=0; NSITES=0; NUNADDR=0
549
+ LABEL="UNADDRESSED"; [ "$MODE" != advisory ] || LABEL="ADVISORY"
550
+ while IFS="$TAB" read -r id kind loc; do
551
+ [ -n "$id" ] || continue
552
+ [ "$kind" != truncated ] || continue
553
+ [ "$kind" != BUDGET_EXCEEDED ] || continue
554
+ [ "$kind" != ambiguous ] || continue
555
+ NSITES=$((NSITES+1))
556
+ f="${loc%:*}"
557
+ grep -Fxq -- "$f" "$TMP/justified" && continue
558
+ echo "$LABEL: $id $loc"; NUNADDR=$((NUNADDR+1)); RC=1
559
+ done < "$TMP/sites"
560
+ if [ "$OVER" -eq 1 ]; then
561
+ echo "BUDGET_EXCEEDED: $OVERWHY (advisory: the sweep is incomplete and never fails the gate)"
562
+ exit 0
563
+ fi
564
+ if [ "$MODE" = advisory ]; then
565
+ echo "OK: consumer sweep advisory ($NSITES site(s), $NUNADDR unaddressed; mode=advisory never fails)"
566
+ exit 0
567
+ fi
568
+ [ "$RC" -ne 0 ] || echo "OK: consumer sweep addressed ($NSITES site(s))"
569
+ exit "$RC"