@erclx/aitk 0.63.2 → 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.
@@ -0,0 +1,416 @@
1
+ import { type BodyLine, visibleText } from '@/markdown/scan'
2
+
3
+ const HEADING = /^#{1,6}\s/
4
+ const LIST_ITEM = /^(\s*)([-*+]|\d+\.)\s+/
5
+ const TABLE_ROW = /^\s*\|/
6
+ const TABLE_SEPARATOR = /^\s*\|[\s:|-]+\|\s*$/
7
+ const BLOCKQUOTE = /^\s*>/
8
+
9
+ /**
10
+ * A sentence ends on terminal punctuation followed by the start of another
11
+ * sentence or by the end of the paragraph.
12
+ *
13
+ * Requiring an opening capital after the space is what keeps a version pin and
14
+ * a decimal from each reading as two sentences, since neither is followed by
15
+ * one. An abbreviation ahead of a capitalized word still counts, which
16
+ * over-reports by one on the sentence that carries it and is why this measure
17
+ * reports rather than gates.
18
+ */
19
+ const SENTENCE_END = /[.!?]["'’”)\]]*(?=\s+["'“(\[]*[A-Z]|\s*$)/g
20
+
21
+ const NUMBER_WORDS: Record<string, number> = {
22
+ one: 1,
23
+ two: 2,
24
+ three: 3,
25
+ four: 4,
26
+ five: 5,
27
+ six: 6,
28
+ seven: 7,
29
+ eight: 8,
30
+ nine: 9,
31
+ ten: 10,
32
+ }
33
+
34
+ /**
35
+ * Checkpoints as `standards/markdown.md` states them today.
36
+ *
37
+ * These are the fallback rather than the definition. Each is read out of the
38
+ * standard per run, and this stands in for one the standard no longer states in
39
+ * a shape the reader recognizes, so a rewording degrades a number rather than
40
+ * the whole check. The report's legend names every checkpoint that fell back,
41
+ * because a stale number quietly measuring the wrong thing is the failure mode
42
+ * a silent default would ship.
43
+ */
44
+ export const DEFAULT_CHECKPOINTS = {
45
+ run: 40,
46
+ peerBullet: 130,
47
+ bullet: 400,
48
+ paragraph: 400,
49
+ sentences: 4,
50
+ renderWidth: 80,
51
+ } as const
52
+
53
+ export type CheckpointName = keyof typeof DEFAULT_CHECKPOINTS
54
+
55
+ /**
56
+ * Columns a source line wraps at when rendered.
57
+ *
58
+ * Nothing in this repository sets a line width and entries are authored one
59
+ * line per bullet, so the rendered width is the viewer's rather than the file's.
60
+ */
61
+ export const RENDER_WIDTH: number = DEFAULT_CHECKPOINTS.renderWidth
62
+
63
+ export interface Checkpoints {
64
+ readonly run: number
65
+ readonly peerBullet: number
66
+ readonly bullet: number
67
+ readonly paragraph: number
68
+ readonly sentences: number
69
+ readonly renderWidth: number
70
+ /** Names that fell back, so the report can say which number is not the standard's. */
71
+ readonly fellBack: readonly CheckpointName[]
72
+ }
73
+
74
+ export interface BulletFinding {
75
+ readonly line: number
76
+ /** Weight as folded, so a report says how far past the checkpoint it sits. */
77
+ readonly characters: number
78
+ }
79
+
80
+ export interface ParagraphFinding {
81
+ readonly line: number
82
+ readonly sentences: number
83
+ readonly characters: number
84
+ }
85
+
86
+ export interface StructureReport {
87
+ readonly rel: string
88
+ /** Rendered lines at the render width, not source lines. */
89
+ readonly longestRun: number
90
+ /** First line of the longest run, or 0 when the file has no run at all. */
91
+ readonly longestRunLine: number
92
+ readonly heavyBullets: readonly BulletFinding[]
93
+ readonly heavyParagraphs: readonly ParagraphFinding[]
94
+ }
95
+
96
+ const PATTERNS: Record<CheckpointName, RegExp> = {
97
+ run: /Past roughly (\d+) rendered lines/,
98
+ peerBullet: /averaging under roughly (\d+) characters/,
99
+ bullet: /Past roughly (\d+) characters in one top-level bullet/,
100
+ paragraph: /Past roughly (\d+) characters in one paragraph/,
101
+ sentences: /Keep paragraphs to ([a-z]+|\d+) sentences or fewer/,
102
+ renderWidth: /wrapping each source line at (\d+) columns/,
103
+ }
104
+
105
+ /**
106
+ * Reads each checkpoint out of the standard stating it.
107
+ *
108
+ * A number held in code is a second place the rule lives, and the two drift
109
+ * silently because nothing compares them. What this buys is that raising a
110
+ * checkpoint is an edit to the sentence a reader is pointed at, and what it
111
+ * costs is a reader of prose, which is bounded by falling back per checkpoint
112
+ * rather than per file.
113
+ */
114
+ export function parseCheckpoints(markdown: string): Checkpoints {
115
+ const fellBack: CheckpointName[] = []
116
+
117
+ const read = (name: CheckpointName): number => {
118
+ const match = markdown.match(PATTERNS[name])
119
+ const raw = match?.[1]
120
+ const value = raw ? (NUMBER_WORDS[raw] ?? Number(raw)) : Number.NaN
121
+
122
+ if (!Number.isFinite(value) || value <= 0) {
123
+ fellBack.push(name)
124
+ return DEFAULT_CHECKPOINTS[name]
125
+ }
126
+
127
+ return value
128
+ }
129
+
130
+ return {
131
+ run: read('run'),
132
+ peerBullet: read('peerBullet'),
133
+ bullet: read('bullet'),
134
+ paragraph: read('paragraph'),
135
+ sentences: read('sentences'),
136
+ renderWidth: read('renderWidth'),
137
+ fellBack,
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Height a source line occupies once wrapped.
143
+ *
144
+ * A blank line renders as the gap it is rather than as nothing, which keeps it
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.
150
+ */
151
+ export function renderedHeight(text: string, width = RENDER_WIDTH): number {
152
+ return Math.max(1, Math.ceil(visibleText(text).length / width))
153
+ }
154
+
155
+ /**
156
+ * Reports whether a run is the peer list the standard exempts.
157
+ *
158
+ * Every non-blank line has to be a list item at one indent. Prose mixed into
159
+ * the run or a nested level inside it ends the exemption, because either one
160
+ * means the block is no longer a flat set a reader can skim. Bullet count says
161
+ * nothing on its own, since a catalog of one-liners and a wall of paragraphs
162
+ * reach the same count and read nothing alike, so the average bullet decides.
163
+ */
164
+ function isScannablePeerList(
165
+ run: readonly BodyLine[],
166
+ checkpoint: number,
167
+ ): boolean {
168
+ const indents = new Set<number>()
169
+ let items = 0
170
+ let characters = 0
171
+
172
+ for (const line of run) {
173
+ const text = line.text.trim()
174
+ if (text === '') continue
175
+
176
+ const match = line.text.match(LIST_ITEM)
177
+ if (!match) return false
178
+ indents.add(match[1].length)
179
+ items++
180
+ characters += visibleText(text).length
181
+ }
182
+
183
+ if (indents.size !== 1) return false
184
+
185
+ return characters / items < checkpoint
186
+ }
187
+
188
+ /**
189
+ * Reports whether a run is a table, the second shape the checkpoint cannot fix.
190
+ *
191
+ * The peer list above is exempt because it is already navigable. A table is
192
+ * exempt for the other reason: the remedy does not exist. A heading dropped
193
+ * inside one splits the table into two tables rather than breaking the run.
194
+ *
195
+ * Every non-blank line has to be a row, and a delimiter is required rather than
196
+ * assumed. A run holding a table between paragraphs is genuinely mixed and a
197
+ * heading breaks it at a seam either side, and a stack of lines opening with a
198
+ * pipe and no delimiter renders as paragraph text.
199
+ */
200
+ function isTableRun(run: readonly BodyLine[]): boolean {
201
+ let separators = 0
202
+
203
+ for (const line of run) {
204
+ if (line.text.trim() === '') continue
205
+ if (!TABLE_ROW.test(line.text)) return false
206
+ if (TABLE_SEPARATOR.test(line.text)) separators++
207
+ }
208
+
209
+ return separators > 0
210
+ }
211
+
212
+ /**
213
+ * Measures the longest run of lines no heading breaks, in rendered lines.
214
+ *
215
+ * Fenced lines are skipped rather than treated as breaks, per the standard:
216
+ * they leave the count without ending the run, so prose either side of an
217
+ * example still measures as the one stretch a reader scrolls through. Blank
218
+ * lines do count, since the checkpoint is about how far a reader travels
219
+ * between signposts and a blank line is distance like any other.
220
+ *
221
+ * Height is what a reader travels, and source lines only stand in for it while
222
+ * lines stay short. A file authored one line per bullet puts a paragraph on
223
+ * each, so a block of fifteen bullets measures as fifteen and renders past
224
+ * sixty. Wrapping every line at a stated width is what closes that gap.
225
+ */
226
+ export function longestRun(
227
+ lines: readonly BodyLine[],
228
+ checkpoints: Checkpoints,
229
+ ): { length: number; line: number } {
230
+ let longest = 0
231
+ let longestLine = 0
232
+ let run: BodyLine[] = []
233
+
234
+ const close = (): void => {
235
+ // The reported line is the run's first non-blank one, since that is what an
236
+ // editor should open. A run of nothing but blank lines is the gap between
237
+ // two headings rather than a stretch a reader travels, so it never counts.
238
+ const first = run.find((line) => line.text.trim() !== '')
239
+
240
+ if (
241
+ first &&
242
+ !isScannablePeerList(run, checkpoints.peerBullet) &&
243
+ !isTableRun(run)
244
+ ) {
245
+ const height = run.reduce(
246
+ (sum, line) => sum + renderedHeight(line.text, checkpoints.renderWidth),
247
+ 0,
248
+ )
249
+
250
+ if (height > longest) {
251
+ longest = height
252
+ longestLine = first.number
253
+ }
254
+ }
255
+ run = []
256
+ }
257
+
258
+ for (const line of lines) {
259
+ if (line.fenced) continue
260
+
261
+ if (HEADING.test(line.text)) {
262
+ close()
263
+ continue
264
+ }
265
+
266
+ run.push(line)
267
+ }
268
+
269
+ close()
270
+
271
+ return { length: longest, line: longestLine }
272
+ }
273
+
274
+ /**
275
+ * Finds the top-level bullets carrying more than a decision.
276
+ *
277
+ * A nested item is left out rather than folded into its parent, since the
278
+ * checkpoint asks what one bullet carries and a child carries its own. Lines
279
+ * continuing a bullet do fold in, so a heavy bullet cannot fall under the
280
+ * checkpoint by being wrapped across two source lines. A fenced line closes the
281
+ * open bullet, which keeps a bullet from absorbing the example below it.
282
+ */
283
+ export function heavyBullets(
284
+ lines: readonly BodyLine[],
285
+ checkpoints: Checkpoints,
286
+ ): BulletFinding[] {
287
+ const findings: BulletFinding[] = []
288
+ let open: BulletFinding | null = null
289
+
290
+ const close = (): void => {
291
+ if (open && open.characters > checkpoints.bullet) findings.push(open)
292
+ open = null
293
+ }
294
+
295
+ for (const line of lines) {
296
+ if (line.fenced) {
297
+ close()
298
+ continue
299
+ }
300
+
301
+ const item = line.text.match(LIST_ITEM)
302
+ const text = line.text.trim()
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.
307
+ if (item) {
308
+ close()
309
+ if (item[1].length === 0) {
310
+ open = { line: line.number, characters: visibleText(text).length }
311
+ }
312
+ continue
313
+ }
314
+
315
+ if (text === '' || HEADING.test(line.text) || TABLE_ROW.test(line.text)) {
316
+ close()
317
+ continue
318
+ }
319
+
320
+ // The joining space a wrapped line would have carried, so folding two
321
+ // source lines measures what one unwrapped line would have.
322
+ if (open)
323
+ open = {
324
+ ...open,
325
+ characters: open.characters + visibleText(text).length + 1,
326
+ }
327
+ }
328
+
329
+ close()
330
+
331
+ return findings
332
+ }
333
+
334
+ function countSentences(text: string): number {
335
+ return [...text.matchAll(SENTENCE_END)].length
336
+ }
337
+
338
+ /**
339
+ * Finds the prose paragraphs past either half of the standard's checkpoint.
340
+ *
341
+ * Both halves are stated in the standard and both are read from it. The weight
342
+ * half was added there rather than borrowed from the bullet checkpoint, which
343
+ * governs a different construct: one number feeding both would move the
344
+ * paragraph rule whenever the bullet rule was changed, and an author reading
345
+ * the sentence cap would find no weight rule to read at all.
346
+ *
347
+ * The two numbers coincide today because paragraph and bullet weight measure
348
+ * one population, sharing a median near 170 characters with no gap behind
349
+ * either candidate. They are separate checkpoints regardless, so either moves
350
+ * without dragging the other.
351
+ *
352
+ * A paragraph is a run of consecutive prose lines. A heading, a list item, a
353
+ * table row, a blockquote, a blank line, and a fence each end one, so a bullet
354
+ * is measured by `heavyBullets` alone and never twice.
355
+ */
356
+ export function heavyParagraphs(
357
+ lines: readonly BodyLine[],
358
+ checkpoints: Checkpoints,
359
+ ): ParagraphFinding[] {
360
+ const findings: ParagraphFinding[] = []
361
+ let block: BodyLine[] = []
362
+
363
+ const close = (): void => {
364
+ if (block.length > 0) {
365
+ const text = block.map((line) => line.text.trim()).join(' ')
366
+ const sentences = countSentences(text)
367
+ const characters = visibleText(text).length
368
+
369
+ if (
370
+ sentences > checkpoints.sentences ||
371
+ characters > checkpoints.paragraph
372
+ ) {
373
+ findings.push({ line: block[0].number, sentences, characters })
374
+ }
375
+ }
376
+ block = []
377
+ }
378
+
379
+ for (const line of lines) {
380
+ const text = line.text.trim()
381
+ const breaks =
382
+ line.fenced ||
383
+ text === '' ||
384
+ HEADING.test(line.text) ||
385
+ LIST_ITEM.test(line.text) ||
386
+ TABLE_ROW.test(line.text) ||
387
+ BLOCKQUOTE.test(line.text)
388
+
389
+ if (breaks) {
390
+ close()
391
+ continue
392
+ }
393
+
394
+ block.push(line)
395
+ }
396
+
397
+ close()
398
+
399
+ return findings
400
+ }
401
+
402
+ export function measureStructure(
403
+ rel: string,
404
+ lines: readonly BodyLine[],
405
+ checkpoints: Checkpoints,
406
+ ): StructureReport {
407
+ const run = longestRun(lines, checkpoints)
408
+
409
+ return {
410
+ rel,
411
+ longestRun: run.length,
412
+ longestRunLine: run.line,
413
+ heavyBullets: heavyBullets(lines, checkpoints),
414
+ heavyParagraphs: heavyParagraphs(lines, checkpoints),
415
+ }
416
+ }
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import { readdir, readFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
4
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
5
+ import { linesOutsideFences } from '@/markdown/scan'
5
6
 
6
7
  export const RECORD_KINDS = ['plans', 'groundwork', 'intake', 'memory'] as const
7
8
 
@@ -142,43 +143,6 @@ const PLAN_REQUIRED: readonly PlanSection[] = [
142
143
  'Questions',
143
144
  ]
144
145
 
145
- const FENCE = /^(`{3,}|~{3,})/
146
-
147
- /**
148
- * Drops every fenced block, so a quoted template is not read as content. A plan
149
- * showing the shape it writes puts real-looking bullets and headings inside a
150
- * fence, and scanning them reports the example rather than the plan.
151
- *
152
- * A closing fence has to match the opening character and be at least as long,
153
- * which is what keeps a ```` block holding a ``` example from closing early. An
154
- * unterminated fence swallows the rest of the document, which under-reports a
155
- * malformed file rather than reporting its remainder as content.
156
- */
157
- export function linesOutsideFences(text: string): string[] {
158
- const kept: string[] = []
159
- let fence: string | undefined
160
-
161
- for (const line of text.split('\n')) {
162
- const match = FENCE.exec(line.trim())
163
-
164
- if (fence) {
165
- const closes =
166
- match && match[1][0] === fence[0] && match[1].length >= fence.length
167
- if (closes) fence = undefined
168
- continue
169
- }
170
-
171
- if (match) {
172
- fence = match[1]
173
- continue
174
- }
175
-
176
- kept.push(line)
177
- }
178
-
179
- return kept
180
- }
181
-
182
146
  /**
183
147
  * A line standing alone as a bold label or an H2, whatever it names. A plan is
184
148
  * free to carry a section of its own, so the split has to see one to close the
@@ -9,7 +9,7 @@ Applies to markdown reference docs, READMEs, and inline documentation in repos.
9
9
 
10
10
  ## Scope
11
11
 
12
- Governs the markdown mechanics of every markdown file: headings, paragraph and list structure, code spans and fences, punctuation, emphasis, and file references. It is an attribute standard rather than a document-type one, so it applies over documents whose shape another standard sets, and it carries no template because mechanics are written across every document and have none of their own to shape.
12
+ Governs the markdown mechanics of every markdown file: headings, paragraph and list structure, code spans and fences, punctuation, emphasis, and file references. It is an attribute standard rather than a document-type one, so it applies over documents whose shape another standard sets, and it carries no template because mechanics are written across every document and have no shape of their own.
13
13
 
14
14
  Does not govern:
15
15
 
@@ -34,6 +34,7 @@ Does not govern:
34
34
 
35
35
  - Use prose by default. Reserve bullets for discrete, unrelated items.
36
36
  - Keep paragraphs to four sentences or fewer. Split longer blocks at the next logical boundary.
37
+ - Past roughly 400 characters in one paragraph, folding in the lines that wrap it, split at the next logical boundary as well. The sentence cap alone is satisfied by writing fewer and longer sentences, and measured across this corpus 344 paragraphs sit inside four sentences and past this number, so a count on its own passes every one of them.
37
38
  - Keep bullets tight. Past roughly 400 characters in one top-level bullet, counting the lines that continue it and excluding any bullet nested under it, the overflow belongs in prose. The number is a checkpoint rather than a cap, and a bullet reading well past it means the number is wrong rather than the rule.
38
39
  - Collapse a stack of bullets narrating one subsystem into a single `###` subsection carrying one narrative. Splitting a heavy bullet into three light ones satisfies the checkpoint above and leaves the reader no better off, and subdividing a block does not lighten the bullets inside it, so the two rules answer different defects.
39
40
  - Use dashes (`-`) not asterisks (`*`) for bulleted lists