@erclx/aitk 0.8.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.
- package/README.md +17 -13
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-address-review/REQUIREMENT.md +39 -0
- package/claude/skills/claude-address-review/SKILL.md +3 -4
- package/claude/skills/claude-autoship/REQUIREMENT.md +41 -0
- package/claude/skills/claude-autoship/SKILL.md +1 -1
- package/claude/skills/claude-diagram/SKILL.md +70 -40
- package/claude/skills/claude-docs/REQUIREMENT.md +39 -0
- 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/REQUIREMENT.md +38 -0
- package/claude/skills/claude-pr-review/SKILL.md +1 -1
- package/claude/skills/claude-review/REQUIREMENT.md +39 -0
- 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 +26 -0
- package/docs/ai-workflow.md +1 -1
- package/docs/target-projects.md +2 -2
- package/governance/rules/claude/560-diagrams.md +10 -2
- package/governance/rules/claude/570-skill.md +1 -1
- package/package.json +1 -1
- package/scripts/core/install-check.sh +1 -1
- package/src/claude/seeds.ts +7 -1
- package/src/cli.ts +4 -0
- package/src/commands/comments.ts +234 -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/indexes/walk.ts +1 -29
- package/standards/diagrams.md +65 -15
- package/standards/index.md +2 -2
- package/standards/prose.md +14 -1
- package/standards/readme.md +15 -1
- package/standards/skill.md +14 -0
- package/tooling/claude/reference.md +5 -0
- package/tooling/claude/seeds/.claude/diagrams/index.md +8 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { $ } from 'bun'
|
|
2
|
+
import { gitEnv } from '@/git-env'
|
|
3
|
+
import {
|
|
4
|
+
countFiles,
|
|
5
|
+
isPruned,
|
|
6
|
+
type Language,
|
|
7
|
+
LANGUAGES,
|
|
8
|
+
type LanguageCount,
|
|
9
|
+
languageFor,
|
|
10
|
+
type ScanOptions,
|
|
11
|
+
type SourceFile,
|
|
12
|
+
} from '@/comments/scan'
|
|
13
|
+
|
|
14
|
+
export interface TrendPoint {
|
|
15
|
+
readonly rev: string
|
|
16
|
+
readonly date: string
|
|
17
|
+
readonly languages: readonly LanguageCount[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_POINTS = 6
|
|
21
|
+
|
|
22
|
+
interface Commit {
|
|
23
|
+
readonly rev: string
|
|
24
|
+
readonly date: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseCommits(text: string): Commit[] {
|
|
28
|
+
return text
|
|
29
|
+
.split('\n')
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.map((line) => {
|
|
32
|
+
const [rev, date] = line.split('\t')
|
|
33
|
+
return { rev, date }
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Lists the commits from `since` to HEAD, oldest first and inclusive of
|
|
39
|
+
* `since` itself.
|
|
40
|
+
*
|
|
41
|
+
* A `since..HEAD` range excludes its own boundary, which drops the reading the
|
|
42
|
+
* caller is measuring against. The whole point of a trend is the before, so
|
|
43
|
+
* the boundary commit is prepended rather than left to the range operator.
|
|
44
|
+
*
|
|
45
|
+
* `--first-parent` keeps a merged branch's own commits out of the sample, so
|
|
46
|
+
* evenly spaced points land on the trunk rather than clustering inside
|
|
47
|
+
* whichever feature happened to carry the most commits.
|
|
48
|
+
*/
|
|
49
|
+
export async function listCommits(
|
|
50
|
+
root: string,
|
|
51
|
+
since: string,
|
|
52
|
+
): Promise<Commit[]> {
|
|
53
|
+
const boundary =
|
|
54
|
+
await $`git -C ${root} log -1 --format=%H%x09%ad --date=short ${since}`
|
|
55
|
+
.env(gitEnv())
|
|
56
|
+
.quiet()
|
|
57
|
+
.nothrow()
|
|
58
|
+
|
|
59
|
+
if (boundary.exitCode !== 0) return []
|
|
60
|
+
|
|
61
|
+
const range =
|
|
62
|
+
await $`git -C ${root} log --first-parent --reverse --format=%H%x09%ad --date=short ${`${since}..HEAD`}`
|
|
63
|
+
.env(gitEnv())
|
|
64
|
+
.quiet()
|
|
65
|
+
.nothrow()
|
|
66
|
+
|
|
67
|
+
const commits = parseCommits(boundary.text())
|
|
68
|
+
if (range.exitCode === 0) commits.push(...parseCommits(range.text()))
|
|
69
|
+
|
|
70
|
+
return commits
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Picks evenly spaced commits from `commits`, always keeping the newest.
|
|
75
|
+
*
|
|
76
|
+
* Sample selection matters more than sample size here. The reading the
|
|
77
|
+
* comment-discipline track needed came from four points spanning six months,
|
|
78
|
+
* so spacing across the window is what the arm optimizes for rather than
|
|
79
|
+
* density of coverage.
|
|
80
|
+
*/
|
|
81
|
+
export function spaceEvenly(
|
|
82
|
+
commits: readonly Commit[],
|
|
83
|
+
points: number,
|
|
84
|
+
): Commit[] {
|
|
85
|
+
if (commits.length <= points) return [...commits]
|
|
86
|
+
if (points <= 1) return [commits[commits.length - 1]]
|
|
87
|
+
|
|
88
|
+
const picked: Commit[] = []
|
|
89
|
+
const step = (commits.length - 1) / (points - 1)
|
|
90
|
+
|
|
91
|
+
for (let index = 0; index < points; index++) {
|
|
92
|
+
picked.push(commits[Math.round(index * step)])
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return picked
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Reads every scannable blob at `rev` without checking anything out.
|
|
100
|
+
*
|
|
101
|
+
* `ls-tree` names the blobs and `cat-file --batch` streams their contents in
|
|
102
|
+
* one process, so a six-point trend costs six subprocesses rather than one per
|
|
103
|
+
* file per commit. Contents arrive as bytes, so the batch stream is walked by
|
|
104
|
+
* byte offset rather than split as text.
|
|
105
|
+
*/
|
|
106
|
+
export async function readRevision(
|
|
107
|
+
root: string,
|
|
108
|
+
rev: string,
|
|
109
|
+
languages: readonly Language[] = LANGUAGES,
|
|
110
|
+
): Promise<SourceFile[]> {
|
|
111
|
+
const listed = await $`git -C ${root} ls-tree -r -z ${rev}`
|
|
112
|
+
.env(gitEnv())
|
|
113
|
+
.quiet()
|
|
114
|
+
.nothrow()
|
|
115
|
+
if (listed.exitCode !== 0) return []
|
|
116
|
+
|
|
117
|
+
const wanted: { oid: string; path: string }[] = []
|
|
118
|
+
|
|
119
|
+
for (const entry of listed.text().split('\0')) {
|
|
120
|
+
if (!entry) continue
|
|
121
|
+
const [meta, path] = entry.split('\t')
|
|
122
|
+
if (!path) continue
|
|
123
|
+
|
|
124
|
+
const [, type, oid] = meta.split(/\s+/)
|
|
125
|
+
if (type !== 'blob') continue
|
|
126
|
+
if (isPruned(path)) continue
|
|
127
|
+
|
|
128
|
+
const language = languageFor(path)
|
|
129
|
+
if (!language || !languages.includes(language)) continue
|
|
130
|
+
|
|
131
|
+
wanted.push({ oid, path })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (wanted.length === 0) return []
|
|
135
|
+
|
|
136
|
+
const stdin = Buffer.from(`${wanted.map(({ oid }) => oid).join('\n')}\n`)
|
|
137
|
+
const batch = await $`git -C ${root} cat-file --batch < ${stdin}`
|
|
138
|
+
.env(gitEnv())
|
|
139
|
+
.quiet()
|
|
140
|
+
.nothrow()
|
|
141
|
+
|
|
142
|
+
if (batch.exitCode !== 0) return []
|
|
143
|
+
|
|
144
|
+
return parseBatch(Buffer.from(batch.arrayBuffer()), wanted)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Walks `git cat-file --batch` output, which frames each object as
|
|
149
|
+
* `<oid> SP <type> SP <size> LF <contents> LF`. The size header is the only
|
|
150
|
+
* safe way to find the next record, since contents may hold anything.
|
|
151
|
+
*/
|
|
152
|
+
function parseBatch(
|
|
153
|
+
buffer: Buffer,
|
|
154
|
+
wanted: readonly { oid: string; path: string }[],
|
|
155
|
+
): SourceFile[] {
|
|
156
|
+
const files: SourceFile[] = []
|
|
157
|
+
let offset = 0
|
|
158
|
+
|
|
159
|
+
for (const { path } of wanted) {
|
|
160
|
+
const headerEnd = buffer.indexOf(0x0a, offset)
|
|
161
|
+
if (headerEnd === -1) break
|
|
162
|
+
|
|
163
|
+
const header = buffer.toString('utf8', offset, headerEnd)
|
|
164
|
+
const size = Number(header.split(' ')[2])
|
|
165
|
+
if (!Number.isFinite(size)) break
|
|
166
|
+
|
|
167
|
+
const start = headerEnd + 1
|
|
168
|
+
files.push({ path, text: buffer.toString('utf8', start, start + size) })
|
|
169
|
+
offset = start + size + 1
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return files
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Counts one revision's tree, reusing the same pass the snapshot arm runs. */
|
|
176
|
+
export async function scanRevision(
|
|
177
|
+
root: string,
|
|
178
|
+
rev: string,
|
|
179
|
+
opts: ScanOptions = {},
|
|
180
|
+
): Promise<LanguageCount[]> {
|
|
181
|
+
const files = await readRevision(root, rev, opts.languages ?? LANGUAGES)
|
|
182
|
+
return countFiles(files, opts)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface TrendOptions extends ScanOptions {
|
|
186
|
+
readonly since: string
|
|
187
|
+
readonly points?: number
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Recomputes the series from git rather than reading a stored ledger. */
|
|
191
|
+
export async function trend(
|
|
192
|
+
root: string,
|
|
193
|
+
opts: TrendOptions,
|
|
194
|
+
): Promise<TrendPoint[]> {
|
|
195
|
+
const commits = await listCommits(root, opts.since)
|
|
196
|
+
const sampled = spaceEvenly(commits, opts.points ?? DEFAULT_POINTS)
|
|
197
|
+
|
|
198
|
+
// Each point is an independent pair of git reads, and the sample is bounded
|
|
199
|
+
// by `points`, so the whole series costs one revision's wall clock.
|
|
200
|
+
return Promise.all(
|
|
201
|
+
sampled.map(async ({ rev, date }) => ({
|
|
202
|
+
rev,
|
|
203
|
+
date,
|
|
204
|
+
languages: await scanRevision(root, rev, opts),
|
|
205
|
+
})),
|
|
206
|
+
)
|
|
207
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The heading a rule carries to publish its degradation list.
|
|
7
|
+
*
|
|
8
|
+
* Discovery is anchored on this rather than on a filename, because governance
|
|
9
|
+
* rules are numbered and a renumber would silently empty the vocabulary while
|
|
10
|
+
* the sweep kept reporting clean. A heading survives the rename.
|
|
11
|
+
*/
|
|
12
|
+
export const VOCABULARY_HEADING = '## Degradation vocabulary'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Roots searched in order. The installed copy wins over the toolkit source, so
|
|
16
|
+
* a target project measures against the rule it actually has rather than one
|
|
17
|
+
* only the toolkit carries.
|
|
18
|
+
*/
|
|
19
|
+
const RULE_ROOTS = ['.claude/rules', 'governance/rules']
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Absent is a distinct state from empty.
|
|
23
|
+
*
|
|
24
|
+
* A sweep with no vocabulary finds nothing, and reporting that as zero hits
|
|
25
|
+
* claims the codebase is clean when nothing was actually looked for. The
|
|
26
|
+
* command reports it as skipped instead.
|
|
27
|
+
*/
|
|
28
|
+
export type Vocabulary =
|
|
29
|
+
| {
|
|
30
|
+
readonly kind: 'loaded'
|
|
31
|
+
readonly source: string
|
|
32
|
+
readonly terms: string[]
|
|
33
|
+
}
|
|
34
|
+
| { readonly kind: 'absent' }
|
|
35
|
+
|
|
36
|
+
/** Pulls the backticked terms out of the bullets under the vocabulary heading. */
|
|
37
|
+
export function parseVocabulary(markdown: string): string[] | undefined {
|
|
38
|
+
const lines = markdown.split('\n')
|
|
39
|
+
const start = lines.findIndex((line) => line.trim() === VOCABULARY_HEADING)
|
|
40
|
+
if (start === -1) return undefined
|
|
41
|
+
|
|
42
|
+
const terms: string[] = []
|
|
43
|
+
|
|
44
|
+
for (const line of lines.slice(start + 1)) {
|
|
45
|
+
if (line.startsWith('## ')) break
|
|
46
|
+
for (const match of line.matchAll(/`([^`]+)`/g)) {
|
|
47
|
+
const term = match[1].trim()
|
|
48
|
+
if (term && !terms.includes(term)) terms.push(term)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return terms
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Finds the rule publishing the vocabulary under `root`.
|
|
57
|
+
*
|
|
58
|
+
* Reading the list out of the rule rather than hardcoding it is what keeps one
|
|
59
|
+
* definition when the rule installs into a target, the same way
|
|
60
|
+
* `.claude/hooks/standards-audit.sh` reads its bans out of `prose.md`.
|
|
61
|
+
*/
|
|
62
|
+
export async function loadVocabulary(root: string): Promise<Vocabulary> {
|
|
63
|
+
for (const ruleRoot of RULE_ROOTS) {
|
|
64
|
+
const dir = resolve(root, ruleRoot)
|
|
65
|
+
if (!existsSync(dir)) continue
|
|
66
|
+
|
|
67
|
+
const paths: string[] = []
|
|
68
|
+
for await (const rel of new Bun.Glob('**/*.md').scan({
|
|
69
|
+
cwd: dir,
|
|
70
|
+
onlyFiles: true,
|
|
71
|
+
})) {
|
|
72
|
+
paths.push(rel)
|
|
73
|
+
}
|
|
74
|
+
paths.sort()
|
|
75
|
+
|
|
76
|
+
for (const rel of paths) {
|
|
77
|
+
const parsed = parseVocabulary(await readFile(resolve(dir, rel), 'utf8'))
|
|
78
|
+
if (parsed && parsed.length > 0) {
|
|
79
|
+
return { kind: 'loaded', source: `${ruleRoot}/${rel}`, terms: parsed }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { kind: 'absent' }
|
|
85
|
+
}
|
package/src/git-env.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository-resolution variables git reads from the environment.
|
|
3
|
+
*
|
|
4
|
+
* A git hook exports these into every process it runs, and they take
|
|
5
|
+
* precedence over `-C`. A command scoped to a subtree then silently reads
|
|
6
|
+
* whatever the hook's repository points at and reports figures for a tree
|
|
7
|
+
* nobody asked about, which is worse than failing because the output looks
|
|
8
|
+
* ordinary. `GIT_PREFIX` is here for the same reason: it re-anchors a relative
|
|
9
|
+
* pathspec to the directory the hook was invoked from.
|
|
10
|
+
*/
|
|
11
|
+
const RESOLUTION_VARS = [
|
|
12
|
+
'GIT_DIR',
|
|
13
|
+
'GIT_WORK_TREE',
|
|
14
|
+
'GIT_COMMON_DIR',
|
|
15
|
+
'GIT_INDEX_FILE',
|
|
16
|
+
'GIT_OBJECT_DIRECTORY',
|
|
17
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
18
|
+
'GIT_NAMESPACE',
|
|
19
|
+
'GIT_PREFIX',
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns the ambient environment with git's repository-resolution variables
|
|
24
|
+
* removed, so `git -C <path>` resolves against `<path>` and nothing else.
|
|
25
|
+
*
|
|
26
|
+
* Read per call rather than captured at module load. A long-lived process can
|
|
27
|
+
* have these set after import, and a snapshot would then hand git the very
|
|
28
|
+
* variables this exists to strip.
|
|
29
|
+
*/
|
|
30
|
+
export function gitEnv(): Record<string, string> {
|
|
31
|
+
return Object.fromEntries(
|
|
32
|
+
Object.entries(process.env).filter(
|
|
33
|
+
([key, value]) => value !== undefined && !RESOLUTION_VARS.includes(key),
|
|
34
|
+
),
|
|
35
|
+
) as Record<string, string>
|
|
36
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import { $ } from 'bun'
|
|
3
|
+
import { gitEnv } from '@/git-env'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reports which of `candidates` git ignores under `root`.
|
|
7
|
+
*
|
|
8
|
+
* Batched through one `check-ignore --stdin` rather than a call per path,
|
|
9
|
+
* since a whole-repo walk hands this thousands of candidates. Outside a git
|
|
10
|
+
* repo nothing is ignored, so a caller's segment prune becomes the only filter
|
|
11
|
+
* that applies.
|
|
12
|
+
*/
|
|
13
|
+
export async function listIgnored(
|
|
14
|
+
root: string,
|
|
15
|
+
candidates: string[],
|
|
16
|
+
): Promise<Set<string>> {
|
|
17
|
+
if (candidates.length === 0) return new Set()
|
|
18
|
+
|
|
19
|
+
const isRepo = await $`git -C ${root} rev-parse --git-dir`
|
|
20
|
+
.env(gitEnv())
|
|
21
|
+
.quiet()
|
|
22
|
+
.nothrow()
|
|
23
|
+
.then((result) => result.exitCode === 0)
|
|
24
|
+
|
|
25
|
+
if (!isRepo) return new Set()
|
|
26
|
+
|
|
27
|
+
const stdin = Buffer.from(`${candidates.join('\n')}\n`)
|
|
28
|
+
|
|
29
|
+
const result = await $`git -C ${root} check-ignore --stdin < ${stdin}`
|
|
30
|
+
.env(gitEnv())
|
|
31
|
+
.quiet()
|
|
32
|
+
.nothrow()
|
|
33
|
+
|
|
34
|
+
// Exit 1 means nothing matched, which is a clean result rather than a
|
|
35
|
+
// failure. Anything above that is a real error and degrades to "ignores
|
|
36
|
+
// nothing" so a broken git never silently shrinks the scanned set.
|
|
37
|
+
if (result.exitCode > 1) return new Set()
|
|
38
|
+
|
|
39
|
+
return new Set(
|
|
40
|
+
result
|
|
41
|
+
.text()
|
|
42
|
+
.split('\n')
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
.map((path) => resolve(root, path)),
|
|
45
|
+
)
|
|
46
|
+
}
|