@erclx/aitk 0.9.0 → 0.10.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,234 @@
1
+ import { resolve } from 'node:path'
2
+ import type { Command } from 'commander'
3
+ import {
4
+ density,
5
+ type Language,
6
+ LANGUAGES,
7
+ type LanguageCount,
8
+ scanTree,
9
+ } from '@/comments/scan'
10
+ import { type TrendPoint, trend } from '@/comments/trend'
11
+ import { loadVocabulary, type Vocabulary } from '@/comments/vocabulary'
12
+ import { intro, logInfo, logStep, logWarn, outro, pipeOutput } from '@/ui'
13
+
14
+ interface ScanCommandOptions {
15
+ readonly json?: boolean
16
+ readonly since?: string
17
+ readonly languages?: string
18
+ }
19
+
20
+ const LABELS: Record<Language, string> = {
21
+ ts: 'TypeScript',
22
+ sh: 'Bash',
23
+ }
24
+
25
+ export function register(program: Command): void {
26
+ const comments = program
27
+ .command('comments')
28
+ .description('Measure comment density and trend across a source tree')
29
+ .helpOption('-h, --help', 'Show this help message')
30
+
31
+ comments
32
+ .command('scan')
33
+ .description('Report comment density by language and by comment kind')
34
+ .argument('[path]', 'Tree to scan, defaulting to the current directory')
35
+ .helpOption('-h, --help', 'Show this help message')
36
+ .option('--json', 'Add a machine-readable record on stdout')
37
+ .option('--since <rev>', 'Report the trend from this revision instead')
38
+ .option('--languages <list>', 'Comma-separated subset of ts,sh')
39
+ .addHelpText(
40
+ 'after',
41
+ [
42
+ '',
43
+ 'Exit codes:',
44
+ ' 0 the scan completed',
45
+ ' 1 refused, with the reason on stderr',
46
+ '',
47
+ 'Examples:',
48
+ ' aitk comments scan',
49
+ ' aitk comments scan src --json',
50
+ ' aitk comments scan --since v0.5.0',
51
+ '',
52
+ ].join('\n'),
53
+ )
54
+ .action(async (path: string | undefined, opts: ScanCommandOptions) => {
55
+ process.exitCode = await runScan(path, opts)
56
+ })
57
+ }
58
+
59
+ function parseLanguages(list: string | undefined): Language[] | string {
60
+ if (!list) return [...LANGUAGES]
61
+
62
+ const requested = list
63
+ .split(',')
64
+ .map((entry) => entry.trim())
65
+ .filter(Boolean)
66
+
67
+ const unknown = requested.filter(
68
+ (entry) => !LANGUAGES.includes(entry as Language),
69
+ )
70
+ if (unknown.length > 0) {
71
+ return `Unknown language: ${unknown.join(', ')}. Known: ${LANGUAGES.join(', ')}.`
72
+ }
73
+
74
+ return requested as Language[]
75
+ }
76
+
77
+ async function runScan(
78
+ path: string | undefined,
79
+ opts: ScanCommandOptions,
80
+ ): Promise<number> {
81
+ const root = resolve(path ?? process.cwd())
82
+ const emitJson = opts.json ?? false
83
+ const languages = parseLanguages(opts.languages)
84
+
85
+ intro('aitk comments scan')
86
+
87
+ if (typeof languages === 'string') {
88
+ logStep('Refused')
89
+ logWarn(languages)
90
+ outro()
91
+ return 1
92
+ }
93
+
94
+ const vocabulary = await loadVocabulary(root)
95
+ const scanOptions = {
96
+ languages,
97
+ vocabulary: vocabulary.kind === 'loaded' ? vocabulary.terms : [],
98
+ }
99
+
100
+ const snapshot = await scanTree(root, scanOptions)
101
+ reportSnapshot(snapshot)
102
+
103
+ const points = opts.since
104
+ ? await trend(root, { ...scanOptions, since: opts.since })
105
+ : []
106
+
107
+ if (opts.since) reportTrend(points, languages)
108
+
109
+ reportVocabulary(vocabulary, snapshot)
110
+ outro()
111
+
112
+ if (emitJson) {
113
+ process.stdout.write(
114
+ `${JSON.stringify({
115
+ path: root,
116
+ vocabulary:
117
+ vocabulary.kind === 'loaded'
118
+ ? { source: vocabulary.source, terms: vocabulary.terms }
119
+ : null,
120
+ snapshot: snapshot.map(record),
121
+ trend: points.map((point) => ({
122
+ rev: point.rev,
123
+ date: point.date,
124
+ languages: point.languages.map(record),
125
+ })),
126
+ })}\n`,
127
+ )
128
+ }
129
+
130
+ return 0
131
+ }
132
+
133
+ function record(count: LanguageCount): Record<string, unknown> {
134
+ return {
135
+ language: count.language,
136
+ files: count.files,
137
+ lines: count.lines,
138
+ commentLines: count.commentLines,
139
+ density: Number(density(count).toFixed(4)),
140
+ docBlocks: count.docBlocks,
141
+ inlineComments: count.inlineComments,
142
+ degradationHits: count.degradationHits,
143
+ }
144
+ }
145
+
146
+ function percent(count: LanguageCount): string {
147
+ return `${(density(count) * 100).toFixed(1)}%`
148
+ }
149
+
150
+ function reportSnapshot(snapshot: readonly LanguageCount[]): void {
151
+ logStep('Snapshot')
152
+
153
+ for (const count of snapshot) {
154
+ if (count.files === 0) {
155
+ logInfo(`${LABELS[count.language]}: no files`)
156
+ continue
157
+ }
158
+
159
+ logInfo(
160
+ `${LABELS[count.language]}: ${count.commentLines} comment lines in ${count.lines} (${percent(count)}), ${count.files} files`,
161
+ )
162
+ logInfo(
163
+ ` ${count.docBlocks} doc blocks, ${count.inlineComments} inline comments`,
164
+ )
165
+ }
166
+ }
167
+
168
+ function reportTrend(
169
+ points: readonly TrendPoint[],
170
+ languages: readonly Language[],
171
+ ): void {
172
+ logStep('Trend')
173
+
174
+ if (points.length === 0) {
175
+ logWarn('No commits in range. Check the revision passed to --since.')
176
+ return
177
+ }
178
+
179
+ for (const language of languages) {
180
+ const rows = points
181
+ .map((point) => {
182
+ const count = point.languages.find(
183
+ (entry) => entry.language === language,
184
+ )
185
+ if (!count) return undefined
186
+ return `${point.rev.slice(0, 8)} ${point.date} ${String(count.lines).padStart(6)} lines ${String(count.commentLines).padStart(5)} comments ${percent(count).padStart(6)}`
187
+ })
188
+ .filter((row): row is string => row !== undefined)
189
+
190
+ // A language the tree does not carry would otherwise print a column of
191
+ // zeros, which reads as a measured decline rather than an absence.
192
+ const measured = points.some((point) =>
193
+ point.languages.some(
194
+ (entry) => entry.language === language && entry.files > 0,
195
+ ),
196
+ )
197
+ if (rows.length === 0 || !measured) continue
198
+
199
+ logInfo(LABELS[language])
200
+ pipeOutput(rows.join('\n'))
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Reports the sweep as skipped when no rule publishes a vocabulary.
206
+ *
207
+ * Zero hits against an empty vocabulary is indistinguishable from a clean
208
+ * codebase in the output, and the two mean opposite things, so the absent case
209
+ * says so rather than printing a count nobody looked for.
210
+ */
211
+ function reportVocabulary(
212
+ vocabulary: Vocabulary,
213
+ snapshot: readonly LanguageCount[],
214
+ ): void {
215
+ logStep('Degradation sweep')
216
+
217
+ if (vocabulary.kind === 'absent') {
218
+ logWarn('Skipped. No rule publishes a "Degradation vocabulary" heading.')
219
+ return
220
+ }
221
+
222
+ const hits = snapshot.flatMap((count) => count.degradationHits)
223
+ logInfo(`${vocabulary.terms.length} terms from ${vocabulary.source}`)
224
+
225
+ if (hits.length === 0) {
226
+ logInfo('No hits.')
227
+ return
228
+ }
229
+
230
+ logWarn(`${hits.length} hits`)
231
+ pipeOutput(
232
+ hits.map((hit) => `${hit.file}:${hit.line} ${hit.term}`).join('\n'),
233
+ )
234
+ }
@@ -0,0 +1,338 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { resolve } from 'node:path'
3
+ import { listIgnored } from '@/git-ignore'
4
+
5
+ export type Language = 'ts' | 'sh'
6
+
7
+ export const LANGUAGES: readonly Language[] = ['ts', 'sh']
8
+
9
+ const EXTENSIONS: Record<Language, readonly string[]> = {
10
+ ts: ['.ts', '.tsx'],
11
+ sh: ['.sh'],
12
+ }
13
+
14
+ /**
15
+ * Pruned by path segment rather than by glob, since `Bun.Glob` has no exclude.
16
+ * `fixtures` and `__fixtures__` are here because fixture trees hold content
17
+ * authored to be parsed rather than run, and counting it reports the harness
18
+ * instead of the source.
19
+ */
20
+ const PRUNED_SEGMENTS = [
21
+ '.git',
22
+ 'node_modules',
23
+ 'dist',
24
+ 'build',
25
+ 'coverage',
26
+ 'fixtures',
27
+ '__fixtures__',
28
+ ]
29
+
30
+ export interface DegradationHit {
31
+ readonly file: string
32
+ readonly line: number
33
+ readonly term: string
34
+ }
35
+
36
+ export interface LanguageCount {
37
+ readonly language: Language
38
+ readonly files: number
39
+ readonly lines: number
40
+ readonly commentLines: number
41
+ readonly docBlocks: number
42
+ readonly inlineComments: number
43
+ readonly degradationHits: readonly DegradationHit[]
44
+ }
45
+
46
+ export interface ScanOptions {
47
+ readonly languages?: readonly Language[]
48
+ readonly vocabulary?: readonly string[]
49
+ }
50
+
51
+ /** One file's text paired with the repo-relative path a hit is reported under. */
52
+ export interface SourceFile {
53
+ readonly path: string
54
+ readonly text: string
55
+ }
56
+
57
+ export function languageFor(path: string): Language | undefined {
58
+ for (const language of LANGUAGES) {
59
+ if (EXTENSIONS[language].some((ext) => path.endsWith(ext))) return language
60
+ }
61
+ return undefined
62
+ }
63
+
64
+ export function isPruned(relativePath: string): boolean {
65
+ return relativePath
66
+ .split('/')
67
+ .some((segment) => PRUNED_SEGMENTS.includes(segment))
68
+ }
69
+
70
+ /**
71
+ * Builds the matcher for one vocabulary term.
72
+ *
73
+ * Case sensitivity is derived from the term rather than from a second list:
74
+ * a term carrying an uppercase letter is a marker convention (`TODO`, `HACK`)
75
+ * and matches exactly, while an all-lowercase term is prose (`used to`) and
76
+ * matches either casing. Matching `FIXED` case-insensitively would hit every
77
+ * comment containing the word "fixed", which is why the split exists.
78
+ */
79
+ function matcherFor(term: string): RegExp {
80
+ const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
81
+ const leading = /^\w/.test(term) ? '(?<!\\w)' : ''
82
+ const trailing = /\w$/.test(term) ? '(?!\\w)' : ''
83
+ const flags = /[A-Z]/.test(term) ? '' : 'i'
84
+
85
+ return new RegExp(`${leading}${escaped}${trailing}`, flags)
86
+ }
87
+
88
+ function sweep(
89
+ text: string,
90
+ file: string,
91
+ line: number,
92
+ matchers: readonly { term: string; pattern: RegExp }[],
93
+ hits: DegradationHit[],
94
+ ): void {
95
+ for (const { term, pattern } of matchers) {
96
+ if (pattern.test(text)) hits.push({ file, line, term })
97
+ }
98
+ }
99
+
100
+ function splitLines(text: string): string[] {
101
+ const lines = text.split('\n')
102
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
103
+ return lines
104
+ }
105
+
106
+ interface FileCount {
107
+ readonly lines: number
108
+ readonly commentLines: number
109
+ readonly docBlocks: number
110
+ readonly inlineComments: number
111
+ }
112
+
113
+ /**
114
+ * Counts one TypeScript file.
115
+ *
116
+ * A line counts as a comment only when its first non-whitespace token opens
117
+ * one, which is what keeps a URL in a string literal from reading as a `//`
118
+ * comment without an AST. The cost is that a `//` line inside a template
119
+ * literal counts, and a trailing comment after code does not.
120
+ */
121
+ function countTypeScript(
122
+ file: SourceFile,
123
+ matchers: readonly { term: string; pattern: RegExp }[],
124
+ hits: DegradationHit[],
125
+ ): FileCount {
126
+ const lines = splitLines(file.text)
127
+ let commentLines = 0
128
+ let docBlocks = 0
129
+ let inlineComments = 0
130
+ let inBlock = false
131
+
132
+ for (const [index, line] of lines.entries()) {
133
+ const trimmed = line.trim()
134
+
135
+ if (inBlock) {
136
+ commentLines++
137
+ sweep(trimmed, file.path, index + 1, matchers, hits)
138
+ if (trimmed.includes('*/')) inBlock = false
139
+ continue
140
+ }
141
+
142
+ if (trimmed.startsWith('/*')) {
143
+ commentLines++
144
+ if (/^\/\*\*(?!\/)/.test(trimmed)) docBlocks++
145
+ sweep(trimmed, file.path, index + 1, matchers, hits)
146
+ if (trimmed.indexOf('*/', 2) === -1) inBlock = true
147
+ continue
148
+ }
149
+
150
+ if (trimmed.startsWith('//')) {
151
+ commentLines++
152
+ inlineComments++
153
+ sweep(trimmed, file.path, index + 1, matchers, hits)
154
+ }
155
+ }
156
+
157
+ return { lines: lines.length, commentLines, docBlocks, inlineComments }
158
+ }
159
+
160
+ const HEREDOC_OPENER = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/
161
+
162
+ /**
163
+ * Counts one bash file, skipping heredoc bodies.
164
+ *
165
+ * A heredoc body is data, and this repository's sandbox scenarios carry
166
+ * markdown inside one, where `#` opens a heading rather than a comment.
167
+ * Counting those inflated a measured 112 comment lines to 427. Body lines
168
+ * leave the denominator as well as the numerator, since a density that drops
169
+ * the numerator alone understates by however much data the file carries.
170
+ *
171
+ * The line-1 shebang is excluded. Every script has one, so counting it puts a
172
+ * floor under density that reports the file count rather than the discipline.
173
+ */
174
+ function countBash(
175
+ file: SourceFile,
176
+ matchers: readonly { term: string; pattern: RegExp }[],
177
+ hits: DegradationHit[],
178
+ ): FileCount {
179
+ const lines = splitLines(file.text)
180
+ let counted = 0
181
+ let commentLines = 0
182
+ let inlineComments = 0
183
+ let delimiter: string | undefined
184
+
185
+ for (const [index, line] of lines.entries()) {
186
+ if (delimiter !== undefined) {
187
+ // `<<-` strips leading tabs from the terminator, and the plain form
188
+ // requires it at column zero. Trimming covers both without tracking
189
+ // which form opened the body.
190
+ if (line.trim() === delimiter) delimiter = undefined
191
+ continue
192
+ }
193
+
194
+ counted++
195
+ const trimmed = line.trim()
196
+
197
+ if (trimmed.startsWith('#')) {
198
+ if (!(index === 0 && trimmed.startsWith('#!'))) {
199
+ commentLines++
200
+ inlineComments++
201
+ sweep(trimmed, file.path, index + 1, matchers, hits)
202
+ }
203
+ continue
204
+ }
205
+
206
+ const opener = HEREDOC_OPENER.exec(line)
207
+ if (opener) delimiter = opener[2]
208
+ }
209
+
210
+ return { lines: counted, commentLines, docBlocks: 0, inlineComments }
211
+ }
212
+
213
+ export function countFile(
214
+ file: SourceFile,
215
+ language: Language,
216
+ vocabulary: readonly string[] = [],
217
+ ): LanguageCount {
218
+ const matchers = vocabulary.map((term) => ({
219
+ term,
220
+ pattern: matcherFor(term),
221
+ }))
222
+ const hits: DegradationHit[] = []
223
+
224
+ const counted =
225
+ language === 'ts'
226
+ ? countTypeScript(file, matchers, hits)
227
+ : countBash(file, matchers, hits)
228
+
229
+ return {
230
+ language,
231
+ files: 1,
232
+ lines: counted.lines,
233
+ commentLines: counted.commentLines,
234
+ docBlocks: counted.docBlocks,
235
+ inlineComments: counted.inlineComments,
236
+ degradationHits: hits,
237
+ }
238
+ }
239
+
240
+ /** Counts a set of already-read files, grouped into one entry per language. */
241
+ export function countFiles(
242
+ files: readonly SourceFile[],
243
+ opts: ScanOptions = {},
244
+ ): LanguageCount[] {
245
+ const languages = opts.languages ?? LANGUAGES
246
+ const vocabulary = opts.vocabulary ?? []
247
+
248
+ return languages.map((language) => {
249
+ const totals = {
250
+ language,
251
+ files: 0,
252
+ lines: 0,
253
+ commentLines: 0,
254
+ docBlocks: 0,
255
+ inlineComments: 0,
256
+ degradationHits: [] as DegradationHit[],
257
+ }
258
+
259
+ for (const file of files) {
260
+ if (languageFor(file.path) !== language) continue
261
+ const count = countFile(file, language, vocabulary)
262
+ totals.files += count.files
263
+ totals.lines += count.lines
264
+ totals.commentLines += count.commentLines
265
+ totals.docBlocks += count.docBlocks
266
+ totals.inlineComments += count.inlineComments
267
+ totals.degradationHits.push(...count.degradationHits)
268
+ }
269
+
270
+ return totals
271
+ })
272
+ }
273
+
274
+ export function density(count: LanguageCount): number {
275
+ return count.lines === 0 ? 0 : count.commentLines / count.lines
276
+ }
277
+
278
+ /** Lists the scannable source files under `root`, honoring `.gitignore`. */
279
+ export async function listSourceFiles(
280
+ root: string,
281
+ languages: readonly Language[] = LANGUAGES,
282
+ ): Promise<string[]> {
283
+ const glob = new Bun.Glob('**/*')
284
+ const candidates: string[] = []
285
+
286
+ for await (const rel of glob.scan({
287
+ cwd: root,
288
+ onlyFiles: true,
289
+ dot: true,
290
+ })) {
291
+ const normalized = rel.split('\\').join('/')
292
+ if (isPruned(normalized)) continue
293
+
294
+ const language = languageFor(normalized)
295
+ if (!language || !languages.includes(language)) continue
296
+
297
+ candidates.push(resolve(root, normalized))
298
+ }
299
+
300
+ candidates.sort()
301
+
302
+ const ignored = await listIgnored(root, candidates)
303
+ return ignored.size === 0
304
+ ? candidates
305
+ : candidates.filter((path) => !ignored.has(path))
306
+ }
307
+
308
+ /**
309
+ * Open descriptors allowed at once while reading a tree.
310
+ *
311
+ * A single `Promise.all` over every path opens one descriptor per file, which
312
+ * exhausts a default 1024 limit on any large repository and fails the whole
313
+ * scan with EMFILE. Reading in bounded batches keeps the parallelism that
314
+ * matters without letting the file count set the ceiling.
315
+ */
316
+ const CONCURRENT_READS = 64
317
+
318
+ /** Scans a working tree on disk. */
319
+ export async function scanTree(
320
+ root: string,
321
+ opts: ScanOptions = {},
322
+ ): Promise<LanguageCount[]> {
323
+ const languages = opts.languages ?? LANGUAGES
324
+ const paths = await listSourceFiles(root, languages)
325
+ const files: SourceFile[] = []
326
+
327
+ for (let start = 0; start < paths.length; start += CONCURRENT_READS) {
328
+ const batch = await Promise.all(
329
+ paths.slice(start, start + CONCURRENT_READS).map(async (path) => ({
330
+ path: path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path,
331
+ text: await readFile(path, 'utf8'),
332
+ })),
333
+ )
334
+ files.push(...batch)
335
+ }
336
+
337
+ return countFiles(files, opts)
338
+ }