@zgeoff/chex 0.0.7 → 0.0.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/chex",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "description": "Run each shared docs and repo check as one subcommand, with the lefthook pre-push baseline that calls them",
5
5
  "keywords": [
6
6
  "checks",
@@ -1,6 +1,5 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { writeStdout } from '@zgeoff/std';
4
3
  import { runProgram } from '../run-program.ts';
5
4
  import type { DependencyGateSources } from './read-dependency-gate-findings.ts';
6
5
  import { readDependencyGateFindings } from './read-dependency-gate-findings.ts';
@@ -41,7 +40,9 @@ export function runDependencyGatesCommand(): number {
41
40
 
42
41
  return existsSync(fullPath) ? readFileSync(fullPath, 'utf8') : null;
43
42
  },
44
- writeOutput: writeStdout,
43
+ writeOutput: (text) => {
44
+ process.stdout.write(text);
45
+ },
45
46
  writeError: (text) => {
46
47
  process.stderr.write(text);
47
48
  },
@@ -8,12 +8,12 @@
8
8
  # .gitattributes.
9
9
  # Fails on: a missing name or description, a name that differs from the folder or breaks the name
10
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
11
+ # key, a link with a line anchor outside code, and a SKILL.md body over 1,700 words.
12
+ # Warns on: a SKILL.md body over 1,500 words, a SKILL.md over 500
14
13
  # 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.
14
+ # with two or more headings and no contents list near its top, a code-formatted span outside a table that names an existing repo file, and a bullet in
15
+ # a rule set's SKILL.md that opens with an article or "Each" and a noun, a code span and a verb, or a
16
+ # label and a colon.
17
17
  set -uo pipefail
18
18
 
19
19
  if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then
@@ -119,6 +119,7 @@ sub check_skill {
119
119
  warn_about($skill, scalar(@lines) . " lines, over 500; check that each section is one most tasks need")
120
120
  if @lines > 500;
121
121
  check_words($skill, @lines);
122
+ check_bullets($skill, @lines) if is_rule_set(@lines);
122
123
  check_frontmatter($dir, $skill, @lines);
123
124
 
124
125
  open my $list, "-|", "git", "-c", "core.quotePath=false", "ls-files", "--cached", "--others",
@@ -143,33 +144,17 @@ sub check_skill {
143
144
  }
144
145
  }
145
146
 
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.
147
+ # Counts the words of the body after the frontmatter, code blocks included. After a compaction,
148
+ # Claude Code brings back only the first 5,000 tokens of each loaded skill, about 1,900 words of a
149
+ # skill, so past the error limit the end of the skill is lost; past the warning limit an agent starts
150
+ # to skim. A rule set gets the same limits, because the agent needs every rule in it.
149
151
  sub check_words {
150
152
  my ($skill, @lines) = @_;
151
153
  my ($warn, $limit) = (1500, 1700);
152
- my ($start, $in_metadata, $indent) = (0, 0, undef);
154
+ my $start = 0;
153
155
  if (@lines && $lines[0] eq "---") {
154
156
  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";
157
+ if ($lines[$i] eq "---") { $start = $i + 1; last }
173
158
  }
174
159
  }
175
160
  my $words = 0;
@@ -183,6 +168,73 @@ sub check_words {
183
168
  }
184
169
  }
185
170
 
171
+ # Returns true when the frontmatter sets kind: rule-set under metadata, in a block or a flow mapping.
172
+ sub is_rule_set {
173
+ my (@lines) = @_;
174
+ return 0 unless @lines && $lines[0] eq "---";
175
+ my ($in_metadata, $indent) = (0, undef);
176
+ for my $i (1 .. $#lines) {
177
+ my $line = $lines[$i];
178
+ return 0 if $line eq "---";
179
+ if ($line =~ /^metadata:(?:\s+#.*)?\s*$/) { ($in_metadata, $indent) = (1, undef); next }
180
+ if ($line =~ /^metadata:\s*\{(.*)\}\s*(?:#.*)?$/) {
181
+ for my $entry (split_flow_entries($1)) {
182
+ return 1 if $entry =~ /^\s*kind:\s*(.*?)\s*$/ && unquote($1) eq "rule-set";
183
+ }
184
+ $in_metadata = 0;
185
+ next;
186
+ }
187
+ # A comment-only line, at any indent, is neither a key nor the end of the mapping.
188
+ next if $line =~ /^\s*#/;
189
+ $in_metadata = 0 if $line =~ /^\S/;
190
+ next unless $in_metadata && $line =~ /^(\s+)(\S.*?)\s*$/;
191
+ # Only a key of metadata itself counts, never a deeper line, such as the text of a block value.
192
+ $indent //= length $1;
193
+ return 1 if length($1) == $indent && $2 =~ /^kind:\s*(.*)$/ && unquote($1) eq "rule-set";
194
+ }
195
+ return 0;
196
+ }
197
+
198
+ # A rule set is a list of bullets the agent applies while it does other work, so each bullet opens
199
+ # with what the agent does, a ban or a condition. A bullet that opens with a thing as its subject,
200
+ # or with a label and a colon, leaves the agent to work out the action.
201
+ sub check_bullets {
202
+ my ($skill, @lines) = @_;
203
+ my ($open, $line_number, $in_frontmatter) = ("", 0, @lines && $lines[0] eq "---");
204
+ for my $line (@lines) {
205
+ $line_number++;
206
+ if ($in_frontmatter) {
207
+ $in_frontmatter = 0 if $line_number > 1 && $line eq "---";
208
+ next;
209
+ }
210
+ # A fence may open inside a list item, after its marker, but closes only on a line of its own.
211
+ # The info string of a backtick fence never holds a backtick, so a line such as
212
+ # "```name``` runs" is an inline code span, not a fence.
213
+ my $marker_pattern = $open eq "" ? qr/^\s*(?:[-*+]\s+|\d+[.)]\s+)?(`{3,}|~{3,})(.*)$/
214
+ : qr/^\s*(`{3,}|~{3,})(.*)$/;
215
+ if ($line =~ $marker_pattern && !($open eq "" && substr($1, 0, 1) eq "`" && $2 =~ /`/)) {
216
+ my ($marker, $rest) = ($1, $2);
217
+ if ($open eq "") { $open = $marker }
218
+ elsif (substr($marker, 0, 1) eq substr($open, 0, 1) && length($marker) >= length($open)
219
+ && $rest =~ /^\s*$/) { $open = "" }
220
+ next;
221
+ }
222
+ next if $open ne "";
223
+ next unless $line =~ /^\s*[-*+]\s+(.*)$/;
224
+ my $text = $1 =~ s/^\*\*//r;
225
+ my $problem;
226
+ if ($text =~ /^(A|An|The|Each|Every)\s+[\w`]/) {
227
+ $problem = "opens with \"$1\" and a noun";
228
+ } elsif (($text =~ /^`[^`]+`\s+([a-z]+)\b/ ? $1 : "") =~ /^(?!(?:as|this|its)\z)(?:are|have|can|may|must|do|[a-z]+s)\z/) {
229
+ $problem = "opens with a code span and a verb";
230
+ } elsif ($text =~ /^([A-Z][\w-]*(?:\s[\w-]+){0,2}):(?:\*\*)?\s/ && $1 !~ /^(?:When|If|In|Never|No)\b/) {
231
+ $problem = "opens with the label \"$1:\"";
232
+ }
233
+ warn_about("$skill:$line_number", "bullet $problem; open it with what the agent does, a ban such as \"Never\", or a condition such as \"When\"")
234
+ if $problem;
235
+ }
236
+ }
237
+
186
238
  # Splits the inside of a YAML flow mapping at each comma that sits outside a quoted value and a
187
239
  # nested flow collection, so a comma inside a value never starts an entry.
188
240
  sub split_flow_entries {
@@ -361,7 +413,6 @@ sub check_markdown {
361
413
  $target =~ s/^<(.*)>$/$1/;
362
414
  fail("$file:$line_number", "link $target has a line anchor; name the target in the link text instead")
363
415
  if $target =~ /#L[0-9]+/;
364
- check_reference_link($dir, $file, $line_number, $target) if $reference;
365
416
  }
366
417
 
367
418
  # A span inside link text is already a link, so only the spans left after links drop are read.
@@ -384,16 +435,6 @@ sub check_markdown {
384
435
  }
385
436
  }
386
437
 
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
438
  sub resolve_path {
398
439
  my ($file, $path) = @_;
399
440
  return undef if $path eq "";