@zgeoff/chex 0.0.1 → 0.0.2

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 (53) hide show
  1. package/README.md +96 -3
  2. package/lefthook.yml +123 -0
  3. package/package.json +31 -3
  4. package/src/build-init-package.ts +57 -0
  5. package/src/build-init-plan.ts +96 -0
  6. package/src/build-lefthook-extends.ts +117 -0
  7. package/src/check-names.ts +18 -0
  8. package/src/cli.ts +49 -0
  9. package/src/collect-command-names.ts +297 -0
  10. package/src/collect-expression-references.ts +17 -0
  11. package/src/dependency-gates/collect-stale-age-gate-excludes.ts +83 -0
  12. package/src/dependency-gates/collect-unneeded-audit-ignores.ts +41 -0
  13. package/src/dependency-gates/collect-unneeded-overrides.ts +70 -0
  14. package/src/dependency-gates/first-party-scopes.ts +1 -0
  15. package/src/dependency-gates/is-record.ts +4 -0
  16. package/src/dependency-gates/parse-audit-advisories.ts +33 -0
  17. package/src/dependency-gates/parse-audit-ignore-ids.ts +15 -0
  18. package/src/dependency-gates/parse-fixable-packages.ts +29 -0
  19. package/src/dependency-gates/parse-lockfile-dependents.ts +77 -0
  20. package/src/dependency-gates/parse-root-manifest.ts +68 -0
  21. package/src/dependency-gates/read-dependency-gate-findings.ts +91 -0
  22. package/src/dependency-gates/run-dependency-gates-check.ts +54 -0
  23. package/src/dependency-gates/types.ts +19 -0
  24. package/src/docs/check-length.sh +117 -0
  25. package/src/docs/check-links.sh +245 -0
  26. package/src/docs/check-prose.sh +657 -0
  27. package/src/docs/check-readmes.sh +102 -0
  28. package/src/docs/check-skill.sh +413 -0
  29. package/src/docs/check-vision.sh +34 -0
  30. package/src/find-install-cache-path.ts +16 -0
  31. package/src/format-commit-lines.ts +3 -0
  32. package/src/make-check-runner.ts +40 -0
  33. package/src/parse-added-lines.ts +112 -0
  34. package/src/parse-chex-args.ts +76 -0
  35. package/src/parse-lane-limits.ts +59 -0
  36. package/src/parse-pushed-refs.ts +29 -0
  37. package/src/parse-shell-words.ts +520 -0
  38. package/src/pick-merge-blobs-command.ts +152 -0
  39. package/src/read-branch-base.ts +31 -0
  40. package/src/read-branch-files.ts +59 -0
  41. package/src/read-pushed-ranges.ts +47 -0
  42. package/src/read-pushed-refs-input.ts +18 -0
  43. package/src/read-range-commits.ts +30 -0
  44. package/src/run-chex.ts +62 -0
  45. package/src/run-commits-check.ts +178 -0
  46. package/src/run-init.ts +129 -0
  47. package/src/run-local-paths-check.ts +185 -0
  48. package/src/run-program.ts +38 -0
  49. package/src/run-test-timeouts-check.ts +498 -0
  50. package/src/run-uncommitted-check.ts +82 -0
  51. package/src/run-workflows-check.ts +610 -0
  52. package/src/shell-escape-parsers.ts +197 -0
  53. package/src/write-text-file.ts +7 -0
@@ -0,0 +1,657 @@
1
+ #!/usr/bin/env bash
2
+ # Greps committed docs for prose violations; exits non-zero when any are found.
3
+ # Violation greps fail the run; candidate greps print for judgment and never fail it.
4
+ # Usage (from the repo root): check-prose.sh [<path>...]
5
+ # With no path, it reads every README.md outside .claude/ and .agents/, every Markdown file under
6
+ # docs/, every spike's results.md, and every skill's Markdown under .agents/skills/ and
7
+ # .claude/skills/, where a consumer repo installs skills, under skills/, where zgeoff/skills keeps
8
+ # them, and under sync/skills/, where zgeoff/tools keeps the shared skill bases.
9
+ # The awk programs are single-quoted on purpose: awk, not the shell, expands their fields.
10
+ # shellcheck disable=SC2016
11
+ set -uo pipefail
12
+
13
+ if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then
14
+ echo "check-prose: run it inside a git work tree, because it lists the files with git" >&2
15
+ exit 2
16
+ fi
17
+
18
+ # A skill folder's README is a skill file, so only the READMEs outside the skill folders are listed
19
+ # one by one.
20
+ if [ "$#" -eq 0 ]; then
21
+ if ! readme_list=$(git -c core.quotePath=false ls-files --cached --others --exclude-standard -- \
22
+ ':(glob)**/README.md' ':(exclude,glob).claude/**' ':(exclude,glob).agents/**'); then
23
+ echo "check-prose: git ls-files failed, so no doc was checked" >&2
24
+ exit 2
25
+ fi
26
+ while IFS= read -r readme; do
27
+ [ -n "$readme" ] && set -- "$@" "$readme"
28
+ done < <(sort -u <<< "$readme_list")
29
+ set -- "$@" ':(glob)docs/**/*.md' ':(glob)**/results.md' ':(glob).agents/skills/**/*.md' \
30
+ ':(glob).claude/skills/**/*.md' ':(glob)skills/**/*.md' ':(glob)sync/skills/**/*.md'
31
+ fi
32
+
33
+ # The paragraph limits in words. repo-sync overwrites this file, so a repo cannot loosen them.
34
+ paragraph_warn=150
35
+ paragraph_fail=250
36
+
37
+ # The skill's own files, the repo's project skill beside them, and the skill-writing and deslop
38
+ # skills document the forbidden patterns and contain them as examples. A consumer repo installs skills under
39
+ # .agents/skills/ or .claude/skills/, and zgeoff/skills keeps them under skills/. A file git marks
40
+ # linguist-vendored belongs to someone else, so a repo marks its third-party skills in
41
+ # .gitattributes.
42
+ for root in .agents/skills .claude/skills skills sync/skills; do
43
+ set -- "$@" ":(exclude)$root/docs-writing" ":(exclude)$root/project-docs-writing" \
44
+ ":(exclude)$root/skill-writing" ":(exclude)$root/deslop"
45
+ done
46
+ set -- "$@" ':(exclude,attr:linguist-vendored)' ':(exclude,attr:linguist-vendored=true)'
47
+
48
+ # The greps below read git's file list in pipelines, which drop git's exit status, so a git that
49
+ # cannot list the files fails here once.
50
+ if ! git ls-files --cached --others --exclude-standard -- "$@" > /dev/null; then
51
+ echo "check-prose: git ls-files failed, so no doc was checked" >&2
52
+ exit 2
53
+ fi
54
+
55
+ # Every grep reads untracked files too, so a new doc is checked before it is staged, and skips
56
+ # binary files.
57
+ grep_docs() {
58
+ git grep --untracked -I "$@"
59
+ }
60
+
61
+ # Prints the Markdown files the paths name, under the directory in $markdown_dir when it is set.
62
+ # Each path ends in a NUL, so it may hold any character, and starts with ./, so awk never reads a
63
+ # name that holds = as an assignment or one that starts with - as an option.
64
+ list_markdown() {
65
+ local file
66
+ while IFS= read -r -d '' file; do
67
+ if [[ $file == *.md && $file == "${markdown_dir:-}"* && -f $file ]]; then
68
+ printf './%s\0' "$file"
69
+ fi
70
+ done < <(git ls-files -z --cached --others --exclude-standard -- "$@")
71
+ }
72
+
73
+ # Tracks fenced code blocks (backtick or tilde, any indent, closed by the same marker at least as
74
+ # long) so the checks below can tell fence lines and fenced content from prose.
75
+ fence_awk='
76
+ function fence_marker(line, m) {
77
+ if (match(line, /^[ \t]*(```+|~~~+)/)) {
78
+ m = substr(line, RSTART, RLENGTH)
79
+ sub(/^[ \t]*/, "", m)
80
+ return m
81
+ }
82
+ return ""
83
+ }
84
+ function fence_rest(line, m) {
85
+ return substr(line, index(line, m) + length(m))
86
+ }
87
+ function closes(m, rest) {
88
+ return substr(m, 1, 1) == substr(open, 1, 1) && length(m) >= length(open) && rest ~ /^[ \t]*$/
89
+ }
90
+ # Returns the file name without the ./ that list_markdown puts before it.
91
+ function file_name() {
92
+ return substr(FILENAME, 3)
93
+ }
94
+ FNR == 1 { open = "" }
95
+ '
96
+
97
+ fail=0
98
+
99
+ report() {
100
+ local label=$1 out=$2
101
+ if [ -n "$out" ]; then
102
+ fail=1
103
+ printf '%s\n%s\n\n' "$label" "$out"
104
+ fi
105
+ }
106
+
107
+ candidate() {
108
+ local label=$1 out=$2
109
+ if [ -n "$out" ]; then
110
+ printf '%s\n%s\n\n' "$label" "$out"
111
+ fi
112
+ }
113
+
114
+ report 'Process residue (date stamps, investigation framing, memory citations, §):' \
115
+ "$(grep_docs -nP 'Verified 20[0-9]{2}-|Investigation count|\(see memory |\([0-9]{2,4}-[0-9]{2}-[0-9]{2}\)|^Mitigation:|§' -- "$@")"
116
+
117
+ # The words every zgeoff repo bans; a repo's AGENTS.md lists them with their legal senses.
118
+ # Judgment-only bans (bites, floor, anchor) are not grepped. CAS stays case-sensitive so "CAs"
119
+ # passes.
120
+ # AGENTS.md and the agents/ partials hold the banned-words registry itself, and "code fence" and
121
+ # "fenced code" are the legal markdown sense of the word. In Markdown, code blocks and code spans
122
+ # are exempt, since an identifier in code may hold a banned word: awk blanks the spans and puts the
123
+ # rest before the line, split by a unit separator, and grep matches the pattern only before that
124
+ # separator.
125
+ banned_words='\bsurfaces?\b|load-bearing|\bseams?\b|\bceilings?\b|(?<!code )\bfenc(e|es|ed|ing)\b(?! code)|(?-i:\bCAS\b)|significantly|near-instant'
126
+ report 'Banned words:' \
127
+ "$(
128
+ grep_docs -nPi "$banned_words" -- "$@" ':(exclude)*.md' ':(exclude)AGENTS.md' ':(exclude)agents'
129
+ list_markdown "$@" ':(exclude)AGENTS.md' ':(exclude)agents' | xargs -0 -r awk "$fence_awk"'
130
+ # Replaces each code span with a space. As in CommonMark, a span closes at the next backtick run
131
+ # as long as the one that opens it, and a run that nothing closes stays as text.
132
+ function strip_spans(s, out, start, n, rest, r, off, closed) {
133
+ out = ""
134
+ while (match(s, /`+/)) {
135
+ start = RSTART
136
+ n = RLENGTH
137
+ out = out substr(s, 1, start - 1)
138
+ rest = substr(s, start + n)
139
+ r = rest
140
+ off = 0
141
+ closed = 0
142
+ while (match(r, /`+/)) {
143
+ if (RLENGTH == n) {
144
+ closed = 1
145
+ off += RSTART
146
+ break
147
+ }
148
+ off += RSTART + RLENGTH - 1
149
+ r = substr(r, RSTART + RLENGTH)
150
+ }
151
+ if (closed) {
152
+ out = out " "
153
+ s = substr(rest, off + n)
154
+ } else {
155
+ out = out substr(s, start, n)
156
+ s = rest
157
+ }
158
+ }
159
+ return out s
160
+ }
161
+ {
162
+ m = fence_marker($0)
163
+ if (m != "") {
164
+ rest = fence_rest($0, m)
165
+ if (open == "") open = m
166
+ else if (closes(m, rest)) open = ""
167
+ next
168
+ }
169
+ if (open != "") next
170
+ text = $0
171
+ gsub(/\037/, "", text)
172
+ text = strip_spans(text)
173
+ print text "\037" file_name()":"FNR":"$0
174
+ }' | grep -aPi "^[^\\x1f]*(?:$banned_words)" | cut -d $'\037' -f 2-
175
+ )"
176
+
177
+ report 'Filler with no term-of-art use:' \
178
+ "$(grep_docs -nPi 'organically|earns its complexity|cheap insurance|lays (the )?foundation|the right level' -- "$@")"
179
+
180
+ report 'Possessive on a markdown link:' \
181
+ "$(grep_docs -nE "\]\([^)]*\)'s" -- "$@")"
182
+
183
+ report 'Role-label headings:' \
184
+ "$(grep_docs -nE '^#{2,4} (Overview|Notes|Details|Rationale)$' -- "$@")"
185
+
186
+ report '"Should succeed" in a procedure — state the expected outcome instead:' \
187
+ "$(grep_docs -nPi 'should (succeed|work|pass)\b' -- "$@")"
188
+
189
+ report 'Number joined to its unit ("64KB"; write "64 KB"; code blocks are exempt):' \
190
+ "$(
191
+ grep_docs -nP '\b[0-9]+(KB|MB|GB|TB|KiB|MiB|GiB|ms)\b' -- "$@" ':(exclude)*.md'
192
+ list_markdown "$@" | xargs -0 -r awk "$fence_awk"'
193
+ {
194
+ m = fence_marker($0)
195
+ if (m != "") {
196
+ rest = fence_rest($0, m)
197
+ if (open == "") open = m
198
+ else if (closes(m, rest)) open = ""
199
+ next
200
+ }
201
+ if (open == "" && $0 ~ /(^|[^[:alnum:]_])[0-9]+(KB|MB|GB|TB|KiB|MiB|GiB|ms)([^[:alnum:]_]|$)/) print file_name()":"FNR":"$0
202
+ }'
203
+ )"
204
+
205
+ # Architecture docs state rules, never values. Code spans are dropped first, since a command the
206
+ # reader types may hold a value. A bare s, d or h joined to its number is no unit here ("1990s",
207
+ # "2d"); its spaced form is.
208
+ report 'Values with units under docs/architecture (the code owns its values; code blocks are exempt):' \
209
+ "$(markdown_dir=docs/architecture/ list_markdown "$@" | xargs -0 -r awk "$fence_awk"'
210
+ {
211
+ m = fence_marker($0)
212
+ if (m != "") {
213
+ rest = fence_rest($0, m)
214
+ if (open == "") open = m
215
+ else if (closes(m, rest)) open = ""
216
+ next
217
+ }
218
+ if (open != "") next
219
+ text = $0
220
+ gsub(/`[^`]*`/, "", text)
221
+ if (text ~ /(^|[^[:alnum:]_.])[0-9]+(\.[0-9]+)? ?(ms|min|KB|MB|GB|TB|KiB|MiB|GiB)([^[:alnum:]_]|$)/ ||
222
+ text ~ /(^|[^[:alnum:]_.])[0-9]+(\.[0-9]+)? (s|h|d)([^[:alnum:]_]|$)/ ||
223
+ text ~ /(^|[^[:alnum:]_.])[0-9]+[ -](second|minute|hour|day|week)s?([^[:alnum:]_]|$)/) print file_name()":"FNR":"$0
224
+ }')"
225
+
226
+ report 'Horizontal rules outside YAML frontmatter:' \
227
+ "$(list_markdown "$@" | xargs -0 -r awk \
228
+ 'FNR==1{fm=($0=="---")} fm&&FNR>1&&$0=="---"{fm=0;next} !fm&&$0=="---"{print substr(FILENAME, 3)": "FNR}')"
229
+
230
+ report 'Untagged code fences:' \
231
+ "$(list_markdown "$@" | xargs -0 -r awk "$fence_awk"'
232
+ {
233
+ m = fence_marker($0)
234
+ if (m == "") next
235
+ rest = fence_rest($0, m)
236
+ if (open == "") {
237
+ open = m
238
+ if (rest ~ /^[ \t]*$/) print file_name()": "FNR
239
+ } else if (closes(m, rest)) {
240
+ open = ""
241
+ }
242
+ }')"
243
+
244
+ # Lists the files the branch changes since its merge base with origin's default branch, then the
245
+ # uncommitted and untracked ones. The paragraph check reads only these, so a doc written before the
246
+ # check fails nobody until a branch edits it. Each path ends in a NUL, so a path may hold any
247
+ # character.
248
+ list_changed() {
249
+ local default base
250
+ default=$(git rev-parse -q --abbrev-ref origin/HEAD 2> /dev/null) || default=origin/main
251
+ # Before the first commit, every staged file is new.
252
+ if ! git rev-parse -q --verify HEAD > /dev/null; then
253
+ git ls-files -z --cached
254
+ else
255
+ if base=$(git merge-base HEAD "$default" 2> /dev/null); then
256
+ git diff -z --name-only --no-renames --diff-filter=d "$base" HEAD
257
+ # A pull-request run in GitHub Actions checks out the PR's merge commit with no history and no
258
+ # origin branches, so the base tip is fetched and the two trees compared.
259
+ elif [ -n "${GITHUB_BASE_REF:-}" ] &&
260
+ git fetch -q --no-tags --depth=1 origin "$GITHUB_BASE_REF" 2> /dev/null; then
261
+ git diff -z --name-only --no-renames --diff-filter=d FETCH_HEAD HEAD
262
+ else
263
+ echo "check-prose: no merge base with $default, so the paragraph check reads only uncommitted files" >&2
264
+ fi
265
+ git diff -z --name-only --no-renames --diff-filter=d HEAD
266
+ fi
267
+ git ls-files -z --others --exclude-standard
268
+ }
269
+
270
+ # Prints file:line:words for each top-level prose paragraph over the warning limit. The parser runs
271
+ # the CommonMark block algorithm (with GFM tables) on each line: it matches the open block quotes
272
+ # and list items, tries block starts after the last one matched, and takes a lazy line only while a
273
+ # paragraph is the open block. Indentation is in columns, with tabs to the next multiple of 4.
274
+ #
275
+ # - A paragraph inside a list item or a block quote never counts; a paragraph outside them does.
276
+ # - A paragraph runs until a blank line or a line that interrupts it: an ATX heading, a fence, an
277
+ # HTML block of types 1 to 6, a block quote, a thematic break, a bullet item with content, or an
278
+ # ordered item numbered 1 with content.
279
+ # - Link reference definitions at the start of a paragraph are dropped before it is counted. A line
280
+ # of = or - under a paragraph makes it a Setext heading, and a delimiter row with as many cells as
281
+ # the paragraph's last line makes that line a table header; the lines above a header still count.
282
+ # - Indented code, fenced code, tables, HTML blocks (types 1 to 5 to their closing marker, types 6
283
+ # and 7 to a blank line), frontmatter and headings never count.
284
+ paragraph_awk='
285
+ BEGIN {
286
+ q = sprintf("%c", 39)
287
+ esc = "\\\\."
288
+ label = "^\\[[ \t\n]*([^][\\\\ \t\n]|" esc ")([^][\\\\]|" esc ")*\\]:"
289
+ angle_destination = "^<([^<>\n\\\\]|" esc ")*>"
290
+ title = "(\"([^\"\\\\]|" esc ")*\"|" q "([^" q "\\\\]|" esc ")*" q "|\\(([^()\\\\]|" esc ")*\\))"
291
+ titled_end = "^([ \t]+|[ \t]*\n[ \t]*)" title "[ \t]*(\n|$)"
292
+ value = "([^ \t\"" q "=<>`]+|" q "[^" q "]*" q "|\"[^\"]*\")"
293
+ attribute = "[ \t]+[A-Za-z_:][A-Za-z0-9_.:-]*([ \t]*=[ \t]*" value ")?"
294
+ open_tag = "^<[A-Za-z][A-Za-z0-9-]*(" attribute ")*[ \t]*/?>[ \t]*$"
295
+ closing_tag = "^</[A-Za-z][A-Za-z0-9-]*[ \t]*>[ \t]*$"
296
+ }
297
+ # Expands each tab to the next column that is a multiple of 4.
298
+ function detab(line, out, i, c, col) {
299
+ if (index(line, "\t") == 0) return line
300
+ out = ""
301
+ col = 0
302
+ for (i = 1; i <= length(line); i++) {
303
+ c = substr(line, i, 1)
304
+ if (c == "\t") {
305
+ out = out substr(" ", 1, 4 - col % 4)
306
+ col += 4 - col % 4
307
+ } else {
308
+ out = out c
309
+ col++
310
+ }
311
+ }
312
+ return out
313
+ }
314
+ function spaces_at(line, p) {
315
+ match(substr(line, p), /^ */)
316
+ return RLENGTH
317
+ }
318
+ function is_atx(s) {
319
+ return match(s, /^#+/) && RLENGTH <= 6 && substr(s, RLENGTH + 1) ~ /^([ \t]|$)/
320
+ }
321
+ function is_thematic(s) {
322
+ return s ~ /^\*[ \t]*\*[ \t]*\*[ \t*]*$/ || s ~ /^-[ \t]*-[ \t]*-[ \t-]*$/ || s ~ /^_[ \t]*_[ \t]*_[ \t_]*$/
323
+ }
324
+ function is_setext_underline(s) {
325
+ return s ~ /^(=+|-+)[ \t]*$/
326
+ }
327
+ # Returns the fence marker this line opens, or "" when it opens none. A backtick fence takes no
328
+ # backtick in its info string.
329
+ function fence_open(s, m) {
330
+ m = fence_marker(s)
331
+ if (m != "" && substr(m, 1, 1) == "`" && index(fence_rest(s, m), "`")) return ""
332
+ return m
333
+ }
334
+ function fence_closes(opener, s, m) {
335
+ m = fence_marker(s)
336
+ return m != "" && substr(m, 1, 1) == substr(opener, 1, 1) && length(m) >= length(opener) &&
337
+ fence_rest(s, m) ~ /^[ \t]*$/
338
+ }
339
+ # Counts the cells of a table row: its unescaped pipes, less an outer pipe at either end, plus one.
340
+ function cell_count(s, t) {
341
+ t = s
342
+ gsub(/\\\|/, "", t)
343
+ sub(/^[ \t]*\|/, "", t)
344
+ sub(/\|[ \t]*$/, "", t)
345
+ return gsub(/\|/, "|", t) + 1
346
+ }
347
+ function is_table_delimiter(s) {
348
+ return s ~ /\|/ && s ~ /^\|?[ \t]*:?-+:?[ \t]*(\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/
349
+ }
350
+ # Returns the pattern that closes the HTML block this line starts for types 1 to 5, "closed" when
351
+ # the same line closes it, "block" for type 6, or "" for any other line. A tag name needs a boundary
352
+ # after it, so <preview> starts no pre block.
353
+ function html_start(s, lower) {
354
+ lower = tolower(s)
355
+ if (match(lower, /^<(script|pre|style|textarea)([ \t>]|$)/)) return html_marker(lower, "</(script|pre|style|textarea)>")
356
+ if (match(lower, /^<!--/)) return html_marker(lower, "-->")
357
+ if (match(lower, /^<\?/)) return html_marker(lower, "[?]>")
358
+ if (match(s, /^<!\[CDATA\[/)) return html_marker(s, "]]>")
359
+ if (match(lower, /^<![a-z]/)) return html_marker(lower, ">")
360
+ if (lower ~ /^<\/?(address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)([ \t>]|\/>|$)/) return "block"
361
+ return ""
362
+ }
363
+ # The end condition holds when the line contains the marker anywhere, so <?> and <!--> close on
364
+ # their own line.
365
+ function html_marker(text, marker) {
366
+ return text ~ marker ? "closed" : marker
367
+ }
368
+ # HTML block type 7: one complete open or closing tag, with valid attributes, alone on its line.
369
+ function is_lone_tag(s) {
370
+ return (s ~ open_tag || s ~ closing_tag) && tolower(s) !~ /^<\/?(script|pre|style|textarea)[^a-z0-9-]/
371
+ }
372
+ # Returns the length of a bare link destination at the start of s, or 0 when there is none. As in
373
+ # the CommonMark reference parser, it ends at a space, a control character or an unmatched ), and
374
+ # an unclosed ( or a < at its start makes it invalid.
375
+ function bare_destination_length(s, i, c, depth) {
376
+ if (substr(s, 1, 1) == "<") return 0
377
+ depth = 0
378
+ for (i = 1; i <= length(s); i++) {
379
+ c = substr(s, i, 1)
380
+ if (c == "\\" && substr(s, i + 1, 1) ~ /[!-\/:-@[-`{-~]/) i++
381
+ else if (c ~ /[[:space:][:cntrl:]]/) break
382
+ else if (c == "(") depth++
383
+ else if (c == ")") {
384
+ if (depth == 0) break
385
+ depth--
386
+ }
387
+ }
388
+ return (depth == 0) ? i - 1 : 0
389
+ }
390
+ # Returns the length of the link reference definition at the start of text, through its line end,
391
+ # or 0 when text starts with none. A label holds at most 999 characters between its brackets.
392
+ function definition_length(text, n, rest, d) {
393
+ if (!match(text, label) || RLENGTH - 3 > 999) return 0
394
+ n = RLENGTH
395
+ rest = substr(text, n + 1)
396
+ match(rest, /^[ \t]*\n?[ \t]*/)
397
+ n += RLENGTH
398
+ rest = substr(rest, RLENGTH + 1)
399
+ if (match(rest, angle_destination)) d = RLENGTH
400
+ else d = bare_destination_length(rest)
401
+ if (d == 0) return 0
402
+ n += d
403
+ rest = substr(rest, d + 1)
404
+ if (match(rest, titled_end) || match(rest, /^[ \t]*(\n|$)/)) return n + RLENGTH
405
+ return 0
406
+ }
407
+ # Returns the index of the first paragraph line that no link reference definition holds.
408
+ function first_text_line(text, i, k, n, matched_text, newlines) {
409
+ text = lines[1]
410
+ for (i = 2; i <= line_count; i++) text = text "\n" lines[i]
411
+ k = 0
412
+ while (text != "" && (n = definition_length(text)) > 0) {
413
+ matched_text = substr(text, 1, n)
414
+ newlines = gsub(/\n/, "\n", matched_text)
415
+ k += (substr(matched_text, n) == "\n") ? newlines : newlines + 1
416
+ text = substr(text, n + 1)
417
+ }
418
+ return k + 1
419
+ }
420
+ # Closes the open leaf block, and prints a top-level paragraph over the warning limit.
421
+ function flush(i, first, words, parts) {
422
+ if (leaf == "para" && para_depth == 0) {
423
+ first = first_text_line()
424
+ words = 0
425
+ for (i = first; i <= line_count; i++) words += split(lines[i], parts)
426
+ if (words > warn) print start_file":"line_numbers[first]":"words
427
+ }
428
+ leaf = ""
429
+ line_count = 0
430
+ }
431
+ function add_line(s) {
432
+ lines[++line_count] = s
433
+ line_numbers[line_count] = FNR
434
+ }
435
+ # Starts a block after the last matched container: closes the containers left unmatched and the
436
+ # open leaf, and marks the enclosing list item as holding a block.
437
+ function begin_block() {
438
+ flush()
439
+ para_matched = 0
440
+ depth = matched
441
+ if (depth > 0) has_child[depth] = 1
442
+ }
443
+ function open_container(type, width) {
444
+ begin_block()
445
+ depth++
446
+ matched = depth
447
+ kind[depth] = type
448
+ width_of[depth] = width
449
+ has_child[depth] = 0
450
+ }
451
+ # Matches the open containers against the line from position p, as CommonMark does: a quote needs
452
+ # a > after at most 3 spaces, and a list item needs its content indent, or a blank line once it
453
+ # holds a block.
454
+ function match_containers(i, sp) {
455
+ matched = 0
456
+ for (i = 1; i <= depth; i++) {
457
+ sp = spaces_at(L, p)
458
+ if (kind[i] == "quote") {
459
+ if (sp > 3 || substr(L, p + sp, 1) != ">") return
460
+ p += sp + 1
461
+ if (substr(L, p, 1) == " ") p++
462
+ } else if (substr(L, p) ~ /^ *$/) {
463
+ if (!has_child[i]) return
464
+ p = length(L) + 1
465
+ } else {
466
+ if (sp < width_of[i]) return
467
+ p += width_of[i]
468
+ }
469
+ matched++
470
+ }
471
+ }
472
+ # Tries the block starts at position p and opens each one found. Returns 1 when the rest of the
473
+ # line belongs to a block it started, and 0 when the line is still to place.
474
+ function start_blocks(sp, t, m, h, mlen, rest, gap, number) {
475
+ while (1) {
476
+ sp = spaces_at(L, p)
477
+ t = substr(L, p + sp)
478
+ if (t == "") return 0
479
+ if (sp >= 4) {
480
+ if (leaf == "para") return 0
481
+ begin_block()
482
+ leaf = "code"
483
+ return 1
484
+ }
485
+ if (substr(t, 1, 1) == ">") {
486
+ open_container("quote", 0)
487
+ p += sp + 1
488
+ if (substr(L, p, 1) == " ") p++
489
+ continue
490
+ }
491
+ if (is_atx(t)) {
492
+ begin_block()
493
+ return 1
494
+ }
495
+ m = fence_open(t)
496
+ if (m != "") {
497
+ begin_block()
498
+ leaf = "fence"
499
+ fence = m
500
+ return 1
501
+ }
502
+ h = html_start(t)
503
+ if (h != "" || (leaf != "para" && is_lone_tag(t))) {
504
+ begin_block()
505
+ if (h == "" || h == "block") leaf = "htmlblock"
506
+ else if (h != "closed") {
507
+ leaf = "html"
508
+ html_end = h
509
+ }
510
+ return 1
511
+ }
512
+ if (para_matched && is_setext_underline(t)) {
513
+ # A paragraph of link definitions alone has no text to underline, so the line is text.
514
+ if (first_text_line() > line_count) return 0
515
+ leaf = ""
516
+ line_count = 0
517
+ return 1
518
+ }
519
+ if (para_matched && is_table_delimiter(t) && cell_count(t) == cell_count(lines[line_count])) {
520
+ line_count--
521
+ flush()
522
+ leaf = "table"
523
+ return 1
524
+ }
525
+ if (is_thematic(t)) {
526
+ begin_block()
527
+ return 1
528
+ }
529
+ if (match(t, /^([-*+]|[0-9]+[.)])([ \t]|$)/)) {
530
+ match(t, /^([-*+]|[0-9]+[.)])/)
531
+ mlen = RLENGTH
532
+ number = substr(t, 1, mlen - 1)
533
+ rest = substr(t, mlen + 1)
534
+ gap = spaces_at(rest, 1)
535
+ if (mlen > 10 && number ~ /^[0-9]+$/) return 0
536
+ if (para_matched && (rest ~ /^ *$/ || (number ~ /^[0-9]+$/ && number + 0 != 1))) return 0
537
+ if (rest ~ /^ *$/ || gap >= 5) gap = 1
538
+ open_container("item", sp + mlen + gap)
539
+ p += sp + mlen + gap
540
+ continue
541
+ }
542
+ return 0
543
+ }
544
+ }
545
+ # A CRLF file ends each line in a carriage return, which is part of the line ending, not the line.
546
+ { sub(/\r$/, "") }
547
+ FNR == 1 {
548
+ flush()
549
+ depth = 0
550
+ front = ($0 == "---")
551
+ if (front) next
552
+ }
553
+ front {
554
+ if ($0 == "---") front = 0
555
+ next
556
+ }
557
+ {
558
+ L = detab($0)
559
+ p = 1
560
+ match_containers()
561
+ all = (matched == depth)
562
+ t = substr(L, p)
563
+ if (all && leaf == "fence") {
564
+ if (spaces_at(L, p) < 4 && fence_closes(fence, substr(t, spaces_at(L, p) + 1))) leaf = ""
565
+ next
566
+ }
567
+ if (all && leaf == "html") {
568
+ if (tolower(t) ~ html_end) leaf = ""
569
+ next
570
+ }
571
+ if (all && leaf == "htmlblock") {
572
+ if (t ~ /^ *$/) leaf = ""
573
+ next
574
+ }
575
+ if (all && leaf == "code") {
576
+ if (t ~ /^ *$/ || spaces_at(L, p) >= 4) next
577
+ leaf = ""
578
+ }
579
+ para_matched = (all && leaf == "para" && t !~ /^ *$/)
580
+ if (start_blocks()) next
581
+ t = substr(L, p)
582
+ sub(/^ +/, "", t)
583
+ # A lazy line continues a paragraph left open in a container that this line did not match.
584
+ if (depth > matched && leaf == "para" && t != "") {
585
+ add_line(t)
586
+ next
587
+ }
588
+ if (depth > matched) {
589
+ flush()
590
+ depth = matched
591
+ }
592
+ if (t == "") {
593
+ flush()
594
+ next
595
+ }
596
+ if (leaf == "para") {
597
+ add_line(t)
598
+ next
599
+ }
600
+ if (leaf == "table") next
601
+ flush()
602
+ if (depth > 0) has_child[depth] = 1
603
+ leaf = "para"
604
+ para_depth = depth
605
+ start_file = file_name()
606
+ add_line(t)
607
+ }
608
+ END { flush() }
609
+ '
610
+
611
+ changed=$'\n'
612
+ while IFS= read -r -d '' file; do
613
+ changed+=$file$'\n'
614
+ done < <(list_changed)
615
+
616
+ # The Markdown files the paths name that the branch changes, each as ./<path> from list_markdown.
617
+ paragraph_files=()
618
+ while IFS= read -r -d '' file; do
619
+ if [[ $changed == *$'\n'"${file#./}"$'\n'* ]]; then
620
+ paragraph_files+=("$file")
621
+ fi
622
+ done < <(list_markdown "$@")
623
+
624
+ paragraphs=
625
+ if [ "${#paragraph_files[@]}" -gt 0 ]; then
626
+ paragraphs=$(awk -v warn="$paragraph_warn" "$fence_awk$paragraph_awk" "${paragraph_files[@]}")
627
+ fi
628
+
629
+ report "Paragraphs over $paragraph_fail words in a changed file (split each one; lists, tables and code blocks are exempt):" \
630
+ "$(awk -F: -v limit="$paragraph_fail" '$NF > limit { print $0" words" }' <<< "$paragraphs")"
631
+
632
+ candidate "Paragraphs over $paragraph_warn words in a changed file (warning; reread each one and split it where it defends a decision):" \
633
+ "$(awk -F: -v limit="$paragraph_fail" '$NF != "" && $NF <= limit { print $0" words" }' <<< "$paragraphs")"
634
+
635
+ candidate 'Candidates — filler words with term-of-art uses; cull the filler, keep the terms:' \
636
+ "$(grep_docs -nPi '\b(naturally|cleanly|trivially|easy|simple|quick)\b' -- "$@")"
637
+
638
+ candidate 'Candidates — speech verbs on data artifacts; the artifact holds, includes, returns, or matches:' \
639
+ "$(grep_docs -nPi '(?<!file |table |column |export |bucket |method |variable |test )\b(names|says|tells|answers|knows|promises)\b(?! (rot|and locations|only))' -- "$@")"
640
+
641
+ candidate 'Candidates — delta-framing words; legal only when the baseline sits in the same doc:' \
642
+ "$(grep_docs -nPi '(^|\. )(Also|Additionally|In addition),? |\b(already|today|before this work|as it does today)\b' -- "$@")"
643
+
644
+ candidate 'Candidates — headings written as claims; move the thesis into the first sentence:' \
645
+ "$(grep_docs -nP '^#{2,4} (?!(What|Why|When|How|Before|Where|Which)\b).*\b(is|are|does|has|cannot|never|not)\b' -- "$@")"
646
+
647
+ candidate 'Candidates — sentence-initial "A x is a" definitions; keep one only where the reader needs the term defined:' \
648
+ "$(grep_docs -nP '\bAn? \w+ is an? ' -- "$@")"
649
+
650
+ candidate 'Candidates — contrast frames, counted lists and stock openers; state the move, name the items, open with the job:' \
651
+ "$(grep_docs -nPi ', not (a|an|the|its|their|from|for|to|in|on|by|only|just)\b|\brather than\b|\bis not the (job|point)\b|\b(two|three|four|five) (ways|traps|mistakes|things|rules)\b|^(This|The) skill (is|helps|lets|explains|finds|builds|shows)\b' -- "$@")"
652
+
653
+ if [ "$fail" -eq 0 ]; then
654
+ echo 'check-prose: clean (candidates above, if any, need judgment only)'
655
+ fi
656
+
657
+ exit "$fail"