@erclx/aitk 0.20.0 → 0.22.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-address-review/REQUIREMENT.md +1 -0
- package/claude/skills/claude-address-review/SKILL.md +4 -3
- package/claude/skills/claude-diagram/SKILL.md +1 -1
- package/claude/skills/claude-pr-review/REQUIREMENT.md +1 -0
- package/claude/skills/claude-pr-review/SKILL.md +1 -1
- package/claude/skills/git-followup/REQUIREMENT.md +1 -1
- package/claude/skills/git-followup/SKILL.md +1 -1
- package/claude/skills/git-issue/REQUIREMENT.md +1 -1
- package/claude/skills/git-issue/SKILL.md +1 -1
- package/claude/skills/git-pr/REQUIREMENT.md +1 -1
- package/claude/skills/git-pr/SKILL.md +1 -1
- package/claude/skills/git-split/REQUIREMENT.md +1 -1
- package/claude/skills/git-split/SKILL.md +1 -1
- package/docs/agents.md +39 -0
- package/docs/target-projects.md +1 -1
- package/package.json +1 -1
- package/scripts/core/verify.sh +9 -0
- package/scripts/lib/sandbox-path.sh +75 -6
- package/src/cli.ts +4 -0
- package/src/commands/context.ts +321 -0
- package/src/context/audit.ts +227 -0
- package/src/context/citations.ts +167 -0
- package/src/context/folders.ts +91 -0
- package/src/context/index-drift.ts +64 -0
- package/standards/index.md +1 -1
- package/standards/prose.md +16 -2
- package/standards/versioning.md +11 -1
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import type { Command } from 'commander'
|
|
3
|
+
import {
|
|
4
|
+
type EntryReport,
|
|
5
|
+
LENGTH_CHECKPOINT,
|
|
6
|
+
measureFolders,
|
|
7
|
+
RUN_CHECKPOINT,
|
|
8
|
+
} from '@/context/audit'
|
|
9
|
+
import { auditCitations, type CitationReport } from '@/context/citations'
|
|
10
|
+
import {
|
|
11
|
+
type AuditedFolder,
|
|
12
|
+
DEFAULT_FOLDERS,
|
|
13
|
+
presentNames,
|
|
14
|
+
resolveFolders,
|
|
15
|
+
} from '@/context/folders'
|
|
16
|
+
import { auditIndexes, type FolderDrift } from '@/context/index-drift'
|
|
17
|
+
import {
|
|
18
|
+
frameError,
|
|
19
|
+
intro,
|
|
20
|
+
logError,
|
|
21
|
+
logInfo,
|
|
22
|
+
logStep,
|
|
23
|
+
logWarn,
|
|
24
|
+
outro,
|
|
25
|
+
pipeOutput,
|
|
26
|
+
} from '@/ui'
|
|
27
|
+
|
|
28
|
+
/** Returned when an unresolved citation is found, which is the gating check. */
|
|
29
|
+
const EXIT_UNRESOLVED = 2
|
|
30
|
+
|
|
31
|
+
/** A name of dots alone is `.` or `..`, both of which escape the audit root. */
|
|
32
|
+
const FOLDER_NAME = /^(?!\.+$)[A-Za-z0-9._-]+$/
|
|
33
|
+
|
|
34
|
+
interface AuditCommandOptions {
|
|
35
|
+
readonly json?: boolean
|
|
36
|
+
readonly folder?: string
|
|
37
|
+
readonly citationsOnly?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function register(program: Command): void {
|
|
41
|
+
const context = program
|
|
42
|
+
.command('context')
|
|
43
|
+
.description('Report the structural state of the context folders')
|
|
44
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
45
|
+
|
|
46
|
+
context
|
|
47
|
+
.command('audit')
|
|
48
|
+
.description('Report entry length, depth, citations, and index drift')
|
|
49
|
+
.argument('[path]', 'Project root, defaulting to the current directory')
|
|
50
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
51
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
52
|
+
.option('--folder <list>', `Comma-separated folder names under .claude/`)
|
|
53
|
+
.option('--citations-only', 'Run the gating citation check alone')
|
|
54
|
+
.addHelpText(
|
|
55
|
+
'after',
|
|
56
|
+
[
|
|
57
|
+
'',
|
|
58
|
+
'Exit codes:',
|
|
59
|
+
' 0 the audit completed with every cited path resolving',
|
|
60
|
+
' 1 refused, with the reason on stderr',
|
|
61
|
+
' 2 a cited path did not resolve',
|
|
62
|
+
'',
|
|
63
|
+
'Only unresolved citations set a failing exit code. Length, depth,',
|
|
64
|
+
'table, and index findings are advisory.',
|
|
65
|
+
'',
|
|
66
|
+
'Examples:',
|
|
67
|
+
' aitk context audit',
|
|
68
|
+
' aitk context audit --json',
|
|
69
|
+
' aitk context audit --citations-only',
|
|
70
|
+
' aitk context audit --folder context,diagrams',
|
|
71
|
+
'',
|
|
72
|
+
].join('\n'),
|
|
73
|
+
)
|
|
74
|
+
.action(async (path: string | undefined, opts: AuditCommandOptions) => {
|
|
75
|
+
process.exitCode = await runAudit(path, opts)
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseFolders(list: string | undefined): string[] | string {
|
|
80
|
+
if (!list) return [...DEFAULT_FOLDERS]
|
|
81
|
+
|
|
82
|
+
const names = list
|
|
83
|
+
.split(',')
|
|
84
|
+
.map((name) => name.trim())
|
|
85
|
+
.filter(Boolean)
|
|
86
|
+
|
|
87
|
+
if (names.length === 0) return 'Empty --folder list. Pass at least one name.'
|
|
88
|
+
|
|
89
|
+
// `..` would resolve the audit root above `.claude/`, where `presentNames`
|
|
90
|
+
// has no folder name to slice out and hands the citation pattern undefined.
|
|
91
|
+
const invalid = names.filter((name) => !FOLDER_NAME.test(name))
|
|
92
|
+
if (invalid.length > 0) {
|
|
93
|
+
return `--folder takes folder names under .claude/, not paths: ${invalid.join(', ')}`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return names
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function runAudit(
|
|
100
|
+
path: string | undefined,
|
|
101
|
+
opts: AuditCommandOptions,
|
|
102
|
+
): Promise<number> {
|
|
103
|
+
const root = resolve(path ?? process.cwd())
|
|
104
|
+
const names = parseFolders(opts.folder)
|
|
105
|
+
const gateOnly = opts.citationsOnly ?? false
|
|
106
|
+
|
|
107
|
+
if (typeof names === 'string') return refuse(names, gateOnly)
|
|
108
|
+
|
|
109
|
+
const folders = await resolveFolders(root, names)
|
|
110
|
+
if (folders.length === 0) {
|
|
111
|
+
return refuse(
|
|
112
|
+
`No audited folder found under .claude/. Looked for: ${names.join(', ')}.`,
|
|
113
|
+
gateOnly,
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const citations = await auditCitations(root, presentNames(folders))
|
|
118
|
+
if (citations.kind === 'unavailable') {
|
|
119
|
+
return refuse(
|
|
120
|
+
'git could not list the tree, so no citation was checked. Run inside a git repository.',
|
|
121
|
+
gateOnly,
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const entries = gateOnly ? [] : await measureFolders(root, folders)
|
|
126
|
+
const drift = gateOnly ? [] : await auditIndexes(folders)
|
|
127
|
+
|
|
128
|
+
if (gateOnly) {
|
|
129
|
+
reportGate(citations)
|
|
130
|
+
} else {
|
|
131
|
+
intro('aitk context audit')
|
|
132
|
+
reportScope(folders)
|
|
133
|
+
reportCitations(citations)
|
|
134
|
+
reportLength(entries)
|
|
135
|
+
reportDepth(entries)
|
|
136
|
+
reportTables(entries)
|
|
137
|
+
reportDrift(drift)
|
|
138
|
+
outro()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (opts.json) {
|
|
142
|
+
process.stdout.write(
|
|
143
|
+
`${JSON.stringify({
|
|
144
|
+
root,
|
|
145
|
+
folders: folders.map((folder) => ({
|
|
146
|
+
path: folder.rel,
|
|
147
|
+
entries: folder.entries.length,
|
|
148
|
+
})),
|
|
149
|
+
citations: {
|
|
150
|
+
scanned: citations.scanned,
|
|
151
|
+
total: citations.total,
|
|
152
|
+
unresolved: citations.unresolved,
|
|
153
|
+
},
|
|
154
|
+
entries,
|
|
155
|
+
indexDrift: drift,
|
|
156
|
+
checkpoints: {
|
|
157
|
+
lines: LENGTH_CHECKPOINT,
|
|
158
|
+
run: RUN_CHECKPOINT,
|
|
159
|
+
runCountsBlankLines: true,
|
|
160
|
+
},
|
|
161
|
+
})}\n`,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return citations.unresolved.length > 0 ? EXIT_UNRESOLVED : 0
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function refuse(message: string, gateOnly: boolean): number {
|
|
169
|
+
if (gateOnly) {
|
|
170
|
+
frameError(message)
|
|
171
|
+
return 1
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
intro('aitk context audit')
|
|
175
|
+
logStep('Refused')
|
|
176
|
+
logWarn(message)
|
|
177
|
+
outro()
|
|
178
|
+
return 1
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Prints nothing when every path resolves.
|
|
183
|
+
*
|
|
184
|
+
* `--citations-only` is what `verify.sh` runs on every push, and that script
|
|
185
|
+
* pipes a stage's whole output into its own frame. A passing gate that printed
|
|
186
|
+
* its frame would nest one inside the other on every contributor's push.
|
|
187
|
+
*/
|
|
188
|
+
function reportGate(report: ScannedCitations): void {
|
|
189
|
+
const count = report.unresolved.length
|
|
190
|
+
if (count === 0) return
|
|
191
|
+
|
|
192
|
+
intro('aitk context audit')
|
|
193
|
+
logError(
|
|
194
|
+
count === 1
|
|
195
|
+
? '1 cited path does not resolve'
|
|
196
|
+
: `${count} cited paths do not resolve`,
|
|
197
|
+
)
|
|
198
|
+
pipeOutput(
|
|
199
|
+
report.unresolved
|
|
200
|
+
.map((citation) => `${citation.file}:${citation.line} ${citation.path}`)
|
|
201
|
+
.join('\n'),
|
|
202
|
+
)
|
|
203
|
+
outro()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function plural(count: number, noun: string): string {
|
|
207
|
+
return `${count} ${noun}${count === 1 ? '' : 's'}`
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function reportScope(folders: readonly AuditedFolder[]): void {
|
|
211
|
+
logStep('Scope')
|
|
212
|
+
|
|
213
|
+
for (const folder of folders) {
|
|
214
|
+
logInfo(`${folder.rel}: ${folder.entries.length} entries`)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
type ScannedCitations = Extract<CitationReport, { kind: 'scanned' }>
|
|
219
|
+
|
|
220
|
+
function reportCitations(report: ScannedCitations): void {
|
|
221
|
+
logStep('Citations')
|
|
222
|
+
logInfo(
|
|
223
|
+
`${plural(report.total, 'cited path')} across ${plural(report.scanned, 'file')}, fixtures and fenced examples excluded`,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if (report.unresolved.length === 0) {
|
|
227
|
+
logInfo('Every cited path resolves.')
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
logWarn(`${report.unresolved.length} unresolved`)
|
|
232
|
+
pipeOutput(
|
|
233
|
+
report.unresolved
|
|
234
|
+
.map((citation) => `${citation.file}:${citation.line} ${citation.path}`)
|
|
235
|
+
.join('\n'),
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function reportLength(entries: readonly EntryReport[]): void {
|
|
240
|
+
logStep('Length')
|
|
241
|
+
|
|
242
|
+
const over = entries
|
|
243
|
+
.filter((entry) => entry.lines > LENGTH_CHECKPOINT)
|
|
244
|
+
.sort((a, b) => b.lines - a.lines)
|
|
245
|
+
|
|
246
|
+
if (over.length === 0) {
|
|
247
|
+
logInfo(`No entry past the ${LENGTH_CHECKPOINT}-line checkpoint.`)
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
logWarn(`${over.length} past the ${LENGTH_CHECKPOINT}-line checkpoint`)
|
|
252
|
+
pipeOutput(
|
|
253
|
+
over.map((entry) => `${entry.rel} ${entry.lines} lines`).join('\n'),
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Names the blank-line convention on every run.
|
|
259
|
+
*
|
|
260
|
+
* The standard settles heading level and fenced blocks and stops there, so a
|
|
261
|
+
* hand reader who drops blank lines lands a line or two below this number.
|
|
262
|
+
* Stating it is what keeps the two measurements reconcilable.
|
|
263
|
+
*/
|
|
264
|
+
function reportDepth(entries: readonly EntryReport[]): void {
|
|
265
|
+
logStep('Depth')
|
|
266
|
+
logInfo('Runs count blank lines. Fenced blocks and peer lists are excluded.')
|
|
267
|
+
|
|
268
|
+
const over = entries
|
|
269
|
+
.filter((entry) => entry.longestRun > RUN_CHECKPOINT)
|
|
270
|
+
.sort((a, b) => b.longestRun - a.longestRun)
|
|
271
|
+
|
|
272
|
+
if (over.length === 0) {
|
|
273
|
+
logInfo(`No run past the ${RUN_CHECKPOINT}-line checkpoint.`)
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
logWarn(`${over.length} past the ${RUN_CHECKPOINT}-line checkpoint`)
|
|
278
|
+
pipeOutput(
|
|
279
|
+
over
|
|
280
|
+
.map(
|
|
281
|
+
(entry) =>
|
|
282
|
+
`${entry.rel}:${entry.longestRunLine} ${entry.longestRun} lines unbroken`,
|
|
283
|
+
)
|
|
284
|
+
.join('\n'),
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function reportTables(entries: readonly EntryReport[]): void {
|
|
289
|
+
logStep('Tables')
|
|
290
|
+
|
|
291
|
+
const candidates = entries.flatMap((entry) =>
|
|
292
|
+
entry.catalogTables.map(
|
|
293
|
+
(table) => `${entry.rel}:${table.line} ${table.rows} rows`,
|
|
294
|
+
),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if (candidates.length === 0) {
|
|
298
|
+
logInfo('No table reads as a catalog that grows a row per shipped thing.')
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
logWarn(`${plural(candidates.length, 'candidate')} for a bullet list`)
|
|
303
|
+
pipeOutput(candidates.join('\n'))
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function reportDrift(drift: readonly FolderDrift[]): void {
|
|
307
|
+
logStep('Index drift')
|
|
308
|
+
|
|
309
|
+
const lines = drift.flatMap((folder) => [
|
|
310
|
+
...folder.unlisted.map((name) => `${folder.rel} unlisted: ${name}`),
|
|
311
|
+
...folder.missing.map((name) => `${folder.rel} missing: ${name}`),
|
|
312
|
+
])
|
|
313
|
+
|
|
314
|
+
if (lines.length === 0) {
|
|
315
|
+
logInfo('Every index agrees with its siblings.')
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
logWarn(plural(lines.length, 'disagreement'))
|
|
320
|
+
pipeOutput(lines.join('\n'))
|
|
321
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { relative } from 'node:path'
|
|
3
|
+
import type { AuditedFolder } from '@/context/folders'
|
|
4
|
+
|
|
5
|
+
/** Checkpoints quoted from `standards/context.md`. Neither is a cap. */
|
|
6
|
+
export const LENGTH_CHECKPOINT = 150
|
|
7
|
+
export const RUN_CHECKPOINT = 40
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A table this size or larger whose first column mostly names artifacts reads
|
|
11
|
+
* as a catalog that grows a row per shipped thing, which is the shape the
|
|
12
|
+
* standard routes to a bullet list. Below it, a table is small enough that a
|
|
13
|
+
* reflow rewrites little.
|
|
14
|
+
*/
|
|
15
|
+
export const CATALOG_ROW_CHECKPOINT = 6
|
|
16
|
+
|
|
17
|
+
/** Share of first cells that must name an artifact for a table to qualify. */
|
|
18
|
+
const CATALOG_NAMED_RATIO = 0.6
|
|
19
|
+
|
|
20
|
+
const FRONTMATTER = /^---\n[\s\S]*?\n---\n?/
|
|
21
|
+
const FENCE = /^\s*(```|~~~)/
|
|
22
|
+
const HEADING = /^#{1,6}\s/
|
|
23
|
+
const LIST_ITEM = /^(\s*)([-*+]|\d+\.)\s+/
|
|
24
|
+
const TABLE_ROW = /^\s*\|/
|
|
25
|
+
const TABLE_SEPARATOR = /^\s*\|[\s:|-]+\|\s*$/
|
|
26
|
+
const NAMED_CELL = /`[^`]+`|\[[^\]]+\]\([^)]+\)/
|
|
27
|
+
|
|
28
|
+
export interface TableFinding {
|
|
29
|
+
readonly line: number
|
|
30
|
+
readonly rows: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface EntryReport {
|
|
34
|
+
readonly rel: string
|
|
35
|
+
readonly lines: number
|
|
36
|
+
readonly longestRun: number
|
|
37
|
+
/** First line of the longest run, or 0 when the entry has no run at all. */
|
|
38
|
+
readonly longestRunLine: number
|
|
39
|
+
readonly catalogTables: readonly TableFinding[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface BodyLine {
|
|
43
|
+
readonly number: number
|
|
44
|
+
readonly text: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Drops the frontmatter while keeping every surviving line's original number,
|
|
49
|
+
* so a finding points at the line an editor opens rather than at an offset into
|
|
50
|
+
* the body.
|
|
51
|
+
*/
|
|
52
|
+
function bodyLines(source: string): BodyLine[] {
|
|
53
|
+
const match = source.match(FRONTMATTER)
|
|
54
|
+
const offset = match ? match[0].split('\n').length - 1 : 0
|
|
55
|
+
|
|
56
|
+
return source
|
|
57
|
+
.slice(match ? match[0].length : 0)
|
|
58
|
+
.replace(/\n$/, '')
|
|
59
|
+
.split('\n')
|
|
60
|
+
.map((text, index) => ({ number: offset + index + 1, text }))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Reports whether a run is the peer list the standard exempts.
|
|
65
|
+
*
|
|
66
|
+
* Every non-blank line has to be a list item at one indent. Prose mixed into
|
|
67
|
+
* the run or a nested level inside it ends the exemption, because either one
|
|
68
|
+
* means the block is no longer a flat set a reader can skim.
|
|
69
|
+
*/
|
|
70
|
+
function isPeerList(run: readonly BodyLine[]): boolean {
|
|
71
|
+
const indents = new Set<number>()
|
|
72
|
+
|
|
73
|
+
for (const line of run) {
|
|
74
|
+
if (line.text.trim() === '') continue
|
|
75
|
+
|
|
76
|
+
const match = line.text.match(LIST_ITEM)
|
|
77
|
+
if (!match) return false
|
|
78
|
+
indents.add(match[1].length)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return indents.size === 1
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Measures the longest run of lines no heading breaks.
|
|
86
|
+
*
|
|
87
|
+
* Fenced blocks are skipped rather than treated as breaks, per the standard:
|
|
88
|
+
* they leave the count without ending the run, so prose either side of an
|
|
89
|
+
* example still measures as the one stretch a reader scrolls through. Blank
|
|
90
|
+
* lines do count, since the checkpoint is about how far a reader travels
|
|
91
|
+
* between signposts and a blank line is distance like any other. A hand reader
|
|
92
|
+
* measuring without them lands one or two lines lower, which the report legend
|
|
93
|
+
* states.
|
|
94
|
+
*/
|
|
95
|
+
function longestRun(lines: readonly BodyLine[]): {
|
|
96
|
+
length: number
|
|
97
|
+
line: number
|
|
98
|
+
} {
|
|
99
|
+
let longest = 0
|
|
100
|
+
let longestLine = 0
|
|
101
|
+
let run: BodyLine[] = []
|
|
102
|
+
let fenced = false
|
|
103
|
+
|
|
104
|
+
const close = (): void => {
|
|
105
|
+
// The reported line is the run's first non-blank one, since that is what an
|
|
106
|
+
// editor should open. A run of nothing but blank lines is the gap between
|
|
107
|
+
// two headings rather than a stretch a reader travels, so it never counts.
|
|
108
|
+
const first = run.find((line) => line.text.trim() !== '')
|
|
109
|
+
|
|
110
|
+
if (first && run.length > longest && !isPeerList(run)) {
|
|
111
|
+
longest = run.length
|
|
112
|
+
longestLine = first.number
|
|
113
|
+
}
|
|
114
|
+
run = []
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const line of lines) {
|
|
118
|
+
if (FENCE.test(line.text)) {
|
|
119
|
+
fenced = !fenced
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
if (fenced) continue
|
|
123
|
+
|
|
124
|
+
if (HEADING.test(line.text)) {
|
|
125
|
+
close()
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
run.push(line)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
close()
|
|
133
|
+
|
|
134
|
+
return { length: longest, line: longestLine }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function firstCell(row: string): string {
|
|
138
|
+
return row.split('|').slice(1)[0] ?? ''
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Finds the tables whose rows name shipped artifacts.
|
|
143
|
+
*
|
|
144
|
+
* A bare table count reports mostly fixed comparison tables, where a reflow
|
|
145
|
+
* costs nothing because no row is ever added. The reflow problem belongs to a
|
|
146
|
+
* catalog that gains a row per artifact, and a first column carrying a path,
|
|
147
|
+
* command, or link is what separates the two without reading the prose.
|
|
148
|
+
*/
|
|
149
|
+
function catalogTables(lines: readonly BodyLine[]): TableFinding[] {
|
|
150
|
+
const findings: TableFinding[] = []
|
|
151
|
+
let fenced = false
|
|
152
|
+
let index = 0
|
|
153
|
+
|
|
154
|
+
while (index < lines.length) {
|
|
155
|
+
const line = lines[index]
|
|
156
|
+
|
|
157
|
+
if (FENCE.test(line.text)) {
|
|
158
|
+
fenced = !fenced
|
|
159
|
+
index++
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
if (fenced || !TABLE_ROW.test(line.text)) {
|
|
163
|
+
index++
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const separator = lines[index + 1]
|
|
168
|
+
if (!separator || !TABLE_SEPARATOR.test(separator.text)) {
|
|
169
|
+
index++
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const body: BodyLine[] = []
|
|
174
|
+
let cursor = index + 2
|
|
175
|
+
while (cursor < lines.length && TABLE_ROW.test(lines[cursor].text)) {
|
|
176
|
+
body.push(lines[cursor])
|
|
177
|
+
cursor++
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const named = body.filter((row) => NAMED_CELL.test(firstCell(row.text)))
|
|
181
|
+
if (
|
|
182
|
+
body.length >= CATALOG_ROW_CHECKPOINT &&
|
|
183
|
+
named.length / body.length >= CATALOG_NAMED_RATIO
|
|
184
|
+
) {
|
|
185
|
+
findings.push({ line: line.number, rows: body.length })
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
index = cursor
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return findings
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function measureEntry(rel: string, source: string): EntryReport {
|
|
195
|
+
const lines = bodyLines(source)
|
|
196
|
+
const run = longestRun(lines)
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
rel,
|
|
200
|
+
lines: source.replace(/\n$/, '').split('\n').length,
|
|
201
|
+
longestRun: run.length,
|
|
202
|
+
longestRunLine: run.line,
|
|
203
|
+
catalogTables: catalogTables(lines),
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Measures every entry in the audited folders. A generated `index.md` is not
|
|
209
|
+
* among them, since its body is rewritten on every regen and no checkpoint
|
|
210
|
+
* describes a catalog.
|
|
211
|
+
*/
|
|
212
|
+
export async function measureFolders(
|
|
213
|
+
root: string,
|
|
214
|
+
folders: readonly AuditedFolder[],
|
|
215
|
+
): Promise<EntryReport[]> {
|
|
216
|
+
const reports: EntryReport[] = []
|
|
217
|
+
|
|
218
|
+
for (const folder of folders) {
|
|
219
|
+
for (const path of folder.entries) {
|
|
220
|
+
reports.push(
|
|
221
|
+
measureEntry(relative(root, path), await readFile(path, 'utf8')),
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return reports
|
|
227
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { $ } from 'bun'
|
|
5
|
+
import { gitEnv } from '@/git-env'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Suppresses citation checking for the source line carrying it.
|
|
9
|
+
*
|
|
10
|
+
* Prose that teaches a naming pattern displays a path rather than pointing at
|
|
11
|
+
* one, and no syntax separates the two: an illustration and a reference are
|
|
12
|
+
* both inline code in a sentence. Location covers the fixture trees and fenced
|
|
13
|
+
* examples, and this covers what is left, which is a sentence naming a
|
|
14
|
+
* hypothetical entry to show the shape of the name.
|
|
15
|
+
*/
|
|
16
|
+
export const IGNORE_MARKER = 'audit-ignore-citations'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Trees holding content authored to be parsed rather than followed.
|
|
20
|
+
*
|
|
21
|
+
* Sandbox scenarios describe paths inside their own scratch fixtures and the
|
|
22
|
+
* eval harness names paths in its target project. Neither is a reference into
|
|
23
|
+
* this repository, so an unresolved path there is correct rather than stale.
|
|
24
|
+
*/
|
|
25
|
+
const FIXTURE_TREES: readonly string[] = ['scripts/sandbox/', 'scripts/eval/']
|
|
26
|
+
|
|
27
|
+
const FIXTURE_SEGMENTS: readonly string[] = ['fixtures', '__fixtures__']
|
|
28
|
+
|
|
29
|
+
const FENCE = /^\s*(```|~~~)/
|
|
30
|
+
|
|
31
|
+
export interface Citation {
|
|
32
|
+
readonly file: string
|
|
33
|
+
readonly line: number
|
|
34
|
+
readonly path: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isFixture(rel: string): boolean {
|
|
38
|
+
if (rel.endsWith('.test.ts')) return true
|
|
39
|
+
if (FIXTURE_TREES.some((tree) => rel.startsWith(tree))) return true
|
|
40
|
+
return rel.split('/').some((segment) => FIXTURE_SEGMENTS.includes(segment))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function citationPattern(folders: readonly string[]): RegExp {
|
|
44
|
+
const names = folders.map((name) =>
|
|
45
|
+
name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
|
|
46
|
+
)
|
|
47
|
+
return new RegExp(
|
|
48
|
+
`\\.claude/(?:${names.join('|')})/[A-Za-z0-9._/-]+\\.md`,
|
|
49
|
+
'g',
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Pulls the cited paths out of one file's text.
|
|
55
|
+
*
|
|
56
|
+
* Fenced blocks are skipped for markdown only. A fence is markdown syntax, and
|
|
57
|
+
* applying it to a shell or TypeScript source would let a heredoc of triple
|
|
58
|
+
* backticks silently hide the rest of the file.
|
|
59
|
+
*/
|
|
60
|
+
export function collectCitations(
|
|
61
|
+
rel: string,
|
|
62
|
+
text: string,
|
|
63
|
+
pattern: RegExp,
|
|
64
|
+
): Citation[] {
|
|
65
|
+
const isMarkdown = rel.endsWith('.md')
|
|
66
|
+
const found: Citation[] = []
|
|
67
|
+
let fenced = false
|
|
68
|
+
|
|
69
|
+
for (const [index, line] of text.split('\n').entries()) {
|
|
70
|
+
if (isMarkdown && FENCE.test(line)) {
|
|
71
|
+
fenced = !fenced
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
if (fenced || line.includes(IGNORE_MARKER)) continue
|
|
75
|
+
|
|
76
|
+
for (const match of line.matchAll(pattern)) {
|
|
77
|
+
found.push({ file: rel, line: index + 1, path: match[0] })
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return found
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Lists the files a citation can live in: tracked, plus untracked files git
|
|
86
|
+
* does not ignore. The untracked half is what keeps a new entry's references
|
|
87
|
+
* checked on the branch that adds it rather than one push later.
|
|
88
|
+
*
|
|
89
|
+
* Returns undefined when git cannot answer, which the caller reports rather
|
|
90
|
+
* than smoothing into an empty list. An empty list resolves every one of its
|
|
91
|
+
* zero citations, so a degraded git would otherwise turn the push gate into an
|
|
92
|
+
* unconditional pass with output indistinguishable from a clean tree.
|
|
93
|
+
*/
|
|
94
|
+
async function listFiles(root: string): Promise<string[] | undefined> {
|
|
95
|
+
const run = async (args: string[]): Promise<string[] | undefined> => {
|
|
96
|
+
const result = await $`git -C ${root} ${args}`
|
|
97
|
+
.env(gitEnv())
|
|
98
|
+
.quiet()
|
|
99
|
+
.nothrow()
|
|
100
|
+
if (result.exitCode !== 0) return undefined
|
|
101
|
+
return result.text().split('\n').filter(Boolean)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const tracked = await run(['ls-files'])
|
|
105
|
+
const untracked = await run(['ls-files', '--others', '--exclude-standard'])
|
|
106
|
+
if (!tracked || !untracked) return undefined
|
|
107
|
+
|
|
108
|
+
return [...new Set([...tracked, ...untracked])].sort()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* `unavailable` is a distinct state from a clean scan.
|
|
113
|
+
*
|
|
114
|
+
* Finding nothing and being unable to look mean opposite things, and only one
|
|
115
|
+
* of them should let a push through.
|
|
116
|
+
*/
|
|
117
|
+
export type CitationReport =
|
|
118
|
+
| {
|
|
119
|
+
readonly kind: 'scanned'
|
|
120
|
+
readonly scanned: number
|
|
121
|
+
readonly total: number
|
|
122
|
+
readonly unresolved: readonly Citation[]
|
|
123
|
+
}
|
|
124
|
+
| { readonly kind: 'unavailable' }
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolves every cited path outside the fixture trees.
|
|
128
|
+
*
|
|
129
|
+
* This is the only check that gates the repository check, which makes it the
|
|
130
|
+
* only one whose false positives cost a contributor anything. It reports a
|
|
131
|
+
* finding solely for a path that does not resolve on disk, and the exclusions
|
|
132
|
+
* above are what keep that from firing on prose about paths.
|
|
133
|
+
*/
|
|
134
|
+
export async function auditCitations(
|
|
135
|
+
root: string,
|
|
136
|
+
folders: readonly string[],
|
|
137
|
+
): Promise<CitationReport> {
|
|
138
|
+
const pattern = citationPattern(folders)
|
|
139
|
+
const listed = await listFiles(root)
|
|
140
|
+
if (!listed) return { kind: 'unavailable' }
|
|
141
|
+
|
|
142
|
+
const candidates = listed.filter((rel) => !isFixture(rel))
|
|
143
|
+
const citations: Citation[] = []
|
|
144
|
+
|
|
145
|
+
for (const rel of candidates) {
|
|
146
|
+
const path = resolve(root, rel)
|
|
147
|
+
if (!existsSync(path)) continue
|
|
148
|
+
|
|
149
|
+
let text: string
|
|
150
|
+
try {
|
|
151
|
+
text = await readFile(path, 'utf8')
|
|
152
|
+
} catch {
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
citations.push(...collectCitations(rel, text, pattern))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
kind: 'scanned',
|
|
161
|
+
scanned: candidates.length,
|
|
162
|
+
total: citations.length,
|
|
163
|
+
unresolved: citations.filter(
|
|
164
|
+
(citation) => !existsSync(resolve(root, citation.path)),
|
|
165
|
+
),
|
|
166
|
+
}
|
|
167
|
+
}
|