@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.
@@ -1,50 +1,19 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { relative } from 'node:path'
3
3
  import type { AuditedFolder } from '@/context/folders'
4
+ import { type BodyLine, bodyLines } from '@/markdown/scan'
5
+ import { renderedHeight } from '@/markdown/structure'
4
6
  import { isStubSeed } from '@/seed-marker'
5
7
 
6
8
  /**
7
- * Checkpoints quoted from the standard stating each. Neither is a cap.
9
+ * Checkpoint quoted from the standard stating it. It is not a cap.
8
10
  *
9
11
  * Entry length rests on one entry per domain, which is a domain fact, so
10
- * `standards/context.md` keeps it. Run depth reads the same over any markdown
11
- * file, so `standards/markdown.md` states it at the attribute tier.
12
+ * `standards/context.md` keeps it. Depth and bullet weight read the same over
13
+ * any markdown file, so they are stated at the attribute tier and measured by
14
+ * `aitk markdown audit` rather than here.
12
15
  */
13
16
  export const LENGTH_CHECKPOINT = 150
14
- export const RUN_CHECKPOINT = 40
15
-
16
- /**
17
- * Columns a source line wraps at when rendered.
18
- *
19
- * Nothing in this repository sets a line width and entries are authored one
20
- * line per bullet, so the rendered width is the viewer's rather than the file's.
21
- * The common terminal and diff width is the reproducible choice, and the report
22
- * legend states it so a reader can arrive at the same number by hand.
23
- */
24
- export const RENDER_WIDTH = 80
25
-
26
- /**
27
- * Characters a bullet averages before its list stops reading as a set of peers.
28
- *
29
- * The exemption below covers a flat catalog of one-liners and a stack of
30
- * paragraphs equally, and only the first is navigable. Measured across this
31
- * corpus the two shapes separate with nothing between roughly 100 and 170
32
- * characters a bullet, so the midpoint splits the population rather than a
33
- * continuum. It is a checkpoint like the two above, not a cap.
34
- */
35
- export const PEER_BULLET_CHECKPOINT = 130
36
-
37
- /**
38
- * Characters a top-level bullet carries before its overflow belongs in prose.
39
- *
40
- * Unlike the peer-list checkpoint above, this corpus has no gap behind the
41
- * number. Bullet weight decays smoothly from a median near 170 with the
42
- * steepest relative fall across this boundary and nothing resembling two
43
- * populations, so the number is a judgment where that one was a measurement.
44
- * A bullet that reads well past it means the number is wrong rather than the
45
- * rule, which is why this reports and never gates.
46
- */
47
- export const BULLET_CHECKPOINT = 400
48
17
 
49
18
  /**
50
19
  * A table this size or larger whose first column mostly names artifacts reads
@@ -72,11 +41,7 @@ const CATALOG_NAMED_RATIO = 0.6
72
41
  */
73
42
  export const REQUIRED_SECTIONS: readonly string[] = ['Overview', 'Layout']
74
43
 
75
- const FRONTMATTER = /^---\n[\s\S]*?\n---\n?/
76
- const FENCE = /^\s*(```|~~~)/
77
- const HEADING = /^#{1,6}\s/
78
44
  const HEADING_TEXT = /^#{1,6}\s+(.+?)\s*$/
79
- const LIST_ITEM = /^(\s*)([-*+]|\d+\.)\s+/
80
45
  const TABLE_ROW = /^\s*\|/
81
46
  const TABLE_SEPARATOR = /^\s*\|[\s:|-]+\|\s*$/
82
47
  const NAMED_CELL = /`[^`]+`|\[[^\]]+\]\([^)]+\)/
@@ -126,12 +91,6 @@ export interface TableFinding {
126
91
  readonly rows: number
127
92
  }
128
93
 
129
- export interface BulletFinding {
130
- readonly line: number
131
- /** Weight as folded, so a report says how far past the checkpoint it sits. */
132
- readonly characters: number
133
- }
134
-
135
94
  export interface ProvenanceFinding {
136
95
  readonly line: number
137
96
  readonly kind: ProvenanceKind
@@ -143,26 +102,20 @@ export interface EntryReport {
143
102
  readonly rel: string
144
103
  /**
145
104
  * Rendered lines across the whole file, counting frontmatter and fenced
146
- * blocks. Both this and `longestRun` measure in the same unit, since the two
147
- * checkpoints they feed sit in one section of the standard and a reader
148
- * compares them.
105
+ * blocks. It shares `renderedHeight` with the depth checkpoint in
106
+ * `src/markdown/structure.ts`, since the two sit in one section of the
107
+ * standard and a reader compares them.
149
108
  *
150
- * The exclusions differ on purpose. `longestRun` skips a fence so an example
151
- * cannot break the run around it, and a file measure has no run to protect.
152
- * Excluding fences here would change which entries report by one and would
153
- * not reach the case that motivates it: the most fenced entry in the corpus
154
- * runs 20 percent fenced and sits past the checkpoint either way.
109
+ * Fences are counted here and excluded there. The depth measure skips one so
110
+ * an example cannot break the run around it, and a file measure has no run to
111
+ * protect. Excluding them here would change which entries report by one and
112
+ * would not reach the case that motivates it: the most fenced entry in the
113
+ * corpus runs 20 percent fenced and sits past the checkpoint either way.
155
114
  */
156
115
  readonly lines: number
157
- /** Rendered lines at `RENDER_WIDTH`, not source lines. */
158
- readonly longestRun: number
159
- /** First line of the longest run, or 0 when the entry has no run at all. */
160
- readonly longestRunLine: number
161
116
  readonly catalogTables: readonly TableFinding[]
162
117
  /** Empty for an entry no standard bans a change narrative in. */
163
118
  readonly provenance: readonly ProvenanceFinding[]
164
- /** Measured in every audited folder, since an attribute standard states it. */
165
- readonly heavyBullets: readonly BulletFinding[]
166
119
  /**
167
120
  * Required sections this entry declares, in the standard's order, and empty
168
121
  * outside the folder whose standard names them. What the folder is short of
@@ -188,163 +141,6 @@ export interface SectionFinding {
188
141
  readonly missing: readonly string[]
189
142
  }
190
143
 
191
- interface BodyLine {
192
- readonly number: number
193
- readonly text: string
194
- }
195
-
196
- /**
197
- * Drops the frontmatter while keeping every surviving line's original number,
198
- * so a finding points at the line an editor opens rather than at an offset into
199
- * the body.
200
- */
201
- function bodyLines(source: string): BodyLine[] {
202
- const match = source.match(FRONTMATTER)
203
- const offset = match ? match[0].split('\n').length - 1 : 0
204
-
205
- return source
206
- .slice(match ? match[0].length : 0)
207
- .replace(/\n$/, '')
208
- .split('\n')
209
- .map((text, index) => ({ number: offset + index + 1, text }))
210
- }
211
-
212
- /**
213
- * Reports whether a run is the peer list the standard exempts.
214
- *
215
- * Every non-blank line has to be a list item at one indent. Prose mixed into
216
- * the run or a nested level inside it ends the exemption, because either one
217
- * means the block is no longer a flat set a reader can skim. Bullet count says
218
- * nothing on its own, since a catalog of one-liners and a wall of paragraphs
219
- * reach the same count and read nothing alike, so the average bullet is what
220
- * decides whether the set is still skimmable.
221
- */
222
- function isScannablePeerList(run: readonly BodyLine[]): boolean {
223
- const indents = new Set<number>()
224
- let items = 0
225
- let characters = 0
226
-
227
- for (const line of run) {
228
- const text = line.text.trim()
229
- if (text === '') continue
230
-
231
- const match = line.text.match(LIST_ITEM)
232
- if (!match) return false
233
- indents.add(match[1].length)
234
- items++
235
- characters += text.length
236
- }
237
-
238
- if (indents.size !== 1) return false
239
-
240
- return characters / items < PEER_BULLET_CHECKPOINT
241
- }
242
-
243
- /**
244
- * Reports whether a run is a table, the second shape the checkpoint cannot fix.
245
- *
246
- * The peer list above is exempt because it is already navigable. A table is
247
- * exempt for the other reason: the remedy does not exist. A heading dropped
248
- * inside one splits the table into two tables rather than breaking the run, so
249
- * a catalog renders as an unbroken stretch by construction and no edit short of
250
- * rewriting it as a list clears the report.
251
- *
252
- * Every non-blank line has to be a row. A run holding a table between
253
- * paragraphs is genuinely mixed, and a heading breaks it at a seam either side,
254
- * so testing whether the run holds a table would hide the case the checkpoint
255
- * exists for.
256
- *
257
- * A delimiter is required rather than assumed, matching the table scan below. A
258
- * stack of lines opening with a pipe and no delimiter renders as paragraph text
259
- * and would otherwise earn the exemption on its punctuation.
260
- */
261
- function isTableRun(run: readonly BodyLine[]): boolean {
262
- let separators = 0
263
-
264
- for (const line of run) {
265
- if (line.text.trim() === '') continue
266
- if (!TABLE_ROW.test(line.text)) return false
267
- if (TABLE_SEPARATOR.test(line.text)) separators++
268
- }
269
-
270
- return separators > 0
271
- }
272
-
273
- /**
274
- * Height a source line occupies once wrapped.
275
- *
276
- * A blank line renders as the gap it is rather than as nothing, which keeps it
277
- * the distance the source measure already counted it as.
278
- */
279
- function renderedHeight(text: string): number {
280
- return Math.max(1, Math.ceil(text.length / RENDER_WIDTH))
281
- }
282
-
283
- /**
284
- * Measures the longest run of lines no heading breaks, in rendered lines.
285
- *
286
- * Fenced blocks are skipped rather than treated as breaks, per the standard:
287
- * they leave the count without ending the run, so prose either side of an
288
- * example still measures as the one stretch a reader scrolls through. Blank
289
- * lines do count, since the checkpoint is about how far a reader travels
290
- * between signposts and a blank line is distance like any other. A hand reader
291
- * measuring without them lands one or two lines lower, which the report legend
292
- * states.
293
- *
294
- * Height is what a reader travels, and source lines only stand in for it while
295
- * lines stay short. An entry authored one line per bullet puts a paragraph on
296
- * each, so a block of fifteen bullets measures as fifteen and renders past
297
- * sixty. Wrapping every line at a stated width is what closes that gap.
298
- */
299
- function longestRun(lines: readonly BodyLine[]): {
300
- length: number
301
- line: number
302
- } {
303
- let longest = 0
304
- let longestLine = 0
305
- let run: BodyLine[] = []
306
- let fenced = false
307
-
308
- const close = (): void => {
309
- // The reported line is the run's first non-blank one, since that is what an
310
- // editor should open. A run of nothing but blank lines is the gap between
311
- // two headings rather than a stretch a reader travels, so it never counts.
312
- const first = run.find((line) => line.text.trim() !== '')
313
-
314
- if (first && !isScannablePeerList(run) && !isTableRun(run)) {
315
- const height = run.reduce(
316
- (sum, line) => sum + renderedHeight(line.text),
317
- 0,
318
- )
319
-
320
- if (height > longest) {
321
- longest = height
322
- longestLine = first.number
323
- }
324
- }
325
- run = []
326
- }
327
-
328
- for (const line of lines) {
329
- if (FENCE.test(line.text)) {
330
- fenced = !fenced
331
- continue
332
- }
333
- if (fenced) continue
334
-
335
- if (HEADING.test(line.text)) {
336
- close()
337
- continue
338
- }
339
-
340
- run.push(line)
341
- }
342
-
343
- close()
344
-
345
- return { length: longest, line: longestLine }
346
- }
347
-
348
144
  function firstCell(row: string): string {
349
145
  return row.split('|').slice(1)[0] ?? ''
350
146
  }
@@ -357,20 +153,15 @@ function firstCell(row: string): string {
357
153
  * catalog that gains a row per artifact, and a first column carrying a path,
358
154
  * command, or link is what separates the two without reading the prose.
359
155
  */
360
- function catalogTables(lines: readonly BodyLine[]): TableFinding[] {
156
+ function catalogTables(entry: readonly BodyLine[]): TableFinding[] {
361
157
  const findings: TableFinding[] = []
362
- let fenced = false
158
+ const lines = entry.filter((line) => !line.fenced)
363
159
  let index = 0
364
160
 
365
161
  while (index < lines.length) {
366
162
  const line = lines[index]
367
163
 
368
- if (FENCE.test(line.text)) {
369
- fenced = !fenced
370
- index++
371
- continue
372
- }
373
- if (fenced || !TABLE_ROW.test(line.text)) {
164
+ if (!TABLE_ROW.test(line.text)) {
374
165
  index++
375
166
  continue
376
167
  }
@@ -402,60 +193,6 @@ function catalogTables(lines: readonly BodyLine[]): TableFinding[] {
402
193
  return findings
403
194
  }
404
195
 
405
- /**
406
- * Finds the top-level bullets carrying more than a decision.
407
- *
408
- * A nested item is left out rather than folded into its parent, since the
409
- * checkpoint asks what one bullet carries and a child carries its own. Lines
410
- * continuing a bullet do fold in, so a heavy bullet cannot fall under the
411
- * checkpoint by being wrapped across two source lines. Fenced blocks are
412
- * skipped for the reason the scans above skip them: a sample an entry displays
413
- * is not a claim it makes.
414
- */
415
- function heavyBullets(lines: readonly BodyLine[]): BulletFinding[] {
416
- const findings: BulletFinding[] = []
417
- let open: BulletFinding | null = null
418
- let fenced = false
419
-
420
- const close = (): void => {
421
- if (open && open.characters > BULLET_CHECKPOINT) findings.push(open)
422
- open = null
423
- }
424
-
425
- for (const line of lines) {
426
- if (FENCE.test(line.text)) {
427
- fenced = !fenced
428
- close()
429
- continue
430
- }
431
- if (fenced) continue
432
-
433
- const item = line.text.match(LIST_ITEM)
434
- const text = line.text.trim()
435
-
436
- if (item) {
437
- close()
438
- if (item[1].length === 0) {
439
- open = { line: line.number, characters: text.length }
440
- }
441
- continue
442
- }
443
-
444
- if (text === '' || HEADING.test(line.text) || TABLE_ROW.test(line.text)) {
445
- close()
446
- continue
447
- }
448
-
449
- // The joining space a wrapped line would have carried, so folding two
450
- // source lines measures what one unwrapped line would have.
451
- if (open) open = { ...open, characters: open.characters + text.length + 1 }
452
- }
453
-
454
- close()
455
-
456
- return findings
457
- }
458
-
459
196
  /**
460
197
  * Finds the markers narrating a change rather than describing the domain.
461
198
  *
@@ -470,14 +207,9 @@ function provenance(lines: readonly BodyLine[]): ProvenanceFinding[] {
470
207
  // holding a date and two change numbers reports them in an order the reader
471
208
  // cannot find by scanning left to right.
472
209
  const found: { finding: ProvenanceFinding; column: number }[] = []
473
- let fenced = false
474
210
 
475
211
  for (const line of lines) {
476
- if (FENCE.test(line.text)) {
477
- fenced = !fenced
478
- continue
479
- }
480
- if (fenced) continue
212
+ if (line.fenced) continue
481
213
 
482
214
  for (const { kind, pattern } of PROVENANCE) {
483
215
  for (const match of line.text.matchAll(pattern)) {
@@ -510,14 +242,9 @@ function provenance(lines: readonly BodyLine[]): ProvenanceFinding[] {
510
242
  */
511
243
  function declaredSections(lines: readonly BodyLine[]): string[] {
512
244
  const found = new Set<string>()
513
- let fenced = false
514
245
 
515
246
  for (const line of lines) {
516
- if (FENCE.test(line.text)) {
517
- fenced = !fenced
518
- continue
519
- }
520
- if (fenced) continue
247
+ if (line.fenced) continue
521
248
 
522
249
  const match = line.text.match(HEADING_TEXT)
523
250
  if (match && REQUIRED_SECTIONS.includes(match[1])) found.add(match[1])
@@ -539,7 +266,6 @@ export function measureEntry(
539
266
  governsContent = true,
540
267
  ): EntryReport {
541
268
  const lines = bodyLines(source)
542
- const run = longestRun(lines)
543
269
 
544
270
  return {
545
271
  rel,
@@ -547,11 +273,8 @@ export function measureEntry(
547
273
  .replace(/\n$/, '')
548
274
  .split('\n')
549
275
  .reduce((sum, text) => sum + renderedHeight(text), 0),
550
- longestRun: run.length,
551
- longestRunLine: run.line,
552
276
  catalogTables: catalogTables(lines),
553
277
  provenance: governsContent ? provenance(lines) : [],
554
- heavyBullets: heavyBullets(lines),
555
278
  sections: governsContent ? declaredSections(lines) : [],
556
279
  stub: isStubSeed(source),
557
280
  }
@@ -1,8 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { readFile } from 'node:fs/promises'
3
3
  import { resolve } from 'node:path'
4
- import { $ } from 'bun'
5
- import { gitEnv } from '@/git-env'
4
+ import { listRepositoryFiles } from '@/git-files'
6
5
 
7
6
  /**
8
7
  * Suppresses citation checking for the source line carrying it.
@@ -81,33 +80,6 @@ export function collectCitations(
81
80
  return found
82
81
  }
83
82
 
84
- /**
85
- * Lists the files a citation can live in: tracked, plus untracked files git
86
- * does not ignore. The untracked half is what keeps a new entry's references
87
- * checked on the branch that adds it rather than one push later.
88
- *
89
- * Returns undefined when git cannot answer, which the caller reports rather
90
- * than smoothing into an empty list. An empty list resolves every one of its
91
- * zero citations, so a degraded git would otherwise turn the push gate into an
92
- * unconditional pass with output indistinguishable from a clean tree.
93
- */
94
- async function listFiles(root: string): Promise<string[] | undefined> {
95
- const run = async (args: string[]): Promise<string[] | undefined> => {
96
- const result = await $`git -C ${root} ${args}`
97
- .env(gitEnv())
98
- .quiet()
99
- .nothrow()
100
- if (result.exitCode !== 0) return undefined
101
- return result.text().split('\n').filter(Boolean)
102
- }
103
-
104
- const tracked = await run(['ls-files'])
105
- const untracked = await run(['ls-files', '--others', '--exclude-standard'])
106
- if (!tracked || !untracked) return undefined
107
-
108
- return [...new Set([...tracked, ...untracked])].sort()
109
- }
110
-
111
83
  /**
112
84
  * `unavailable` is a distinct state from a clean scan.
113
85
  *
@@ -136,7 +108,7 @@ export async function auditCitations(
136
108
  folders: readonly string[],
137
109
  ): Promise<CitationReport> {
138
110
  const pattern = citationPattern(folders)
139
- const listed = await listFiles(root)
111
+ const listed = await listRepositoryFiles(root)
140
112
  if (!listed) return { kind: 'unavailable' }
141
113
 
142
114
  const candidates = listed.filter((rel) => !isFixture(rel))
@@ -0,0 +1,31 @@
1
+ import { $ } from 'bun'
2
+ import { gitEnv } from '@/git-env'
3
+
4
+ /**
5
+ * Lists the files under `root`: tracked, plus untracked files git does not
6
+ * ignore. The untracked half is what keeps a file added on this branch in scope
7
+ * rather than one push later.
8
+ *
9
+ * Returns undefined when git cannot answer, which every caller reports rather
10
+ * than smoothing into an empty list. An empty list passes each of its zero
11
+ * files, so a degraded git would otherwise be indistinguishable from a clean
12
+ * tree in the output.
13
+ */
14
+ export async function listRepositoryFiles(
15
+ root: string,
16
+ ): Promise<string[] | undefined> {
17
+ const run = async (args: string[]): Promise<string[] | undefined> => {
18
+ const result = await $`git -C ${root} ${args}`
19
+ .env(gitEnv())
20
+ .quiet()
21
+ .nothrow()
22
+ if (result.exitCode !== 0) return undefined
23
+ return result.text().split('\n').filter(Boolean)
24
+ }
25
+
26
+ const tracked = await run(['ls-files'])
27
+ const untracked = await run(['ls-files', '--others', '--exclude-standard'])
28
+ if (!tracked || !untracked) return undefined
29
+
30
+ return [...new Set([...tracked, ...untracked])].sort()
31
+ }