@erclx/aitk 0.63.2 → 0.64.0

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,383 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { resolve } from 'node:path'
3
+ import type { Command } from 'commander'
4
+ import { type BanReport, banReport, loadStandards } from '@/markdown/bans'
5
+ import { resolveMarkdown } from '@/markdown/files'
6
+ import { type BanFinding, bodyLines, scanBans } from '@/markdown/scan'
7
+ import {
8
+ type Checkpoints,
9
+ measureStructure,
10
+ parseCheckpoints,
11
+ type StructureReport,
12
+ } from '@/markdown/structure'
13
+ import {
14
+ intro,
15
+ logInfo,
16
+ logStep,
17
+ logWarn,
18
+ outro,
19
+ pipeOutput,
20
+ plural,
21
+ } from '@/ui'
22
+
23
+ interface AuditCommandOptions {
24
+ readonly json?: boolean
25
+ }
26
+
27
+ interface FileReport {
28
+ readonly rel: string
29
+ readonly bans: readonly BanFinding[]
30
+ readonly structure: StructureReport
31
+ }
32
+
33
+ export function register(program: Command): void {
34
+ const markdown = program
35
+ .command('markdown')
36
+ .description('Report markdown files against the attribute standards')
37
+ .helpOption('-h, --help', 'Show this help message')
38
+
39
+ markdown
40
+ .command('audit')
41
+ .description(
42
+ 'Report the character bans, the word bans, and bullet, paragraph, and depth weight',
43
+ )
44
+ .argument(
45
+ '[path...]',
46
+ 'Markdown files, directories, or globs, defaulting to every markdown file git lists',
47
+ )
48
+ .helpOption('-h, --help', 'Show this help message')
49
+ .option('--json', 'Add a machine-readable record on stdout')
50
+ .addHelpText(
51
+ 'after',
52
+ [
53
+ '',
54
+ 'Exit codes:',
55
+ ' 0 the audit completed',
56
+ ' 1 refused, with the reason on stderr',
57
+ '',
58
+ 'Every finding reports and none gates. A banned character is a fact and',
59
+ 'will gate once the corpus has been measured and fixed, which is the',
60
+ 'follow-up rather than this command.',
61
+ '',
62
+ 'Bans and checkpoints are read from markdown.md and prose.md, resolved',
63
+ 'under .claude/standards/ then standards/. No folder has to resolve and',
64
+ 'no index.md has to exist, so .claude/rules/, governance/, and snippets/',
65
+ 'are in reach.',
66
+ '',
67
+ 'Examples:',
68
+ ' aitk markdown audit',
69
+ ' aitk markdown audit --json',
70
+ ' aitk markdown audit .claude/rules governance',
71
+ ' aitk markdown audit docs/agents/commands.md',
72
+ " aitk markdown audit 'snippets/**/*.md'",
73
+ '',
74
+ ].join('\n'),
75
+ )
76
+ .action(async (paths: string[], opts: AuditCommandOptions) => {
77
+ process.exitCode = await runAudit(paths, opts)
78
+ })
79
+ }
80
+
81
+ async function runAudit(
82
+ paths: string[],
83
+ opts: AuditCommandOptions,
84
+ ): Promise<number> {
85
+ const root = process.cwd()
86
+ const scope = await resolveMarkdown(root, paths)
87
+
88
+ if (scope.kind === 'unavailable') {
89
+ return refuse(
90
+ 'git could not list the tree, so no corpus was built. Run inside a git repository.',
91
+ )
92
+ }
93
+
94
+ if (scope.files.length === 0) {
95
+ return refuse(
96
+ paths.length === 0
97
+ ? 'No markdown file in the tree.'
98
+ : `No markdown file matched: ${scope.unmatched.join(', ')}`,
99
+ )
100
+ }
101
+
102
+ const standards = await loadStandards(root)
103
+ const bans = banReport(standards)
104
+ const checkpoints = parseCheckpoints(standards.markdown?.text ?? '')
105
+
106
+ const reports: FileReport[] = await Promise.all(
107
+ scope.files.map(async (rel) => {
108
+ const lines = bodyLines(await readFile(resolve(root, rel), 'utf8'))
109
+ return {
110
+ rel,
111
+ bans: scanBans(lines, bans),
112
+ structure: measureStructure(rel, lines, checkpoints),
113
+ }
114
+ }),
115
+ )
116
+
117
+ intro('aitk markdown audit')
118
+ reportScope(scope.files, scope.unmatched)
119
+ reportBans(reports, bans)
120
+ reportBullets(reports, checkpoints)
121
+ reportParagraphs(reports, checkpoints)
122
+ reportDepth(reports, checkpoints)
123
+ outro()
124
+
125
+ if (opts.json) {
126
+ process.stdout.write(
127
+ `${JSON.stringify({
128
+ root,
129
+ files: scope.files,
130
+ unmatchedPaths: scope.unmatched,
131
+ bans: {
132
+ characters: bans.characters,
133
+ words: bans.words,
134
+ spellings: bans.spellings,
135
+ sources: bans.sources,
136
+ missingStandards: bans.missing,
137
+ },
138
+ checkpoints: {
139
+ run: checkpoints.run,
140
+ peerBullet: checkpoints.peerBullet,
141
+ bullet: checkpoints.bullet,
142
+ paragraph: checkpoints.paragraph,
143
+ sentences: checkpoints.sentences,
144
+ renderWidth: checkpoints.renderWidth,
145
+ fellBack: checkpoints.fellBack,
146
+ },
147
+ entries: reports.map((report) => ({
148
+ path: report.rel,
149
+ bans: report.bans,
150
+ longestRun: report.structure.longestRun,
151
+ longestRunLine: report.structure.longestRunLine,
152
+ heavyBullets: report.structure.heavyBullets,
153
+ heavyParagraphs: report.structure.heavyParagraphs,
154
+ })),
155
+ })}\n`,
156
+ )
157
+ }
158
+
159
+ return 0
160
+ }
161
+
162
+ function refuse(message: string): number {
163
+ intro('aitk markdown audit')
164
+ logStep('Refused')
165
+ logWarn(message)
166
+ outro()
167
+ return 1
168
+ }
169
+
170
+ function reportScope(
171
+ files: readonly string[],
172
+ unmatched: readonly string[],
173
+ ): void {
174
+ logStep('Scope')
175
+ logInfo(`${plural(files.length, 'markdown file')}`)
176
+
177
+ if (unmatched.length === 0) return
178
+
179
+ logWarn(`Matched no markdown file: ${unmatched.join(', ')}`)
180
+ }
181
+
182
+ /**
183
+ * Names what the closed sets reach and what they leave to a reader.
184
+ *
185
+ * The two standards state bans in three shapes and only two of them are a
186
+ * closed set. A phrase ban carries a placeholder standing in for the rest of
187
+ * the sentence and every voice rule is a judgment, so a report listing hits
188
+ * without naming those would read as a verdict on the whole standard.
189
+ */
190
+ function reportBans(reports: readonly FileReport[], bans: BanReport): void {
191
+ logStep('Bans')
192
+
193
+ if (bans.missing.length > 0) {
194
+ logWarn(
195
+ `Not measured. Found neither copy of: ${bans.missing.join(', ')}. Looked under .claude/standards/ then standards/.`,
196
+ )
197
+ if (bans.sources.length === 0) return
198
+ }
199
+
200
+ logInfo(
201
+ `${plural(bans.characters.length, 'character')}, ${plural(bans.words.length, 'word')}, and ${plural(bans.spellings.length, 'spelling')} read from ${bans.sources.join(' and ')}`,
202
+ )
203
+ logInfo(
204
+ 'Frontmatter, fenced blocks, code spans, and link destinations are excluded.',
205
+ )
206
+ logInfo(
207
+ 'Phrase bans and every voice rule are patterns rather than closed sets, and stay a judgment for the reader.',
208
+ )
209
+
210
+ const carrying = reports
211
+ .filter((report) => report.bans.length > 0)
212
+ .sort((a, b) => b.bans.length - a.bans.length)
213
+
214
+ if (carrying.length === 0) {
215
+ logInfo('No banned character, word, or spelling.')
216
+ return
217
+ }
218
+
219
+ const total = carrying.reduce((sum, report) => sum + report.bans.length, 0)
220
+ logWarn(`${plural(total, 'hit')} across ${plural(carrying.length, 'file')}`)
221
+ pipeOutput(
222
+ carrying
223
+ .map(
224
+ (report) =>
225
+ `${report.rel} ${plural(report.bans.length, 'hit')}\n${report.bans
226
+ .map(
227
+ (found) =>
228
+ ` :${found.line}:${found.column + 1} ${found.kind} ${found.term}`,
229
+ )
230
+ .join('\n')}`,
231
+ )
232
+ .join('\n'),
233
+ )
234
+ }
235
+
236
+ function reportBullets(
237
+ reports: readonly FileReport[],
238
+ checkpoints: Checkpoints,
239
+ ): void {
240
+ logStep('Bullets')
241
+ logInfo(
242
+ 'Top-level bullets measure characters, folding in continuation lines.',
243
+ )
244
+ logInfo(
245
+ 'Nested items and fenced blocks are excluded. Weight is a judgment, never a defect.',
246
+ )
247
+
248
+ const carrying = reports
249
+ .filter((report) => report.structure.heavyBullets.length > 0)
250
+ .sort(
251
+ (a, b) =>
252
+ b.structure.heavyBullets.length - a.structure.heavyBullets.length,
253
+ )
254
+
255
+ if (carrying.length === 0) {
256
+ logInfo(`No bullet past the ${checkpoints.bullet}-character checkpoint.`)
257
+ return
258
+ }
259
+
260
+ const total = carrying.reduce(
261
+ (sum, report) => sum + report.structure.heavyBullets.length,
262
+ 0,
263
+ )
264
+ logWarn(
265
+ `${plural(total, 'bullet')} past the ${checkpoints.bullet}-character checkpoint across ${plural(carrying.length, 'file')}`,
266
+ )
267
+ pipeOutput(
268
+ carrying
269
+ .map(
270
+ (report) =>
271
+ `${report.rel} ${plural(report.structure.heavyBullets.length, 'bullet')}\n${report.structure.heavyBullets
272
+ .map((found) => ` :${found.line} ${found.characters} characters`)
273
+ .join('\n')}`,
274
+ )
275
+ .join('\n'),
276
+ )
277
+ }
278
+
279
+ /**
280
+ * States both halves of the checkpoint on every run.
281
+ *
282
+ * The standard states a sentence cap and a weight, and a report naming only the
283
+ * first would leave a reader unable to tell why a two-sentence paragraph is
284
+ * listed.
285
+ */
286
+ function reportParagraphs(
287
+ reports: readonly FileReport[],
288
+ checkpoints: Checkpoints,
289
+ ): void {
290
+ logStep('Paragraphs')
291
+ logInfo(
292
+ `Prose paragraphs report past ${checkpoints.sentences} sentences or past ${checkpoints.paragraph} characters.`,
293
+ )
294
+ logInfo(
295
+ 'The standard states both. Weight matches the bullet checkpoint today and moves independently of it.',
296
+ )
297
+ logInfo(
298
+ 'Bullets, headings, tables, quotes, and fenced blocks each end a paragraph, so a bullet is measured once.',
299
+ )
300
+
301
+ const carrying = reports
302
+ .filter((report) => report.structure.heavyParagraphs.length > 0)
303
+ .sort(
304
+ (a, b) =>
305
+ b.structure.heavyParagraphs.length - a.structure.heavyParagraphs.length,
306
+ )
307
+
308
+ if (carrying.length === 0) {
309
+ logInfo('No paragraph past either checkpoint.')
310
+ return
311
+ }
312
+
313
+ const total = carrying.reduce(
314
+ (sum, report) => sum + report.structure.heavyParagraphs.length,
315
+ 0,
316
+ )
317
+ logWarn(
318
+ `${plural(total, 'paragraph')} past a checkpoint across ${plural(carrying.length, 'file')}`,
319
+ )
320
+ pipeOutput(
321
+ carrying
322
+ .map(
323
+ (report) =>
324
+ `${report.rel} ${plural(report.structure.heavyParagraphs.length, 'paragraph')}\n${report.structure.heavyParagraphs
325
+ .map(
326
+ (found) =>
327
+ ` :${found.line} ${found.sentences} sentences, ${found.characters} characters`,
328
+ )
329
+ .join('\n')}`,
330
+ )
331
+ .join('\n'),
332
+ )
333
+ }
334
+
335
+ /**
336
+ * Names the render width and the blank-line convention on every run.
337
+ *
338
+ * The standard settles heading level and fenced blocks and stops there, so a
339
+ * hand reader who drops blank lines lands a line or two below this number.
340
+ * Stating both keeps the two measurements reconcilable, and the width matters
341
+ * more, since a number counted in rendered lines cannot be reproduced without
342
+ * it.
343
+ */
344
+ function reportDepth(
345
+ reports: readonly FileReport[],
346
+ checkpoints: Checkpoints,
347
+ ): void {
348
+ logStep('Depth')
349
+ logInfo(
350
+ `Runs measure rendered lines at ${checkpoints.renderWidth} columns and count blank lines.`,
351
+ )
352
+ logInfo(
353
+ `Fenced blocks are excluded, and so are peer lists averaging under ${checkpoints.peerBullet} characters a bullet.`,
354
+ )
355
+ logInfo(
356
+ 'A run that is entirely table rows is excluded too, since a heading inside a table splits the table rather than the run.',
357
+ )
358
+
359
+ if (checkpoints.fellBack.length > 0) {
360
+ logWarn(
361
+ `Read no number from the standard for: ${checkpoints.fellBack.join(', ')}. Measured against the shipped default instead.`,
362
+ )
363
+ }
364
+
365
+ const over = reports
366
+ .filter((report) => report.structure.longestRun > checkpoints.run)
367
+ .sort((a, b) => b.structure.longestRun - a.structure.longestRun)
368
+
369
+ if (over.length === 0) {
370
+ logInfo(`No run past the ${checkpoints.run}-line checkpoint.`)
371
+ return
372
+ }
373
+
374
+ logWarn(`${over.length} past the ${checkpoints.run}-line checkpoint`)
375
+ pipeOutput(
376
+ over
377
+ .map(
378
+ (report) =>
379
+ `${report.structure.rel}:${report.structure.longestRunLine} ${report.structure.longestRun} rendered lines unbroken`,
380
+ )
381
+ .join('\n'),
382
+ )
383
+ }