@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.
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,102 @@
1
+ #!/usr/bin/env bash
2
+ # Fails on each README.md that is not one of the five kinds in docs-writing's
3
+ # references/readmes.md, and prints what to do with it. Its last line counts the READMEs and those of
4
+ # no kind.
5
+ # Usage (from the repo root): check-readmes.sh
6
+ # The five kinds:
7
+ # 1. the root README.md;
8
+ # 2. a README in a folder that holds a package.json, go.mod or Cargo.toml;
9
+ # 3. docs/README.md;
10
+ # 4. a design README: docs/design/<work>/README.md, or the README of a folder inside a design folder
11
+ # whose own README is a design README, outside any spikes/ folder;
12
+ # 5. a spike README, whose first H1 starts "Spike: ", or, outside docs/design/, the README of a
13
+ # folder whose direct subfolders hold a spike README. A design README lists its own spikes.
14
+ # A README git marks linguist-vendored belongs to someone else and is skipped.
15
+ set -uo pipefail
16
+
17
+ if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then
18
+ echo "check-readmes: run it inside a git work tree, because it lists the files with git" >&2
19
+ exit 2
20
+ fi
21
+
22
+ if [ "$#" -gt 0 ]; then
23
+ echo "usage: check-readmes.sh" >&2
24
+ exit 2
25
+ fi
26
+
27
+ # Lists tracked and untracked READMEs, so a new README is checked before it is staged. A file deleted
28
+ # from the working tree stays in the index until the deletion is staged, so it is dropped here.
29
+ if ! listing=$(git -c core.quotePath=false ls-files --cached --others --exclude-standard -- \
30
+ ':(glob)**/README.md' ':(exclude,attr:linguist-vendored)' ':(exclude,attr:linguist-vendored=true)'); then
31
+ echo "check-readmes: git ls-files failed, so no README was checked" >&2
32
+ exit 2
33
+ fi
34
+
35
+ readmes=()
36
+ while IFS= read -r file; do
37
+ [ -f "$file" ] && readmes+=("$file")
38
+ done < <(sort -u <<< "$listing")
39
+
40
+ declare -A listed=()
41
+ for readme in "${readmes[@]}"; do
42
+ listed[$readme]=1
43
+ done
44
+
45
+ # Holds when the first H1 outside a code block starts "Spike: ".
46
+ is_spike() {
47
+ awk '
48
+ match($0, /^[ \t]*(```+|~~~+)/) {
49
+ marker = substr($0, RSTART, RLENGTH)
50
+ sub(/^[ \t]*/, "", marker)
51
+ rest = substr($0, RSTART + RLENGTH)
52
+ # A fence closes only with the opening character, at least as long, and nothing after it.
53
+ if (open == "") open = marker
54
+ else if (substr(marker, 1, 1) == substr(open, 1, 1) && length(marker) >= length(open) && rest ~ /^[ \t]*$/) open = ""
55
+ next
56
+ }
57
+ open == "" && /^# / { found = ($0 ~ /^# Spike: /); exit }
58
+ END { exit !found }
59
+ ' "$1"
60
+ }
61
+
62
+ # Holds when a README in one of the folder's direct subfolders is a spike README.
63
+ indexes_spikes() {
64
+ local dir=$1 readme
65
+ for readme in "${readmes[@]}"; do
66
+ if [[ $readme == "$dir"/*/README.md && $readme != "$dir"/*/*/README.md ]] && is_spike "$readme"; then
67
+ return 0
68
+ fi
69
+ done
70
+ return 1
71
+ }
72
+
73
+ is_design() {
74
+ local readme=$1 parent
75
+ [[ $readme == docs/design/*/README.md && /$readme/ != */spikes/* ]] || return 1
76
+ [[ $readme != docs/design/*/*/README.md ]] && return 0
77
+ parent=$(dirname "$(dirname "$readme")")/README.md
78
+ [ -n "${listed[$parent]:-}" ] && is_design "$parent"
79
+ }
80
+
81
+ is_known() {
82
+ local readme=$1 dir
83
+ dir=$(dirname "$readme")
84
+
85
+ [ "$readme" = README.md ] && return 0
86
+ [ -f "$dir/package.json" ] || [ -f "$dir/go.mod" ] || [ -f "$dir/Cargo.toml" ] && return 0
87
+ [ "$readme" = docs/README.md ] && return 0
88
+ is_design "$readme" && return 0
89
+ is_spike "$readme" && return 0
90
+ [[ $dir != docs/design/* ]] && indexes_spikes "$dir"
91
+ }
92
+
93
+ unknown=0
94
+ for readme in "${readmes[@]}"; do
95
+ if ! is_known "$readme"; then
96
+ unknown=$((unknown + 1))
97
+ echo "$readme: not one of the five kinds of README; move its content into docs/, or into a code comment beside the code it explains"
98
+ fi
99
+ done
100
+
101
+ echo "check-readmes: ${#readmes[@]} READMEs, $unknown not one of the five kinds"
102
+ [ "$unknown" -eq 0 ]
@@ -0,0 +1,413 @@
1
+ #!/usr/bin/env bash
2
+ # Checks the structure of each skill; exits non-zero when a skill breaks a rule that fails the run.
3
+ # Usage (from the repo root): check-skill.sh [<skill-dir>...]
4
+ # With no argument it checks every directory holding a SKILL.md under .agents/skills/ and
5
+ # .claude/skills/, where a consumer repo installs skills, under skills/, where zgeoff/skills keeps
6
+ # them, and under sync/skills/, where zgeoff/tools keeps the shared skill bases. A skill whose
7
+ # SKILL.md git marks linguist-vendored is skipped, so a repo marks its third-party skills in
8
+ # .gitattributes.
9
+ # Fails on: a missing name or description, a name that differs from the folder or breaks the name
10
+ # format, a description over 1,024 characters, a plain description that holds a colon YAML reads as a
11
+ # key, a link with a line anchor outside code, and a SKILL.md body over 1,700 words, or over 5,000
12
+ # for a rule set (metadata.kind: rule-set in the frontmatter).
13
+ # Warns on: a SKILL.md body over 1,500 words, or over 4,000 for a rule set, a SKILL.md over 500
14
+ # lines, a reference that SKILL.md does not list under ## References, a reference over 100 lines
15
+ # with two or more headings and no contents list near its top, a reference that links to another
16
+ # reference, and a code-formatted span outside a table that names an existing repo file.
17
+ set -uo pipefail
18
+
19
+ if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then
20
+ echo "check-skill: run it inside a git work tree, because it lists the files with git" >&2
21
+ exit 2
22
+ fi
23
+
24
+ # Lists tracked and untracked files, so a new skill is checked before it is staged.
25
+ # A caller runs it in a command substitution and exits on its failure, because git's failure
26
+ # otherwise reaches the check as an empty list.
27
+ list_files() {
28
+ local listing
29
+ if ! listing=$(git -c core.quotePath=false ls-files --cached --others --exclude-standard -- "$@"); then
30
+ echo "check-skill: git ls-files failed, so no skill was checked" >&2
31
+ exit 2
32
+ fi
33
+ sort -u <<< "$listing"
34
+ }
35
+
36
+ is_vendored() {
37
+ local value
38
+ value=$(git check-attr linguist-vendored -- "$1/SKILL.md" | sed 's/^.*: //')
39
+ [ "$value" = set ] || [ "$value" = true ]
40
+ }
41
+
42
+ dirs=()
43
+
44
+ if [ "$#" -gt 0 ]; then
45
+ for dir in "$@"; do
46
+ dir=${dir%/}
47
+ if [ ! -f "$dir/SKILL.md" ]; then
48
+ echo "check-skill: $dir has no SKILL.md" >&2
49
+ exit 2
50
+ fi
51
+ dirs+=("$dir")
52
+ done
53
+ else
54
+ skill_files=$(list_files ':(glob).agents/skills/**/SKILL.md' ':(glob).claude/skills/**/SKILL.md' \
55
+ ':(glob)skills/**/SKILL.md' ':(glob)sync/skills/**/SKILL.md') || exit 2
56
+ while IFS= read -r file; do
57
+ [ -n "$file" ] && [ -f "$file" ] && dirs+=("$(dirname "$file")")
58
+ done <<< "$skill_files"
59
+ fi
60
+
61
+ checked=()
62
+
63
+ for dir in "${dirs[@]}"; do
64
+ is_vendored "$dir" || checked+=("$dir")
65
+ done
66
+
67
+ if [ "${#checked[@]}" -eq 0 ]; then
68
+ echo 'check-skill: no skills to check'
69
+ exit 0
70
+ fi
71
+
72
+ perl -COE -e '
73
+ use strict;
74
+ use warnings;
75
+
76
+ my ($errors, $warnings) = (0, 0);
77
+
78
+ for my $dir (@ARGV) {
79
+ $dir =~ s{^\./}{};
80
+ check_skill($dir);
81
+ }
82
+
83
+ printf "check-skill: %d skills, %d errors, %d warnings\n", scalar @ARGV, $errors, $warnings;
84
+ exit($errors ? 1 : 0);
85
+
86
+ sub fail {
87
+ my ($where, $message) = @_;
88
+ print "$where: $message\n";
89
+ $errors++;
90
+ }
91
+
92
+ sub warn_about {
93
+ my ($where, $message) = @_;
94
+ print "$where: $message (warning)\n";
95
+ $warnings++;
96
+ }
97
+
98
+ sub to_bytes {
99
+ my ($text) = @_;
100
+ utf8::encode($text);
101
+ return $text;
102
+ }
103
+
104
+ sub read_lines {
105
+ my ($path) = @_;
106
+ local $/ = "\n";
107
+ open my $fh, "<:encoding(UTF-8)", to_bytes($path) or die "check-skill: cannot read $path: $!\n";
108
+ my @lines = <$fh>;
109
+ close $fh;
110
+ chomp @lines;
111
+ return @lines;
112
+ }
113
+
114
+ sub check_skill {
115
+ my ($dir) = @_;
116
+ my $skill = "$dir/SKILL.md";
117
+ my @lines = read_lines($skill);
118
+
119
+ warn_about($skill, scalar(@lines) . " lines, over 500; check that each section is one most tasks need")
120
+ if @lines > 500;
121
+ check_words($skill, @lines);
122
+ check_frontmatter($dir, $skill, @lines);
123
+
124
+ open my $list, "-|", "git", "-c", "core.quotePath=false", "ls-files", "--cached", "--others",
125
+ "--exclude-standard", "--", $dir, ":(exclude,attr:linguist-vendored)",
126
+ ":(exclude,attr:linguist-vendored=true)" or die "check-skill: cannot list $dir: $!\n";
127
+ my %files;
128
+ while (my $file = <$list>) {
129
+ chomp $file;
130
+ utf8::decode($file);
131
+ $files{$file} = 1 if $file =~ /\.md\z/ && -f to_bytes($file);
132
+ }
133
+ close $list;
134
+
135
+ for my $file (sort keys %files) {
136
+ check_markdown($dir, $file, $file eq $skill);
137
+ }
138
+
139
+ my %listed = map { $_ => 1 } list_references($skill, @lines);
140
+ for my $file (sort grep { m{^\Q$dir\E/references/[^/]+\.md\z} } keys %files) {
141
+ warn_about($file, "SKILL.md does not list it under ## References; add a line naming the task that needs it")
142
+ unless $listed{$file};
143
+ }
144
+ }
145
+
146
+ # Counts the words of the body after the frontmatter, code blocks included: past the warning limit
147
+ # an agent starts to skim, and past the error limit the skill fails the run. A rule set, such as a
148
+ # code style, holds the rules an agent needs on every task of its kind, so it gets higher limits.
149
+ sub check_words {
150
+ my ($skill, @lines) = @_;
151
+ my ($warn, $limit) = (1500, 1700);
152
+ my ($start, $in_metadata, $indent) = (0, 0, undef);
153
+ if (@lines && $lines[0] eq "---") {
154
+ for my $i (1 .. $#lines) {
155
+ my $line = $lines[$i];
156
+ if ($line eq "---") { $start = $i + 1; last }
157
+ if ($line =~ /^metadata:(?:\s+#.*)?\s*$/) { ($in_metadata, $indent) = (1, undef); next }
158
+ if ($line =~ /^metadata:\s*\{(.*)\}\s*(?:#.*)?$/) {
159
+ for my $entry (split_flow_entries($1)) {
160
+ ($warn, $limit) = (4000, 5000) if $entry =~ /^\s*kind:\s*(.*?)\s*$/ && unquote($1) eq "rule-set";
161
+ }
162
+ $in_metadata = 0;
163
+ next;
164
+ }
165
+ # A comment-only line, at any indent, is neither a key nor the end of the mapping.
166
+ next if $line =~ /^\s*#/;
167
+ $in_metadata = 0 if $line =~ /^\S/;
168
+ next unless $in_metadata && $line =~ /^(\s+)(\S.*?)\s*$/;
169
+ # Only a key of metadata itself counts, never a deeper line, such as the text of a block value.
170
+ $indent //= length $1;
171
+ next unless length($1) == $indent && $2 =~ /^kind:\s*(.*)$/;
172
+ ($warn, $limit) = (4000, 5000) if unquote($1) eq "rule-set";
173
+ }
174
+ }
175
+ my $words = 0;
176
+ for my $line (@lines[$start .. $#lines]) {
177
+ $words += () = $line =~ /\S+/g;
178
+ }
179
+ if ($words > $limit) {
180
+ fail($skill, "body is $words words, over the limit of $limit; cut it, or move what only some tasks need into a reference");
181
+ } elsif ($words > $warn) {
182
+ warn_about($skill, "body is $words words, over $warn; cut it, or move what only some tasks need into a reference");
183
+ }
184
+ }
185
+
186
+ # Splits the inside of a YAML flow mapping at each comma that sits outside a quoted value and a
187
+ # nested flow collection, so a comma inside a value never starts an entry.
188
+ sub split_flow_entries {
189
+ my ($text) = @_;
190
+ my (@entries, $quote);
191
+ my ($entry, $depth) = ("", 0);
192
+ for (my $i = 0; $i < length $text; $i++) {
193
+ my $char = substr($text, $i, 1);
194
+ if (defined $quote) {
195
+ if ($quote eq "\"" && $char eq "\\") { $entry .= substr($text, $i, 2); $i++; next }
196
+ undef $quote if $char eq $quote;
197
+ } elsif ($char eq "\"" || $char eq "\x27") {
198
+ $quote = $char;
199
+ } elsif ($char eq "{" || $char eq "[") {
200
+ $depth++;
201
+ } elsif ($char eq "}" || $char eq "]") {
202
+ $depth--;
203
+ } elsif ($char eq "," && $depth == 0) {
204
+ push @entries, $entry;
205
+ $entry = "";
206
+ next;
207
+ }
208
+ $entry .= $char;
209
+ }
210
+ return (@entries, $entry);
211
+ }
212
+
213
+ # Returns the files that the links under the ## References heading of SKILL.md point at.
214
+ sub list_references {
215
+ my ($skill, @lines) = @_;
216
+ my ($in_section, @found) = (0);
217
+ for my $line (@lines) {
218
+ if ($line =~ /^##\s+(.*?)\s*$/) {
219
+ $in_section = $1 eq "References";
220
+ next;
221
+ }
222
+ next unless $in_section;
223
+ while ($line =~ /\]\(\s*([^()\s#]+)/g) {
224
+ my $resolved = resolve_path($skill, $1);
225
+ push @found, $resolved if defined $resolved;
226
+ }
227
+ }
228
+ return @found;
229
+ }
230
+
231
+ # Reads the frontmatter block: a name, and a description that may fold over indented lines.
232
+ sub check_frontmatter {
233
+ my ($dir, $skill, @lines) = @_;
234
+ my ($name, $description, $in_description, $closed, $raw_description, $folded, $style, $colon);
235
+
236
+ if (@lines && $lines[0] eq "---") {
237
+ for my $line (@lines[1 .. $#lines]) {
238
+ if ($line eq "---") { $closed = 1; last }
239
+ if ($line =~ /^name:\s*(.*?)\s*$/) {
240
+ $name = unquote($1);
241
+ $in_description = 0;
242
+ } elsif ($line =~ /^description:\s*(.*?)\s*$/) {
243
+ $raw_description = $1;
244
+ $style = pick_scalar_style($raw_description);
245
+ $description = ($style // "") eq "block" ? "" : unquote($raw_description);
246
+ $colon = 1 if ($style // "") eq "plain" && has_key_colon($description);
247
+ $in_description = 1;
248
+ } elsif ($in_description && $line =~ /^\s+(\S.*?)\s*$/) {
249
+ $description .= ($description eq "" ? "" : " ") . $1;
250
+ $style //= $1 =~ /\A["\x27]/ ? "quoted" : "plain";
251
+ $colon = 1 if $style eq "plain" && has_key_colon($1);
252
+ $folded = 1;
253
+ } elsif ($line =~ /^[A-Za-z_-]+:/) {
254
+ $in_description = 0;
255
+ }
256
+ }
257
+ }
258
+
259
+ # A plain null (null, Null, NULL or ~) on one line has no text, as YAML reads it.
260
+ $description = "" if !$folded && defined $raw_description
261
+ && $raw_description =~ /\A(?:null|Null|NULL|~)(?:\s+#.*)?\z/;
262
+
263
+ unless ($closed) {
264
+ fail($skill, "has no frontmatter block");
265
+ return;
266
+ }
267
+
268
+ if (!defined $name || $name eq "") {
269
+ fail($skill, "has no name");
270
+ } else {
271
+ my $folder = $dir =~ s{.*/}{}r;
272
+ fail($skill, "name $name does not match the folder $folder") if $name ne $folder;
273
+ fail($skill, "name $name is not lowercase letters, digits and hyphens of at most 64 characters")
274
+ if $name !~ /\A[a-z0-9-]{1,64}\z/;
275
+ }
276
+
277
+ if (!defined $description || $description eq "") {
278
+ fail($skill, "has no description");
279
+ } elsif (length $description > 1024) {
280
+ fail($skill, "description is " . length($description) . " characters, over the limit of 1024");
281
+ }
282
+
283
+ fail($skill, "description holds a colon and a space, which YAML reads as a key in a plain scalar; reword it, or quote it or write it as a block scalar (>-)")
284
+ if $colon;
285
+ }
286
+
287
+ # Returns block, quoted or plain for the text after a key, or undef when the text is empty or only a
288
+ # comment, since the scalar then starts on the next line. A block header may hold an indentation
289
+ # digit and a chomping sign in either order, then a comment.
290
+ sub pick_scalar_style {
291
+ my ($text) = @_;
292
+ return undef if $text eq "" || $text =~ /\A#/;
293
+ return "block" if $text =~ /\A[|>](?:[1-9][+-]?|[+-][1-9]?)?(?:\s+#.*)?\z/;
294
+ return "quoted" if $text =~ /\A["\x27]/;
295
+ return "plain";
296
+ }
297
+
298
+ # A colon followed by a space or the line end starts a mapping inside a plain YAML scalar, so a
299
+ # loader rejects the frontmatter. A space and a hash end the scalar and start a comment.
300
+ sub has_key_colon {
301
+ my ($text) = @_;
302
+ return ($text =~ s/\s+#.*\z//r) =~ /:(?:\s|\z)/;
303
+ }
304
+
305
+ # Returns the value of a scalar: the text inside its quotes, or plain text without a trailing comment.
306
+ sub unquote {
307
+ my ($text) = @_;
308
+ return $2 if $text =~ /\A(["\x27])(.*?)\1\s*(?:#.*)?\z/;
309
+ return "" if $text =~ /\A#/;
310
+ $text =~ s/\s+#.*\z//;
311
+ return $text;
312
+ }
313
+
314
+ sub check_markdown {
315
+ my ($dir, $file, $is_skill) = @_;
316
+ my @lines = read_lines($file);
317
+ my $open = "";
318
+ my $line_number = 0;
319
+ my $contents_links = 0;
320
+ my $headings = 0;
321
+ my $reference = !$is_skill;
322
+ my $in_comment = 0;
323
+
324
+ for my $line (@lines) {
325
+ $line_number++;
326
+ if ($line =~ /^\s*(`{3,}|~{3,})(.*)$/) {
327
+ my ($marker, $rest) = ($1, $2);
328
+ if ($open eq "") { $open = $marker }
329
+ elsif (substr($marker, 0, 1) eq substr($open, 0, 1) && length($marker) >= length($open)
330
+ && $rest =~ /^\s*$/) { $open = "" }
331
+ next;
332
+ }
333
+ next if $open ne "";
334
+ $headings++ if $line =~ /^#{2,6}\s/;
335
+
336
+ # An HTML comment never renders, so its text holds no link and no path.
337
+ # A comment marker inside inline code renders as text, so the spans hide their markers first.
338
+ my $visible = $line =~ s{(`+)(.+?)\1}{my $span = $&; $span =~ s/<!--/<\x{1}!--/g; $span =~ s/-->/--\x{1}>/g; $span}ger;
339
+ if ($in_comment) {
340
+ next unless $visible =~ s/\A.*?-->//;
341
+ $in_comment = 0;
342
+ }
343
+ $visible =~ s/<!--.*?-->//g;
344
+ $in_comment = 1 if $visible =~ s/<!--.*\z//;
345
+ $visible =~ s/\x{1}//g;
346
+
347
+ my $text = $visible;
348
+ my @spans;
349
+ $text =~ s/(`+)(.+?)\1/push @spans, $2; ""/ge;
350
+
351
+ if ($line_number <= 30) {
352
+ $contents_links++ while $text =~ /\]\(#[^)\s]+\)/g;
353
+ }
354
+
355
+ my @targets;
356
+ push @targets, $1 while $text =~ /\]\(\s*(<[^>]*>|(?:[^()\s]|\([^()\s]*\))+)/g;
357
+ push @targets, $1 if $text =~ /^\s{0,3}\[(?!\^)[^\]]+\]:\s*(<[^>]*>|\S+)/;
358
+ push @targets, $2 while $text =~ /\b(?:href|src)\s*=\s*(["\x27])(.*?)\1/g;
359
+
360
+ for my $target (@targets) {
361
+ $target =~ s/^<(.*)>$/$1/;
362
+ fail("$file:$line_number", "link $target has a line anchor; name the target in the link text instead")
363
+ if $target =~ /#L[0-9]+/;
364
+ check_reference_link($dir, $file, $line_number, $target) if $reference;
365
+ }
366
+
367
+ # A span inside link text is already a link, so only the spans left after links drop are read.
368
+ # A table row holds commands the agent types, such as the commands table of a skill, so it is skipped.
369
+ next if $visible =~ /^\s*\|/;
370
+ my $outside_links = $visible =~ s/!?\[[^\]]*\]\([^)]*\)//gr;
371
+ my @bare;
372
+ $outside_links =~ s/(`+)(.+?)\1/push @bare, $2; ""/ge;
373
+
374
+ for my $span (@bare) {
375
+ $span =~ s/^\s+|\s+$//g;
376
+ next if $span !~ m{\A[\w.@/-]+\z} || $span !~ m{/};
377
+ warn_about("$file:$line_number", "`$span` names a file in the repo; link it instead")
378
+ if -f to_bytes($span);
379
+ }
380
+ }
381
+
382
+ if ($reference && @lines > 100 && $headings >= 2 && $contents_links < 2) {
383
+ warn_about($file, scalar(@lines) . " lines with no contents list near the top; open with links to its headings");
384
+ }
385
+ }
386
+
387
+ sub check_reference_link {
388
+ my ($dir, $file, $line_number, $target) = @_;
389
+ return if $target eq "" || $target =~ m{^(?:[a-z][a-z0-9+.-]*:|//|#)}i;
390
+ my ($path) = split /[#?]/, $target, 2;
391
+ my $resolved = resolve_path($file, $path);
392
+ return unless defined $resolved && $resolved ne $file;
393
+ return unless $resolved =~ m{^\Q$dir\E/references/.*\.md\z};
394
+ warn_about("$file:$line_number", "links to another reference ($resolved); link it from SKILL.md instead");
395
+ }
396
+
397
+ sub resolve_path {
398
+ my ($file, $path) = @_;
399
+ return undef if $path eq "";
400
+ my @parts = $path =~ m{^/} ? () : split m{/}, $file;
401
+ pop @parts unless $path =~ m{^/};
402
+ for my $part (split m{/}, $path) {
403
+ next if $part eq "" || $part eq ".";
404
+ if ($part eq "..") {
405
+ return undef unless @parts;
406
+ pop @parts;
407
+ } else {
408
+ push @parts, $part;
409
+ }
410
+ }
411
+ return @parts ? join("/", @parts) : undef;
412
+ }
413
+ ' -- "${checked[@]}"
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env bash
2
+ # Checks the repo's vision doc, docs/vision.md, against the docs-writing skill's contract. Fails when
3
+ # the doc is missing or runs past one page.
4
+ # Usage (from the repo root): check-vision.sh
5
+ set -uo pipefail
6
+
7
+ [ "$#" -eq 0 ] || {
8
+ echo "usage: check-vision.sh" >&2
9
+ exit 2
10
+ }
11
+
12
+ vision=docs/vision.md
13
+ limit=60
14
+
15
+ # A tracked or untracked doc counts, so a new one passes before it is staged, and an ignored one
16
+ # does not.
17
+ if ! listed=$(git -c core.quotePath=false ls-files --cached --others --exclude-standard -- "$vision"); then
18
+ echo "check-vision: git ls-files failed, so the vision doc was not checked" >&2
19
+ exit 2
20
+ fi
21
+
22
+ if [ -z "$listed" ] || [ ! -f "$vision" ]; then
23
+ echo "$vision: missing; write the vision doc the docs-writing skill's docs-tree reference describes (error)"
24
+ exit 1
25
+ fi
26
+
27
+ lines=$(awk 'END { print NR }' "$vision")
28
+
29
+ if [ "$lines" -gt "$limit" ]; then
30
+ echo "$vision: $lines lines, over the vision doc limit of $limit (error: cut points to fit one page)"
31
+ exit 1
32
+ fi
33
+
34
+ echo "$vision: $lines lines (vision doc; limit $limit)"
@@ -0,0 +1,16 @@
1
+ // A path segment, so `node_modules_backup` and `.bun/install/cache-old` are other folders.
2
+ const installCachePattern = /(?:^|\/)(?:node_modules|\.bun\/install\/cache)(?:\/|$)/u;
3
+
4
+ // actions/cache reads one path per line, and a line that starts with `!` leaves that path out. A
5
+ // line that holds an expression is left alone, because GitHub resolves it only when the job runs.
6
+ export function findInstallCachePath(paths: unknown): string | undefined {
7
+ return typeof paths === 'string'
8
+ ? paths
9
+ .split('\n')
10
+ .map((path) => path.trim())
11
+ .find(
12
+ (path) =>
13
+ !path.startsWith('!') && !path.includes('${{') && installCachePattern.test(path),
14
+ )
15
+ : undefined;
16
+ }
@@ -0,0 +1,3 @@
1
+ export function formatCommitLines(commits: readonly string[]): string {
2
+ return commits.map((commit) => `${commit}\n`).join('');
3
+ }
@@ -0,0 +1,40 @@
1
+ import { join } from 'node:path';
2
+ import type { CheckName } from './check-names.ts';
3
+
4
+ export type CheckRunner = (args: readonly string[]) => Promise<number> | number;
5
+
6
+ type DocsCheckName = 'prose' | 'length' | 'links' | 'readmes' | 'vision' | 'skill';
7
+
8
+ export type RepoCheckName = Exclude<CheckName, DocsCheckName>;
9
+
10
+ export interface CheckRunnerDeps {
11
+ // The folder that holds the docs checks' shell scripts.
12
+ readonly docsFolder: string;
13
+
14
+ // Runs a command with this process's output streams, and returns its exit code.
15
+ readonly runScript: (argv: readonly string[]) => Promise<number>;
16
+
17
+ readonly repoChecks: Readonly<Record<RepoCheckName, CheckRunner>>;
18
+ }
19
+
20
+ export function makeCheckRunner(
21
+ deps: CheckRunnerDeps,
22
+ ): (name: CheckName, args: readonly string[]) => Promise<number> {
23
+ const makeDocsCheckRunner =
24
+ (script: string): CheckRunner =>
25
+ (args) =>
26
+ deps.runScript(['bash', join(deps.docsFolder, script), ...args]);
27
+
28
+ // Typed over every check name, so a new check fails the type check until it has a runner.
29
+ const runners: Readonly<Record<CheckName, CheckRunner>> = {
30
+ prose: makeDocsCheckRunner('check-prose.sh'),
31
+ length: makeDocsCheckRunner('check-length.sh'),
32
+ links: makeDocsCheckRunner('check-links.sh'),
33
+ readmes: makeDocsCheckRunner('check-readmes.sh'),
34
+ vision: makeDocsCheckRunner('check-vision.sh'),
35
+ skill: makeDocsCheckRunner('check-skill.sh'),
36
+ ...deps.repoChecks,
37
+ };
38
+
39
+ return (name, args) => Promise.resolve(runners[name](args));
40
+ }