@iceinvein/agent-skills 0.1.40 → 0.3.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/README.md +18 -2
- package/dist/cli/index.js +115 -34
- package/package.json +1 -1
- package/skills/index.json +14 -2
- package/skills/magpie/SKILL.md +118 -40
- package/skills/magpie/bin/magpie.ts +43 -0
- package/skills/magpie/fixtures/fake-gh-nodiff.sh +38 -0
- package/skills/magpie/package.json +1 -1
- package/skills/magpie/references/peer-review.md +7 -2
- package/skills/magpie/references/specialists.md +38 -7
- package/skills/magpie/scripts/__tests__/cli.test.ts +101 -1
- package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +187 -0
- package/skills/magpie/scripts/__tests__/diff-chunks.test.ts +51 -0
- package/skills/magpie/scripts/__tests__/filter-diff-preservation.test.ts +54 -0
- package/skills/magpie/scripts/__tests__/findings-files.test.ts +35 -0
- package/skills/magpie/scripts/__tests__/gh.test.ts +69 -0
- package/skills/magpie/scripts/__tests__/git-diff.test.ts +83 -0
- package/skills/magpie/scripts/__tests__/helpers/git-fixture.ts +47 -0
- package/skills/magpie/scripts/__tests__/path-filter.test.ts +27 -0
- package/skills/magpie/scripts/__tests__/render-cmd.test.ts +95 -0
- package/skills/magpie/scripts/__tests__/render-findings.test.ts +33 -0
- package/skills/magpie/scripts/__tests__/render-progress.test.ts +42 -0
- package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +83 -1
- package/skills/magpie/scripts/__tests__/shard.test.ts +165 -0
- package/skills/magpie/scripts/__tests__/skill-lint.test.ts +96 -1
- package/skills/magpie/scripts/dedupe-cmd.ts +58 -3
- package/skills/magpie/scripts/diff-chunks.ts +28 -0
- package/skills/magpie/scripts/findings-files.ts +32 -0
- package/skills/magpie/scripts/gh.ts +64 -13
- package/skills/magpie/scripts/git-diff.ts +111 -0
- package/skills/magpie/scripts/path-filter.ts +9 -5
- package/skills/magpie/scripts/refresh.ts +8 -0
- package/skills/magpie/scripts/render-cmd.ts +28 -9
- package/skills/magpie/scripts/render-findings.ts +11 -1
- package/skills/magpie/scripts/render-progress.ts +6 -1
- package/skills/magpie/scripts/setup-cmd.ts +38 -1
- package/skills/magpie/scripts/shard.ts +171 -0
- package/skills/magpie/scripts/status-cmd.ts +4 -1
- package/skills/magpie/skill.json +2 -2
- package/skills/magpie/templates/styles.css +5 -0
- package/skills/migrate/README.md +194 -0
- package/skills/migrate/SKILL.md +197 -0
- package/skills/migrate/bin/migrate +15 -0
- package/skills/migrate/bin/migrate.ts +309 -0
- package/skills/migrate/biome.json +35 -0
- package/skills/migrate/bun.lock +24 -0
- package/skills/migrate/docs/architecture.md +294 -0
- package/skills/migrate/docs/reference.md +590 -0
- package/skills/migrate/fixtures/tiny-express/GROUND-TRUTH.md +39 -0
- package/skills/migrate/fixtures/tiny-express/app.js +29 -0
- package/skills/migrate/fixtures/tiny-express/cron.js +6 -0
- package/skills/migrate/fixtures/tiny-express/reports/daily-users.json +6 -0
- package/skills/migrate/fixtures/tiny-express/schema.sql +12 -0
- package/skills/migrate/fixtures/tiny-express/settings.json +4 -0
- package/skills/migrate/fixtures/tiny-express/views/users.html +9 -0
- package/skills/migrate/fixtures/tiny-webforms/Controllers/UsersController.cs +68 -0
- package/skills/migrate/fixtures/tiny-webforms/Default.aspx +7 -0
- package/skills/migrate/fixtures/tiny-webforms/Default.aspx.cs +14 -0
- package/skills/migrate/fixtures/tiny-webforms/GROUND-TRUTH.md +50 -0
- package/skills/migrate/fixtures/tiny-webforms/Integrations/BillingClient.cs +16 -0
- package/skills/migrate/fixtures/tiny-webforms/Jobs/NightlyDigestJob.cs +33 -0
- package/skills/migrate/fixtures/tiny-webforms/Reports/DailyUsers.rdl +11 -0
- package/skills/migrate/fixtures/tiny-webforms/Schema.sql +12 -0
- package/skills/migrate/fixtures/tiny-webforms/Site.master +16 -0
- package/skills/migrate/fixtures/tiny-webforms/Users.aspx +8 -0
- package/skills/migrate/fixtures/tiny-webforms/Users.aspx.cs +14 -0
- package/skills/migrate/fixtures/tiny-webforms/web.config +10 -0
- package/skills/migrate/install.sh +68 -0
- package/skills/migrate/package.json +17 -0
- package/skills/migrate/references/phases/enumerate.md +291 -0
- package/skills/migrate/references/phases/extract.md +652 -0
- package/skills/migrate/references/phases/parity.md +275 -0
- package/skills/migrate/references/phases/probe.md +135 -0
- package/skills/migrate/references/phases/queue.md +242 -0
- package/skills/migrate/references/phases/seam.md +416 -0
- package/skills/migrate/references/recipes/README.md +116 -0
- package/skills/migrate/references/recipes/aspnet.md +287 -0
- package/skills/migrate/references/run-ops.md +280 -0
- package/skills/migrate/scripts/__tests__/census.test.ts +775 -0
- package/skills/migrate/scripts/__tests__/check.test.ts +458 -0
- package/skills/migrate/scripts/__tests__/citations.test.ts +156 -0
- package/skills/migrate/scripts/__tests__/cli.test.ts +183 -0
- package/skills/migrate/scripts/__tests__/concurrency.test.ts +164 -0
- package/skills/migrate/scripts/__tests__/config.test.ts +112 -0
- package/skills/migrate/scripts/__tests__/e2e-express.test.ts +1093 -0
- package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +1276 -0
- package/skills/migrate/scripts/__tests__/e2e.test.ts +320 -0
- package/skills/migrate/scripts/__tests__/ids.test.ts +38 -0
- package/skills/migrate/scripts/__tests__/import.test.ts +155 -0
- package/skills/migrate/scripts/__tests__/init.test.ts +192 -0
- package/skills/migrate/scripts/__tests__/leaks.test.ts +176 -0
- package/skills/migrate/scripts/__tests__/lock.test.ts +183 -0
- package/skills/migrate/scripts/__tests__/paths.test.ts +129 -0
- package/skills/migrate/scripts/__tests__/phase-cmd.test.ts +151 -0
- package/skills/migrate/scripts/__tests__/phases.test.ts +70 -0
- package/skills/migrate/scripts/__tests__/queue.test.ts +475 -0
- package/skills/migrate/scripts/__tests__/report.test.ts +150 -0
- package/skills/migrate/scripts/__tests__/run-state.test.ts +136 -0
- package/skills/migrate/scripts/__tests__/status-reset.test.ts +318 -0
- package/skills/migrate/scripts/__tests__/store.test.ts +132 -0
- package/skills/migrate/scripts/__tests__/validate.test.ts +54 -0
- package/skills/migrate/scripts/census-cmd.ts +109 -0
- package/skills/migrate/scripts/census.ts +342 -0
- package/skills/migrate/scripts/check-cmd.ts +24 -0
- package/skills/migrate/scripts/check.ts +376 -0
- package/skills/migrate/scripts/citations.ts +92 -0
- package/skills/migrate/scripts/config.ts +237 -0
- package/skills/migrate/scripts/ids.ts +31 -0
- package/skills/migrate/scripts/import-cmd.ts +141 -0
- package/skills/migrate/scripts/init-cmd.ts +118 -0
- package/skills/migrate/scripts/leaks.ts +184 -0
- package/skills/migrate/scripts/lock.ts +188 -0
- package/skills/migrate/scripts/paths.ts +103 -0
- package/skills/migrate/scripts/phase-cmd.ts +63 -0
- package/skills/migrate/scripts/phases.ts +113 -0
- package/skills/migrate/scripts/queue-cmd.ts +98 -0
- package/skills/migrate/scripts/queue.ts +258 -0
- package/skills/migrate/scripts/report-cmd.ts +47 -0
- package/skills/migrate/scripts/report.ts +131 -0
- package/skills/migrate/scripts/reset-cmd.ts +120 -0
- package/skills/migrate/scripts/status-cmd.ts +52 -0
- package/skills/migrate/scripts/store.ts +159 -0
- package/skills/migrate/scripts/types.ts +137 -0
- package/skills/migrate/scripts/validate.ts +221 -0
- package/skills/migrate/skill.json +33 -0
- package/skills/migrate/templates/config.toml +27 -0
- package/skills/migrate/templates/queue-item.md +17 -0
- package/skills/migrate/tsconfig.json +18 -0
- package/skills/migrate/uninstall.sh +31 -0
- package/skills/sluice/SKILL.md +95 -0
- package/skills/sluice/references/deep-channel.md +114 -0
- package/skills/sluice/references/finish.md +37 -0
- package/skills/sluice/references/intent.md +29 -0
- package/skills/sluice/references/meter.md +38 -0
- package/skills/sluice/references/review.md +42 -0
- package/skills/sluice/references/root-cause.md +38 -0
- package/skills/sluice/references/show-or-say.md +36 -0
- package/skills/sluice/references/test-first.md +35 -0
- package/skills/sluice/references/verify.md +26 -0
- package/skills/sluice/scripts/run-stats.sh +236 -0
- package/skills/sluice/skill.json +33 -0
|
@@ -317,3 +317,36 @@ test('subsystem role and issue title escape quotes in their title="" attribute c
|
|
|
317
317
|
// purpose escaping (already covered above) must remain intact alongside this.
|
|
318
318
|
expect(html).toContain('Adds bounded retries to the upload path')
|
|
319
319
|
})
|
|
320
|
+
|
|
321
|
+
test('findings header notes a locally-built diff', () => {
|
|
322
|
+
const html = renderFindingsHtml({
|
|
323
|
+
findings: [],
|
|
324
|
+
postStatus: {},
|
|
325
|
+
pr: { number: 7, branch: 'feature-x', headSha: 'abc123abc123' },
|
|
326
|
+
diffSource: { source: 'git', mergeBase: 'a'.repeat(40) },
|
|
327
|
+
highlighter: hl,
|
|
328
|
+
})
|
|
329
|
+
expect(html).toContain('local clone')
|
|
330
|
+
expect(html).toContain('aaaaaaaaaaaa')
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
test('findings header stays quiet when the diff came from gh', () => {
|
|
334
|
+
const html = renderFindingsHtml({
|
|
335
|
+
findings: [],
|
|
336
|
+
postStatus: {},
|
|
337
|
+
pr: { number: 7, branch: 'feature-x', headSha: 'abc123abc123' },
|
|
338
|
+
diffSource: { source: 'gh', mergeBase: null },
|
|
339
|
+
highlighter: hl,
|
|
340
|
+
})
|
|
341
|
+
expect(html).not.toContain('local clone')
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
test('findings header stays quiet when there is no diff source at all', () => {
|
|
345
|
+
const html = renderFindingsHtml({
|
|
346
|
+
findings: [],
|
|
347
|
+
postStatus: {},
|
|
348
|
+
pr: { number: 7, branch: 'feature-x', headSha: 'abc123abc123' },
|
|
349
|
+
highlighter: hl,
|
|
350
|
+
})
|
|
351
|
+
expect(html).not.toContain('local clone')
|
|
352
|
+
})
|
|
@@ -157,3 +157,45 @@ test('progress-pane block replaces diff-pane on the progress page', () => {
|
|
|
157
157
|
expect(html).toContain('data-role="progress-pane"')
|
|
158
158
|
expect(html).toContain('Indexing repo symbols')
|
|
159
159
|
})
|
|
160
|
+
|
|
161
|
+
test('progress names the shard count while specialists run', () => {
|
|
162
|
+
const html = renderProgressHtml({
|
|
163
|
+
prNumber: 7,
|
|
164
|
+
headSha: 'abc123abc123',
|
|
165
|
+
branch: 'feature-x',
|
|
166
|
+
stages: {
|
|
167
|
+
setup: 'done',
|
|
168
|
+
context: 'done',
|
|
169
|
+
specialists: 'running',
|
|
170
|
+
dedupe: 'pending',
|
|
171
|
+
critic: 'pending',
|
|
172
|
+
'peer-review': 'pending',
|
|
173
|
+
report: 'pending',
|
|
174
|
+
post: 'pending',
|
|
175
|
+
},
|
|
176
|
+
specialistCounts: {},
|
|
177
|
+
shardCount: 6,
|
|
178
|
+
})
|
|
179
|
+
expect(html).toContain('Five reviewers across 6 shards')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('progress keeps the single-shard wording when there is one shard', () => {
|
|
183
|
+
const html = renderProgressHtml({
|
|
184
|
+
prNumber: 7,
|
|
185
|
+
headSha: 'abc123abc123',
|
|
186
|
+
branch: 'feature-x',
|
|
187
|
+
stages: {
|
|
188
|
+
setup: 'done',
|
|
189
|
+
context: 'done',
|
|
190
|
+
specialists: 'running',
|
|
191
|
+
dedupe: 'pending',
|
|
192
|
+
critic: 'pending',
|
|
193
|
+
'peer-review': 'pending',
|
|
194
|
+
report: 'pending',
|
|
195
|
+
post: 'pending',
|
|
196
|
+
},
|
|
197
|
+
specialistCounts: {},
|
|
198
|
+
shardCount: 1,
|
|
199
|
+
})
|
|
200
|
+
expect(html).toContain('Five reviewers reading the diff in parallel')
|
|
201
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
-
import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { runSetup } from '../setup-cmd.ts'
|
|
@@ -179,3 +179,85 @@ test('runSetup with failing gh removes any partial state', async () => {
|
|
|
179
179
|
expect(contents).not.toContain('worktree')
|
|
180
180
|
expect(contents).not.toContain('pr.json')
|
|
181
181
|
})
|
|
182
|
+
|
|
183
|
+
test('runSetup shards the filtered diff and logs the result', async () => {
|
|
184
|
+
const exit = await runSetup({
|
|
185
|
+
runDir,
|
|
186
|
+
prNumber: 1234,
|
|
187
|
+
repoPath: repo,
|
|
188
|
+
deps: { bun: 'bun', gh: FAKE_GH, codex: 'echo', git: 'git' },
|
|
189
|
+
})
|
|
190
|
+
expect(exit).toBe(0)
|
|
191
|
+
const manifest = JSON.parse(await readFile(join(runDir, 'shards', 'manifest.json'), 'utf8'))
|
|
192
|
+
expect(manifest.shards.length).toBeGreaterThanOrEqual(1)
|
|
193
|
+
// The fake PR is one small file, so it stays a single-shard passthrough.
|
|
194
|
+
expect(manifest.shards[0].path).toBe('diff.patch')
|
|
195
|
+
const log = await readFile(join(runDir, 'log.jsonl'), 'utf8')
|
|
196
|
+
const shardEntry = log
|
|
197
|
+
.split('\n')
|
|
198
|
+
.filter(Boolean)
|
|
199
|
+
.map((l) => JSON.parse(l))
|
|
200
|
+
.find((e) => e.stage === 'shard')
|
|
201
|
+
expect(shardEntry).toMatchObject({ status: 'done', shards: 1 })
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('runSetup cleans up a populated shards/ when worktree creation fails after sharding', async () => {
|
|
205
|
+
// `git worktree add` refuses a target path that already exists and is
|
|
206
|
+
// non-empty, which forces the worktree step to fail *after* fetchPr,
|
|
207
|
+
// applyPathFilter, and runShard have already run successfully, without
|
|
208
|
+
// needing a fake git binary.
|
|
209
|
+
await mkdir(join(runDir, 'worktree'), { recursive: true })
|
|
210
|
+
await writeFile(join(runDir, 'worktree', 'occupied'), 'x')
|
|
211
|
+
|
|
212
|
+
const exit = await runSetup({
|
|
213
|
+
runDir,
|
|
214
|
+
prNumber: 1234,
|
|
215
|
+
repoPath: repo,
|
|
216
|
+
deps: { bun: 'bun', gh: FAKE_GH, codex: 'echo', git: 'git' },
|
|
217
|
+
})
|
|
218
|
+
expect(exit).toBe(5)
|
|
219
|
+
|
|
220
|
+
const log = await readFile(join(runDir, 'log.jsonl'), 'utf8')
|
|
221
|
+
const entries = log
|
|
222
|
+
.split('\n')
|
|
223
|
+
.filter(Boolean)
|
|
224
|
+
.map((l) => JSON.parse(l))
|
|
225
|
+
// Sharding really did happen before the worktree failure: this isn't a
|
|
226
|
+
// false pass from the worktree step failing before shard ever ran.
|
|
227
|
+
expect(entries.find((e) => e.stage === 'shard')).toMatchObject({ status: 'done', shards: 1 })
|
|
228
|
+
expect(entries.find((e) => e.stage === 'worktree')).toMatchObject({ status: 'error' })
|
|
229
|
+
|
|
230
|
+
const contents = await readdir(runDir).catch(() => [])
|
|
231
|
+
expect(contents).not.toContain('shards')
|
|
232
|
+
expect(contents).not.toContain('worktree')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('runSetup treats a shardDiff throw as a hard stop: logs, cleans up, exits 6', async () => {
|
|
236
|
+
// shardDiff creates $RUN_DIR/shards via a plain recursive mkdir. Seeding
|
|
237
|
+
// 'shards' as a regular file ahead of time forces that mkdir to throw
|
|
238
|
+
// EEXIST, a genuine non-ENOENT failure out of shardDiff. diff.patch itself
|
|
239
|
+
// can't be used for this: fetchPr and applyPathFilter both write it earlier
|
|
240
|
+
// in the pipeline, so pre-seeding it as something unreadable would fail
|
|
241
|
+
// before the shard step is ever reached.
|
|
242
|
+
await writeFile(join(runDir, 'shards'), 'not a directory')
|
|
243
|
+
|
|
244
|
+
const exit = await runSetup({
|
|
245
|
+
runDir,
|
|
246
|
+
prNumber: 1234,
|
|
247
|
+
repoPath: repo,
|
|
248
|
+
deps: { bun: 'bun', gh: FAKE_GH, codex: 'echo', git: 'git' },
|
|
249
|
+
})
|
|
250
|
+
expect(exit).toBe(6)
|
|
251
|
+
|
|
252
|
+
const log = await readFile(join(runDir, 'log.jsonl'), 'utf8')
|
|
253
|
+
const entries = log
|
|
254
|
+
.split('\n')
|
|
255
|
+
.filter(Boolean)
|
|
256
|
+
.map((l) => JSON.parse(l))
|
|
257
|
+
const shardEntry = entries.find((e) => e.stage === 'shard')
|
|
258
|
+
expect(shardEntry).toMatchObject({ status: 'error' })
|
|
259
|
+
expect(typeof (shardEntry as { error: unknown }).error).toBe('string')
|
|
260
|
+
|
|
261
|
+
const contents = await readdir(runDir).catch(() => [])
|
|
262
|
+
expect(contents.filter((c) => c !== 'log.jsonl')).toHaveLength(0)
|
|
263
|
+
})
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { DEFAULT_SHARD_BUDGET, groupKey, planShards, shardDiff } from '../shard.ts'
|
|
7
|
+
|
|
8
|
+
let runDir: string
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
runDir = await mkdtemp(join(tmpdir(), 'magpie-shard-'))
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await rm(runDir, { recursive: true, force: true })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
/** A file chunk with `bodyLines` added lines, shaped like a real patch. */
|
|
19
|
+
function chunk(path: string, bodyLines: number): string {
|
|
20
|
+
const body = Array.from({ length: bodyLines }, (_, i) => `+line ${i}`).join('\n')
|
|
21
|
+
return [
|
|
22
|
+
`diff --git a/${path} b/${path}`,
|
|
23
|
+
'index 0000000..1111111 100644',
|
|
24
|
+
`--- a/${path}`,
|
|
25
|
+
`+++ b/${path}`,
|
|
26
|
+
`@@ -0,0 +1,${bodyLines} @@`,
|
|
27
|
+
body,
|
|
28
|
+
'',
|
|
29
|
+
].join('\n')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test('groupKey caps the grouping directory at two segments', () => {
|
|
33
|
+
expect(groupKey('src/cli/commands/install.ts')).toBe('src/cli')
|
|
34
|
+
expect(groupKey('src/a.ts')).toBe('src')
|
|
35
|
+
expect(groupKey('README.md')).toBe('.')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('planShards keeps a directory group together when it fits', () => {
|
|
39
|
+
const chunks = [
|
|
40
|
+
{ path: 'src/cli/a.ts', text: '', lines: 100 },
|
|
41
|
+
{ path: 'src/web/b.ts', text: '', lines: 100 },
|
|
42
|
+
{ path: 'src/cli/c.ts', text: '', lines: 100 },
|
|
43
|
+
]
|
|
44
|
+
const shards = planShards(chunks, 1000, 80)
|
|
45
|
+
expect(shards).toHaveLength(1)
|
|
46
|
+
// Grouping reorders: both src/cli files are adjacent.
|
|
47
|
+
expect(shards[0]?.map((c) => c.path)).toEqual(['src/cli/a.ts', 'src/cli/c.ts', 'src/web/b.ts'])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('planShards splits when the line budget is exceeded', () => {
|
|
51
|
+
const chunks = [
|
|
52
|
+
{ path: 'src/a/1.ts', text: '', lines: 600 },
|
|
53
|
+
{ path: 'src/b/2.ts', text: '', lines: 600 },
|
|
54
|
+
{ path: 'src/c/3.ts', text: '', lines: 600 },
|
|
55
|
+
]
|
|
56
|
+
const shards = planShards(chunks, 1000, 80)
|
|
57
|
+
expect(shards).toHaveLength(3)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('planShards splits when max-files is reached before the budget', () => {
|
|
61
|
+
const chunks = Array.from({ length: 5 }, (_, i) => ({
|
|
62
|
+
path: `src/g${i}/f.ts`,
|
|
63
|
+
text: '',
|
|
64
|
+
lines: 1,
|
|
65
|
+
}))
|
|
66
|
+
const shards = planShards(chunks, 10_000, 2)
|
|
67
|
+
expect(shards).toHaveLength(3)
|
|
68
|
+
expect(shards[0]).toHaveLength(2)
|
|
69
|
+
expect(shards[2]).toHaveLength(1)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('planShards gives an over-budget file a shard to itself', () => {
|
|
73
|
+
const chunks = [
|
|
74
|
+
{ path: 'src/a/small.ts', text: '', lines: 10 },
|
|
75
|
+
{ path: 'src/b/huge.ts', text: '', lines: 9000 },
|
|
76
|
+
{ path: 'src/c/other.ts', text: '', lines: 10 },
|
|
77
|
+
]
|
|
78
|
+
const shards = planShards(chunks, 1000, 80)
|
|
79
|
+
expect(shards).toHaveLength(3)
|
|
80
|
+
expect(shards[1]?.map((c) => c.path)).toEqual(['src/b/huge.ts'])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('shardDiff writes a single-shard manifest pointing at diff.patch', async () => {
|
|
84
|
+
await writeFile(join(runDir, 'diff.patch'), chunk('src/a.ts', 3))
|
|
85
|
+
const manifest = await shardDiff(runDir)
|
|
86
|
+
expect(manifest.shards).toHaveLength(1)
|
|
87
|
+
expect(manifest.shards[0]?.path).toBe('diff.patch')
|
|
88
|
+
expect(manifest.totalFiles).toBe(1)
|
|
89
|
+
expect(manifest.budget).toBe(DEFAULT_SHARD_BUDGET)
|
|
90
|
+
expect(existsSync(join(runDir, 'shards', 'shard-1.patch'))).toBe(false)
|
|
91
|
+
const onDisk = JSON.parse(await readFile(join(runDir, 'shards', 'manifest.json'), 'utf8'))
|
|
92
|
+
expect(onDisk.shards[0].files).toEqual(['src/a.ts'])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('shardDiff writes one patch per shard when the budget forces a split', async () => {
|
|
96
|
+
const diff = chunk('src/a/one.ts', 200) + chunk('src/b/two.ts', 200)
|
|
97
|
+
await writeFile(join(runDir, 'diff.patch'), diff)
|
|
98
|
+
const manifest = await shardDiff(runDir, { budget: 150 })
|
|
99
|
+
expect(manifest.shards).toHaveLength(2)
|
|
100
|
+
expect(manifest.shards[0]?.path).toBe('shards/shard-1.patch')
|
|
101
|
+
const first = await readFile(join(runDir, 'shards', 'shard-1.patch'), 'utf8')
|
|
102
|
+
expect(first).toContain('diff --git a/src/a/one.ts')
|
|
103
|
+
expect(first).not.toContain('src/b/two.ts')
|
|
104
|
+
// diff.patch is a view source, never replaced.
|
|
105
|
+
const whole = await readFile(join(runDir, 'diff.patch'), 'utf8')
|
|
106
|
+
expect(whole).toContain('src/a/one.ts')
|
|
107
|
+
expect(whole).toContain('src/b/two.ts')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('a re-split into fewer shards leaves no orphaned shard patches behind', async () => {
|
|
111
|
+
const diff =
|
|
112
|
+
chunk('src/a/one.ts', 200) + chunk('src/b/two.ts', 200) + chunk('src/c/three.ts', 200)
|
|
113
|
+
await writeFile(join(runDir, 'diff.patch'), diff)
|
|
114
|
+
const first = await shardDiff(runDir, { budget: 150 })
|
|
115
|
+
expect(first.shards).toHaveLength(3)
|
|
116
|
+
expect(existsSync(join(runDir, 'shards', 'shard-3.patch'))).toBe(true)
|
|
117
|
+
|
|
118
|
+
// Re-split with room for two shards. shard-3.patch describes a file set no
|
|
119
|
+
// shard id owns any more, so it must not survive the new split.
|
|
120
|
+
const second = await shardDiff(runDir, { budget: 450 })
|
|
121
|
+
expect(second.shards).toHaveLength(2)
|
|
122
|
+
const patches = (await readdir(join(runDir, 'shards'))).filter((n) => n.endsWith('.patch')).sort()
|
|
123
|
+
expect(patches).toEqual(['shard-1.patch', 'shard-2.patch'])
|
|
124
|
+
expect(existsSync(join(runDir, 'shards', 'manifest.json'))).toBe(true)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('a re-split down to one shard clears every shard patch and keeps the manifest', async () => {
|
|
128
|
+
const diff = chunk('src/a/one.ts', 200) + chunk('src/b/two.ts', 200)
|
|
129
|
+
await writeFile(join(runDir, 'diff.patch'), diff)
|
|
130
|
+
expect((await shardDiff(runDir, { budget: 150 })).shards).toHaveLength(2)
|
|
131
|
+
const manifest = await shardDiff(runDir, { budget: 10_000 })
|
|
132
|
+
expect(manifest.shards).toHaveLength(1)
|
|
133
|
+
expect(manifest.shards[0]?.path).toBe('diff.patch')
|
|
134
|
+
// Only the manifest is left: the single-shard entry points at diff.patch, and
|
|
135
|
+
// diff.patch itself lives outside shards/ and is never touched.
|
|
136
|
+
expect(await readdir(join(runDir, 'shards'))).toEqual(['manifest.json'])
|
|
137
|
+
expect(existsSync(join(runDir, 'diff.patch'))).toBe(true)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('shardDiff handles an empty diff without writing shard files', async () => {
|
|
141
|
+
await writeFile(join(runDir, 'diff.patch'), '')
|
|
142
|
+
const manifest = await shardDiff(runDir)
|
|
143
|
+
expect(manifest.shards).toEqual([])
|
|
144
|
+
expect(manifest.totalFiles).toBe(0)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('shardDiff propagates a non-ENOENT read failure instead of treating it as an empty diff', async () => {
|
|
148
|
+
// Make diff.patch a directory: reading it as text is a real, portable I/O
|
|
149
|
+
// failure (EISDIR) that is not "file absent" and must not be swallowed.
|
|
150
|
+
await mkdir(join(runDir, 'diff.patch'))
|
|
151
|
+
await expect(shardDiff(runDir)).rejects.toThrow()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('shardDiff treats a diff.patch that does not exist yet as an empty diff', async () => {
|
|
155
|
+
// No writeFile at all here: diff.patch is genuinely absent, the one case
|
|
156
|
+
// the ENOENT branch exists to tolerate. Task 4 wires shardDiff into
|
|
157
|
+
// runSetup and Task 6 reads the manifest unconditionally, so "no diff yet"
|
|
158
|
+
// must still leave a readable manifest rather than a missing file.
|
|
159
|
+
expect(existsSync(join(runDir, 'diff.patch'))).toBe(false)
|
|
160
|
+
const manifest = await shardDiff(runDir)
|
|
161
|
+
expect(manifest.shards).toEqual([])
|
|
162
|
+
expect(manifest.totalFiles).toBe(0)
|
|
163
|
+
expect(manifest.totalLines).toBe(0)
|
|
164
|
+
expect(existsSync(join(runDir, 'shards', 'manifest.json'))).toBe(true)
|
|
165
|
+
})
|
|
@@ -117,7 +117,9 @@ test('SKILL.md no longer inlines the prompt bodies it moved out', async () => {
|
|
|
117
117
|
expect(text).not.toContain(`\`\`\`${tag}`)
|
|
118
118
|
}
|
|
119
119
|
// The walkthrough is the always-read part; keep it small enough to be cheap.
|
|
120
|
-
|
|
120
|
+
// Raised from 2600 when the sharded-dispatch and fallback-diff prose was added:
|
|
121
|
+
// that's real, load-bearing procedure, not bloat.
|
|
122
|
+
expect(text.split(/\s+/).length).toBeLessThan(3000)
|
|
121
123
|
})
|
|
122
124
|
|
|
123
125
|
test('SKILL.md never instructs the agent to approve indexing', async () => {
|
|
@@ -327,3 +329,96 @@ test('the output contract tells specialists to look before they hedge', async ()
|
|
|
327
329
|
expect(contract).toContain('Needs verification:')
|
|
328
330
|
expect(contract).toMatch(/look before .*hedg/i)
|
|
329
331
|
})
|
|
332
|
+
|
|
333
|
+
test('the output contract documents both findings filenames', async () => {
|
|
334
|
+
const text = await readFile(ref('specialists.md'), 'utf8')
|
|
335
|
+
const contract = text.slice(0, text.indexOf('```magpie-specialist-'))
|
|
336
|
+
// The pre-existing assertion must keep holding.
|
|
337
|
+
expect(contract).toMatch(/findings\/<focus>\.json/)
|
|
338
|
+
expect(contract).toMatch(/findings\/<focus>\.shard-<n>\.json/)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
test('the run header documents the shard lines', async () => {
|
|
342
|
+
const text = await readFile(ref('specialists.md'), 'utf8')
|
|
343
|
+
expect(text).toContain('Shard: <n> of <N>')
|
|
344
|
+
expect(text).toContain('shards/shard-<n>.patch')
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test('specialists are told excluded files are still reachable', async () => {
|
|
348
|
+
const text = await readFile(ref('specialists.md'), 'utf8')
|
|
349
|
+
expect(text).toContain('diff.full.patch')
|
|
350
|
+
expect(text).toContain('excluded-files.json')
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
test('stage 4 pins the shard field on the specialist log entry', async () => {
|
|
354
|
+
const text = await readFile(SKILL, 'utf8')
|
|
355
|
+
const start = text.indexOf('### 4. Specialists')
|
|
356
|
+
expect(start).toBeGreaterThan(-1)
|
|
357
|
+
const section = text.slice(start, text.indexOf('\n### 5.', start))
|
|
358
|
+
// render-cmd sums per-focus findings counts keyed on this field. Drop it from a
|
|
359
|
+
// sharded run and every shard of a focus lands in one 'all' bucket,
|
|
360
|
+
// last-write-wins, so the progress page under-reports by up to the shard count
|
|
361
|
+
// with no error anywhere. This prose is the only thing that produces the field.
|
|
362
|
+
expect(section).toMatch(/\{stage: specialist,[^}]*\bshard: <n>/)
|
|
363
|
+
// ... and the unsharded path must still omit it, or 'all' would never be used.
|
|
364
|
+
expect(section).toMatch(/omit `shard`/i)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test('stage 4 reconciles the expected findings files before dedupe', async () => {
|
|
368
|
+
const text = await readFile(SKILL, 'utf8')
|
|
369
|
+
const start = text.indexOf('### 4. Specialists')
|
|
370
|
+
const section = text.slice(start, text.indexOf('\n### 5.', start))
|
|
371
|
+
// Stage 4 fails only when every specialist fails, so losing 1 of 30 agents
|
|
372
|
+
// otherwise renders a report indistinguishable from a complete one.
|
|
373
|
+
expect(section).toMatch(/findings\/<focus>\.shard-<n>\.json/)
|
|
374
|
+
expect(section).toMatch(/5 × <shard count>/)
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
test('the stage-4 gate offers only options the pipeline can carry out', async () => {
|
|
378
|
+
const text = await readFile(SKILL, 'utf8')
|
|
379
|
+
const start = text.indexOf('**More than four shards')
|
|
380
|
+
expect(start).toBeGreaterThan(-1)
|
|
381
|
+
const gate = text.slice(start, text.indexOf('\n\n', start))
|
|
382
|
+
// Option 2: --max-files is what binds on a PR of many small files, so naming
|
|
383
|
+
// only --budget sends the user back to an unchanged shard count.
|
|
384
|
+
expect(gate).toContain('--budget')
|
|
385
|
+
expect(gate).toContain('--max-files')
|
|
386
|
+
// Option 3 must name a mechanism that exists. There is no unreviewed marker in
|
|
387
|
+
// the report, so the record is a diagnostic log entry, on a stage outside
|
|
388
|
+
// status-cmd's ORDER ladder and never `status: error`.
|
|
389
|
+
const { ORDER } = await import('../status-cmd.ts')
|
|
390
|
+
const stage = gate.match(/\{stage: ([a-z-]+), status: ([a-z-]+)/)
|
|
391
|
+
expect(stage?.[1]).toBeDefined()
|
|
392
|
+
expect(ORDER as readonly string[]).not.toContain(stage?.[1])
|
|
393
|
+
expect(stage?.[2]).not.toBe('error')
|
|
394
|
+
expect(gate).toMatch(/skipped/)
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
test('the excluded-files note is part of the specialist prompt, not commentary', async () => {
|
|
398
|
+
const spec = await readFile(ref('specialists.md'), 'utf8')
|
|
399
|
+
// Every other specialist-directed line lives in a fenced block; this one used to
|
|
400
|
+
// sit unfenced between two numbered assembly parts, where an assembling agent
|
|
401
|
+
// reads it as a note to itself and the specialist never sees it.
|
|
402
|
+
const fences = [...spec.matchAll(/^```[a-z-]*\n([\s\S]*?)^```/gm)].map((m) => m[1] ?? '')
|
|
403
|
+
const inFence = fences.some((body) => body.includes('diff.full.patch'))
|
|
404
|
+
expect(inFence).toBe(true)
|
|
405
|
+
// And it must be a numbered part of the assembly list, not floating text.
|
|
406
|
+
expect(spec).toMatch(/^\d+\. The excluded-files block below/m)
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
test('the specialist assembly list is numbered contiguously from 1', async () => {
|
|
410
|
+
const spec = await readFile(ref('specialists.md'), 'utf8')
|
|
411
|
+
// Anchor on the heading, not on item 3's inline `## Output Contract` reference.
|
|
412
|
+
const preamble = spec.slice(0, spec.indexOf('\n## Output Contract'))
|
|
413
|
+
const numbers = [...preamble.matchAll(/^(\d+)\. /gm)].map((m) => Number(m[1]))
|
|
414
|
+
expect(numbers.length).toBeGreaterThan(4)
|
|
415
|
+
expect(numbers).toEqual(numbers.map((_, i) => i + 1))
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
test('SKILL.md documents the shard manifest and the fan-out gate', async () => {
|
|
419
|
+
const text = await readFile(SKILL, 'utf8')
|
|
420
|
+
expect(text).toContain('shards/manifest.json')
|
|
421
|
+
expect(text).toContain('diff.patch')
|
|
422
|
+
// The confirmation gate above four shards is the design's only interactive stop.
|
|
423
|
+
expect(text).toMatch(/more than four shards/i)
|
|
424
|
+
})
|
|
@@ -3,6 +3,7 @@ import { join } from 'node:path'
|
|
|
3
3
|
import { annotateChangedLines } from './changed-lines.ts'
|
|
4
4
|
import { deduplicateFindings } from './dedupe.ts'
|
|
5
5
|
import { verifyEvidence } from './evidence-filter.ts'
|
|
6
|
+
import { namespaceId, parseFindingsFilename } from './findings-files.ts'
|
|
6
7
|
import { DEFAULT_THRESHOLD, scoreRisk } from './score.ts'
|
|
7
8
|
import { FOCUS_IDS, parseFinding, type ReviewFinding } from './types.ts'
|
|
8
9
|
|
|
@@ -16,6 +17,49 @@ export type RunDedupeOptions = {
|
|
|
16
17
|
threshold?: number
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The five focuses a specialist subagent writes. `tests` is a `FOCUS_IDS` entry
|
|
22
|
+
* too, but setup writes `findings/tests.json` once for the whole run with no
|
|
23
|
+
* subagent, so it is never expected per shard.
|
|
24
|
+
*/
|
|
25
|
+
const SPECIALIST_FOCUS_IDS = FOCUS_IDS.filter((id) => id !== 'tests')
|
|
26
|
+
|
|
27
|
+
type Coverage = { expected: number; missing: string[] }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reconcile the findings files on disk against the `(focus, shard)` pairs the
|
|
31
|
+
* shard manifest implies. Stage 4 fails the run only when *every* specialist
|
|
32
|
+
* fails, so a sharded run that lost one agent out of thirty still logs
|
|
33
|
+
* `specialists: done` and renders a report indistinguishable from a complete
|
|
34
|
+
* one. Returns null when there is nothing to reconcile against (no manifest, an
|
|
35
|
+
* unreadable one, or a manifest with no shards, which is what an empty or
|
|
36
|
+
* fully-filtered diff produces), leaving pre-sharder runs behaving exactly as
|
|
37
|
+
* they did before.
|
|
38
|
+
*/
|
|
39
|
+
async function reconcileCoverage(runDir: string, present: Set<string>): Promise<Coverage | null> {
|
|
40
|
+
let shards: Array<Record<string, unknown>>
|
|
41
|
+
try {
|
|
42
|
+
const manifest = (await Bun.file(join(runDir, 'shards', 'manifest.json')).json()) as {
|
|
43
|
+
shards?: unknown
|
|
44
|
+
}
|
|
45
|
+
if (!Array.isArray(manifest.shards) || manifest.shards.length === 0) return null
|
|
46
|
+
shards = manifest.shards as Array<Record<string, unknown>>
|
|
47
|
+
} catch {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
// A single-shard manifest means stage 4 took the unsharded path, so its
|
|
51
|
+
// specialists write `<focus>.json` with no shard suffix.
|
|
52
|
+
const sharded = shards.length > 1
|
|
53
|
+
const expected: string[] = []
|
|
54
|
+
for (const [i, shard] of shards.entries()) {
|
|
55
|
+
const id = typeof shard?.id === 'number' ? shard.id : i + 1
|
|
56
|
+
for (const focus of SPECIALIST_FOCUS_IDS) {
|
|
57
|
+
expected.push(sharded ? `${focus}.shard-${id}.json` : `${focus}.json`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { expected: expected.length, missing: expected.filter((name) => !present.has(name)) }
|
|
61
|
+
}
|
|
62
|
+
|
|
19
63
|
export async function runDedupe(runDir: string, options: RunDedupeOptions = {}): Promise<number> {
|
|
20
64
|
const threshold = options.threshold ?? DEFAULT_THRESHOLD
|
|
21
65
|
const findingsDir = join(runDir, 'findings')
|
|
@@ -29,8 +73,8 @@ export async function runDedupe(runDir: string, options: RunDedupeOptions = {}):
|
|
|
29
73
|
|
|
30
74
|
for (const name of files) {
|
|
31
75
|
if (!name.endsWith('.json')) continue
|
|
32
|
-
const
|
|
33
|
-
if (!FOCUS_IDS.includes(focus as (typeof FOCUS_IDS)[number])) {
|
|
76
|
+
const parsed = parseFindingsFilename(name)
|
|
77
|
+
if (!parsed || !FOCUS_IDS.includes(parsed.focus as (typeof FOCUS_IDS)[number])) {
|
|
34
78
|
await logLine(runDir, {
|
|
35
79
|
stage: 'dedupe',
|
|
36
80
|
status: 'skip',
|
|
@@ -39,6 +83,7 @@ export async function runDedupe(runDir: string, options: RunDedupeOptions = {}):
|
|
|
39
83
|
})
|
|
40
84
|
continue
|
|
41
85
|
}
|
|
86
|
+
const { focus, shard } = parsed
|
|
42
87
|
const path = join(findingsDir, name)
|
|
43
88
|
let raw: unknown
|
|
44
89
|
try {
|
|
@@ -63,7 +108,8 @@ export async function runDedupe(runDir: string, options: RunDedupeOptions = {}):
|
|
|
63
108
|
}
|
|
64
109
|
for (const item of raw) {
|
|
65
110
|
try {
|
|
66
|
-
|
|
111
|
+
const finding = parseFinding(item)
|
|
112
|
+
collected.push({ ...finding, id: namespaceId(finding.id, focus, shard) })
|
|
67
113
|
} catch (err) {
|
|
68
114
|
await logLine(runDir, {
|
|
69
115
|
stage: 'dedupe',
|
|
@@ -107,6 +153,14 @@ export async function runDedupe(runDir: string, options: RunDedupeOptions = {}):
|
|
|
107
153
|
)}\n`,
|
|
108
154
|
)
|
|
109
155
|
}
|
|
156
|
+
const coverage = await reconcileCoverage(runDir, new Set(files))
|
|
157
|
+
if (coverage) {
|
|
158
|
+
process.stdout.write(
|
|
159
|
+
coverage.missing.length === 0
|
|
160
|
+
? `dedupe: all ${coverage.expected} expected findings files present\n`
|
|
161
|
+
: `dedupe: ${coverage.missing.length} of ${coverage.expected} expected findings files missing (re-dispatch those specialists): ${coverage.missing.join(', ')}\n`,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
110
164
|
await logLine(runDir, {
|
|
111
165
|
stage: 'dedupe',
|
|
112
166
|
status: 'done',
|
|
@@ -118,6 +172,7 @@ export async function runDedupe(runDir: string, options: RunDedupeOptions = {}):
|
|
|
118
172
|
skipped: evidence.skipped,
|
|
119
173
|
dropped: evidence.dropped.length,
|
|
120
174
|
},
|
|
175
|
+
...(coverage ? { coverage } : {}),
|
|
121
176
|
})
|
|
122
177
|
return 0
|
|
123
178
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type FileChunk = { path: string; text: string; lines: number }
|
|
2
|
+
|
|
3
|
+
export const FILE_HEADER = /^diff --git a\/(.+?) b\/(.+?)$/m
|
|
4
|
+
|
|
5
|
+
/** Split a unified diff on file boundaries. The first element may be a preamble
|
|
6
|
+
* that is not a file chunk; callers decide what to do with it. */
|
|
7
|
+
export function splitRawChunks(diff: string): string[] {
|
|
8
|
+
return diff.split(/^(?=diff --git )/m)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The b-side path of a file chunk, falling back to the a-side for deletions. */
|
|
12
|
+
export function chunkPath(chunk: string): string | null {
|
|
13
|
+
const m = chunk.match(FILE_HEADER)
|
|
14
|
+
return m?.[2] ?? m?.[1] ?? null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** File chunks only, with their paths and patch-line counts resolved. */
|
|
18
|
+
export function splitFileChunks(diff: string): FileChunk[] {
|
|
19
|
+
if (!diff.trim()) return []
|
|
20
|
+
const out: FileChunk[] = []
|
|
21
|
+
for (const chunk of splitRawChunks(diff)) {
|
|
22
|
+
if (!chunk.startsWith('diff --git ')) continue
|
|
23
|
+
const path = chunkPath(chunk)
|
|
24
|
+
if (!path) continue
|
|
25
|
+
out.push({ path, text: chunk, lines: chunk.split('\n').length })
|
|
26
|
+
}
|
|
27
|
+
return out
|
|
28
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A specialist writes `findings/<focus>.json`, or `findings/<focus>.shard-<n>.json`
|
|
3
|
+
* when stage 4 fanned the focus out across shards. Returns null for anything
|
|
4
|
+
* else, which the caller logs and skips.
|
|
5
|
+
*/
|
|
6
|
+
export function parseFindingsFilename(
|
|
7
|
+
name: string,
|
|
8
|
+
): { focus: string; shard: number | null } | null {
|
|
9
|
+
if (!name.endsWith('.json')) return null
|
|
10
|
+
const stem = name.slice(0, -'.json'.length)
|
|
11
|
+
const parts = stem.split('.')
|
|
12
|
+
const focus = parts[0]
|
|
13
|
+
if (!focus) return null
|
|
14
|
+
if (parts.length === 1) return { focus, shard: null }
|
|
15
|
+
if (parts.length > 2) return null
|
|
16
|
+
const m = /^shard-(\d+)$/.exec(parts[1] ?? '')
|
|
17
|
+
if (!m) return null
|
|
18
|
+
return { focus, shard: Number(m[1]) }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Every shard's security specialist mints `security-1` for its first finding,
|
|
23
|
+
* so a five-shard run yields five findings sharing one id, and the report and
|
|
24
|
+
* post stages key on id. Rewriting deterministically here beats asking five
|
|
25
|
+
* prompts to encode the shard themselves.
|
|
26
|
+
*/
|
|
27
|
+
export function namespaceId(id: string, focus: string, shard: number | null): string {
|
|
28
|
+
if (shard === null) return id
|
|
29
|
+
const tag = `s${shard}`
|
|
30
|
+
const prefix = `${focus}-`
|
|
31
|
+
return id.startsWith(prefix) ? `${prefix}${tag}-${id.slice(prefix.length)}` : `${tag}-${id}`
|
|
32
|
+
}
|