@erclx/aitk 0.9.0 → 0.11.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.
- package/README.md +17 -13
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-address-review/SKILL.md +3 -4
- package/claude/skills/claude-autoship/SKILL.md +1 -1
- package/claude/skills/claude-diagram/SKILL.md +1 -1
- package/claude/skills/claude-docs/SKILL.md +1 -1
- package/claude/skills/claude-memory-review/SKILL.md +1 -1
- package/claude/skills/claude-pr-review/SKILL.md +1 -1
- package/claude/skills/claude-review/SKILL.md +1 -1
- package/claude/skills/claude-seed-sync/SKILL.md +1 -1
- package/claude/skills/claude-ui-test/SKILL.md +1 -1
- package/claude/skills/claude-ux-audit/SKILL.md +1 -1
- package/claude/skills/claude-worktree/SKILL.md +2 -2
- package/claude/skills/git-followup/SKILL.md +1 -1
- package/claude/skills/git-issue/SKILL.md +1 -6
- package/claude/skills/git-pr/SKILL.md +1 -6
- package/claude/skills/git-split/REQUIREMENT.md +2 -1
- package/claude/skills/git-split/SKILL.md +2 -0
- package/docs/agents.md +36 -0
- package/docs/target-projects.md +1 -1
- package/package.json +1 -1
- package/scripts/core/regen-claude-copies.sh +10 -1
- package/scripts/core/verify.sh +1 -1
- package/src/cli.ts +4 -0
- package/src/commands/comments.ts +234 -0
- package/src/commands/gov.ts +40 -0
- package/src/comments/scan.ts +338 -0
- package/src/comments/trend.ts +207 -0
- package/src/comments/vocabulary.ts +85 -0
- package/src/git-env.ts +36 -0
- package/src/git-ignore.ts +46 -0
- package/src/gov/consumed.ts +129 -0
- package/src/indexes/walk.ts +1 -29
- package/standards/index.md +1 -1
- package/standards/prose.md +14 -1
- package/standards/readme.md +15 -1
- package/standards/skill.md +14 -0
|
@@ -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
|
+
}
|
package/src/commands/gov.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { Command } from 'commander'
|
|
|
5
5
|
import { registerPassThroughVerbs } from '@/commands/pass-through'
|
|
6
6
|
import { PROJECT_ROOT } from '@/exec'
|
|
7
7
|
import { createGovAdapter } from '@/gov/adapter'
|
|
8
|
+
import { regenConsumedRules } from '@/gov/consumed'
|
|
8
9
|
import { hasStandards, installRules, lookupRules } from '@/gov/install'
|
|
9
10
|
import { buildRulesPayload, listRuleFiles } from '@/gov/payload'
|
|
10
11
|
import {
|
|
@@ -39,6 +40,10 @@ interface InstallOptions {
|
|
|
39
40
|
readonly add?: string
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
interface RegenOptions {
|
|
44
|
+
readonly root?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
42
47
|
export function register(program: Command): void {
|
|
43
48
|
const gov = program
|
|
44
49
|
.command('gov')
|
|
@@ -95,9 +100,44 @@ export function register(program: Command): void {
|
|
|
95
100
|
process.exitCode = await runBuild(target)
|
|
96
101
|
})
|
|
97
102
|
|
|
103
|
+
gov
|
|
104
|
+
.command('regen')
|
|
105
|
+
.description("Rebuild a repository's own .claude/rules/ from its record")
|
|
106
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
107
|
+
.option('--root <path>', 'Repository root to regenerate', PROJECT_ROOT)
|
|
108
|
+
.addHelpText(
|
|
109
|
+
'after',
|
|
110
|
+
[
|
|
111
|
+
'',
|
|
112
|
+
'Reads internal/governance.toml and installs the stack it names, plus',
|
|
113
|
+
'any rules under internal/rules/. Unlike install and sync, this runs',
|
|
114
|
+
'against the toolkit root, whose .claude/rules/ is produced output.',
|
|
115
|
+
'',
|
|
116
|
+
].join('\n'),
|
|
117
|
+
)
|
|
118
|
+
.action(async (opts: RegenOptions) => {
|
|
119
|
+
process.exitCode = await runRegen(opts)
|
|
120
|
+
})
|
|
121
|
+
|
|
98
122
|
registerPassThroughVerbs(gov, 'gov', PASS_THROUGH_VERBS)
|
|
99
123
|
}
|
|
100
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Silent on success so the consumed-copy stage that calls it stays as quiet as
|
|
127
|
+
* the three `mirror_dir` lines it sits beside. The installed set is readable on
|
|
128
|
+
* disk, so printing it would only add noise to every `bun run check`.
|
|
129
|
+
*/
|
|
130
|
+
async function runRegen(opts: RegenOptions): Promise<number> {
|
|
131
|
+
const result = await regenConsumedRules(resolve(opts.root ?? PROJECT_ROOT))
|
|
132
|
+
|
|
133
|
+
if (!result.ok) {
|
|
134
|
+
process.stderr.write(`Consumed-rules regen failed: ${result.reason}\n`)
|
|
135
|
+
return 1
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return 0
|
|
139
|
+
}
|
|
140
|
+
|
|
101
141
|
/**
|
|
102
142
|
* Renders the target-relative path the prompt quotes, keeping the argument the
|
|
103
143
|
* caller typed rather than the absolute path it resolves to.
|
|
@@ -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
|
+
}
|