@iceinvein/agent-skills 0.1.39 → 0.2.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 +105 -28
- package/package.json +1 -1
- package/skills/index.json +14 -2
- package/skills/magpie/README.md +2 -1
- package/skills/magpie/SKILL.md +138 -45
- package/skills/magpie/bin/magpie.ts +43 -0
- package/skills/magpie/fixtures/example-pr/brief.json +18 -0
- package/skills/magpie/fixtures/fake-gh-nodiff.sh +38 -0
- package/skills/magpie/fixtures/fake-gh.sh +14 -0
- package/skills/magpie/package.json +1 -1
- package/skills/magpie/references/peer-review.md +7 -2
- package/skills/magpie/references/scout.md +90 -0
- package/skills/magpie/references/specialists.md +117 -6
- 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 +90 -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__/preview-cmd.test.ts +16 -0
- package/skills/magpie/scripts/__tests__/refresh.test.ts +45 -0
- package/skills/magpie/scripts/__tests__/render-cmd.test.ts +173 -1
- package/skills/magpie/scripts/__tests__/render-findings.test.ts +151 -1
- 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 +207 -7
- package/skills/magpie/scripts/__tests__/types.test.ts +47 -0
- 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 +68 -14
- package/skills/magpie/scripts/git-diff.ts +111 -0
- package/skills/magpie/scripts/path-filter.ts +9 -5
- package/skills/magpie/scripts/preview-cmd.ts +11 -1
- package/skills/magpie/scripts/refresh.ts +32 -3
- package/skills/magpie/scripts/render-cmd.ts +35 -11
- package/skills/magpie/scripts/render-findings.ts +78 -2
- 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 +7 -4
- package/skills/magpie/scripts/types.ts +50 -0
- package/skills/magpie/skill.json +2 -2
- package/skills/magpie/templates/styles.css +66 -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 +82 -0
- package/skills/sluice/references/deep-channel.md +94 -0
- package/skills/sluice/references/finish.md +35 -0
- package/skills/sluice/references/intent.md +29 -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/skill.json +32 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { runCheck } from '../check.ts'
|
|
6
|
+
import { writeConfig } from '../config.ts'
|
|
7
|
+
import { storePaths } from '../paths.ts'
|
|
8
|
+
import { savePhases } from '../phases.ts'
|
|
9
|
+
import { writeRows } from '../store.ts'
|
|
10
|
+
import type { Census, Element, Requirement } from '../types.ts'
|
|
11
|
+
|
|
12
|
+
let root: string
|
|
13
|
+
let source: string
|
|
14
|
+
|
|
15
|
+
const SURFACES = ['routes', 'tables']
|
|
16
|
+
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
root = await mkdtemp(join(tmpdir(), 'migrate-check-'))
|
|
19
|
+
source = join(root, 'legacy')
|
|
20
|
+
await mkdir(join(root, '.migrate', 'queue'), { recursive: true })
|
|
21
|
+
await mkdir(source, { recursive: true })
|
|
22
|
+
await writeConfig(root, { sourcePath: source, scope: 'all', targetName: 'newapp' })
|
|
23
|
+
// Narrow the declared sets so the fixture only has to satisfy two surfaces
|
|
24
|
+
// and no closers.
|
|
25
|
+
const cfgPath = storePaths(root).config
|
|
26
|
+
const text = await Bun.file(cfgPath).text()
|
|
27
|
+
await writeFile(
|
|
28
|
+
cfgPath,
|
|
29
|
+
text
|
|
30
|
+
.replace(/^types = .*$/m, `types = ${JSON.stringify(SURFACES)}`)
|
|
31
|
+
.replace(/^set = .*$/m, 'set = []'),
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
await rm(root, { recursive: true, force: true })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const ELEMENT: Element = {
|
|
40
|
+
id: 'route-get-api-users',
|
|
41
|
+
surface: 'routes',
|
|
42
|
+
element: 'GET /api/users',
|
|
43
|
+
found_by: ['code'],
|
|
44
|
+
disposition: { kind: 'mapped', fr: 'UM-001' },
|
|
45
|
+
refs: [],
|
|
46
|
+
lens: 'code',
|
|
47
|
+
batch: 'b-1',
|
|
48
|
+
notes: '',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const REQUIREMENT: Requirement = {
|
|
52
|
+
id: 'UM-001',
|
|
53
|
+
cap: 'user-management',
|
|
54
|
+
requirement: 'List users',
|
|
55
|
+
actors: 'User',
|
|
56
|
+
objects: 'User',
|
|
57
|
+
rules: '-',
|
|
58
|
+
origin: 'intended',
|
|
59
|
+
confidence: { kind: 'confirmed' },
|
|
60
|
+
citations: [{ kind: 'ledger', id: 'route-get-api-users' }],
|
|
61
|
+
parity: { kind: 'rubric', level: 'high' },
|
|
62
|
+
batch: 'b-1',
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function lensCensus(surface: string, total: number, inLedger: number): Census {
|
|
66
|
+
return {
|
|
67
|
+
kind: 'lens',
|
|
68
|
+
surface,
|
|
69
|
+
phase: 'enumerate',
|
|
70
|
+
directions: {
|
|
71
|
+
code: { count: total, evidence: `rg -n "${surface}" app.js | wc -l` },
|
|
72
|
+
nav: { count: total, evidence: 'manual review of route registrations' },
|
|
73
|
+
},
|
|
74
|
+
total,
|
|
75
|
+
in_ledger: inLedger,
|
|
76
|
+
added: 0,
|
|
77
|
+
skipped: [],
|
|
78
|
+
queued: [],
|
|
79
|
+
batch: 'b-1',
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function seedClean(): Promise<void> {
|
|
84
|
+
const p = storePaths(root)
|
|
85
|
+
await writeRows(p.elements, [ELEMENT], source)
|
|
86
|
+
await writeRows(p.requirements, [REQUIREMENT], source)
|
|
87
|
+
await writeRows(
|
|
88
|
+
p.capabilities,
|
|
89
|
+
[{ slug: 'user-management', title: 'Users', ns: 'UM', elements: [] }],
|
|
90
|
+
source,
|
|
91
|
+
)
|
|
92
|
+
await writeRows(p.census, [lensCensus('routes', 1, 1), lensCensus('tables', 0, 0)], source)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The run-state gate now checks whether the run actually happened, so a
|
|
96
|
+
// store seeded by writeRows alone (bypassing import/census's recordBatch)
|
|
97
|
+
// carries no evidence that probe or enumerate ran. Advancing phase state
|
|
98
|
+
// directly with savePhases (rather than via the CLI, since this suite talks
|
|
99
|
+
// to runCheck's own API) and bounding the check at --phase enumerate keeps
|
|
100
|
+
// this test about the nine other gates: it only has to prove enough phase
|
|
101
|
+
// history exists for run-state to agree the census batch it seeded was
|
|
102
|
+
// committed, not simulate the rest of a Milestone 2 run this fixture never
|
|
103
|
+
// performs.
|
|
104
|
+
async function markThroughEnumerate(): Promise<void> {
|
|
105
|
+
await savePhases(
|
|
106
|
+
root,
|
|
107
|
+
{
|
|
108
|
+
probe: { status: 'done', batches: [], pending: [] },
|
|
109
|
+
enumerate: { status: 'done', batches: [{ id: 'b-1', count: 1 }], pending: [] },
|
|
110
|
+
seam: { status: 'pending', batches: [], pending: [] },
|
|
111
|
+
extract: { status: 'pending', batches: [], pending: [] },
|
|
112
|
+
parity: { status: 'pending', batches: [], pending: [] },
|
|
113
|
+
queue: { status: 'pending', batches: [], pending: [] },
|
|
114
|
+
adjudicate: { status: 'pending', batches: [], pending: [] },
|
|
115
|
+
handoff: { status: 'pending', batches: [], pending: [] },
|
|
116
|
+
},
|
|
117
|
+
source,
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
test('a clean store passes with the summary line', async () => {
|
|
122
|
+
await seedClean()
|
|
123
|
+
await markThroughEnumerate()
|
|
124
|
+
const result = await runCheck({ root, phase: 'enumerate' })
|
|
125
|
+
expect(result.violations).toEqual([])
|
|
126
|
+
expect(result.summary).toBe('1/1 mapped, 0 out-of-scope, 0 unaccounted')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('an unaccounted element is a coverage violation naming its id', async () => {
|
|
130
|
+
await seedClean()
|
|
131
|
+
await writeRows(
|
|
132
|
+
storePaths(root).elements,
|
|
133
|
+
[{ ...ELEMENT, disposition: { kind: 'unaccounted' } }],
|
|
134
|
+
source,
|
|
135
|
+
)
|
|
136
|
+
const result = await runCheck({ root })
|
|
137
|
+
expect(result.summary).toBe('0/1 mapped, 0 out-of-scope, 1 unaccounted')
|
|
138
|
+
const coverage = result.violations.filter((v) => v.gate === 'coverage')
|
|
139
|
+
expect(coverage).toHaveLength(1)
|
|
140
|
+
expect(coverage[0]?.message).toContain('route-get-api-users')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// Important finding 2: balanceOf only checks a census record's own numbers
|
|
144
|
+
// against each other; nothing tied in_ledger + added to elements.jsonl
|
|
145
|
+
// itself, so a lens record could claim any in_ledger/added split it liked
|
|
146
|
+
// as long as it balanced internally. `total` is self-reported and cannot be
|
|
147
|
+
// checked against anything, but in_ledger + added is a concrete claim about
|
|
148
|
+
// how many rows now exist in the ledger for that surface, and that claim is
|
|
149
|
+
// directly countable against elements.jsonl.
|
|
150
|
+
test('a lens census claiming elements that were never added to the ledger is a census violation', async () => {
|
|
151
|
+
await seedClean()
|
|
152
|
+
// Balances on its own (50 = 50 + 0 + 0 + 0) but elements.jsonl holds zero
|
|
153
|
+
// rows for 'tables': the padding surface the report describes verbatim.
|
|
154
|
+
await writeRows(
|
|
155
|
+
storePaths(root).census,
|
|
156
|
+
[lensCensus('routes', 1, 1), { ...lensCensus('tables', 50, 50) }],
|
|
157
|
+
source,
|
|
158
|
+
)
|
|
159
|
+
const result = await runCheck({ root })
|
|
160
|
+
const census = result.violations.filter((v) => v.gate === 'census')
|
|
161
|
+
expect(census).toHaveLength(1)
|
|
162
|
+
expect(census[0]?.message).toContain('tables')
|
|
163
|
+
expect(census[0]?.message).toContain('50')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('a lens census whose in_ledger + added matches the real ledger count passes', async () => {
|
|
167
|
+
await seedClean()
|
|
168
|
+
// routes: in_ledger 1 + added 0 = 1, and exactly one 'routes' element
|
|
169
|
+
// exists in the seeded store, so this must not be flagged.
|
|
170
|
+
const result = await runCheck({ root })
|
|
171
|
+
const census = result.violations.filter((v) => v.gate === 'census')
|
|
172
|
+
expect(census).toEqual([])
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('a declared surface with no census record is a census violation', async () => {
|
|
176
|
+
await seedClean()
|
|
177
|
+
await writeRows(storePaths(root).census, [lensCensus('routes', 1, 1)], source)
|
|
178
|
+
const result = await runCheck({ root })
|
|
179
|
+
const census = result.violations.filter((v) => v.gate === 'census')
|
|
180
|
+
expect(census).toHaveLength(1)
|
|
181
|
+
expect(census[0]?.message).toContain('tables')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// Review round 1, Finding 1: census.jsonl is a store file readRows only
|
|
185
|
+
// asserts a type onto; nothing checked its shape at gate time before this,
|
|
186
|
+
// so a hand-edited row that never passed through census-cmd.ts's
|
|
187
|
+
// validateCensus call could sit there silently. A single-direction lens
|
|
188
|
+
// record satisfies boundsOf's arithmetic trivially (max == sum == total
|
|
189
|
+
// when there is exactly one direction), and an old-shape record turns
|
|
190
|
+
// boundsOf's max/sum into NaN, against which every comparison is false: both
|
|
191
|
+
// reached exit 0 before this fix. This is the test whose absence let that
|
|
192
|
+
// hole through, so it writes census.jsonl by hand rather than through
|
|
193
|
+
// writeRows, and checks both rows are named by line and by the reason
|
|
194
|
+
// validateCensus rejects each of them.
|
|
195
|
+
//
|
|
196
|
+
// Review round 2: the first version of this test asserted with .some(...),
|
|
197
|
+
// which only proves the expected violations are present and says nothing
|
|
198
|
+
// about extra ones. It missed that the original fix skipped both rows'
|
|
199
|
+
// entire loop iteration, so neither 'routes' nor 'tables' was ever
|
|
200
|
+
// registered in surfacesWithCensus, and gate 2 additionally (and wrongly)
|
|
201
|
+
// reported both as having no lens census record at all -- wrong because the
|
|
202
|
+
// lens did run and did write a record; only its shape is defective. Tightened
|
|
203
|
+
// to assert the exact violation count, so a regression that reintroduces
|
|
204
|
+
// those two extra, misleading violations fails this test instead of passing
|
|
205
|
+
// it.
|
|
206
|
+
test('a hand-edited census.jsonl with a single-direction row and an old-shape row is named by line, not silently passed through', async () => {
|
|
207
|
+
await seedClean()
|
|
208
|
+
const singleDirection = {
|
|
209
|
+
kind: 'lens',
|
|
210
|
+
surface: 'routes',
|
|
211
|
+
phase: 'enumerate',
|
|
212
|
+
directions: { code: { count: 1, evidence: 'rg -n "app.(get|post)" app.js' } },
|
|
213
|
+
total: 1,
|
|
214
|
+
in_ledger: 1,
|
|
215
|
+
added: 0,
|
|
216
|
+
skipped: [],
|
|
217
|
+
queued: [],
|
|
218
|
+
batch: 'b-1',
|
|
219
|
+
}
|
|
220
|
+
const oldShape = {
|
|
221
|
+
kind: 'lens',
|
|
222
|
+
surface: 'tables',
|
|
223
|
+
phase: 'enumerate',
|
|
224
|
+
directions: { ddl: 1, orm: 1 },
|
|
225
|
+
total: 1,
|
|
226
|
+
in_ledger: 1,
|
|
227
|
+
added: 0,
|
|
228
|
+
skipped: [],
|
|
229
|
+
queued: [],
|
|
230
|
+
batch: 'b-1',
|
|
231
|
+
}
|
|
232
|
+
await writeFile(
|
|
233
|
+
storePaths(root).census,
|
|
234
|
+
`${JSON.stringify(singleDirection)}\n${JSON.stringify(oldShape)}\n`,
|
|
235
|
+
)
|
|
236
|
+
const result = await runCheck({ root })
|
|
237
|
+
const census = result.violations.filter((v) => v.gate === 'census')
|
|
238
|
+
// Exactly three: the single-direction row's one shape error, plus the
|
|
239
|
+
// old-shape row's two bare-count errors (one per direction). Nothing else:
|
|
240
|
+
// in particular, no "has no lens census record" for either surface, since
|
|
241
|
+
// both rows named a real surface and a real kind, even though both were
|
|
242
|
+
// otherwise malformed.
|
|
243
|
+
expect(census).toHaveLength(3)
|
|
244
|
+
expect(
|
|
245
|
+
census.some(
|
|
246
|
+
(v) =>
|
|
247
|
+
v.message.includes('line 1') && v.message.includes('at least two independent directions'),
|
|
248
|
+
),
|
|
249
|
+
).toBe(true)
|
|
250
|
+
expect(
|
|
251
|
+
census.filter(
|
|
252
|
+
(v) => v.message.includes('line 2') && v.message.includes('old bare-count shape'),
|
|
253
|
+
),
|
|
254
|
+
).toHaveLength(2)
|
|
255
|
+
expect(census.some((v) => v.message.includes('has no lens census record'))).toBe(false)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
// Review round 2: pins the guard on the recovery above. A row with no kind
|
|
259
|
+
// at all (not merely the wrong kind) cannot claim any surface, so unlike the
|
|
260
|
+
// test above, both declared surfaces must still be reported missing here --
|
|
261
|
+
// 'routes' because the one row that names it cannot claim to be its lens
|
|
262
|
+
// census, and 'tables' because no row names it at all.
|
|
263
|
+
test('a census row with no kind cannot claim a surface, so both declared surfaces are still reported missing', async () => {
|
|
264
|
+
await seedClean()
|
|
265
|
+
const noKind = {
|
|
266
|
+
surface: 'routes',
|
|
267
|
+
phase: 'enumerate',
|
|
268
|
+
directions: {
|
|
269
|
+
code: { count: 1, evidence: 'rg -n "app.(get|post)" app.js' },
|
|
270
|
+
nav: { count: 1, evidence: 'manual review of route registrations' },
|
|
271
|
+
},
|
|
272
|
+
total: 1,
|
|
273
|
+
in_ledger: 1,
|
|
274
|
+
added: 0,
|
|
275
|
+
skipped: [],
|
|
276
|
+
queued: [],
|
|
277
|
+
batch: 'b-1',
|
|
278
|
+
}
|
|
279
|
+
await writeFile(storePaths(root).census, `${JSON.stringify(noKind)}\n`)
|
|
280
|
+
const result = await runCheck({ root })
|
|
281
|
+
const census = result.violations.filter((v) => v.gate === 'census')
|
|
282
|
+
expect(
|
|
283
|
+
census.some((v) => v.message.includes('line 1') && v.message.includes('unknown kind')),
|
|
284
|
+
).toBe(true)
|
|
285
|
+
expect(
|
|
286
|
+
census.some(
|
|
287
|
+
(v) => v.message.includes('routes') && v.message.includes('has no lens census record'),
|
|
288
|
+
),
|
|
289
|
+
).toBe(true)
|
|
290
|
+
expect(
|
|
291
|
+
census.some(
|
|
292
|
+
(v) => v.message.includes('tables') && v.message.includes('has no lens census record'),
|
|
293
|
+
),
|
|
294
|
+
).toBe(true)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test('a mapped disposition pointing at no requirement is a refs violation', async () => {
|
|
298
|
+
await seedClean()
|
|
299
|
+
await writeRows(
|
|
300
|
+
storePaths(root).elements,
|
|
301
|
+
[{ ...ELEMENT, disposition: { kind: 'mapped', fr: 'UM-999' } }],
|
|
302
|
+
source,
|
|
303
|
+
)
|
|
304
|
+
const result = await runCheck({ root })
|
|
305
|
+
const refs = result.violations.filter((v) => v.gate === 'refs')
|
|
306
|
+
expect(refs.some((v) => v.message.includes('UM-999'))).toBe(true)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
test('a queued confidence with no queue file is a refs violation', async () => {
|
|
310
|
+
await seedClean()
|
|
311
|
+
await writeRows(
|
|
312
|
+
storePaths(root).requirements,
|
|
313
|
+
[{ ...REQUIREMENT, confidence: { kind: 'queued', queue: 'q-missing' }, parity: null }],
|
|
314
|
+
source,
|
|
315
|
+
)
|
|
316
|
+
const result = await runCheck({ root })
|
|
317
|
+
expect(result.violations.some((v) => v.gate === 'refs' && v.message.includes('q-missing'))).toBe(
|
|
318
|
+
true,
|
|
319
|
+
)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
test('a requirement with no parity plan is a parity violation', async () => {
|
|
323
|
+
await seedClean()
|
|
324
|
+
await writeRows(storePaths(root).requirements, [{ ...REQUIREMENT, parity: null }], source)
|
|
325
|
+
const result = await runCheck({ root })
|
|
326
|
+
expect(result.violations.some((v) => v.gate === 'parity' && v.message.includes('UM-001'))).toBe(
|
|
327
|
+
true,
|
|
328
|
+
)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
test('an unsigned delta is a deltas violation', async () => {
|
|
332
|
+
await seedClean()
|
|
333
|
+
await writeRows(
|
|
334
|
+
storePaths(root).deltas,
|
|
335
|
+
[
|
|
336
|
+
{
|
|
337
|
+
id: 'delta-multi-tenancy',
|
|
338
|
+
scope: 'all tables',
|
|
339
|
+
rationale: 'SaaS',
|
|
340
|
+
parity_exclusion: 'ignore tenant id',
|
|
341
|
+
validation: 'leak tests',
|
|
342
|
+
owner_signed: null,
|
|
343
|
+
batch: 'b-1',
|
|
344
|
+
},
|
|
345
|
+
],
|
|
346
|
+
source,
|
|
347
|
+
)
|
|
348
|
+
const result = await runCheck({ root })
|
|
349
|
+
expect(
|
|
350
|
+
result.violations.some((v) => v.gate === 'deltas' && v.message.includes('delta-multi-tenancy')),
|
|
351
|
+
).toBe(true)
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
test('citations run by default and are only skipped when explicitly disabled', async () => {
|
|
355
|
+
await seedClean()
|
|
356
|
+
await writeRows(
|
|
357
|
+
storePaths(root).requirements,
|
|
358
|
+
[{ ...REQUIREMENT, citations: [{ kind: 'src', path: 'Ghost.cs' }] }],
|
|
359
|
+
source,
|
|
360
|
+
)
|
|
361
|
+
const byDefault = await runCheck({ root })
|
|
362
|
+
expect(byDefault.violations.some((v) => v.gate === 'citations')).toBe(true)
|
|
363
|
+
const disabled = await runCheck({ root, citations: false })
|
|
364
|
+
expect(disabled.violations.filter((v) => v.gate === 'citations')).toEqual([])
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test('a dirty source checkout is a source violation', async () => {
|
|
368
|
+
await seedClean()
|
|
369
|
+
await Bun.spawn(['git', 'init', '-q'], { cwd: source }).exited
|
|
370
|
+
await writeFile(join(source, 'dirty.txt'), 'uncommitted\n')
|
|
371
|
+
const result = await runCheck({ root })
|
|
372
|
+
expect(result.violations.some((v) => v.gate === 'source')).toBe(true)
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
// Review round: the refs gate must catch duplicate identities on its own,
|
|
376
|
+
// not assume some other gate (or another command) already ruled them out.
|
|
377
|
+
// `capabilities.jsonl` in particular has no import path at all yet, so
|
|
378
|
+
// hand-editing is currently the only way a row lands there, making a
|
|
379
|
+
// duplicate slug directly reachable today, not a hypothetical.
|
|
380
|
+
|
|
381
|
+
test('two requirements sharing an id both survive but are flagged as a refs violation naming the id and count', async () => {
|
|
382
|
+
await seedClean()
|
|
383
|
+
const dup: Requirement = { ...REQUIREMENT, requirement: 'List users, second copy' }
|
|
384
|
+
await writeRows(storePaths(root).requirements, [REQUIREMENT, dup], source)
|
|
385
|
+
const result = await runCheck({ root })
|
|
386
|
+
const refs = result.violations.filter((v) => v.gate === 'refs')
|
|
387
|
+
expect(
|
|
388
|
+
refs.some((v) => v.message.includes('UM-001') && v.message.includes('appears 2 times')),
|
|
389
|
+
).toBe(true)
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
test('two capabilities sharing a slug are flagged as a refs violation naming the slug and count', async () => {
|
|
393
|
+
await seedClean()
|
|
394
|
+
await writeRows(
|
|
395
|
+
storePaths(root).capabilities,
|
|
396
|
+
[
|
|
397
|
+
{ slug: 'user-management', title: 'Users', ns: 'UM', elements: [] },
|
|
398
|
+
{ slug: 'user-management', title: 'Users (duplicate)', ns: 'UM', elements: [] },
|
|
399
|
+
],
|
|
400
|
+
source,
|
|
401
|
+
)
|
|
402
|
+
const result = await runCheck({ root })
|
|
403
|
+
const refs = result.violations.filter((v) => v.gate === 'refs')
|
|
404
|
+
expect(
|
|
405
|
+
refs.some(
|
|
406
|
+
(v) => v.message.includes('user-management') && v.message.includes('appears 2 times'),
|
|
407
|
+
),
|
|
408
|
+
).toBe(true)
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
test('two elements sharing an id are flagged as a refs violation naming the id and count', async () => {
|
|
412
|
+
await seedClean()
|
|
413
|
+
const dup: Element = { ...ELEMENT, element: 'GET /api/users, second copy' }
|
|
414
|
+
await writeRows(storePaths(root).elements, [ELEMENT, dup], source)
|
|
415
|
+
const result = await runCheck({ root })
|
|
416
|
+
const refs = result.violations.filter((v) => v.gate === 'refs')
|
|
417
|
+
expect(
|
|
418
|
+
refs.some(
|
|
419
|
+
(v) => v.message.includes('route-get-api-users') && v.message.includes('appears 2 times'),
|
|
420
|
+
),
|
|
421
|
+
).toBe(true)
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
test('ids that merely share a prefix are not confused with duplicates', async () => {
|
|
425
|
+
await seedClean()
|
|
426
|
+
const other: Requirement = {
|
|
427
|
+
...REQUIREMENT,
|
|
428
|
+
id: 'UM-0010',
|
|
429
|
+
citations: [{ kind: 'ledger', id: 'route-get-api-users' }],
|
|
430
|
+
}
|
|
431
|
+
await writeRows(storePaths(root).requirements, [REQUIREMENT, other], source)
|
|
432
|
+
const result = await runCheck({ root })
|
|
433
|
+
expect(result.violations.filter((v) => v.gate === 'refs')).toEqual([])
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
test('two citations of the same missing queue id on one requirement produce two distinguishable refs violations', async () => {
|
|
437
|
+
await seedClean()
|
|
438
|
+
await writeRows(
|
|
439
|
+
storePaths(root).requirements,
|
|
440
|
+
[
|
|
441
|
+
{
|
|
442
|
+
...REQUIREMENT,
|
|
443
|
+
confidence: { kind: 'queued', queue: 'q-missing' },
|
|
444
|
+
parity: { kind: 'rubric', level: 'moderate', queue: 'q-missing' },
|
|
445
|
+
},
|
|
446
|
+
],
|
|
447
|
+
source,
|
|
448
|
+
)
|
|
449
|
+
const result = await runCheck({ root })
|
|
450
|
+
const refs = result.violations.filter((v) => v.gate === 'refs' && v.message.includes('q-missing'))
|
|
451
|
+
expect(refs).toHaveLength(2)
|
|
452
|
+
// Not deduplicated: both citations are real and both need fixing, so the
|
|
453
|
+
// two violations must remain distinct entries with distinct messages,
|
|
454
|
+
// each naming which field it came from.
|
|
455
|
+
expect(new Set(refs.map((v) => v.message)).size).toBe(2)
|
|
456
|
+
expect(refs.some((v) => v.message.includes('confidence.queue'))).toBe(true)
|
|
457
|
+
expect(refs.some((v) => v.message.includes('parity.queue'))).toBe(true)
|
|
458
|
+
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { resolveCitations } from '../citations.ts'
|
|
6
|
+
import type { Requirement } from '../types.ts'
|
|
7
|
+
|
|
8
|
+
let source: string
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
source = await mkdtemp(join(tmpdir(), 'migrate-src-'))
|
|
12
|
+
await mkdir(join(source, 'Controllers'), { recursive: true })
|
|
13
|
+
await writeFile(
|
|
14
|
+
join(source, 'Controllers', 'Auth.cs'),
|
|
15
|
+
Array.from({ length: 50 }, (_, i) => `line ${i + 1}`).join('\n'),
|
|
16
|
+
)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
await rm(source, { recursive: true, force: true })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
function req(citations: Requirement['citations']): Requirement {
|
|
24
|
+
return {
|
|
25
|
+
id: 'UM-001',
|
|
26
|
+
cap: 'user-management',
|
|
27
|
+
requirement: 'User logs in',
|
|
28
|
+
actors: 'User',
|
|
29
|
+
objects: 'Credentials',
|
|
30
|
+
rules: '-',
|
|
31
|
+
origin: 'intended',
|
|
32
|
+
confidence: { kind: 'confirmed' },
|
|
33
|
+
citations,
|
|
34
|
+
parity: { kind: 'rubric', level: 'high' },
|
|
35
|
+
batch: 'b-1',
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('a src citation inside the file passes', async () => {
|
|
40
|
+
const v = await resolveCitations(
|
|
41
|
+
[req([{ kind: 'src', path: 'Controllers/Auth.cs', lines: [20, 35] }])],
|
|
42
|
+
source,
|
|
43
|
+
)
|
|
44
|
+
expect(v).toEqual([])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('a src citation to a missing file is a violation naming the path', async () => {
|
|
48
|
+
const v = await resolveCitations([req([{ kind: 'src', path: 'Controllers/Ghost.cs' }])], source)
|
|
49
|
+
expect(v).toHaveLength(1)
|
|
50
|
+
expect(v[0]?.message).toContain('Controllers/Ghost.cs')
|
|
51
|
+
expect(v[0]?.gate).toBe('citations')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('a line range past the end of the file is a violation', async () => {
|
|
55
|
+
const v = await resolveCitations(
|
|
56
|
+
[req([{ kind: 'src', path: 'Controllers/Auth.cs', lines: [45, 900] }])],
|
|
57
|
+
source,
|
|
58
|
+
)
|
|
59
|
+
expect(v).toHaveLength(1)
|
|
60
|
+
expect(v[0]?.message).toContain('900')
|
|
61
|
+
expect(v[0]?.message).toContain('50')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('a ledger citation is not resolved against the source tree', async () => {
|
|
65
|
+
const v = await resolveCitations([req([{ kind: 'ledger', id: 'route-post-api-login' }])], source)
|
|
66
|
+
expect(v).toEqual([])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('a citation escaping the source root is a violation', async () => {
|
|
70
|
+
const v = await resolveCitations([req([{ kind: 'src', path: '../outside.cs' }])], source)
|
|
71
|
+
expect(v).toHaveLength(1)
|
|
72
|
+
expect(v[0]?.message).toContain('outside the source tree')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('an inverted line range is a violation', async () => {
|
|
76
|
+
const v = await resolveCitations(
|
|
77
|
+
[req([{ kind: 'src', path: 'Controllers/Auth.cs', lines: [30, 10] }])],
|
|
78
|
+
source,
|
|
79
|
+
)
|
|
80
|
+
expect(v).toHaveLength(1)
|
|
81
|
+
expect(v[0]?.message).toContain('inverted')
|
|
82
|
+
expect(v[0]?.gate).toBe('citations')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('a symlink inside source tree pointing outside is a violation', async () => {
|
|
86
|
+
const outside = await mkdtemp(join(tmpdir(), 'migrate-out-'))
|
|
87
|
+
try {
|
|
88
|
+
const outside_file = join(outside, 'outside.cs')
|
|
89
|
+
await writeFile(outside_file, 'outside content')
|
|
90
|
+
const link_path = join(source, 'link.cs')
|
|
91
|
+
try {
|
|
92
|
+
await symlink(outside_file, link_path)
|
|
93
|
+
} catch {
|
|
94
|
+
// Symlinks not supported on this platform, skip
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
const v = await resolveCitations([req([{ kind: 'src', path: 'link.cs' }])], source)
|
|
98
|
+
expect(v).toHaveLength(1)
|
|
99
|
+
expect(v[0]?.message).toContain('outside the source tree')
|
|
100
|
+
} finally {
|
|
101
|
+
await rm(outside, { recursive: true, force: true })
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('an absolute path is rejected', async () => {
|
|
106
|
+
const v = await resolveCitations([req([{ kind: 'src', path: '/absolute/path.cs' }])], source)
|
|
107
|
+
expect(v).toHaveLength(1)
|
|
108
|
+
expect(v[0]?.message).toContain('absolute path')
|
|
109
|
+
expect(v[0]?.gate).toBe('citations')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('a file with no trailing newline passes', async () => {
|
|
113
|
+
await writeFile(join(source, 'NoNewline.cs'), 'line 1\nline 2')
|
|
114
|
+
const v = await resolveCitations(
|
|
115
|
+
[req([{ kind: 'src', path: 'NoNewline.cs', lines: [1, 2] }])],
|
|
116
|
+
source,
|
|
117
|
+
)
|
|
118
|
+
expect(v).toEqual([])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('a path with spaces passes', async () => {
|
|
122
|
+
await writeFile(join(source, 'File With Spaces.cs'), 'line 1')
|
|
123
|
+
const v = await resolveCitations(
|
|
124
|
+
[req([{ kind: 'src', path: 'File With Spaces.cs', lines: [1, 1] }])],
|
|
125
|
+
source,
|
|
126
|
+
)
|
|
127
|
+
expect(v).toEqual([])
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('a path with unicode passes', async () => {
|
|
131
|
+
await writeFile(join(source, '文件.cs'), 'line 1')
|
|
132
|
+
const v = await resolveCitations([req([{ kind: 'src', path: '文件.cs', lines: [1, 1] }])], source)
|
|
133
|
+
expect(v).toEqual([])
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('a path with leading ./ passes', async () => {
|
|
137
|
+
const v = await resolveCitations(
|
|
138
|
+
[req([{ kind: 'src', path: './Controllers/Auth.cs', lines: [1, 10] }])],
|
|
139
|
+
source,
|
|
140
|
+
)
|
|
141
|
+
expect(v).toEqual([])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('a one-line file cited [1,1] passes', async () => {
|
|
145
|
+
await writeFile(join(source, 'OneLine.cs'), 'single line')
|
|
146
|
+
const v = await resolveCitations(
|
|
147
|
+
[req([{ kind: 'src', path: 'OneLine.cs', lines: [1, 1] }])],
|
|
148
|
+
source,
|
|
149
|
+
)
|
|
150
|
+
expect(v).toEqual([])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('a src ref with no lines passes', async () => {
|
|
154
|
+
const v = await resolveCitations([req([{ kind: 'src', path: 'Controllers/Auth.cs' }])], source)
|
|
155
|
+
expect(v).toEqual([])
|
|
156
|
+
})
|