@erclx/aitk 3.52.0 → 3.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,335 @@
1
+ /**
2
+ * The heading whose bullets state what a branch changed.
3
+ *
4
+ * Read alone, and never widened to a sibling section. `## Technical Context`
5
+ * legitimately names files a branch never touched, such as an install stamp
6
+ * inside a target, so a reader that took the whole body would manufacture a
7
+ * finding out of every argument the author made for the change.
8
+ */
9
+ export const KEY_CHANGES = 'Key Changes'
10
+
11
+ /** What one bullet claimed, kept with its bullet so a finding can quote it. */
12
+ export interface PathClaim {
13
+ /** Repository-relative, with a trailing slash kept on a directory claim. */
14
+ readonly path: string
15
+ /** True when the span named a folder, which covers every file beneath it. */
16
+ readonly directory: boolean
17
+ /**
18
+ * True when the first segment names an entry the tree actually holds.
19
+ *
20
+ * An unanchored claim is a path written partially, such as
21
+ * `claude-worker/SKILL.md` for a file under `claude/skills/`. It can confirm
22
+ * that a changed file was named and can never accuse one of being absent,
23
+ * because the comparison has no way to tell a partial spelling from a
24
+ * genuinely wrong one.
25
+ */
26
+ readonly anchored: boolean
27
+ /** The span exactly as the body wrote it, before the line suffix came off. */
28
+ readonly span: string
29
+ /** One-based index of the bullet inside the section. */
30
+ readonly bullet: number
31
+ /** The bullet, trimmed, so a finding names the sentence it came from. */
32
+ readonly preview: string
33
+ }
34
+
35
+ export type KeyChangeRead =
36
+ | {
37
+ readonly kind: 'read'
38
+ readonly claims: readonly PathClaim[]
39
+ /** Bullets the section carried, so an empty claim set is separable. */
40
+ readonly bullets: number
41
+ }
42
+ | { readonly kind: 'no-section' }
43
+
44
+ /** The longest bullet a claim carries forward, matching the citation sweep. */
45
+ const PREVIEW_LIMIT = 200
46
+
47
+ /** A backticked span, the only carrier this corpus writes a path in. */
48
+ const BACKTICKED = /`([^`\n]+)`/g
49
+
50
+ /** A list item at any indent, in either bullet spelling or as an ordinal. */
51
+ const BULLET = /^\s*(?:[-*+]|\d+\.)\s+(.*)$/
52
+
53
+ const FENCE = /^\s*(?:```|~~~)/
54
+
55
+ const HEADING = /^(#{1,6})\s+(.+?)\s*$/
56
+
57
+ /**
58
+ * A `file.ts:42` or `file.ts:42-58` suffix, which is a reader's click target
59
+ * rather than part of the name.
60
+ */
61
+ const LINE_SUFFIX = /:\d+(?:-\d+)?$/
62
+
63
+ /**
64
+ * A character that puts the span outside a path this comparison resolves.
65
+ *
66
+ * Whitespace separates a backticked command from a backticked path, and it is
67
+ * the whole answer to one of the four observed false-positive classes:
68
+ * `aitk markdown audit .claude/rules --json` carries a slash and names no file.
69
+ * Angle brackets answer a second, since `.claude/plans/feature-<slug>.md`
70
+ * describes a shape rather than naming a file. A glob and a caret describe a
71
+ * shape too, `^src/` being a grep pattern one body spelled in Key Changes, and
72
+ * a leading anchor names something outside this repository.
73
+ *
74
+ * Deliberately not shared with `classifySpan` in `@/gov/citations`, which asks
75
+ * a different question. That sweep resolves what a rule points a reader at,
76
+ * against the filesystem, with a sibling resolving inside the citing rule's own
77
+ * folder. This one resolves what a bullet claims to have changed, against a
78
+ * changed-file list, and it admits a folder where that sweep declines one.
79
+ */
80
+ function isNotRepositoryPath(span: string): boolean {
81
+ if (/[\s<>$*|?^]/.test(span)) return true
82
+ if (span.includes('://')) return true
83
+ return /^[/~@#!]/.test(span)
84
+ }
85
+
86
+ /**
87
+ * Whether the span's last segment carries a file extension.
88
+ *
89
+ * The extension has to start with a letter, which is what keeps `127.0.0.1`
90
+ * out. A bare dotted number reaching the comparison is the shape that put
91
+ * `src/serve/127.0.0.1` in a report over a body that was correct.
92
+ */
93
+ function hasExtension(span: string): boolean {
94
+ const segment = span.slice(span.lastIndexOf('/') + 1)
95
+ return /\.[A-Za-z][A-Za-z0-9]*$/.test(segment)
96
+ }
97
+
98
+ /** Blanks every backticked span so a cue search never fires inside one. */
99
+ function maskSpans(text: string): string {
100
+ return text.replace(/`[^`\n]*`/g, (span) => ' '.repeat(span.length))
101
+ }
102
+
103
+ /**
104
+ * The part of a bullet that asserts a change, which ends at its first comma.
105
+ *
106
+ * This is the one lever that separates a claim from a mention, and it was
107
+ * chosen by measurement rather than by grammar. Over the 23 merged pull
108
+ * requests in this repository that carry the section, reading whole bullets
109
+ * reported 16 paths as claimed-but-untouched and every one of them was a file
110
+ * the body named for context. Cutting at the comma left 110 claims of the
111
+ * original 149 and took the false reports to 2. A list of clause-opening words
112
+ * tried beside it (`which`, `since`, `because`, `rather than`, and eleven more)
113
+ * removed nothing the comma had not already removed, because this corpus
114
+ * punctuates every one of them.
115
+ *
116
+ * What it costs is a claim in a second coordinated clause, as in "Add `x` to
117
+ * `a.ts`, and delete the old inline `y` from `b.ts`", where `b.ts` stops being
118
+ * claimed and falls to the unnamed direction instead. That direction reports
119
+ * without grading, so the cost lands where it does no damage.
120
+ */
121
+ function claimRegion(bullet: string): string {
122
+ const at = maskSpans(bullet).indexOf(',')
123
+ return at === -1 ? bullet : bullet.slice(0, at)
124
+ }
125
+
126
+ /**
127
+ * A claim region that asserts nothing changed.
128
+ *
129
+ * A body writes such a bullet to record a decision it declined, and the path it
130
+ * names is the file it deliberately did not touch, which is the exact inverse
131
+ * of a claim. `#1274` opens one with "Leave `...expect.toml` untouched" and the
132
+ * path sits ahead of the first comma, so the region cut cannot reach it: a
133
+ * stricter cut would not catch this and a looser one would find more.
134
+ *
135
+ * The marker rather than the leading verb decides it, because `keep` and
136
+ * `leave` both open a real claim often enough and neither is safe alone. The
137
+ * set is deliberately three words. `in place` was measured and dropped, since
138
+ * rewriting a file in place is an ordinary claim, and `no other line` was
139
+ * dropped because `#1269` writes "as one insertion that touches no other line"
140
+ * about a change it did make. `alone` was in the set and came out on review:
141
+ * every occurrence across the 40-pull-request corpus sits past the first
142
+ * comma, where the region cut already excludes it, so the word caught nothing
143
+ * real there. Kept, it turns restrictive on a comma-free bullet, which is this
144
+ * repository's more common use of the word: "Move the threshold read into
145
+ * `src/gate/stages.ts` alone." asserts an edit and voided to an empty claim
146
+ * set while the word was in the set, unlike the other three, which disclaim
147
+ * wherever they land.
148
+ */
149
+ const NO_CHANGE =
150
+ /\b(?:untouched|unchanged)\b|\bas written\b|^\s*(?:do not|don't|never)\b/i
151
+
152
+ function disclaimsChange(region: string): boolean {
153
+ return NO_CHANGE.test(maskSpans(region))
154
+ }
155
+
156
+ /**
157
+ * The lines under a heading, ending at the next heading of the same level or
158
+ * higher. Undefined when the body carries no such heading, which the caller
159
+ * reports rather than reading as an empty section.
160
+ */
161
+ export function readSection(body: string, title: string): string | undefined {
162
+ const lines = body.replace(/\r\n/g, '\n').split('\n')
163
+ const wanted = title.toLowerCase()
164
+
165
+ let start = -1
166
+ let level = 0
167
+ let fenced = false
168
+
169
+ for (const [index, line] of lines.entries()) {
170
+ if (FENCE.test(line)) {
171
+ fenced = !fenced
172
+ continue
173
+ }
174
+ if (fenced) continue
175
+ const heading = line.match(HEADING)
176
+ if (heading === null) continue
177
+ if ((heading[2] ?? '').toLowerCase() !== wanted) continue
178
+ start = index + 1
179
+ level = (heading[1] ?? '').length
180
+ break
181
+ }
182
+
183
+ if (start === -1) return undefined
184
+
185
+ const out: string[] = []
186
+ fenced = false
187
+ for (const line of lines.slice(start)) {
188
+ if (FENCE.test(line)) fenced = !fenced
189
+ const heading = fenced ? null : line.match(HEADING)
190
+ if (heading !== null && (heading[1] ?? '').length <= level) break
191
+ out.push(line)
192
+ }
193
+
194
+ return out.join('\n')
195
+ }
196
+
197
+ /**
198
+ * Splits a section into bullets, folding a wrapped continuation line into the
199
+ * bullet above it and starting a new one at every list marker.
200
+ *
201
+ * A nested bullet is its own bullet rather than part of its parent, which keeps
202
+ * one claim region per claim a reader sees.
203
+ */
204
+ function splitBullets(section: string): string[] {
205
+ const bullets: string[] = []
206
+ let current: string[] | undefined
207
+ let fenced = false
208
+
209
+ for (const line of section.split('\n')) {
210
+ if (FENCE.test(line)) {
211
+ fenced = !fenced
212
+ current?.push(line)
213
+ continue
214
+ }
215
+
216
+ const marker = fenced ? null : line.match(BULLET)
217
+ if (marker !== null) {
218
+ if (current !== undefined) bullets.push(current.join(' '))
219
+ current = [marker[1] ?? '']
220
+ continue
221
+ }
222
+
223
+ if (current === undefined) continue
224
+ if (!fenced && line.trim() === '') {
225
+ bullets.push(current.join(' '))
226
+ current = undefined
227
+ continue
228
+ }
229
+ current.push(line.trim())
230
+ }
231
+
232
+ if (current !== undefined) bullets.push(current.join(' '))
233
+ return bullets
234
+ }
235
+
236
+ /** What one span resolved to, or nothing when it names no comparable path. */
237
+ interface ResolvedSpan {
238
+ readonly path: string
239
+ readonly directory: boolean
240
+ }
241
+
242
+ function resolveSpan(span: string): ResolvedSpan | undefined {
243
+ if (span === '' || isNotRepositoryPath(span)) return undefined
244
+
245
+ // A bare name is the fourth observed false-positive class and it drops
246
+ // outright. Resolved as a sibling of a path earlier in the bullet it produced
247
+ // seven wrong paths across this corpus against two right ones, because a
248
+ // compound bullet names a sibling folder as often as a sibling file. Dropping
249
+ // it under-reports in the unnamed direction and never fires in the other.
250
+ if (!span.includes('/')) return undefined
251
+
252
+ if (span.endsWith('/')) {
253
+ // A single top-level folder is never a claim. Nobody reports having changed
254
+ // the whole of `src/`, and every body that spelled one was naming where
255
+ // something lives.
256
+ return span.indexOf('/') === span.length - 1
257
+ ? undefined
258
+ : { path: span, directory: true }
259
+ }
260
+
261
+ return hasExtension(span) ? { path: span, directory: false } : undefined
262
+ }
263
+
264
+ /**
265
+ * Every path the `## Key Changes` section claims a change to.
266
+ *
267
+ * `roots` names the entries the tree holds at its top level, which is what
268
+ * separates a whole path from one written partially. It is passed in rather
269
+ * than read here so the extractor stays a pure function of the body, and there
270
+ * is no default: an absent set would silently mark every claim anchored, which
271
+ * is the direction that accuses.
272
+ *
273
+ * Reports `no-section` rather than an empty read when the heading is absent,
274
+ * and an empty claim set with a bullet count when the heading is there and
275
+ * nothing resolved. The caller needs those apart. A body with no section states
276
+ * nothing, a section that produced no claim is this extractor failing over
277
+ * prose, and only a section that produced claims supports a comparison. An
278
+ * empty extraction read as a clean pass is the failure shape this repository
279
+ * has already recorded twice.
280
+ */
281
+ export function extractKeyChangePaths(
282
+ body: string,
283
+ roots: ReadonlySet<string>,
284
+ title: string = KEY_CHANGES,
285
+ ): KeyChangeRead {
286
+ const section = readSection(body, title)
287
+ if (section === undefined) return { kind: 'no-section' }
288
+
289
+ const bullets = splitBullets(section)
290
+ const claims: PathClaim[] = []
291
+ const seen = new Set<string>()
292
+
293
+ for (const [index, bullet] of bullets.entries()) {
294
+ const trimmed = bullet.trim()
295
+ const preview =
296
+ trimmed.length > PREVIEW_LIMIT
297
+ ? `${trimmed.slice(0, PREVIEW_LIMIT)}…`
298
+ : trimmed
299
+ const region = claimRegion(trimmed)
300
+ if (disclaimsChange(region)) continue
301
+
302
+ let claimed = false
303
+
304
+ for (const match of region.matchAll(BACKTICKED)) {
305
+ const span = match[1] ?? ''
306
+ const bare = span.replace(LINE_SUFFIX, '')
307
+
308
+ // A `file:line` span following another claim in the same bullet is a
309
+ // citation into a file being described rather than a second claim, which
310
+ // is what "the stages at `verify.sh:634` and `:642`" is doing inside a
311
+ // bullet whose claim is the context entry that describes them. Leading
312
+ // its bullet it is an ordinary claim, which is how a body names the exact
313
+ // line it rewrote.
314
+ const cited = bare !== span
315
+ if (cited && claimed) continue
316
+
317
+ const resolved = resolveSpan(bare)
318
+ if (resolved === undefined) continue
319
+ claimed = true
320
+ if (seen.has(resolved.path)) continue
321
+ seen.add(resolved.path)
322
+
323
+ claims.push({
324
+ path: resolved.path,
325
+ directory: resolved.directory,
326
+ anchored: roots.has(resolved.path.slice(0, resolved.path.indexOf('/'))),
327
+ span,
328
+ bullet: index + 1,
329
+ preview,
330
+ })
331
+ }
332
+ }
333
+
334
+ return { kind: 'read', claims, bullets: bullets.length }
335
+ }