@zgeoff/chex 0.0.0 → 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.
- package/README.md +96 -3
- package/lefthook.yml +123 -0
- package/package.json +31 -3
- package/src/build-init-package.ts +57 -0
- package/src/build-init-plan.ts +96 -0
- package/src/build-lefthook-extends.ts +117 -0
- package/src/check-names.ts +18 -0
- package/src/cli.ts +49 -0
- package/src/collect-command-names.ts +297 -0
- package/src/collect-expression-references.ts +17 -0
- package/src/dependency-gates/collect-stale-age-gate-excludes.ts +83 -0
- package/src/dependency-gates/collect-unneeded-audit-ignores.ts +41 -0
- package/src/dependency-gates/collect-unneeded-overrides.ts +70 -0
- package/src/dependency-gates/first-party-scopes.ts +1 -0
- package/src/dependency-gates/is-record.ts +4 -0
- package/src/dependency-gates/parse-audit-advisories.ts +33 -0
- package/src/dependency-gates/parse-audit-ignore-ids.ts +15 -0
- package/src/dependency-gates/parse-fixable-packages.ts +29 -0
- package/src/dependency-gates/parse-lockfile-dependents.ts +77 -0
- package/src/dependency-gates/parse-root-manifest.ts +68 -0
- package/src/dependency-gates/read-dependency-gate-findings.ts +91 -0
- package/src/dependency-gates/run-dependency-gates-check.ts +54 -0
- package/src/dependency-gates/types.ts +19 -0
- package/src/docs/check-length.sh +117 -0
- package/src/docs/check-links.sh +245 -0
- package/src/docs/check-prose.sh +657 -0
- package/src/docs/check-readmes.sh +102 -0
- package/src/docs/check-skill.sh +413 -0
- package/src/docs/check-vision.sh +34 -0
- package/src/find-install-cache-path.ts +16 -0
- package/src/format-commit-lines.ts +3 -0
- package/src/make-check-runner.ts +40 -0
- package/src/parse-added-lines.ts +112 -0
- package/src/parse-chex-args.ts +76 -0
- package/src/parse-lane-limits.ts +59 -0
- package/src/parse-pushed-refs.ts +29 -0
- package/src/parse-shell-words.ts +520 -0
- package/src/pick-merge-blobs-command.ts +152 -0
- package/src/read-branch-base.ts +31 -0
- package/src/read-branch-files.ts +59 -0
- package/src/read-pushed-ranges.ts +47 -0
- package/src/read-pushed-refs-input.ts +18 -0
- package/src/read-range-commits.ts +30 -0
- package/src/run-chex.ts +62 -0
- package/src/run-commits-check.ts +178 -0
- package/src/run-init.ts +129 -0
- package/src/run-local-paths-check.ts +185 -0
- package/src/run-program.ts +38 -0
- package/src/run-test-timeouts-check.ts +498 -0
- package/src/run-uncommitted-check.ts +82 -0
- package/src/run-workflows-check.ts +610 -0
- package/src/shell-escape-parsers.ts +197 -0
- 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"
|