devflow-kit 3.6.0 → 3.8.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 (197) hide show
  1. package/CHANGELOG.md +438 -1
  2. package/README.md +15 -11
  3. package/dist/agents/code.md +9 -15
  4. package/dist/agents/design.md +1 -1
  5. package/dist/agents/fix.md +11 -13
  6. package/dist/agents/git.md +1 -1
  7. package/dist/agents/harden.md +1 -1
  8. package/dist/agents/refactor.md +7 -10
  9. package/dist/agents/review.md +6 -4
  10. package/dist/agents/skim.md +2 -2
  11. package/dist/agents/triage.md +2 -2
  12. package/dist/cli/commands/capture.js +1 -1
  13. package/dist/cli/commands/curation-move-io.js +102 -0
  14. package/dist/cli/commands/curation-move-step.js +374 -0
  15. package/dist/cli/commands/{learning.js → curation.js} +302 -99
  16. package/dist/cli/commands/gate-level-prompts.js +426 -0
  17. package/dist/cli/commands/gates.js +299 -0
  18. package/dist/cli/commands/init-seed.js +16 -5
  19. package/dist/cli/commands/init.js +263 -54
  20. package/dist/cli/commands/memory.js +20 -2
  21. package/dist/cli/commands/prompt-io.js +37 -0
  22. package/dist/cli/commands/uninstall.js +81 -9
  23. package/dist/cli.js +4 -2
  24. package/dist/commands/bug-analysis.md +25 -36
  25. package/dist/commands/code-review.md +77 -73
  26. package/dist/commands/debug.md +5 -5
  27. package/dist/commands/dynamic-build.md +76 -83
  28. package/dist/commands/dynamic-plan.md +5 -5
  29. package/dist/commands/dynamic-profile.md +5 -5
  30. package/dist/commands/dynamic-tickets.md +5 -5
  31. package/dist/commands/explore.md +6 -6
  32. package/dist/commands/implement.md +68 -102
  33. package/dist/commands/plan.md +19 -29
  34. package/dist/commands/release.md +5 -5
  35. package/dist/commands/research.md +6 -6
  36. package/dist/commands/resolve.md +29 -31
  37. package/dist/commands/self-review.md +5 -13
  38. package/dist/core/assets.js +20 -20
  39. package/dist/core/curate-tuning-config.js +5 -6
  40. package/dist/core/curation-move-notice.js +84 -0
  41. package/dist/core/curation-move-skip.js +36 -0
  42. package/dist/core/curation-move.js +35 -0
  43. package/dist/core/curation-queue-cleanup.js +134 -0
  44. package/dist/core/curation-store.js +98 -0
  45. package/dist/core/{learning-variants.js → curation-variants.js} +65 -41
  46. package/dist/core/echo-value.js +48 -0
  47. package/dist/core/evidence-policy.js +18 -6
  48. package/dist/core/feature-switch.js +95 -19
  49. package/dist/core/fs-atomic.js +1 -1
  50. package/dist/core/gate-level.js +221 -0
  51. package/dist/core/git.js +16 -14
  52. package/dist/core/ledger-root.js +82 -22
  53. package/dist/core/linked-path.js +4 -4
  54. package/dist/core/manifest-lock.js +238 -0
  55. package/dist/core/manifest.js +84 -24
  56. package/dist/core/migrations.js +1 -13
  57. package/dist/core/model-discovery.js +7 -1
  58. package/dist/core/observations.js +2 -2
  59. package/dist/core/plugins.js +15 -15
  60. package/dist/core/pool.js +26 -0
  61. package/dist/core/project-paths.js +103 -34
  62. package/dist/core/queue-drain.js +2 -2
  63. package/dist/core/tracked-areas.js +35 -0
  64. package/dist/core/tracker.js +6 -34
  65. package/dist/{learning-off → curation-off}/agents/code.md +9 -15
  66. package/dist/{learning-off → curation-off}/agents/fix.md +11 -13
  67. package/dist/{learning-off → curation-off}/agents/refactor.md +7 -10
  68. package/dist/{learning-off → curation-off}/agents/review.md +5 -3
  69. package/dist/{learning-off → curation-off}/agents/triage.md +1 -1
  70. package/dist/{learning-off → curation-off}/commands/bug-analysis.md +20 -31
  71. package/dist/{learning-off → curation-off}/commands/code-review.md +74 -70
  72. package/dist/{learning-off → curation-off}/commands/debug.md +2 -2
  73. package/dist/{learning-off → curation-off}/commands/dynamic-build.md +73 -80
  74. package/dist/{learning-off → curation-off}/commands/explore.md +2 -2
  75. package/dist/{learning-off → curation-off}/commands/implement.md +65 -99
  76. package/dist/{learning-off → curation-off}/commands/plan.md +15 -25
  77. package/dist/{learning-off → curation-off}/commands/resolve.md +26 -28
  78. package/dist/{learning-off → curation-off}/commands/self-review.md +2 -10
  79. package/dist/hud/components/{learning-counts.js → curation-counts.js} +36 -17
  80. package/dist/hud/config.js +1 -1
  81. package/dist/hud/index.js +6 -6
  82. package/dist/hud/render.js +3 -3
  83. package/dist/skills/git/references/pr/validate-branch.md +8 -5
  84. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +20 -14
  85. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +20 -20
  86. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +20 -20
  87. package/dist/targets/claude-code/{learning-install.js → curation-install.js} +99 -67
  88. package/dist/targets/claude-code/installer.js +15 -15
  89. package/dist/targets/claude-code/language-stamp.js +5 -5
  90. package/dist/targets/claude-code/post-install.js +2 -2
  91. package/dist/targets/claude-code/tracker-servers.js +1 -1
  92. package/package.json +3 -2
  93. package/src/assets/agents/_partials/_code.mds +16 -17
  94. package/src/assets/agents/code.mds +4 -7
  95. package/src/assets/agents/curate.md +12 -14
  96. package/src/assets/agents/debug.mds +6 -6
  97. package/src/assets/agents/design.mds +18 -18
  98. package/src/assets/agents/fix.mds +6 -5
  99. package/src/assets/agents/git.mds +1 -1
  100. package/src/assets/agents/harden.mds +7 -7
  101. package/src/assets/agents/knowledge.mds +23 -23
  102. package/src/assets/agents/refactor.mds +2 -2
  103. package/src/assets/agents/research.mds +15 -15
  104. package/src/assets/agents/review.mds +45 -43
  105. package/src/assets/agents/skim.mds +11 -11
  106. package/src/assets/agents/synthesize.md +2 -2
  107. package/src/assets/agents/triage.mds +22 -22
  108. package/src/assets/agents/validate.md +13 -26
  109. package/src/assets/commands/_partials/_decisions.mds +13 -13
  110. package/src/assets/commands/_partials/_engine.mds +8 -8
  111. package/src/assets/commands/_partials/_knowledge.mds +29 -15
  112. package/src/assets/commands/_partials/_preamble.mds +4 -4
  113. package/src/assets/commands/_partials/_settings.mds +3 -3
  114. package/src/assets/commands/_partials/_wave.mds +17 -24
  115. package/src/assets/commands/bug-analysis.mds +37 -48
  116. package/src/assets/commands/code-review.mds +90 -86
  117. package/src/assets/commands/debug.mds +14 -14
  118. package/src/assets/commands/dynamic-build.mds +78 -78
  119. package/src/assets/commands/dynamic-plan.mds +22 -22
  120. package/src/assets/commands/dynamic-profile.mds +2 -2
  121. package/src/assets/commands/dynamic-tickets.mds +8 -8
  122. package/src/assets/commands/explore.mds +20 -20
  123. package/src/assets/commands/implement.mds +92 -118
  124. package/src/assets/commands/plan.mds +41 -51
  125. package/src/assets/commands/release.mds +12 -12
  126. package/src/assets/commands/research.mds +19 -19
  127. package/src/assets/commands/resolve.mds +56 -50
  128. package/src/assets/commands/self-review.mds +20 -20
  129. package/src/assets/mds/git/_ops.mds +3 -1
  130. package/src/assets/mds/git/_ops_pr.mds +1 -1
  131. package/src/assets/mds/git/_ops_steps.mds +13 -7
  132. package/src/assets/mds/git/_pr.mds +7 -4
  133. package/src/assets/mds/tracker/_common.mds +23 -12
  134. package/src/assets/mds/tracker/_github.mds +2 -9
  135. package/src/assets/mds/tracker/_jira.mds +2 -15
  136. package/src/assets/mds/tracker/_linear.mds +2 -15
  137. package/src/assets/rules/quality.md +1 -0
  138. package/src/assets/scripts/ci-wait.cjs +22 -4
  139. package/src/assets/scripts/claude-md-audit.cjs +66 -51
  140. package/src/assets/scripts/hooks/background-memory-update +406 -97
  141. package/src/assets/scripts/hooks/capture-prompt +12 -12
  142. package/src/assets/scripts/hooks/capture-question +11 -11
  143. package/src/assets/scripts/hooks/capture-turn +12 -12
  144. package/src/assets/scripts/hooks/{learning-lock → curation-lock} +2 -2
  145. package/src/assets/scripts/hooks/curation-move +293 -0
  146. package/src/assets/scripts/hooks/ensure-devflow-init +45 -13
  147. package/src/assets/scripts/hooks/ensure-proxy +4 -4
  148. package/src/assets/scripts/hooks/ensure-root-gitignore +4 -4
  149. package/src/assets/scripts/hooks/git-marker +81 -0
  150. package/src/assets/scripts/hooks/json-helper.cjs +145 -51
  151. package/src/assets/scripts/hooks/json-parse +19 -4
  152. package/src/assets/scripts/hooks/lib/curation-move.cjs +1582 -0
  153. package/src/assets/scripts/hooks/lib/{learning-store.cjs → curation-store.cjs} +242 -118
  154. package/src/assets/scripts/hooks/lib/decisions-format.cjs +3 -3
  155. package/src/assets/scripts/hooks/lib/mkdir-lock.cjs +24 -7
  156. package/src/assets/scripts/hooks/lib/project-paths.cjs +132 -39
  157. package/src/assets/scripts/hooks/lib/render-decisions.cjs +34 -22
  158. package/src/assets/scripts/hooks/lib/tracked-areas.cjs +259 -0
  159. package/src/assets/scripts/hooks/memory-permission +358 -0
  160. package/src/assets/scripts/hooks/memory-worker +1 -1
  161. package/src/assets/scripts/hooks/pre-compact-memory +6 -16
  162. package/src/assets/scripts/hooks/queue-append +228 -103
  163. package/src/assets/scripts/hooks/resolve-project-root +2 -2
  164. package/src/assets/scripts/hooks/session-start-context +145 -71
  165. package/src/assets/scripts/hooks/session-start-memory +27 -46
  166. package/src/assets/scripts/lib/project-config.cjs +31 -9
  167. package/src/assets/scripts/resolve-base.cjs +1043 -0
  168. package/src/assets/scripts/resolve-settings.cjs +238 -46
  169. package/src/assets/skills/agent-references/SKILL.md +9 -2
  170. package/src/assets/skills/agent-references/references/commands/code-review-levels.md +19 -0
  171. package/src/assets/skills/agent-references/references/commands/implement-levels.md +27 -0
  172. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +6 -4
  173. package/src/assets/skills/agent-references/references/commands/plan-levels.md +40 -0
  174. package/src/assets/skills/agent-references/references/commands/resolve-levels.md +24 -0
  175. package/src/assets/skills/agent-references/references/synthesize/planning.md +9 -22
  176. package/src/assets/skills/agent-references/references/synthesize/review.md +11 -11
  177. package/src/assets/skills/apply-decisions/SKILL.md +1 -1
  178. package/src/assets/skills/docs-framework/SKILL.md +7 -7
  179. package/src/assets/skills/docs-framework/references/patterns.md +3 -4
  180. package/src/assets/skills/quality-gates/SKILL.md +1 -1
  181. package/src/assets/skills/worktree-support/references/roots.md +2 -2
  182. package/templates/lint-pack/.lint-pack.conf +86 -0
  183. package/templates/lint-pack/README.md +190 -0
  184. package/templates/lint-pack/lint-pack.sh +905 -0
  185. package/dist/core/learning-queue-cleanup.js +0 -27
  186. package/dist/core/learning-store.js +0 -61
  187. /package/dist/{learning-off → curation-off}/agents/debug.md +0 -0
  188. /package/dist/{learning-off → curation-off}/agents/design.md +0 -0
  189. /package/dist/{learning-off → curation-off}/agents/harden.md +0 -0
  190. /package/dist/{learning-off → curation-off}/agents/knowledge.md +0 -0
  191. /package/dist/{learning-off → curation-off}/agents/research.md +0 -0
  192. /package/dist/{learning-off → curation-off}/agents/skim.md +0 -0
  193. /package/dist/{learning-off → curation-off}/commands/dynamic-plan.md +0 -0
  194. /package/dist/{learning-off → curation-off}/commands/dynamic-profile.md +0 -0
  195. /package/dist/{learning-off → curation-off}/commands/dynamic-tickets.md +0 -0
  196. /package/dist/{learning-off → curation-off}/commands/release.md +0 -0
  197. /package/dist/{learning-off → curation-off}/commands/research.md +0 -0
@@ -0,0 +1,905 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # lint-pack.sh: a project-owned lint pack. Copy it into your repository (for
4
+ # example as scripts/lint-pack.sh) and wire it to a `lint-pack` build-file entry.
5
+ # devflow ships this template. It never runs the template itself, and it never
6
+ # installs or updates your copy. At the `lean` gate level, Validate runs your
7
+ # wired copy. The README is templates/lint-pack/README.md in devflow-kit.
8
+ #
9
+ # Usage: bash scripts/lint-pack.sh <base-sha>
10
+ #
11
+ # <base-sha> a full 40-digit lower-case commit id that is an ancestor of HEAD.
12
+ #
13
+ # It reads the committed change <base-sha>..HEAD and the config blob that
14
+ # `.lint-pack.conf` holds at HEAD (data only: it is never sourced and never
15
+ # evaluated). Three checks are off until the config enables them:
16
+ #
17
+ # stale-symbol a symbol the branch removed from the source globs, that is on
18
+ # no added line in the source globs and in no tracked file
19
+ # outside the doc and help globs, but that a doc or help line
20
+ # at HEAD still names
21
+ # plan-id configured POSIX ERE patterns (plan IDs, tombstone phrases)
22
+ # that match as whole words on added lines
23
+ # function-size diagnostics of a configured linter that fall inside added hunks
24
+ #
25
+ # Output (stdout only; stderr stays empty):
26
+ #
27
+ # <check-id> <path>:<line> <id> one record per match, sorted, at most 50
28
+ # +<k> more records beyond the first 50
29
+ # pack-changed <path> a pack file the branch changed; at most 20, then
30
+ # +<k> more; these lines also print before an error
31
+ # line
32
+ # pack-dirty <path> a pack file that differs from HEAD in the work
33
+ # tree or the index, or that is untracked; at most
34
+ # 20, then +<k> more; these lines print after the
35
+ # pack-changed lines, and also before an error line
36
+ # skipped-binary <path> at most 10, then +<k> more
37
+ # symbol-cap <n> | unsafe-tokens <n> | unmatched-diagnostics <n>
38
+ # dropped-long-lines <n> counts, printed only when not zero
39
+ # base <sha> the commit the run compared: the line just before
40
+ # the LINT_PACK line, on every run that got past the
41
+ # base checks, an error exit included
42
+ # LINT_PACK: clean | no-config exit 0
43
+ # LINT_PACK: matches <n> exit 1
44
+ # LINT_PACK: error <reason> exit 2
45
+ #
46
+ # Error reasons: usage, git-too-old, not-a-repo, bad-base, base-not-ancestor,
47
+ # bad-config <n>, bad-pattern <id>, git-failed, interrupted, linter-missing,
48
+ # linter-exit <n>.
49
+ #
50
+ # bad-config <n> is the line number in the config; 0 means that .lint-pack.conf
51
+ # at HEAD is not a regular file (for example a symlink or a directory)
52
+ # git-too-old git is older than 2.30; a version string that cannot be read is
53
+ # git-failed
54
+ # interrupted the run got SIGTERM or SIGINT, for example from a timeout
55
+ # linter-exit the linter exited with a status outside the accepted set, or with
56
+ # any non-zero status and no "<path>:<line>:" line in its output
57
+ #
58
+ # The pack files are the config, this script, any .gitattributes, and the build
59
+ # files package.json, Makefile, makefile and GNUmakefile (GNU make reads the last
60
+ # two before Makefile). A build file counts only when a changed line names
61
+ # lint-pack or BASE; for pack-dirty, an untracked build file counts too.
62
+ #
63
+ # Needs bash 3.2 or newer and git 2.30 or newer. No line of added text is ever
64
+ # printed. The last line is always the LINT_PACK line.
65
+
66
+ set -u -o pipefail
67
+
68
+ LC_ALL=C
69
+ export LC_ALL
70
+ unset GIT_LITERAL_PATHSPECS GIT_NOGLOB_PATHSPECS GIT_GLOB_PATHSPECS GIT_ICASE_PATHSPECS
71
+ # GIT_DIFF_OPTS overrides -U0 and would add context lines to the diff.
72
+ unset GIT_DIFF_OPTS
73
+ unset GREP_OPTIONS GREP_COLOR GREP_COLORS
74
+ # A set CDPATH makes a relative cd print the directory it chose.
75
+ unset CDPATH
76
+
77
+ # Fixed bounds: every loop below runs over a file, an array or one of these.
78
+ MAX_CONF_LINES=1000
79
+ MAX_RECORDS=50
80
+ MAX_SYMBOLS=500
81
+ MAX_BINARY=10
82
+ MAX_PACK=20
83
+ MIN_GIT_MAJOR=2
84
+ MIN_GIT_MINOR=30
85
+
86
+ TAB=$(printf '\t')
87
+ KEY_RE='^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+$'
88
+ ID_RE='^[a-z0-9-]{1,32}$'
89
+ BASE_RE='^[0-9a-f]{40}$'
90
+ BLANK_RE='^[[:space:]]*$'
91
+ NUM_RE='^[0-9]{1,3}$'
92
+ BAD_ESCAPE_RE='\\[A-Za-z0-9<>]'
93
+ GIT_VERSION_RE='^([0-9]+)\.([0-9]+)'
94
+ # A diagnostic starts with a path and holds no colon before its line number. An
95
+ # indented stack frame, and a "node:internal/..." prefix, are not diagnostics.
96
+ DIAGNOSTIC_RE='^[^[:space:]][^:]*:[0-9]+:'
97
+
98
+ # Every git call: no pager, and quoted paths in git's C-quoted form.
99
+ g() { command git --no-pager -c core.quotePath=true "$@"; }
100
+ DIFF_PINS=(--no-ext-diff --no-textconv --no-color --no-relative --src-prefix=a/ --dst-prefix=b/
101
+ --diff-algorithm=myers --inter-hunk-context=0 --indent-heuristic)
102
+ # The build files that run the pack, and the changed lines in them that count.
103
+ BUILD_FILES=(package.json Makefile makefile GNUmakefile)
104
+ BUILD_LINE_RE='lint-pack|BASE'
105
+ # A submodule file is not a tracked file of this repository: never search it.
106
+ GREP_PINS=(--no-color --no-column --full-name --no-recurse-submodules)
107
+
108
+ TMP=''
109
+ DONE=0
110
+ ROOT=''
111
+ BASE=''
112
+ SELF_ABS=''
113
+ SELF_REL=''
114
+ BASE_OK=0
115
+ FLAGS=''
116
+ PC_READY=0
117
+ SEEN=' '
118
+ ANY_ENABLED=0
119
+
120
+ STALE_ENABLED=false
121
+ STALE_LINE=0
122
+ STALE_SOURCE=()
123
+ STALE_DOC=()
124
+ STALE_HELP=()
125
+ STALE_SYMBOL=()
126
+ STALE_ALLOW=()
127
+ STALE_MIN=6
128
+ IDS_ENABLED=false
129
+ IDS_LINE=0
130
+ IDS_ID=()
131
+ IDS_RE=()
132
+ IDS_ALLOW=()
133
+ SIZE_ENABLED=false
134
+ SIZE_LINE=0
135
+ SIZE_ARG=()
136
+ SIZE_OK=()
137
+ SIZE_ALLOW=()
138
+ SIZE_MATCH=''
139
+
140
+ SYMBOL_CAP_N=0
141
+ UNSAFE_N=0
142
+ UNMATCHED_N=0
143
+ DROPPED_N=0
144
+
145
+ # ---- Exit handling ----
146
+
147
+ # File descriptor 9 is the real stdout. Every result line goes to it, never to fd 1:
148
+ # a redirect that is active when a signal lands (a `g ... >file` call, the result
149
+ # block) would take the lines, and the last line would not be the LINT_PACK line.
150
+ exec 9>&1
151
+
152
+ # The pack-changed and pack-dirty lines, once they are built. An error exit prints
153
+ # them too, so a branch that breaks its own pack still shows that it changed it.
154
+ report_pack_lines() {
155
+ if [ "$PC_READY" = 1 ]; then
156
+ PC_READY=0
157
+ cat "$TMP/pc.out" 2>/dev/null
158
+ fi
159
+ return 0
160
+ }
161
+
162
+ # The base the run compared, once the base checks have passed. It is the line just
163
+ # before the result line, an error result included.
164
+ report_base() {
165
+ if [ "$BASE_OK" = 1 ]; then printf 'base %s\n' "$BASE"; fi
166
+ return 0
167
+ }
168
+
169
+ # The error result on the real stdout: the pack lines, the base line, the reason.
170
+ print_error() {
171
+ report_pack_lines
172
+ report_base
173
+ printf 'LINT_PACK: error %s\n' "$1"
174
+ } >&9
175
+
176
+ # One error, one reason from the closed set. Only the pack lines and the base line
177
+ # come before it on stdout.
178
+ die_error() {
179
+ print_error "$1"
180
+ DONE=1
181
+ exit 2
182
+ }
183
+
184
+ # Remove the temp dir. A run that ends without its result line is a git-failed.
185
+ # A signal that lands now changes nothing, and it must not cut the removal short.
186
+ on_exit() {
187
+ local rc=$?
188
+ trap '' TERM INT
189
+ if [ "$DONE" != 1 ]; then
190
+ print_error git-failed
191
+ rc=2
192
+ fi
193
+ if [ -n "$TMP" ]; then rm -rf "$TMP"; fi
194
+ exit "$rc"
195
+ }
196
+ trap on_exit EXIT
197
+
198
+ # A timeout wrapper ends a run with SIGTERM, a user with SIGINT: say so, and let
199
+ # the exit trap remove the temp dir. Further signals are ignored meanwhile, and so
200
+ # is every signal once the result is final.
201
+ on_signal() {
202
+ trap '' TERM INT
203
+ if [ "$DONE" = 1 ]; then return 0; fi
204
+ die_error interrupted
205
+ }
206
+ trap on_signal TERM INT
207
+
208
+ # ---- Small helpers ----
209
+
210
+ # Print the line count of a file without wc's padding.
211
+ count_lines() {
212
+ local n
213
+ n=$(wc -l <"$1") || return 1
214
+ printf '%s' "${n//[[:space:]]/}"
215
+ }
216
+
217
+ # Keep the lines of $1 that are at most 200 bytes in $2; count the others.
218
+ bound_lines() {
219
+ local rc k
220
+ command grep -a -E -v -e '^.{201}' "$1" >"$2"
221
+ rc=$?
222
+ [ "$rc" -le 1 ] || die_error git-failed
223
+ k=$(command grep -a -c -E -e '^.{201}' "$1")
224
+ rc=$?
225
+ [ "$rc" -le 1 ] || die_error git-failed
226
+ DROPPED_N=$((DROPPED_N + k))
227
+ }
228
+
229
+ # ---- Config (data, one key=value per line) ----
230
+
231
+ cfg_fail() { die_error "bad-config $1"; }
232
+
233
+ # A scalar key may appear once.
234
+ cfg_once() {
235
+ case "$SEEN" in *" $1 "*) cfg_fail "$2" ;; esac
236
+ SEEN="$SEEN$1 "
237
+ }
238
+
239
+ cfg_bool() {
240
+ case "$1" in true | false) ;; *) cfg_fail "$2" ;; esac
241
+ }
242
+
243
+ cfg_value() {
244
+ [ -n "$1" ] || cfg_fail "$2"
245
+ }
246
+
247
+ # A whole number of at most three digits that is not above $3.
248
+ cfg_number() {
249
+ [[ $1 =~ $NUM_RE ]] || cfg_fail "$2"
250
+ [ $((10#$1)) -le "$3" ] || cfg_fail "$2"
251
+ }
252
+
253
+ read_config() {
254
+ local line n=0 key val id pat
255
+ while IFS= read -r line || [ -n "$line" ]; do
256
+ n=$((n + 1))
257
+ [ "$n" -le "$MAX_CONF_LINES" ] || cfg_fail "$n"
258
+ line=${line%$'\r'}
259
+ case "$line" in '' | '#'*) continue ;; esac
260
+ if [[ $line =~ $BLANK_RE ]]; then continue; fi
261
+ case "$line" in *=*) ;; *) cfg_fail "$n" ;; esac
262
+ key=${line%%=*}
263
+ val=${line#*=}
264
+ [[ $key =~ $KEY_RE ]] || cfg_fail "$n"
265
+ case "$key" in
266
+ stale.enabled)
267
+ cfg_once "$key" "$n"; cfg_bool "$val" "$n"
268
+ STALE_ENABLED=$val; STALE_LINE=$n ;;
269
+ stale.source)
270
+ cfg_value "$val" "$n"; STALE_SOURCE[${#STALE_SOURCE[@]}]=$val ;;
271
+ stale.doc)
272
+ cfg_value "$val" "$n"; STALE_DOC[${#STALE_DOC[@]}]=$val ;;
273
+ stale.help)
274
+ cfg_value "$val" "$n"; STALE_HELP[${#STALE_HELP[@]}]=$val ;;
275
+ stale.symbol)
276
+ cfg_value "$val" "$n"; STALE_SYMBOL[${#STALE_SYMBOL[@]}]=$val ;;
277
+ stale.min-length)
278
+ cfg_once "$key" "$n"; cfg_number "$val" "$n" 64
279
+ [ $((10#$val)) -ge 1 ] || cfg_fail "$n"
280
+ STALE_MIN=$((10#$val)) ;;
281
+ stale.allow)
282
+ cfg_value "$val" "$n"; STALE_ALLOW[${#STALE_ALLOW[@]}]=$val ;;
283
+ ids.enabled)
284
+ cfg_once "$key" "$n"; cfg_bool "$val" "$n"
285
+ IDS_ENABLED=$val; IDS_LINE=$n ;;
286
+ ids.pattern)
287
+ case "$val" in *' '*) ;; *) cfg_fail "$n" ;; esac
288
+ id=${val%% *}
289
+ pat=${val#* }
290
+ [[ $id =~ $ID_RE ]] || cfg_fail "$n"
291
+ [[ $pat =~ $BLANK_RE ]] && cfg_fail "$n"
292
+ IDS_ID[${#IDS_ID[@]}]=$id
293
+ IDS_RE[${#IDS_RE[@]}]=$pat ;;
294
+ ids.allow)
295
+ cfg_value "$val" "$n"; IDS_ALLOW[${#IDS_ALLOW[@]}]=$val ;;
296
+ size.enabled)
297
+ cfg_once "$key" "$n"; cfg_bool "$val" "$n"
298
+ SIZE_ENABLED=$val; SIZE_LINE=$n ;;
299
+ size.arg)
300
+ cfg_value "$val" "$n"; SIZE_ARG[${#SIZE_ARG[@]}]=$val ;;
301
+ size.match)
302
+ cfg_once "$key" "$n"; cfg_value "$val" "$n"
303
+ SIZE_MATCH=$val ;;
304
+ size.ok-exit)
305
+ cfg_number "$val" "$n" 255
306
+ SIZE_OK[${#SIZE_OK[@]}]=$((10#$val)) ;;
307
+ size.allow)
308
+ cfg_value "$val" "$n"; SIZE_ALLOW[${#SIZE_ALLOW[@]}]=$val ;;
309
+ *) cfg_fail "$n" ;;
310
+ esac
311
+ done <"$TMP/conf"
312
+ }
313
+
314
+ # An enabled check needs its keys; a disabled check is ignored whole.
315
+ finish_config() {
316
+ if [ "$STALE_ENABLED" = true ]; then
317
+ [ "${#STALE_SOURCE[@]}" -gt 0 ] && [ "${#STALE_SYMBOL[@]}" -gt 0 ] || cfg_fail "$STALE_LINE"
318
+ ANY_ENABLED=1
319
+ else
320
+ STALE_SOURCE=(); STALE_DOC=(); STALE_HELP=(); STALE_SYMBOL=(); STALE_ALLOW=()
321
+ fi
322
+ if [ "$IDS_ENABLED" = true ]; then
323
+ [ "${#IDS_ID[@]}" -gt 0 ] || cfg_fail "$IDS_LINE"
324
+ ANY_ENABLED=1
325
+ else
326
+ IDS_ID=(); IDS_RE=(); IDS_ALLOW=()
327
+ fi
328
+ if [ "$SIZE_ENABLED" = true ]; then
329
+ [ "${#SIZE_ARG[@]}" -gt 0 ] || cfg_fail "$SIZE_LINE"
330
+ ANY_ENABLED=1
331
+ else
332
+ SIZE_ARG=(); SIZE_OK=(); SIZE_ALLOW=(); SIZE_MATCH=''
333
+ fi
334
+ if [ "${#STALE_DOC[@]}" -eq 0 ]; then STALE_DOC[0]='*.md'; fi
335
+ if [ "${#SIZE_OK[@]}" -eq 0 ]; then SIZE_OK[0]=0; SIZE_OK[1]=1; fi
336
+ }
337
+
338
+ # The whole-word wrapper of a plan-ID pattern.
339
+ wrap_pattern() {
340
+ printf '%s' "(^|[^A-Za-z0-9_])($1)([^A-Za-z0-9_]|\$)"
341
+ }
342
+
343
+ # POSIX ERE only: no \d \w \s \b, no back-reference, no regex the grep engine
344
+ # rejects, no pattern that matches the empty string. $3 is 1 for a wrapped one.
345
+ validate_pattern() {
346
+ local rc
347
+ if [[ $2 =~ $BAD_ESCAPE_RE ]]; then die_error "bad-pattern $1"; fi
348
+ command grep -a -E -e "($2)" </dev/null >/dev/null 2>&1
349
+ rc=$?
350
+ [ "$rc" -le 1 ] || die_error "bad-pattern $1"
351
+ if [ "$3" = 1 ]; then
352
+ command grep -a -E -e "$(wrap_pattern "$2")" </dev/null >/dev/null 2>&1
353
+ rc=$?
354
+ [ "$rc" -le 1 ] || die_error "bad-pattern $1"
355
+ fi
356
+ command grep -a -E -e "^($2)\$" <<<'' >/dev/null 2>&1
357
+ rc=$?
358
+ [ "$rc" -eq 1 ] || die_error "bad-pattern $1"
359
+ }
360
+
361
+ validate_patterns() {
362
+ local i p
363
+ for p in ${STALE_SYMBOL[@]+"${STALE_SYMBOL[@]}"}; do
364
+ validate_pattern stale-symbol "$p" 0
365
+ done
366
+ i=0
367
+ while [ "$i" -lt "${#IDS_ID[@]}" ]; do
368
+ validate_pattern "${IDS_ID[$i]}" "${IDS_RE[$i]}" 1
369
+ i=$((i + 1))
370
+ done
371
+ if [ -n "$SIZE_MATCH" ]; then validate_pattern size-match "$SIZE_MATCH" 0; fi
372
+ }
373
+
374
+ # ---- Paths and flags ----
375
+
376
+ # Path of this script, resolved before the first cd.
377
+ resolve_self() {
378
+ local dir
379
+ dir=$(cd "$(dirname -- "$0")" 2>/dev/null && pwd -P) || return 0
380
+ SELF_ABS="$dir/$(basename -- "$0")"
381
+ }
382
+
383
+ # Does raw path $1 match any of the globs after it? A glob is a shell glob in
384
+ # which * also matches /. The pattern is data: case never runs it.
385
+ glob_match() {
386
+ local path=$1 glob
387
+ shift
388
+ for glob in "$@"; do
389
+ case "$path" in $glob) return 0 ;; esac
390
+ done
391
+ return 1
392
+ }
393
+
394
+ # FLAGS for raw path $1: = then one letter per property.
395
+ # O own file of the pack P pack-changed by itself S stale source
396
+ # D doc or help glob A stale allow B ids allow C size allow
397
+ classify_path() {
398
+ FLAGS='='
399
+ if [ "$1" = '.lint-pack.conf' ]; then FLAGS="${FLAGS}OP"; fi
400
+ if [ -n "$SELF_REL" ] && [ "$1" = "$SELF_REL" ]; then FLAGS="${FLAGS}OP"; fi
401
+ case "$1" in .gitattributes | */.gitattributes) FLAGS="${FLAGS}P" ;; esac
402
+ if glob_match "$1" ${STALE_SOURCE[@]+"${STALE_SOURCE[@]}"}; then FLAGS="${FLAGS}S"; fi
403
+ if glob_match "$1" ${STALE_DOC[@]+"${STALE_DOC[@]}"} ${STALE_HELP[@]+"${STALE_HELP[@]}"}; then FLAGS="${FLAGS}D"; fi
404
+ if glob_match "$1" ${STALE_ALLOW[@]+"${STALE_ALLOW[@]}"}; then FLAGS="${FLAGS}A"; fi
405
+ if glob_match "$1" ${IDS_ALLOW[@]+"${IDS_ALLOW[@]}"}; then FLAGS="${FLAGS}B"; fi
406
+ if glob_match "$1" ${SIZE_ALLOW[@]+"${SIZE_ALLOW[@]}"}; then FLAGS="${FLAGS}C"; fi
407
+ return 0
408
+ }
409
+
410
+ # Join a quoted listing ($1, one path per line) with the same listing made with
411
+ # -z ($2, raw paths): line n of one is entry n of the other. Writes a map of
412
+ # quoted path, raw path, flags (tab separated) to $3. $4 is 1 to drop the
413
+ # "HEAD:" prefix that git grep adds.
414
+ build_map() {
415
+ local q r rq
416
+ : >"$3"
417
+ exec 3<"$1" 4<"$2" 5>>"$3"
418
+ while IFS= read -r q <&3; do
419
+ IFS= read -r -d '' r <&4 || die_error git-failed
420
+ if [ "$4" = 1 ]; then q=${q#HEAD:}; r=${r#HEAD:}; fi
421
+ classify_path "$r"
422
+ rq=$r
423
+ case "$rq" in *"$TAB"* | *$'\n'*) rq='' ;; esac
424
+ printf '%s\t%s\t%s\n' "$q" "$rq" "$FLAGS" >&5
425
+ done
426
+ if IFS= read -r -d '' r <&4; then die_error git-failed; fi
427
+ exec 3<&- 4<&- 5>&-
428
+ }
429
+
430
+ # ---- Diffs ----
431
+
432
+ # Every changed path, old and new side of a rename included, with its flags.
433
+ build_diffmap() {
434
+ g diff "${DIFF_PINS[@]}" --no-renames --name-only --end-of-options "$BASE" HEAD -- \
435
+ >"$TMP/dm.q" 2>/dev/null || die_error git-failed
436
+ g diff "${DIFF_PINS[@]}" --no-renames --name-only -z --end-of-options "$BASE" HEAD -- \
437
+ >"$TMP/dm.z" 2>/dev/null || die_error git-failed
438
+ build_map "$TMP/dm.q" "$TMP/dm.z" "$TMP/dm.map" 0
439
+ }
440
+
441
+ # The "+<k> more" line for the lines of file $1 beyond the first $MAX_PACK, if any.
442
+ more_after_cap() {
443
+ local total
444
+ total=$(count_lines "$1") || die_error git-failed
445
+ if [ "$total" -gt "$MAX_PACK" ]; then printf '+%s more\n' $((total - MAX_PACK)); fi
446
+ return 0
447
+ }
448
+
449
+ # The pack files the branch changed, sorted: the config, this script and any
450
+ # .gitattributes by name, a build file when a changed line names lint-pack or BASE.
451
+ # A config renamed away or deleted shows as its old path. Then the pack files that
452
+ # differ from HEAD in the work tree. The lines need only the changed paths and fixed
453
+ # names, so they are built before the config is read and every later error exit
454
+ # prints them. At most $MAX_PACK lines of each kind print, then "+<k> more". The
455
+ # build-file diff reads a binary file as text, so a NUL byte hides no line.
456
+ pack_changed() {
457
+ command awk -F"$TAB" 'index($3, "P") > 0 { print $1 }' "$TMP/dm.map" >"$TMP/pc.txt" || die_error git-failed
458
+ g diff "${DIFF_PINS[@]}" --no-renames --name-only --text "-G$BUILD_LINE_RE" --end-of-options "$BASE" HEAD -- \
459
+ "${BUILD_FILES[@]}" >>"$TMP/pc.txt" 2>/dev/null || die_error git-failed
460
+ command sort -u "$TMP/pc.txt" >"$TMP/pc.sorted" || die_error git-failed
461
+ command head -n "$MAX_PACK" "$TMP/pc.sorted" | command sed 's/^/pack-changed /' >"$TMP/pc.lines" || die_error git-failed
462
+ more_after_cap "$TMP/pc.sorted" >>"$TMP/pc.lines"
463
+ pack_dirty
464
+ bound_lines "$TMP/pc.lines" "$TMP/pc.out"
465
+ PC_READY=1
466
+ }
467
+
468
+ # The pack files that differ from HEAD in the work tree or the index, or that are
469
+ # untracked and not ignored, sorted, appended to the pack lines. The script is a
470
+ # literal pathspec: its name may hold glob characters. A build file counts only for
471
+ # a changed line that names lint-pack or BASE, or when it is untracked.
472
+ pack_dirty() {
473
+ local side
474
+ local -a mode specs
475
+ specs=(':(literal).lint-pack.conf' .gitattributes '*/.gitattributes')
476
+ if [ -n "$SELF_REL" ]; then specs[${#specs[@]}]=":(literal)$SELF_REL"; fi
477
+ : >"$TMP/pd.txt"
478
+ for side in tree index; do
479
+ mode=()
480
+ if [ "$side" = index ]; then mode=(--cached); fi
481
+ g diff "${DIFF_PINS[@]}" --no-renames --name-only ${mode[@]+"${mode[@]}"} --end-of-options HEAD -- \
482
+ "${specs[@]}" >>"$TMP/pd.txt" 2>/dev/null || die_error git-failed
483
+ g diff "${DIFF_PINS[@]}" --no-renames --name-only --text "-G$BUILD_LINE_RE" ${mode[@]+"${mode[@]}"} --end-of-options HEAD -- \
484
+ "${BUILD_FILES[@]}" >>"$TMP/pd.txt" 2>/dev/null || die_error git-failed
485
+ done
486
+ g ls-files --others --exclude-standard -- "${specs[@]}" "${BUILD_FILES[@]}" >>"$TMP/pd.txt" 2>/dev/null || die_error git-failed
487
+ command sort -u "$TMP/pd.txt" >"$TMP/pd.sorted" || die_error git-failed
488
+ command head -n "$MAX_PACK" "$TMP/pd.sorted" | command sed 's/^/pack-dirty /' >>"$TMP/pc.lines" || die_error git-failed
489
+ more_after_cap "$TMP/pd.sorted" >>"$TMP/pc.lines"
490
+ }
491
+
492
+ # One -U0 diff, split by an awk pass into the streams each check reads.
493
+ diff_main() {
494
+ local ids_on=0 size_on=0
495
+ if [ "$IDS_ENABLED" = true ]; then ids_on=1; fi
496
+ if [ "$SIZE_ENABLED" = true ]; then size_on=1; fi
497
+ g diff "${DIFF_PINS[@]}" -M -U0 --end-of-options "$BASE" HEAD -- >"$TMP/main.diff" 2>/dev/null || die_error git-failed
498
+ command awk -v MAP="$TMP/dm.map" -v IDS_ON="$ids_on" -v SIZE_ON="$size_on" \
499
+ -v RM="$TMP/rm.txt" -v AD1="$TMP/ad1.txt" -v AD2T="$TMP/ad2.txt" -v AD2M="$TMP/ad2.meta" -v RNG="$TMP/ranges.tsv" \
500
+ -f "$TMP/split.awk" "$TMP/main.diff" || die_error git-failed
501
+ }
502
+
503
+ # ---- Check 1: stale symbols ----
504
+
505
+ # Every match of every stale.symbol pattern in $1, one per line, into $2.
506
+ extract_tokens() {
507
+ local pat rc
508
+ : >"$2"
509
+ for pat in ${STALE_SYMBOL[@]+"${STALE_SYMBOL[@]}"}; do
510
+ command grep -a -o -E -e "$pat" "$1" >>"$2"
511
+ rc=$?
512
+ [ "$rc" -le 1 ] || die_error "bad-pattern stale-symbol"
513
+ done
514
+ }
515
+
516
+ check_stale() {
517
+ local total rc
518
+ extract_tokens "$TMP/rm.txt" "$TMP/rm.tok"
519
+ extract_tokens "$TMP/ad1.txt" "$TMP/ad.tok"
520
+ command sort -u "$TMP/rm.tok" >"$TMP/rm.sorted" || die_error git-failed
521
+ command sort -u "$TMP/ad.tok" >"$TMP/ad.sorted" || die_error git-failed
522
+ command awk -v MIN="$STALE_MIN" -v UNSAFE="$TMP/unsafe.n" -f "$TMP/safe.awk" "$TMP/rm.sorted" >"$TMP/rm.safe" || die_error git-failed
523
+ UNSAFE_N=$(cat "$TMP/unsafe.n") || die_error git-failed
524
+ command comm -23 "$TMP/rm.safe" "$TMP/ad.sorted" >"$TMP/cand" || die_error git-failed
525
+ total=$(count_lines "$TMP/cand") || die_error git-failed
526
+ if [ "$total" -gt "$MAX_SYMBOLS" ]; then SYMBOL_CAP_N=$((total - MAX_SYMBOLS)); fi
527
+ command head -n "$MAX_SYMBOLS" "$TMP/cand" >"$TMP/tokens" || die_error git-failed
528
+ [ -s "$TMP/tokens" ] || return 0
529
+
530
+ # Three passes over HEAD only: the files that hold a symbol, quoted and raw,
531
+ # for their flags; then the lines. git grep never leaves the repository.
532
+ g grep -l -F -w "${GREP_PINS[@]}" -f "$TMP/tokens" HEAD -- >"$TMP/hf.q" 2>/dev/null
533
+ rc=$?
534
+ [ "$rc" -le 1 ] || die_error git-failed
535
+ g grep -l -z -F -w "${GREP_PINS[@]}" -f "$TMP/tokens" HEAD -- >"$TMP/hf.z" 2>/dev/null
536
+ rc=$?
537
+ [ "$rc" -le 1 ] || die_error git-failed
538
+ build_map "$TMP/hf.q" "$TMP/hf.z" "$TMP/hf.map" 1
539
+ g grep -n -o -F -w "${GREP_PINS[@]}" -f "$TMP/tokens" HEAD -- >"$TMP/hits" 2>/dev/null
540
+ rc=$?
541
+ [ "$rc" -le 1 ] || die_error git-failed
542
+ command awk -v TOKENS="$TMP/tokens" -v FMAP="$TMP/hf.map" -v SB="$TMP/sb1.txt" -v REC="$TMP/rec.stale" \
543
+ -f "$TMP/hits.awk" "$TMP/hits" || die_error git-failed
544
+ }
545
+
546
+ # ---- Check 2: plan IDs and tombstones ----
547
+
548
+ check_ids() {
549
+ local i=0 id rc
550
+ while [ "$i" -lt "${#IDS_ID[@]}" ]; do
551
+ id=${IDS_ID[$i]}
552
+ command grep -a -n -E -e "$(wrap_pattern "${IDS_RE[$i]}")" "$TMP/ad2.txt" >"$TMP/ids.hit"
553
+ rc=$?
554
+ case "$rc" in
555
+ 0)
556
+ command cut -d: -f1 "$TMP/ids.hit" >"$TMP/ids.nums" || die_error git-failed
557
+ command awk -F"$TAB" -v ID="$id" \
558
+ 'NR == FNR { HIT[$1] = 1; next } (FNR in HIT) { print "plan-id\t" $1 "\t" $2 "\t" ID }' \
559
+ "$TMP/ids.nums" "$TMP/ad2.meta" >>"$TMP/rec.ids" || die_error git-failed ;;
560
+ 1) ;;
561
+ *) die_error "bad-pattern $id" ;;
562
+ esac
563
+ i=$((i + 1))
564
+ done
565
+ # A changed file that git shows as binary is not searched: say so.
566
+ g diff "${DIFF_PINS[@]}" --no-renames --numstat --end-of-options "$BASE" HEAD -- >"$TMP/numstat" 2>/dev/null || die_error git-failed
567
+ command awk -F"$TAB" '
568
+ NR == FNR { FL[$1] = $3; next }
569
+ $1 == "-" && $2 == "-" {
570
+ f = ($3 in FL) ? FL[$3] : "="
571
+ if (index(f, "O") == 0 && index(f, "B") == 0) print $3
572
+ }' "$TMP/dm.map" "$TMP/numstat" >>"$TMP/sb2.txt" || die_error git-failed
573
+ }
574
+
575
+ # ---- Check 3: function size ----
576
+
577
+ check_size() {
578
+ local rc ok e
579
+ # The linter is argv, one word per config line, run from the root. No shell.
580
+ # The outer redirect keeps bash's own "Terminated" note off stderr when a signal
581
+ # ends the linter's subshell. The linter does not get fd 9, so a daemon that it
582
+ # leaves behind never holds the caller's pipe open.
583
+ { ( cd "$ROOT" && "${SIZE_ARG[@]}" ) </dev/null >"$TMP/lint.out" 2>&1 9>&-; } 2>/dev/null
584
+ rc=$?
585
+ case "$rc" in 126 | 127) die_error linter-missing ;; esac
586
+ ok=0
587
+ for e in ${SIZE_OK[@]+"${SIZE_OK[@]}"}; do
588
+ if [ "$rc" -eq "$e" ]; then ok=1; fi
589
+ done
590
+ [ "$ok" = 1 ] || die_error "linter-exit $rc"
591
+ # A non-zero status without one "<path>:<line>:" line is not a lint result: the
592
+ # linter did not run (a failed fetch, a missing module). Never read it as clean.
593
+ if [ "$rc" -ne 0 ]; then
594
+ command grep -a -q -E -e "$DIAGNOSTIC_RE" "$TMP/lint.out"
595
+ case "$?" in 0) ;; 1) die_error "linter-exit $rc" ;; *) die_error git-failed ;; esac
596
+ fi
597
+ if [ -n "$SIZE_MATCH" ]; then
598
+ command grep -a -E -e "$SIZE_MATCH" "$TMP/lint.out" >"$TMP/lint.sel"
599
+ rc=$?
600
+ [ "$rc" -le 1 ] || die_error "bad-pattern size-match"
601
+ else
602
+ cp "$TMP/lint.out" "$TMP/lint.sel" || die_error git-failed
603
+ fi
604
+ ROOT_FOR_AWK=$ROOT command awk -v MAP="$TMP/dm.map" -v RNG="$TMP/ranges.tsv" \
605
+ -v REC="$TMP/rec.size" -v UNM="$TMP/unmatched.n" \
606
+ -f "$TMP/size.awk" "$TMP/lint.sel" || die_error git-failed
607
+ UNMATCHED_N=$(cat "$TMP/unmatched.n") || die_error git-failed
608
+ }
609
+
610
+ # ---- Result ----
611
+
612
+ emit() {
613
+ local total more state code sb_total sb_more
614
+ command sort -t "$TAB" -k1,1 -k2,2 -k3,3n -k4,4 -u "$TMP/rec.stale" "$TMP/rec.ids" "$TMP/rec.size" >"$TMP/rec.sorted" || die_error git-failed
615
+ total=$(count_lines "$TMP/rec.sorted") || die_error git-failed
616
+ command head -n "$MAX_RECORDS" "$TMP/rec.sorted" >"$TMP/rec.head" || die_error git-failed
617
+ command awk -F"$TAB" '{ print $1 " " $2 ":" $3 " " $4 }' "$TMP/rec.head" >"$TMP/rec.lines" || die_error git-failed
618
+ bound_lines "$TMP/rec.lines" "$TMP/rec.out"
619
+ more=0
620
+ if [ "$total" -gt "$MAX_RECORDS" ]; then more=$((total - MAX_RECORDS)); fi
621
+
622
+ cat "$TMP/sb1.txt" "$TMP/sb2.txt" | command sort -u >"$TMP/sb.sorted" || die_error git-failed
623
+ sb_total=$(count_lines "$TMP/sb.sorted") || die_error git-failed
624
+ command head -n "$MAX_BINARY" "$TMP/sb.sorted" | command sed 's/^/skipped-binary /' >"$TMP/sb.lines" || die_error git-failed
625
+ bound_lines "$TMP/sb.lines" "$TMP/sb.out"
626
+ sb_more=0
627
+ if [ "$sb_total" -gt "$MAX_BINARY" ]; then sb_more=$((sb_total - MAX_BINARY)); fi
628
+
629
+ if [ "$total" -gt 0 ]; then
630
+ state="matches $total"; code=1
631
+ elif [ "$ANY_ENABLED" = 1 ]; then
632
+ state=clean; code=0
633
+ else
634
+ state=no-config; code=0
635
+ fi
636
+
637
+ {
638
+ cat "$TMP/rec.out"
639
+ if [ "$more" -gt 0 ]; then printf '+%s more\n' "$more"; fi
640
+ cat "$TMP/pc.out"
641
+ cat "$TMP/sb.out"
642
+ if [ "$sb_more" -gt 0 ]; then printf '+%s more\n' "$sb_more"; fi
643
+ if [ "$SYMBOL_CAP_N" -gt 0 ]; then printf 'symbol-cap %s\n' "$SYMBOL_CAP_N"; fi
644
+ if [ "$UNSAFE_N" -gt 0 ]; then printf 'unsafe-tokens %s\n' "$UNSAFE_N"; fi
645
+ if [ "$UNMATCHED_N" -gt 0 ]; then printf 'unmatched-diagnostics %s\n' "$UNMATCHED_N"; fi
646
+ if [ "$DROPPED_N" -gt 0 ]; then printf 'dropped-long-lines %s\n' "$DROPPED_N"; fi
647
+ report_base
648
+ printf 'LINT_PACK: %s\n' "$state"
649
+ } >"$TMP/result.txt"
650
+ # The result is final: a signal that lands now changes nothing, and it must not
651
+ # cut the print short. The print goes to the real stdout.
652
+ trap '' TERM INT
653
+ DONE=1
654
+ cat "$TMP/result.txt" >&9
655
+ exit "$code"
656
+ }
657
+
658
+ # ---- The awk passes (POSIX awk: no interval expressions) ----
659
+
660
+ write_awk_programs() {
661
+ # Split the -U0 diff. A hunk header gives the exact line counts, so a content
662
+ # line that starts with "--- " or "+++ " is never read as a file header.
663
+ cat >"$TMP/split.awk" <<'AWK' || die_error git-failed
664
+ function flagsof(p) { return (p in FL) ? FL[p] : "=" }
665
+ function unprefix(p) {
666
+ sub(/\t$/, "", p)
667
+ if (p == "/dev/null") return ""
668
+ if (substr(p, 1, 1) == "\"") return "\"" substr(p, 4)
669
+ return substr(p, 3)
670
+ }
671
+ function removed(text, fl) {
672
+ fl = flagsof(oldp)
673
+ if (index(fl, "O") == 0 && index(fl, "S") > 0) print text > RM
674
+ }
675
+ function added(text, fl, ln) {
676
+ fl = flagsof(newp)
677
+ ln = nextnew
678
+ nextnew++
679
+ if (index(fl, "O") > 0) return
680
+ if (index(fl, "S") > 0) print text > AD1
681
+ if (IDS_ON == 1 && index(fl, "B") == 0) {
682
+ print text > AD2T
683
+ print newp "\t" ln > AD2M
684
+ }
685
+ }
686
+ function hunk(line, hdr, parts, o, n, oa, na, fl) {
687
+ if (!match(line, /^@@ -[0-9]+(,[0-9]+)? \+[0-9]+(,[0-9]+)? @@/)) return
688
+ hdr = substr(line, 4, RLENGTH - 6)
689
+ split(hdr, parts, " ")
690
+ o = substr(parts[1], 2)
691
+ n = substr(parts[2], 2)
692
+ if (index(o, ",") > 0) { split(o, oa, ","); remold = oa[2] + 0 } else remold = 1
693
+ if (index(n, ",") > 0) { split(n, na, ","); nextnew = na[1] + 0; remnew = na[2] + 0 } else { nextnew = n + 0; remnew = 1 }
694
+ if (SIZE_ON == 1 && remnew > 0) {
695
+ fl = flagsof(newp)
696
+ if (index(fl, "O") == 0 && index(fl, "C") == 0) print newp "\t" nextnew "\t" (nextnew + remnew - 1) > RNG
697
+ }
698
+ }
699
+ BEGIN {
700
+ while ((getline l < MAP) > 0) {
701
+ split(l, f, "\t")
702
+ FL[f[1]] = f[3]
703
+ }
704
+ close(MAP)
705
+ }
706
+ {
707
+ line = $0
708
+ if (remold > 0 || remnew > 0) {
709
+ c = substr(line, 1, 1)
710
+ if (c == "-" && remold > 0) { remold--; removed(substr(line, 2)); next }
711
+ if (c == "+" && remnew > 0) { remnew--; added(substr(line, 2)); next }
712
+ if (c == "\\") next
713
+ remold = 0
714
+ remnew = 0
715
+ }
716
+ if (substr(line, 1, 4) == "--- ") { oldp = unprefix(substr(line, 5)); next }
717
+ if (substr(line, 1, 4) == "+++ ") { newp = unprefix(substr(line, 5)); next }
718
+ if (substr(line, 1, 11) == "diff --git ") { oldp = ""; newp = ""; next }
719
+ if (substr(line, 1, 3) == "@@ ") hunk(line)
720
+ }
721
+ AWK
722
+
723
+ # Keep safe symbols of the minimum length; count the unsafe ones.
724
+ cat >"$TMP/safe.awk" <<'AWK' || die_error git-failed
725
+ {
726
+ t = $0
727
+ if (length(t) < MIN) next
728
+ if (length(t) > 64 || t !~ "^[A-Za-z0-9_.:/-]+$") { unsafe++; next }
729
+ print t
730
+ }
731
+ END { print unsafe + 0 > UNSAFE }
732
+ AWK
733
+
734
+ # Read "HEAD:<path>:<line>:<symbol>" lines. A path may hold colons, so the
735
+ # split is the first ":<digits>:" whose rest is a known symbol. A hit in a
736
+ # code file kills the symbol; a hit in a doc or help file is a candidate.
737
+ cat >"$TMP/hits.awk" <<'AWK' || die_error git-failed
738
+ function flagsof(p) { return (p in FL) ? FL[p] : "=" }
739
+ BEGIN {
740
+ while ((getline l < TOKENS) > 0) TOK[l] = 1
741
+ close(TOKENS)
742
+ while ((getline l < FMAP) > 0) {
743
+ split(l, f, "\t")
744
+ FL[f[1]] = f[3]
745
+ }
746
+ close(FMAP)
747
+ }
748
+ {
749
+ line = $0
750
+ len = length(line)
751
+ if (substr(line, 1, 17) == "Binary file HEAD:" && substr(line, len - 7) == " matches") {
752
+ name = substr(line, 18, len - 25)
753
+ if (index(flagsof(name), "O") == 0) print name > SB
754
+ next
755
+ }
756
+ if (substr(line, 1, 5) != "HEAD:") next
757
+ rest = substr(line, 6)
758
+ rlen = length(rest)
759
+ start = 1
760
+ while (start <= rlen) {
761
+ s = substr(rest, start)
762
+ if (!match(s, /:[0-9]+:/)) break
763
+ pos = start + RSTART - 1
764
+ llen = RLENGTH
765
+ t = substr(rest, pos + llen)
766
+ if (t in TOK) {
767
+ path = substr(rest, 1, pos - 1)
768
+ lineno = substr(rest, pos + 1, llen - 2)
769
+ fl = flagsof(path)
770
+ if (index(fl, "O") > 0) break
771
+ if (index(fl, "D") > 0) {
772
+ if (index(fl, "A") == 0) { nc++; CP[nc] = path; CL[nc] = lineno; CT[nc] = t }
773
+ } else KILL[t] = 1
774
+ break
775
+ }
776
+ start = pos + 1
777
+ }
778
+ }
779
+ END {
780
+ for (i = 1; i <= nc; i++) {
781
+ if (!(CT[i] in KILL)) print "stale-symbol\t" CP[i] "\t" CL[i] "\t" CT[i] > REC
782
+ }
783
+ }
784
+ AWK
785
+
786
+ # Keep a diagnostic "<path>:<line>:..." that lies inside an added hunk.
787
+ cat >"$TMP/size.awk" <<'AWK' || die_error git-failed
788
+ BEGIN {
789
+ while ((getline l < MAP) > 0) {
790
+ split(l, f, "\t")
791
+ if (f[2] != "") { RQ[f[2]] = f[1]; RF[f[2]] = f[3] }
792
+ }
793
+ close(MAP)
794
+ while ((getline l < RNG) > 0) {
795
+ split(l, f, "\t")
796
+ RANGE[f[1]] = RANGE[f[1]] " " f[2] "-" f[3]
797
+ }
798
+ close(RNG)
799
+ rp = ENVIRON["ROOT_FOR_AWK"] "/"
800
+ }
801
+ {
802
+ line = $0
803
+ if (!match(line, /:[0-9]+:/)) next
804
+ p = substr(line, 1, RSTART - 1)
805
+ ln = substr(line, RSTART + 1, RLENGTH - 2) + 0
806
+ if (index(p, rp) == 1) p = substr(p, length(rp) + 1)
807
+ else if (substr(p, 1, 2) == "./") p = substr(p, 3)
808
+ if (!(p in RQ)) { unmatched++; next }
809
+ q = RQ[p]
810
+ fl = RF[p]
811
+ if (index(fl, "O") > 0 || index(fl, "C") > 0) next
812
+ n = split(RANGE[q], r, " ")
813
+ for (i = 1; i <= n; i++) {
814
+ split(r[i], se, "-")
815
+ if (ln >= se[1] + 0 && ln <= se[2] + 0) {
816
+ print "function-size\t" q "\t" ln "\tfunction-size" > REC
817
+ break
818
+ }
819
+ }
820
+ }
821
+ END { print unmatched + 0 > UNM }
822
+ AWK
823
+ }
824
+
825
+ # ---- Main ----
826
+
827
+ # The config is the blob at HEAD, never the work tree. The tree says whether it
828
+ # exists, so a blob that will not read is git-failed and never no-config. Only a
829
+ # regular file is a config. The map is rebuilt once the globs are known.
830
+ load_config() {
831
+ local mode rest
832
+ g ls-tree HEAD -- .lint-pack.conf >"$TMP/conf.entry" 2>/dev/null || die_error git-failed
833
+ [ -s "$TMP/conf.entry" ] || return 0
834
+ IFS=' ' read -r mode rest <"$TMP/conf.entry" || die_error git-failed
835
+ case "$mode" in 100644 | 100755) ;; *) cfg_fail 0 ;; esac
836
+ g cat-file blob HEAD:.lint-pack.conf >"$TMP/conf" 2>/dev/null || die_error git-failed
837
+ read_config
838
+ finish_config
839
+ validate_patterns
840
+ build_map "$TMP/dm.q" "$TMP/dm.z" "$TMP/dm.map" 0
841
+ }
842
+
843
+ # git 2.30 or newer: the base checks use --end-of-options, which an older git
844
+ # rejects, and that would read as bad-base. Compare numbers, never strings.
845
+ check_git_version() {
846
+ local out major minor
847
+ out=$(command git --version 2>/dev/null) || die_error git-failed
848
+ out=${out#git version }
849
+ [[ $out =~ $GIT_VERSION_RE ]] || die_error git-failed
850
+ major=$((10#${BASH_REMATCH[1]}))
851
+ minor=$((10#${BASH_REMATCH[2]}))
852
+ if [ "$major" -lt "$MIN_GIT_MAJOR" ]; then die_error git-too-old; fi
853
+ if [ "$major" -eq "$MIN_GIT_MAJOR" ] && [ "$minor" -lt "$MIN_GIT_MINOR" ]; then die_error git-too-old; fi
854
+ }
855
+
856
+ main() {
857
+ local top rc
858
+ [ "$#" -eq 1 ] || die_error usage
859
+ resolve_self
860
+ command -v git >/dev/null 2>&1 || die_error git-failed
861
+ check_git_version
862
+ top=$(command git rev-parse --show-toplevel 2>/dev/null) || die_error not-a-repo
863
+ [ -n "$top" ] || die_error not-a-repo
864
+ cd "$top" 2>/dev/null || die_error not-a-repo
865
+ ROOT=$(pwd -P) || die_error git-failed
866
+
867
+ BASE=$1
868
+ [[ $BASE =~ $BASE_RE ]] || die_error bad-base
869
+ # The commit the argument names, so the base line shows what the run compared.
870
+ BASE=$(g rev-parse --verify --quiet --end-of-options "${BASE}^{commit}" 2>/dev/null) || die_error bad-base
871
+ [[ $BASE =~ $BASE_RE ]] || die_error bad-base
872
+ g merge-base --is-ancestor "$BASE" HEAD >/dev/null 2>&1
873
+ rc=$?
874
+ case "$rc" in 0) BASE_OK=1 ;; 1) die_error base-not-ancestor ;; *) die_error git-failed ;; esac
875
+
876
+ TMP=$(mktemp -d "${TMPDIR:-/tmp}/lint-pack.XXXXXX") || die_error git-failed
877
+ case "$SELF_ABS" in "$ROOT"/*) SELF_REL=${SELF_ABS#"$ROOT"/} ;; esac
878
+
879
+ # The pack-changed lines come before the config, so an error exit shows them.
880
+ build_diffmap
881
+ pack_changed
882
+ load_config
883
+
884
+ : >"$TMP/rec.stale"
885
+ : >"$TMP/rec.ids"
886
+ : >"$TMP/rec.size"
887
+ : >"$TMP/sb1.txt"
888
+ : >"$TMP/sb2.txt"
889
+ : >"$TMP/rm.txt"
890
+ : >"$TMP/ad1.txt"
891
+ : >"$TMP/ad2.txt"
892
+ : >"$TMP/ad2.meta"
893
+ : >"$TMP/ranges.tsv"
894
+ write_awk_programs
895
+
896
+ if [ "$ANY_ENABLED" = 1 ]; then
897
+ diff_main
898
+ if [ "$STALE_ENABLED" = true ]; then check_stale; fi
899
+ if [ "$IDS_ENABLED" = true ]; then check_ids; fi
900
+ if [ "$SIZE_ENABLED" = true ]; then check_size; fi
901
+ fi
902
+ emit
903
+ }
904
+
905
+ main "$@"