@erclx/aitk 3.52.1 → 3.54.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-pr-review/SKILL.md +8 -0
- package/docs/agents/commands.md +1 -0
- package/docs/agents/index.md +1 -0
- package/docs/agents/key-changes.md +103 -0
- package/docs/agents/sandbox.md +11 -7
- package/docs/operating-model.md +8 -0
- package/package.json +1 -1
- package/src/cli.ts +3 -0
- package/src/commands/pr.ts +411 -0
- package/src/commands/sandbox.ts +22 -5
- package/src/pr/bijection.ts +145 -0
- package/src/pr/paths.ts +335 -0
- package/src/sandbox/expect.ts +97 -7
package/src/commands/sandbox.ts
CHANGED
|
@@ -76,6 +76,8 @@ const CLEAN_ENVELOPE: RunEnvelope = { isError: false, turns: 0, denials: 0 }
|
|
|
76
76
|
interface CheckOptions {
|
|
77
77
|
readonly envelope?: string
|
|
78
78
|
readonly writes?: string
|
|
79
|
+
readonly escapes?: string
|
|
80
|
+
readonly escapesWatched?: boolean
|
|
79
81
|
readonly json?: boolean
|
|
80
82
|
readonly strict?: boolean
|
|
81
83
|
}
|
|
@@ -152,11 +154,13 @@ function readEnvelope(path: string | undefined): RunEnvelope | undefined {
|
|
|
152
154
|
}
|
|
153
155
|
|
|
154
156
|
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
157
|
+
* Shared by `--writes` and `--escapes`, which are both a newline-delimited
|
|
158
|
+
* path list written by `run.sh`. Undefined when no file was given, which is
|
|
159
|
+
* not the same as a run that produced no paths. Write scope and escape scope
|
|
160
|
+
* both need that distinction: an empty list is a finding, an absent list is a
|
|
161
|
+
* gap in what the caller supplied.
|
|
158
162
|
*/
|
|
159
|
-
function
|
|
163
|
+
function readPathList(path: string | undefined): string[] | undefined {
|
|
160
164
|
if (path === undefined) return undefined
|
|
161
165
|
if (!existsSync(path)) return []
|
|
162
166
|
|
|
@@ -360,7 +364,12 @@ function runCheck(
|
|
|
360
364
|
expectFilePath(PROJECT_ROOT, parsed.category, parsed.command, arm ?? ''),
|
|
361
365
|
{
|
|
362
366
|
sandboxDir,
|
|
363
|
-
writes:
|
|
367
|
+
writes: readPathList(options.writes),
|
|
368
|
+
escapes: readPathList(options.escapes),
|
|
369
|
+
escapesWatched:
|
|
370
|
+
options.escapes === undefined
|
|
371
|
+
? undefined
|
|
372
|
+
: options.escapesWatched === true,
|
|
364
373
|
envelope: readEnvelope(options.envelope),
|
|
365
374
|
},
|
|
366
375
|
)
|
|
@@ -404,6 +413,14 @@ export function register(program: Command): void {
|
|
|
404
413
|
.helpOption('-h, --help', 'Show this help message')
|
|
405
414
|
.option('--envelope <file>', 'Run envelope JSON from claude -p')
|
|
406
415
|
.option('--writes <file>', 'Newline-delimited paths the session wrote')
|
|
416
|
+
.option(
|
|
417
|
+
'--escapes <file>',
|
|
418
|
+
'Newline-delimited paths written to a watched toolkit root, for escape scope',
|
|
419
|
+
)
|
|
420
|
+
.option(
|
|
421
|
+
'--escapes-watched',
|
|
422
|
+
'At least one watched root held a target this run, so a zero-escape file is a clean watch rather than one with nothing to watch',
|
|
423
|
+
)
|
|
407
424
|
.option('--json', 'Emit the verdict as JSON on stdout')
|
|
408
425
|
.option('--strict', 'Exit non-zero when the arm declares no expectation')
|
|
409
426
|
.addHelpText(
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { extractKeyChangePaths, KEY_CHANGES, type PathClaim } from '@/pr/paths'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Why the comparison produced no reading.
|
|
5
|
+
*
|
|
6
|
+
* The three are apart because each names a different repair. `no-section` is a
|
|
7
|
+
* body missing the heading, `no-claims` is the extractor failing over a section
|
|
8
|
+
* full of prose, and `no-changes` is a pull request with no files. Folding any
|
|
9
|
+
* of them into a clean pass is the failure this check exists to prevent, since
|
|
10
|
+
* a reader cannot tell a check that found nothing from one that read nothing.
|
|
11
|
+
*/
|
|
12
|
+
export type BijectionRefusal = 'no-section' | 'no-claims' | 'no-changes'
|
|
13
|
+
|
|
14
|
+
export interface BijectionReport {
|
|
15
|
+
readonly kind: 'measured'
|
|
16
|
+
/** The commit the changed set was read at, which a finding has to name. */
|
|
17
|
+
readonly head: string | undefined
|
|
18
|
+
readonly changed: readonly string[]
|
|
19
|
+
readonly claims: readonly PathClaim[]
|
|
20
|
+
/**
|
|
21
|
+
* Anchored claims no changed file answers, which is the graded direction. A
|
|
22
|
+
* bullet naming an untouched file is wrong more often than not, and this
|
|
23
|
+
* corpus reported zero of them across 23 correct bodies.
|
|
24
|
+
*/
|
|
25
|
+
readonly unmet: readonly PathClaim[]
|
|
26
|
+
/**
|
|
27
|
+
* Claims whose first segment names no entry in the tree, so the comparison
|
|
28
|
+
* could not judge them either way. Reported so a run says what it declined
|
|
29
|
+
* rather than counting a partial spelling as met.
|
|
30
|
+
*/
|
|
31
|
+
readonly unresolved: readonly PathClaim[]
|
|
32
|
+
/**
|
|
33
|
+
* Changed files no claim reaches. Reported without a severity, since the
|
|
34
|
+
* class covers a real omission and equally a lockfile, a generated asset, or
|
|
35
|
+
* a regenerated index that legitimately earns no bullet.
|
|
36
|
+
*/
|
|
37
|
+
readonly unnamed: readonly string[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type Bijection =
|
|
41
|
+
| BijectionReport
|
|
42
|
+
| { readonly kind: 'refused'; readonly reason: BijectionRefusal }
|
|
43
|
+
|
|
44
|
+
export interface BijectionInput {
|
|
45
|
+
readonly body: string
|
|
46
|
+
readonly changed: readonly string[]
|
|
47
|
+
/** Top-level entries the tree holds, which decides what counts as anchored. */
|
|
48
|
+
readonly roots: ReadonlySet<string>
|
|
49
|
+
readonly head?: string
|
|
50
|
+
readonly title?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether one changed path is the file, or a file under the folder, a claim
|
|
55
|
+
* names.
|
|
56
|
+
*
|
|
57
|
+
* An unanchored claim matches on a segment-anchored suffix, which is what lets
|
|
58
|
+
* `claude-worker/SKILL.md` credit `claude/skills/claude-worker/SKILL.md`. That
|
|
59
|
+
* asymmetry is deliberate: a partial spelling can confirm a changed file was
|
|
60
|
+
* named and never accuse one of being absent, because nothing here separates a
|
|
61
|
+
* path written short from a path written wrong.
|
|
62
|
+
*/
|
|
63
|
+
function covers(claim: PathClaim, path: string): boolean {
|
|
64
|
+
if (claim.directory) {
|
|
65
|
+
return claim.anchored
|
|
66
|
+
? path.startsWith(claim.path)
|
|
67
|
+
: path.includes(`/${claim.path}`)
|
|
68
|
+
}
|
|
69
|
+
if (path === claim.path) return true
|
|
70
|
+
return !claim.anchored && path.endsWith(`/${claim.path}`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Compares what a pull request body claims to have changed against what it
|
|
75
|
+
* actually changed, in both directions.
|
|
76
|
+
*
|
|
77
|
+
* The two directions are reported apart because they want different
|
|
78
|
+
* tolerances. A claim nobody made good on is a defect in the record that
|
|
79
|
+
* squash-merges onto the trunk, and a changed file nobody recorded is often
|
|
80
|
+
* correct. Merging them into one count would either grade the second or excuse
|
|
81
|
+
* the first.
|
|
82
|
+
*
|
|
83
|
+
* Pure, so the whole judgment is testable against a fixture. The caller reads
|
|
84
|
+
* the body, the changed set, and the tree roots and hands all three in.
|
|
85
|
+
*/
|
|
86
|
+
export function compareKeyChanges(input: BijectionInput): Bijection {
|
|
87
|
+
if (input.changed.length === 0)
|
|
88
|
+
return { kind: 'refused', reason: 'no-changes' }
|
|
89
|
+
|
|
90
|
+
const read = extractKeyChangePaths(
|
|
91
|
+
input.body,
|
|
92
|
+
input.roots,
|
|
93
|
+
input.title ?? KEY_CHANGES,
|
|
94
|
+
)
|
|
95
|
+
if (read.kind === 'no-section')
|
|
96
|
+
return { kind: 'refused', reason: 'no-section' }
|
|
97
|
+
if (read.claims.length === 0) return { kind: 'refused', reason: 'no-claims' }
|
|
98
|
+
|
|
99
|
+
const unmet: PathClaim[] = []
|
|
100
|
+
const unresolved: PathClaim[] = []
|
|
101
|
+
const named = new Set<string>()
|
|
102
|
+
|
|
103
|
+
for (const claim of read.claims) {
|
|
104
|
+
const hits = input.changed.filter((path) => covers(claim, path))
|
|
105
|
+
for (const path of hits) named.add(path)
|
|
106
|
+
if (hits.length > 0) continue
|
|
107
|
+
if (claim.anchored) unmet.push(claim)
|
|
108
|
+
else unresolved.push(claim)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
kind: 'measured',
|
|
113
|
+
head: input.head,
|
|
114
|
+
changed: [...input.changed],
|
|
115
|
+
claims: read.claims,
|
|
116
|
+
unmet,
|
|
117
|
+
unresolved,
|
|
118
|
+
unnamed: input.changed.filter((path) => !named.has(path)),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The top-level folders a claim may be anchored on: the first segment of every
|
|
124
|
+
* path the tree holds, plus the first segment of every changed path.
|
|
125
|
+
*
|
|
126
|
+
* The changed half is what admits a folder this branch created. Reading the
|
|
127
|
+
* tree alone would mark every claim under a new top-level directory unanchored,
|
|
128
|
+
* and an unanchored claim never accuses, so the first branch to open one would
|
|
129
|
+
* silently lose the graded direction.
|
|
130
|
+
*
|
|
131
|
+
* A path with no folder above it contributes nothing, since a claim carrying no
|
|
132
|
+
* slash never reaches the extractor's output and no root would ever be read
|
|
133
|
+
* against it.
|
|
134
|
+
*/
|
|
135
|
+
export function treeRoots(
|
|
136
|
+
tracked: readonly string[],
|
|
137
|
+
changed: readonly string[],
|
|
138
|
+
): Set<string> {
|
|
139
|
+
const roots = new Set<string>()
|
|
140
|
+
for (const path of [...tracked, ...changed]) {
|
|
141
|
+
const at = path.indexOf('/')
|
|
142
|
+
if (at > 0) roots.add(path.slice(0, at))
|
|
143
|
+
}
|
|
144
|
+
return roots
|
|
145
|
+
}
|
package/src/pr/paths.ts
ADDED
|
@@ -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
|
+
}
|