@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.
Files changed (46) hide show
  1. package/README.md +17 -13
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/claude-address-review/REQUIREMENT.md +39 -0
  4. package/claude/skills/claude-address-review/SKILL.md +3 -4
  5. package/claude/skills/claude-autoship/REQUIREMENT.md +41 -0
  6. package/claude/skills/claude-autoship/SKILL.md +1 -1
  7. package/claude/skills/claude-diagram/SKILL.md +70 -40
  8. package/claude/skills/claude-docs/REQUIREMENT.md +39 -0
  9. package/claude/skills/claude-docs/SKILL.md +1 -1
  10. package/claude/skills/claude-memory-review/SKILL.md +1 -1
  11. package/claude/skills/claude-pr-review/REQUIREMENT.md +38 -0
  12. package/claude/skills/claude-pr-review/SKILL.md +1 -1
  13. package/claude/skills/claude-review/REQUIREMENT.md +39 -0
  14. package/claude/skills/claude-review/SKILL.md +1 -1
  15. package/claude/skills/claude-seed-sync/SKILL.md +1 -1
  16. package/claude/skills/claude-ui-test/SKILL.md +1 -1
  17. package/claude/skills/claude-ux-audit/SKILL.md +1 -1
  18. package/claude/skills/claude-worktree/SKILL.md +2 -2
  19. package/claude/skills/git-followup/SKILL.md +1 -1
  20. package/claude/skills/git-issue/SKILL.md +1 -6
  21. package/claude/skills/git-pr/SKILL.md +1 -6
  22. package/claude/skills/git-split/REQUIREMENT.md +2 -1
  23. package/claude/skills/git-split/SKILL.md +2 -0
  24. package/docs/agents.md +26 -0
  25. package/docs/ai-workflow.md +1 -1
  26. package/docs/target-projects.md +2 -2
  27. package/governance/rules/claude/560-diagrams.md +10 -2
  28. package/governance/rules/claude/570-skill.md +1 -1
  29. package/package.json +1 -1
  30. package/scripts/core/install-check.sh +1 -1
  31. package/src/claude/seeds.ts +7 -1
  32. package/src/cli.ts +4 -0
  33. package/src/commands/comments.ts +234 -0
  34. package/src/comments/scan.ts +338 -0
  35. package/src/comments/trend.ts +207 -0
  36. package/src/comments/vocabulary.ts +85 -0
  37. package/src/git-env.ts +36 -0
  38. package/src/git-ignore.ts +46 -0
  39. package/src/indexes/walk.ts +1 -29
  40. package/standards/diagrams.md +65 -15
  41. package/standards/index.md +2 -2
  42. package/standards/prose.md +14 -1
  43. package/standards/readme.md +15 -1
  44. package/standards/skill.md +14 -0
  45. package/tooling/claude/reference.md +5 -0
  46. 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
+ }