@zgeoff/chex 0.0.1 → 0.0.3
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-checks-profile.ts +57 -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 +613 -0
- package/src/shell-escape-parsers.ts +197 -0
- 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,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
|
+
}
|