@iceinvein/agent-skills 0.23.9 → 0.23.13
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/package.json +1 -1
- package/skills/index.json +2 -2
- package/skills/magpie/SKILL.md +21 -20
- package/skills/magpie/bin/magpie.ts +63 -2
- package/skills/magpie/evals/quality/README.md +74 -1
- package/skills/magpie/evals/quality/__tests__/probe-specialist.test.ts +498 -0
- package/skills/magpie/evals/quality/__tests__/replay-full.test.ts +9 -0
- package/skills/magpie/evals/quality/probe-specialist.ts +672 -0
- package/skills/magpie/evals/quality/replay-full.ts +6 -2
- package/skills/magpie/fixtures/fake-gh-with-lockfile.sh +37 -2
- package/skills/magpie/fixtures/fake-gh.sh +49 -2
- package/skills/magpie/references/critic.md +20 -4
- package/skills/magpie/references/proof.md +102 -0
- package/skills/magpie/references/scout.md +6 -1
- package/skills/magpie/references/specialists.md +15 -9
- package/skills/magpie/scripts/__tests__/cleanup-cmd.test.ts +103 -7
- package/skills/magpie/scripts/__tests__/cli.test.ts +69 -0
- package/skills/magpie/scripts/__tests__/critic-cmd.test.ts +46 -0
- package/skills/magpie/scripts/__tests__/critic.test.ts +11 -1
- package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +20 -1
- package/skills/magpie/scripts/__tests__/git-diff.test.ts +23 -4
- package/skills/magpie/scripts/__tests__/handoff-cmd.test.ts +68 -0
- package/skills/magpie/scripts/__tests__/history.test.ts +232 -0
- package/skills/magpie/scripts/__tests__/impact-cmd.test.ts +477 -0
- package/skills/magpie/scripts/__tests__/impact-render.test.ts +365 -0
- package/skills/magpie/scripts/__tests__/impact.test.ts +915 -0
- package/skills/magpie/scripts/__tests__/incremental.test.ts +59 -8
- package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +1 -1
- package/skills/magpie/scripts/__tests__/post-cmd.test.ts +58 -0
- package/skills/magpie/scripts/__tests__/pr-activity.test.ts +281 -0
- package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +4 -4
- package/skills/magpie/scripts/__tests__/previous-findings.test.ts +267 -0
- package/skills/magpie/scripts/__tests__/proof-cmd.test.ts +260 -0
- package/skills/magpie/scripts/__tests__/proof.test.ts +226 -0
- package/skills/magpie/scripts/__tests__/refresh.test.ts +71 -0
- package/skills/magpie/scripts/__tests__/render-annotation.test.ts +34 -0
- package/skills/magpie/scripts/__tests__/render-findings.test.ts +75 -0
- package/skills/magpie/scripts/__tests__/render-progress.test.ts +9 -0
- package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +376 -1
- package/skills/magpie/scripts/__tests__/shard-coverage.test.ts +79 -0
- package/skills/magpie/scripts/__tests__/skill-lint.test.ts +48 -2
- package/skills/magpie/scripts/__tests__/status-cmd.test.ts +20 -8
- package/skills/magpie/scripts/__tests__/types.test.ts +46 -0
- package/skills/magpie/scripts/cleanup-cmd.ts +88 -0
- package/skills/magpie/scripts/critic-cmd.ts +2 -0
- package/skills/magpie/scripts/critic.ts +4 -0
- package/skills/magpie/scripts/dedupe-cmd.ts +4 -0
- package/skills/magpie/scripts/evidence-filter.ts +10 -0
- package/skills/magpie/scripts/git-diff.ts +50 -19
- package/skills/magpie/scripts/handoff-cmd.ts +42 -3
- package/skills/magpie/scripts/history.ts +143 -0
- package/skills/magpie/scripts/impact-cmd.ts +202 -0
- package/skills/magpie/scripts/impact-render.ts +158 -0
- package/skills/magpie/scripts/impact.ts +622 -0
- package/skills/magpie/scripts/incremental.ts +44 -5
- package/skills/magpie/scripts/labels-cmd.ts +7 -3
- package/skills/magpie/scripts/post-cmd.ts +46 -0
- package/skills/magpie/scripts/pr-activity.ts +243 -0
- package/skills/magpie/scripts/preview-cmd.ts +3 -0
- package/skills/magpie/scripts/previous-findings.ts +181 -0
- package/skills/magpie/scripts/proof-cmd.ts +226 -0
- package/skills/magpie/scripts/proof.ts +150 -0
- package/skills/magpie/scripts/refresh.ts +11 -0
- package/skills/magpie/scripts/render-annotation.ts +17 -0
- package/skills/magpie/scripts/render-cmd.ts +12 -0
- package/skills/magpie/scripts/render-findings.ts +37 -1
- package/skills/magpie/scripts/render-progress.ts +3 -0
- package/skills/magpie/scripts/setup-cmd.ts +142 -11
- package/skills/magpie/scripts/shard-coverage.ts +53 -0
- package/skills/magpie/scripts/status-cmd.ts +2 -0
- package/skills/magpie/scripts/types.ts +39 -0
- package/skills/magpie/skill.json +1 -1
- package/skills/magpie/templates/styles.css +30 -0
- package/skills/sluice/scripts/status.sh +32 -13
- package/skills/sluice/skill.json +1 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { appendFile, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import {
|
|
4
|
+
buildImpactMap,
|
|
5
|
+
extractDiffNames,
|
|
6
|
+
type GrepHit,
|
|
7
|
+
type GrepPlan,
|
|
8
|
+
type ImpactMap,
|
|
9
|
+
parseGrepOutput,
|
|
10
|
+
planSearches,
|
|
11
|
+
} from './impact.ts'
|
|
12
|
+
import {
|
|
13
|
+
IMPACT_BRIEF_CAP_BYTES,
|
|
14
|
+
IMPACT_BRIEF_MAX_CHILDREN,
|
|
15
|
+
IMPACT_FULL_CAP_BYTES,
|
|
16
|
+
renderImpactMarkdown,
|
|
17
|
+
} from './impact-render.ts'
|
|
18
|
+
import { loadPathFilterConfig, matchesAny, type PathFilterConfig } from './path-filter.ts'
|
|
19
|
+
import type { ShardEntry } from './shard.ts'
|
|
20
|
+
import { type ReviewTarget, readTarget } from './target.ts'
|
|
21
|
+
|
|
22
|
+
const IMPACT_FILE =
|
|
23
|
+
/^impact(?:\.brief)?\.md$|^impact\.json$|^impact\.shard-.*\.md$|^impact\..*\.tmp$/
|
|
24
|
+
|
|
25
|
+
async function logLine(runDir: string, entry: Record<string, unknown>): Promise<void> {
|
|
26
|
+
await appendFile(join(runDir, 'log.jsonl'), `${JSON.stringify({ ...entry, ts: Date.now() })}\n`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isMissingFile(err: unknown): boolean {
|
|
30
|
+
return (err as NodeJS.ErrnoException)?.code === 'ENOENT'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function readDiff(runDir: string): Promise<string> {
|
|
34
|
+
const path = join(runDir, 'diff.patch')
|
|
35
|
+
try {
|
|
36
|
+
return await readFile(path, 'utf8')
|
|
37
|
+
} catch (err) {
|
|
38
|
+
if (isMissingFile(err)) throw new Error(`${path} not found`)
|
|
39
|
+
throw err
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// git grep exits 1 when nothing matches, which is an empty result, not a failure.
|
|
44
|
+
async function runGrep(root: string, plan: GrepPlan): Promise<GrepHit[]> {
|
|
45
|
+
const proc = Bun.spawn(['git', ...plan.args], {
|
|
46
|
+
cwd: root,
|
|
47
|
+
stdin: new TextEncoder().encode(`${plan.patterns.join('\n')}\n`),
|
|
48
|
+
stdout: 'pipe',
|
|
49
|
+
stderr: 'pipe',
|
|
50
|
+
})
|
|
51
|
+
const [out, err, exit] = await Promise.all([
|
|
52
|
+
new Response(proc.stdout).text(),
|
|
53
|
+
new Response(proc.stderr).text(),
|
|
54
|
+
proc.exited,
|
|
55
|
+
])
|
|
56
|
+
if (exit === 1) return []
|
|
57
|
+
if (exit !== 0) throw new Error(`git grep exited ${exit}: ${err.trim()}`)
|
|
58
|
+
return parseGrepOutput(out)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A run with no manifest was never sharded, which is the same as one shard. */
|
|
62
|
+
async function readShards(runDir: string): Promise<ShardEntry[]> {
|
|
63
|
+
const path = join(runDir, 'shards', 'manifest.json')
|
|
64
|
+
let text: string
|
|
65
|
+
try {
|
|
66
|
+
text = await readFile(path, 'utf8')
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (isMissingFile(err)) return []
|
|
69
|
+
throw err
|
|
70
|
+
}
|
|
71
|
+
const shards = (JSON.parse(text) as { shards?: unknown }).shards
|
|
72
|
+
if (!Array.isArray(shards)) throw new Error(`${path}: shards must be an array`)
|
|
73
|
+
for (const s of shards as Array<Partial<ShardEntry>>) {
|
|
74
|
+
if (typeof s?.id !== 'number' || !Array.isArray(s.files)) {
|
|
75
|
+
throw new Error(`${path}: each shard needs a numeric id and a files array`)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return shards as ShardEntry[]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The same include/exclude decision filterDiff applies to the diff.
|
|
82
|
+
function isFilteredOut(file: string, config: PathFilterConfig): boolean {
|
|
83
|
+
if (config.include.length > 0) return matchesAny(file, config.include) === null
|
|
84
|
+
return matchesAny(file, config.exclude) !== null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function removeImpactFiles(runDir: string): Promise<void> {
|
|
88
|
+
for (const name of await readdir(runDir)) {
|
|
89
|
+
if (IMPACT_FILE.test(name)) await rm(join(runDir, name), { force: true })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Written beside the target and renamed over it, so a run cut off mid-write leaves
|
|
94
|
+
// either the whole file or none: a reader of a half-written file gets malformed JSON.
|
|
95
|
+
async function writeAtomic(path: string, text: string): Promise<void> {
|
|
96
|
+
const temp = `${path}.tmp`
|
|
97
|
+
await writeFile(temp, text)
|
|
98
|
+
await rename(temp, path)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function writeIfNonEmpty(path: string, text: string): Promise<void> {
|
|
102
|
+
if (text.length > 0) await writeAtomic(path, `${text}\n`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function writeRenders(runDir: string, map: ImpactMap, prefix: string, shard: number | null) {
|
|
106
|
+
await writeIfNonEmpty(
|
|
107
|
+
join(runDir, `${prefix}.md`),
|
|
108
|
+
renderImpactMarkdown(map, { shard, capBytes: IMPACT_FULL_CAP_BYTES }),
|
|
109
|
+
)
|
|
110
|
+
await writeIfNonEmpty(
|
|
111
|
+
join(runDir, `${prefix}.brief.md`),
|
|
112
|
+
renderImpactMarkdown(map, {
|
|
113
|
+
shard,
|
|
114
|
+
capBytes: IMPACT_BRIEF_CAP_BYTES,
|
|
115
|
+
maxChildren: IMPACT_BRIEF_MAX_CHILDREN,
|
|
116
|
+
}),
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// setup reads .magpie.json from the user's checkout. A PR run's root is a worktree
|
|
121
|
+
// at the PR head, so the checkout is the one owning the shared git dir.
|
|
122
|
+
async function configDir(target: ReviewTarget): Promise<string> {
|
|
123
|
+
if (target.mode === 'local') return target.root
|
|
124
|
+
const args = ['rev-parse', '--path-format=absolute', '--git-common-dir']
|
|
125
|
+
const proc = Bun.spawn(['git', ...args], { cwd: target.root, stdout: 'pipe', stderr: 'pipe' })
|
|
126
|
+
const [out, err, exit] = await Promise.all([
|
|
127
|
+
new Response(proc.stdout).text(),
|
|
128
|
+
new Response(proc.stderr).text(),
|
|
129
|
+
proc.exited,
|
|
130
|
+
])
|
|
131
|
+
if (exit !== 0) throw new Error(`git ${args.join(' ')} exited ${exit}: ${err.trim()}`)
|
|
132
|
+
return dirname(out.trim())
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function buildMap(
|
|
136
|
+
runDir: string,
|
|
137
|
+
target: ReviewTarget,
|
|
138
|
+
shards: ShardEntry[],
|
|
139
|
+
): Promise<ImpactMap> {
|
|
140
|
+
const root = target.root
|
|
141
|
+
const names = extractDiffNames(await readDiff(runDir))
|
|
142
|
+
const config = await loadPathFilterConfig(await configDir(target))
|
|
143
|
+
const hits: Record<GrepPlan['kind'], GrepHit[]> = {
|
|
144
|
+
references: [],
|
|
145
|
+
definitions: [],
|
|
146
|
+
writers: [],
|
|
147
|
+
routes: [],
|
|
148
|
+
docs: [],
|
|
149
|
+
}
|
|
150
|
+
for (const plan of planSearches(names)) hits[plan.kind].push(...(await runGrep(root, plan)))
|
|
151
|
+
const sharded = shards.length > 1
|
|
152
|
+
return buildImpactMap({
|
|
153
|
+
names,
|
|
154
|
+
hits,
|
|
155
|
+
shardOf: (file) => (sharded ? (shards.find((s) => s.files.includes(file))?.id ?? null) : null),
|
|
156
|
+
excluded: (file) => isFilteredOut(file, config),
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function runImpact(runDir: string): Promise<number> {
|
|
161
|
+
const isDir = await stat(runDir)
|
|
162
|
+
.then((s) => s.isDirectory())
|
|
163
|
+
.catch(() => false)
|
|
164
|
+
if (!isDir) {
|
|
165
|
+
process.stderr.write(`impact: no such run directory: ${runDir}\n`)
|
|
166
|
+
return 2
|
|
167
|
+
}
|
|
168
|
+
let target: ReviewTarget
|
|
169
|
+
try {
|
|
170
|
+
target = await readTarget(runDir)
|
|
171
|
+
} catch (err) {
|
|
172
|
+
process.stderr.write(`impact: ${err instanceof Error ? err.message : String(err)}\n`)
|
|
173
|
+
return 2
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
// Cleared before the search so a skipped re-run leaves no map from an older diff.
|
|
178
|
+
await removeImpactFiles(runDir)
|
|
179
|
+
const shards = await readShards(runDir)
|
|
180
|
+
const map = await buildMap(runDir, target, shards)
|
|
181
|
+
await writeAtomic(join(runDir, 'impact.json'), `${JSON.stringify(map, null, 2)}\n`)
|
|
182
|
+
await writeRenders(runDir, map, 'impact', null)
|
|
183
|
+
if (shards.length > 1) {
|
|
184
|
+
for (const s of shards) await writeRenders(runDir, map, `impact.shard-${s.id}`, s.id)
|
|
185
|
+
}
|
|
186
|
+
await logLine(runDir, {
|
|
187
|
+
stage: 'impact',
|
|
188
|
+
status: 'done',
|
|
189
|
+
symbols: map.symbols.length,
|
|
190
|
+
uses: map.uses.length,
|
|
191
|
+
stores: map.stores.length,
|
|
192
|
+
routes: map.routes.length,
|
|
193
|
+
docs: map.docs.length,
|
|
194
|
+
})
|
|
195
|
+
return 0
|
|
196
|
+
} catch (err) {
|
|
197
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
198
|
+
await logLine(runDir, { stage: 'impact', status: 'skipped', error: message })
|
|
199
|
+
process.stderr.write(`impact: skipped: ${message}\n`)
|
|
200
|
+
return 0
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { COMMON_NAME_HITS, type ImpactLocation, type ImpactMap, type ImpactRef } from './impact.ts'
|
|
2
|
+
|
|
3
|
+
export const IMPACT_FULL_CAP_BYTES = 8192
|
|
4
|
+
export const IMPACT_BRIEF_CAP_BYTES = 2048
|
|
5
|
+
export const IMPACT_BRIEF_MAX_CHILDREN = 3
|
|
6
|
+
|
|
7
|
+
const SECTIONS = [
|
|
8
|
+
'Other writers of the same data',
|
|
9
|
+
'References to changed symbols',
|
|
10
|
+
'References to added symbols',
|
|
11
|
+
'Definitions this change relies on',
|
|
12
|
+
'Sibling routes',
|
|
13
|
+
'Docs naming what changed',
|
|
14
|
+
] as const
|
|
15
|
+
|
|
16
|
+
type Entry = {
|
|
17
|
+
section: number
|
|
18
|
+
anchor: ImpactLocation
|
|
19
|
+
shards: number[]
|
|
20
|
+
head: string
|
|
21
|
+
children: ImpactRef[]
|
|
22
|
+
// Symbols report their hit count; every other kind reports its child count.
|
|
23
|
+
total: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function codeSpan(text: string): string {
|
|
27
|
+
return text.includes('`') ? `\`\` ${text} \`\`` : `\`${text}\``
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function head(name: string, at: ImpactLocation): string {
|
|
31
|
+
return `- \`${name}\` (diff: ${at.file}:${at.line})`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function firstLocation(list: ImpactLocation[], what: string): ImpactLocation {
|
|
35
|
+
const at = list[0]
|
|
36
|
+
if (!at) throw new Error(`Impact ${what} has no diff location`)
|
|
37
|
+
return at
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function shardList(shard: number | null): number[] {
|
|
41
|
+
return shard === null ? [] : [shard]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Entries with no facts outside the diff are left out: they tell a reviewer nothing.
|
|
45
|
+
function entriesOf(map: ImpactMap): Entry[] {
|
|
46
|
+
const entries: Entry[] = []
|
|
47
|
+
for (const s of map.stores) {
|
|
48
|
+
if (s.writers.length === 0) continue
|
|
49
|
+
const anchor = firstLocation(s.usedIn, `store ${s.name}`)
|
|
50
|
+
entries.push({
|
|
51
|
+
section: 0,
|
|
52
|
+
anchor,
|
|
53
|
+
shards: shardList(s.shard),
|
|
54
|
+
head: head(s.name, anchor),
|
|
55
|
+
children: s.writers,
|
|
56
|
+
total: s.writers.length,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
for (const s of map.symbols) {
|
|
60
|
+
const common = s.hits > COMMON_NAME_HITS
|
|
61
|
+
if (!common && s.references.length === 0) continue
|
|
62
|
+
const anchor = firstLocation(s.definedAt, `symbol ${s.name}`)
|
|
63
|
+
entries.push({
|
|
64
|
+
section: s.kind === 'added' ? 2 : 1,
|
|
65
|
+
anchor,
|
|
66
|
+
shards: shardList(s.shard),
|
|
67
|
+
head: common ? `- \`${s.name}\`: ${s.hits} hits, too common to list` : head(s.name, anchor),
|
|
68
|
+
children: common ? [] : s.references,
|
|
69
|
+
total: common ? 0 : s.hits,
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
for (const u of map.uses) {
|
|
73
|
+
if (u.definedAt.length === 0) continue
|
|
74
|
+
entries.push({
|
|
75
|
+
section: 3,
|
|
76
|
+
anchor: u.usedIn,
|
|
77
|
+
shards: shardList(u.shard),
|
|
78
|
+
head: head(u.name, u.usedIn),
|
|
79
|
+
children: u.definedAt,
|
|
80
|
+
total: u.definedAt.length,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
for (const r of map.routes) {
|
|
84
|
+
if (r.siblings.length === 0) continue
|
|
85
|
+
entries.push({
|
|
86
|
+
section: 4,
|
|
87
|
+
anchor: r.usedIn,
|
|
88
|
+
shards: shardList(r.shard),
|
|
89
|
+
head: head(r.path, r.usedIn),
|
|
90
|
+
children: r.siblings,
|
|
91
|
+
total: r.siblings.length,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
for (const d of map.docs) {
|
|
95
|
+
entries.push({
|
|
96
|
+
section: 5,
|
|
97
|
+
anchor: { file: d.file, line: d.line },
|
|
98
|
+
shards: d.shards,
|
|
99
|
+
head: `- ${d.file}:${d.line} ${codeSpan(d.text)} (names: ${d.mentions.join(', ')})`,
|
|
100
|
+
children: [],
|
|
101
|
+
total: 0,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
return entries.sort((a, b) => {
|
|
105
|
+
if (a.section !== b.section) return a.section - b.section
|
|
106
|
+
if (a.anchor.file !== b.anchor.file) return a.anchor.file < b.anchor.file ? -1 : 1
|
|
107
|
+
return a.anchor.line - b.anchor.line
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function entryLines(e: Entry, maxChildren: number): string[] {
|
|
112
|
+
const shown = e.children.slice(0, maxChildren)
|
|
113
|
+
const lines = [e.head, ...shown.map((r) => ` - ${r.file}:${r.line} ${codeSpan(r.text)}`)]
|
|
114
|
+
if (e.total > shown.length) lines.push(` - ${shown.length} of ${e.total} shown`)
|
|
115
|
+
return lines
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function renderEntries(kept: Entry[], left: number, maxChildren: number): string {
|
|
119
|
+
const lines: string[] = []
|
|
120
|
+
let section = -1
|
|
121
|
+
for (const e of kept) {
|
|
122
|
+
if (e.section !== section) {
|
|
123
|
+
if (lines.length > 0) lines.push('')
|
|
124
|
+
section = e.section
|
|
125
|
+
lines.push(`### ${SECTIONS[section]}`)
|
|
126
|
+
}
|
|
127
|
+
lines.push(...entryLines(e, maxChildren))
|
|
128
|
+
}
|
|
129
|
+
if (left > 0) {
|
|
130
|
+
if (lines.length > 0) lines.push('')
|
|
131
|
+
lines.push(`${left} more entries in impact.json`)
|
|
132
|
+
}
|
|
133
|
+
return lines.join('\n')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function renderCapped(entries: Entry[], capBytes: number, maxChildren: number): string {
|
|
137
|
+
const fits = (s: string): boolean => Buffer.byteLength(s, 'utf8') <= capBytes
|
|
138
|
+
let kept: Entry[] = []
|
|
139
|
+
for (const e of entries) {
|
|
140
|
+
const trial = [...kept, e]
|
|
141
|
+
// Counting every entry not yet kept bounds the final count line from above.
|
|
142
|
+
if (fits(renderEntries(trial, entries.length - trial.length, maxChildren))) kept = trial
|
|
143
|
+
}
|
|
144
|
+
const out = renderEntries(kept, entries.length - kept.length, maxChildren)
|
|
145
|
+
if (!fits(out)) {
|
|
146
|
+
throw new Error(`Impact render cap of ${capBytes} bytes cannot hold its own count line`)
|
|
147
|
+
}
|
|
148
|
+
return out
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function renderImpactMarkdown(
|
|
152
|
+
map: ImpactMap,
|
|
153
|
+
opts: { shard: number | null; capBytes: number; maxChildren?: number },
|
|
154
|
+
): string {
|
|
155
|
+
const { shard } = opts
|
|
156
|
+
const entries = entriesOf(map).filter((e) => shard === null || e.shards.includes(shard))
|
|
157
|
+
return renderCapped(entries, opts.capBytes, opts.maxChildren ?? Number.POSITIVE_INFINITY)
|
|
158
|
+
}
|