@erclx/aitk 0.64.0 → 0.64.1

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "0.64.0",
4
+ "version": "0.64.1",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -19,7 +19,7 @@ It reports rather than gates by default, the closer call because a missing secti
19
19
 
20
20
  ## Length
21
21
 
22
- Length quotes its checkpoint from `.claude/standards/context.md`: roughly 150 rendered lines for an entry. It counts rendered lines rather than source lines, wrapping each line at 80 columns and summing the heights. Entries here are authored one line per bullet, so a block of fifteen paragraph-bullets occupies fifteen source lines and renders past sixty, which source counting cannot see. The measure counts fenced blocks and frontmatter, so a reference-heavy entry ranks by its examples, which the legend states on every run alongside the width, since a number in rendered lines cannot be reproduced without it.
22
+ Length quotes its checkpoint from `.claude/standards/context.md`: roughly 150 rendered lines for an entry. It counts rendered lines rather than source lines, wrapping each line at 80 columns and summing the heights. A line is wrapped at the width it renders at, so a link counts as its anchor text and a backticked path counts whole. That moves 2 of the 48 entries measured, `extensions.md` by 2 lines and `governance.md` by 1, because an entry cites a path in backticks rather than as a link. Entries here are authored one line per bullet, so a block of fifteen paragraph-bullets occupies fifteen source lines and renders past sixty, which source counting cannot see. The measure counts fenced blocks and frontmatter, so a reference-heavy entry ranks by its examples, which the legend states on every run alongside the width, since a number in rendered lines cannot be reproduced without it.
23
23
 
24
24
  Depth and bullet weight are quoted from `.claude/standards/markdown.md`, which states both over every markdown file rather than over a context entry, so `aitk markdown audit` measures them and this command no longer does. They share `renderedHeight` with the length measure, since the two checkpoints sit in one section of that standard and a reader compares them. What the split costs is that a session wanting both numbers for one entry runs two commands, and what it buys is that either number can be had for a file in a folder this audit refuses to resolve.
25
25
 
@@ -41,13 +41,21 @@ Deriving the spellings rather than pattern-matching a suffix is what keeps `exer
41
41
 
42
42
  Frontmatter, fenced blocks, inline code spans, and link destinations are excluded. Without the code-span exclusion each standard would report its own backticked examples, and without the link exclusion a semicolon in a query string would report as prose no rewrite can fix.
43
43
 
44
+ A banned word is bounded on a word character or a hyphen either side. A plain word boundary sits after a hyphen, so a banned word ending a hyphenated compound reported from inside one, and a compound is a single word to the reader who wrote it.
45
+
46
+ A banned spelling keeps the plain word boundary, hyphens included. The two bans target different things: a word ban targets the word, so reading a compound as one word is correct, while a spelling ban targets the orthography inside it, which sits in `behaviour-driven` as plainly as it sits alone.
47
+
44
48
  Two ban shapes stay unmeasured and the report says so on every run. A phrase ban carries a placeholder standing in for the rest of the sentence, so no literal match reaches it, and every rule under `## Voice` is a judgment. A report listing hits without naming those would read as a verdict on the whole standard.
45
49
 
46
50
  ### Bullets, paragraphs, and depth
47
51
 
48
52
  Bullet weight and depth are the checks that moved off `aitk context audit`, unchanged in what they measure. A top-level bullet reports past roughly 400 characters with continuation lines folded in and nested items left out. A run of lines no heading breaks reports past roughly 40 rendered lines, measured at 80 columns, skipping fenced blocks and exempting a flat peer list averaging under 130 characters a bullet and a run that is entirely table rows.
49
53
 
50
- The paragraph check is new and measures both halves of one rule. `markdown.md` caps a paragraph at four sentences, and a sentence cap on its own is satisfied by writing fewer and longer ones: measured across 2906 paragraphs, 344 sit inside four sentences and past 400 characters, and the heaviest runs 1159 characters in two. The standard therefore states a weight beside the sentence cap, and the verb reads it as its own checkpoint.
54
+ Every weight and depth measure counts the text a reader is shown. A link reduces to its anchor text and an autolink drops whole, since no reader is shown either destination. A backticked path stays counted, which is where these measures part from the ban scan above: that one blanks a code span so a standard quoting its own banned character does not report itself, and discounting the same span here would under-report a paragraph carrying several. One file holds both span sets and each answers its own question.
55
+
56
+ A code span is walked around rather than through, so a path quoting link or angle-bracket syntax keeps the width the page gives it. Masking inside one takes back the decision to count it, and the placeholders this toolkit writes are where that shows.
57
+
58
+ The paragraph check is new and measures both halves of one rule. `markdown.md` caps a paragraph at four sentences, and a sentence cap on its own is satisfied by writing fewer and longer ones: measured across 3374 paragraphs, 486 sit inside four sentences and past 400 characters, and the heaviest of those runs 1121 characters. The standard therefore states a weight beside the sentence cap, and the verb reads it as its own checkpoint.
51
59
 
52
60
  The paragraph weight and the bullet weight are the same number today because the two shapes measure one population, sharing a median near 170 characters with no gap behind either candidate. They are separate checkpoints in the standard and separate patterns in the parser, so retuning one leaves the other where it is.
53
61
 
@@ -59,7 +67,9 @@ Exit codes are `0` for a completed run and `1` for a refusal. Every finding repo
59
67
 
60
68
  A banned character is a fact rather than a judgment, which is the test that would ordinarily make it gate. What holds it back is that gating on day one against a corpus never checked mechanically fails loudly on work nobody has had a chance to fix. The order is to land the verb reporting, measure the corpus once, fix what it finds, and turn the gate on as its own change. Bullet, paragraph, and depth weight are judgments and stay advisory under any later gate.
61
69
 
62
- Measured across 443 files on the day this shipped: 9 word hits, no character or spelling hits, 109 heavy bullets, 610 heavy paragraphs, and 42 files carrying a run past the depth checkpoint. The ban count is what a gate would have to hold at zero, and it is the only one of the five a gate should ever read.
70
+ Measured across 444 files once the weight measures moved to visible text: 8 word hits, no character or spelling hits, 119 heavy bullets, 615 heavy paragraphs, and 40 files carrying a run past the depth checkpoint. The ban count is what a gate would have to hold at zero, and it is the only one of the five a gate should ever read.
71
+
72
+ Masking took 7 of the 493 weight-only paragraphs and 4 of the 44 files under their checkpoints, and no bullet at all. The first corpus triage put those at 31 paragraphs and 2 bullets, and neither reproduces: a code span is walked around, so a backticked path holding an angle-bracket placeholder keeps the width the page gives it, and both bullets the triage counted were that shape.
63
73
 
64
74
  ## What it does not cover
65
75
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.64.0",
4
+ "version": "0.64.1",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -23,6 +23,17 @@ const CODE_SPAN = /(`+)(?:(?!\1).)*\1/g
23
23
  const LINK_DESTINATION = /\]\([^)]*\)/g
24
24
  const AUTOLINK = /<[^>\s]+>/g
25
25
 
26
+ /**
27
+ * A whole inline link, capturing the anchor text a reader is shown.
28
+ *
29
+ * `LINK_DESTINATION` covers the span both measures drop and this covers the
30
+ * brackets only the weight measure drops, which a reader is no more shown than
31
+ * the destination. The narrower pattern still runs after this one, since a link
32
+ * wrapped across two source lines puts its opening bracket on a line this one
33
+ * never matches.
34
+ */
35
+ const LINK = /\[([^\]]*)\]\([^)]*\)/g
36
+
26
37
  export interface BodyLine {
27
38
  readonly number: number
28
39
  readonly text: string
@@ -128,16 +139,81 @@ export function maskDisplayed(text: string): string {
128
139
  .replace(AUTOLINK, blank)
129
140
  }
130
141
 
142
+ /**
143
+ * Drops the spans a reader is never shown, returning the text they read.
144
+ *
145
+ * This is what a weight measure counts, and it is deliberately not
146
+ * `maskDisplayed`. That one holds each span's width so a ban finding can name a
147
+ * column, which leaves behind the very characters a weight measure exists to
148
+ * discount. The two also disagree on the span set: a backticked path is text a
149
+ * reader reads and stays counted here, while the ban scan blanks it so a
150
+ * standard quoting its own banned character does not report itself. One file
151
+ * therefore holds two answers to what a reader sees, each correct for its own
152
+ * measure, and collapsing them into one helper breaks whichever loses.
153
+ *
154
+ * A code span is walked around rather than through, since keeping it counted
155
+ * and then dropping spans from inside it takes back the decision. The
156
+ * placeholders this repository writes are the case: a reader is shown all of
157
+ * `.claude/context/<domain>.md` and the autolink pattern reaches the angle
158
+ * brackets in the middle of it.
159
+ */
160
+ export function visibleText(text: string): string {
161
+ const drop = (segment: string): string =>
162
+ segment
163
+ .replace(LINK, '$1')
164
+ .replace(LINK_DESTINATION, '')
165
+ .replace(AUTOLINK, '')
166
+
167
+ let visible = ''
168
+ let read = 0
169
+
170
+ for (const span of text.matchAll(CODE_SPAN)) {
171
+ visible += drop(text.slice(read, span.index)) + span[0]
172
+ read = span.index + span[0].length
173
+ }
174
+
175
+ return visible + drop(text.slice(read))
176
+ }
177
+
131
178
  function escape(term: string): string {
132
179
  return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
133
180
  }
134
181
 
182
+ /**
183
+ * Bounds a banned word on a word character or a hyphen either side.
184
+ *
185
+ * `\b` sits after a hyphen, so a banned word ending a hyphenated compound
186
+ * reports from inside it: `allows` came back out of `auto-allows`. A compound
187
+ * is one word to a reader, and the ban is on the word rather than on a morpheme
188
+ * of it. Stripping hyphens before matching was the alternative and it joins the
189
+ * compound into a token neither half reaches.
190
+ *
191
+ * A spelling ban takes `wordBoundary` instead. This is not that rule with a
192
+ * wider fence, since the two bans target different things.
193
+ */
194
+ function bannedWord(term: string): RegExp {
195
+ return new RegExp(`(?<![\\w-])${escape(term)}(?![\\w-])`, 'gi')
196
+ }
197
+
198
+ /**
199
+ * Bounds a banned spelling on a word character alone, hyphens included.
200
+ *
201
+ * A word ban targets the word, so a compound reading as one word is correct.
202
+ * A spelling ban targets the orthography inside it, and a compound is exactly
203
+ * where the orthography still sits: `behaviour-driven` carries the banned
204
+ * spelling as plainly as `behaviour` does, and rejecting a hyphen here would
205
+ * leave the usual spelling of that phrase unreported.
206
+ */
207
+ function bannedSpelling(term: string): RegExp {
208
+ return new RegExp(`\\b${escape(term)}\\b`, 'gi')
209
+ }
210
+
135
211
  /**
136
212
  * Finds every banned term outside a fence, a code span, and a link.
137
213
  *
138
- * A word ban matches on word boundaries and either casing, since the standard
139
- * states each in lowercase and bans the word rather than a spelling of it. This
140
- * is what separates the check from the pattern that produced most of the
214
+ * A word ban matches in either casing, since the standard states each in
215
+ * lowercase and bans the word rather than a spelling of it. A closed set of
216
+ * whole words separates the check from the pattern that produced most of the
141
217
  * intake's false positives: `exercises` and `promises` end in the banned
142
218
  * suffix and are not the banned words, and a closed set never reaches them.
143
219
  */
@@ -151,12 +227,12 @@ export function scanBans(
151
227
  ...bans.words.map((term) => ({
152
228
  kind: 'word' as const,
153
229
  term,
154
- pattern: new RegExp(`\\b${escape(term)}\\b`, 'gi'),
230
+ pattern: bannedWord(term),
155
231
  })),
156
232
  ...bans.spellings.map((term) => ({
157
233
  kind: 'spelling' as const,
158
234
  term,
159
- pattern: new RegExp(`\\b${escape(term)}\\b`, 'gi'),
235
+ pattern: bannedSpelling(term),
160
236
  })),
161
237
  ]
162
238
 
@@ -1,4 +1,4 @@
1
- import type { BodyLine } from '@/markdown/scan'
1
+ import { type BodyLine, visibleText } from '@/markdown/scan'
2
2
 
3
3
  const HEADING = /^#{1,6}\s/
4
4
  const LIST_ITEM = /^(\s*)([-*+]|\d+\.)\s+/
@@ -143,9 +143,13 @@ export function parseCheckpoints(markdown: string): Checkpoints {
143
143
  *
144
144
  * A blank line renders as the gap it is rather than as nothing, which keeps it
145
145
  * the distance the source measure already counted it as.
146
+ *
147
+ * The width is measured against what renders, since a rendered line shows a
148
+ * link's anchor text rather than its destination and a source measure
149
+ * over-counts exactly where this rule cares how far a reader travels.
146
150
  */
147
151
  export function renderedHeight(text: string, width = RENDER_WIDTH): number {
148
- return Math.max(1, Math.ceil(text.length / width))
152
+ return Math.max(1, Math.ceil(visibleText(text).length / width))
149
153
  }
150
154
 
151
155
  /**
@@ -173,7 +177,7 @@ function isScannablePeerList(
173
177
  if (!match) return false
174
178
  indents.add(match[1].length)
175
179
  items++
176
- characters += text.length
180
+ characters += visibleText(text).length
177
181
  }
178
182
 
179
183
  if (indents.size !== 1) return false
@@ -297,10 +301,13 @@ export function heavyBullets(
297
301
  const item = line.text.match(LIST_ITEM)
298
302
  const text = line.text.trim()
299
303
 
304
+ // Structure is read off the raw line and only the weight is masked. A line
305
+ // carrying nothing but an autolink has no visible text at all, and reading
306
+ // its masked form as blank would close the bullet it continues.
300
307
  if (item) {
301
308
  close()
302
309
  if (item[1].length === 0) {
303
- open = { line: line.number, characters: text.length }
310
+ open = { line: line.number, characters: visibleText(text).length }
304
311
  }
305
312
  continue
306
313
  }
@@ -312,7 +319,11 @@ export function heavyBullets(
312
319
 
313
320
  // The joining space a wrapped line would have carried, so folding two
314
321
  // source lines measures what one unwrapped line would have.
315
- if (open) open = { ...open, characters: open.characters + text.length + 1 }
322
+ if (open)
323
+ open = {
324
+ ...open,
325
+ characters: open.characters + visibleText(text).length + 1,
326
+ }
316
327
  }
317
328
 
318
329
  close()
@@ -353,16 +364,13 @@ export function heavyParagraphs(
353
364
  if (block.length > 0) {
354
365
  const text = block.map((line) => line.text.trim()).join(' ')
355
366
  const sentences = countSentences(text)
367
+ const characters = visibleText(text).length
356
368
 
357
369
  if (
358
370
  sentences > checkpoints.sentences ||
359
- text.length > checkpoints.paragraph
371
+ characters > checkpoints.paragraph
360
372
  ) {
361
- findings.push({
362
- line: block[0].number,
363
- sentences,
364
- characters: text.length,
365
- })
373
+ findings.push({ line: block[0].number, sentences, characters })
366
374
  }
367
375
  }
368
376
  block = []