@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,775 @@
|
|
|
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 { balanceOf, boundsOf, validateCensus } from '../census.ts'
|
|
6
|
+
import { runCensus } from '../census-cmd.ts'
|
|
7
|
+
import { writeConfig } from '../config.ts'
|
|
8
|
+
import { storePaths } from '../paths.ts'
|
|
9
|
+
import { readRows } from '../store.ts'
|
|
10
|
+
import type { Census } from '../types.ts'
|
|
11
|
+
|
|
12
|
+
let root: string
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
root = await mkdtemp(join(tmpdir(), 'migrate-census-'))
|
|
16
|
+
await mkdir(join(root, '.migrate'), { recursive: true })
|
|
17
|
+
await writeConfig(root, { sourcePath: join(root, 'legacy'), scope: 'all', targetName: 'newapp' })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await rm(root, { recursive: true, force: true })
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const LENS: Census = {
|
|
25
|
+
kind: 'lens',
|
|
26
|
+
surface: 'tables',
|
|
27
|
+
phase: 'enumerate',
|
|
28
|
+
directions: {
|
|
29
|
+
ddl: { count: 43, evidence: 'rg -n "CREATE TABLE" schema.sql | wc -l' },
|
|
30
|
+
orm: { count: 40, evidence: 'rg -n "\\[Table\\(" --type cs | wc -l' },
|
|
31
|
+
},
|
|
32
|
+
total: 45,
|
|
33
|
+
in_ledger: 44,
|
|
34
|
+
added: 1,
|
|
35
|
+
skipped: [],
|
|
36
|
+
queued: [],
|
|
37
|
+
batch: 'b-1',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test('a balanced lens census passes', () => {
|
|
41
|
+
expect(balanceOf(LENS)).toBeNull()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('an unbalanced lens census names the arithmetic', () => {
|
|
45
|
+
const msg = balanceOf({ ...LENS, total: 46 })
|
|
46
|
+
expect(msg).toContain('46')
|
|
47
|
+
expect(msg).toContain('tables')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('skipped and queued count toward the lens total', () => {
|
|
51
|
+
const balanced: Census = {
|
|
52
|
+
...LENS,
|
|
53
|
+
total: 47,
|
|
54
|
+
skipped: [{ element: '__EFMigrationsHistory', reason: 'framework-owned' }],
|
|
55
|
+
queued: ['q-table-ownership'],
|
|
56
|
+
}
|
|
57
|
+
expect(balanceOf(balanced)).toBeNull()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('attribute balance is explained plus queued equals behavioral', () => {
|
|
61
|
+
const base: Census = {
|
|
62
|
+
kind: 'attribute',
|
|
63
|
+
surface: 'tables',
|
|
64
|
+
subject: 'table-roster-days',
|
|
65
|
+
phase: 'enumerate',
|
|
66
|
+
directions: {
|
|
67
|
+
ddl: { count: 14, evidence: 'rg -n "CREATE TABLE" schema.sql | wc -l' },
|
|
68
|
+
entity: { count: 13, evidence: 'rg -n "\\[Table\\(" --type cs | wc -l' },
|
|
69
|
+
},
|
|
70
|
+
total: 15,
|
|
71
|
+
behavioral: 7,
|
|
72
|
+
explained: 6,
|
|
73
|
+
queued: ['q-ros-007'],
|
|
74
|
+
batch: 'b-2',
|
|
75
|
+
}
|
|
76
|
+
expect(balanceOf(base)).toBeNull()
|
|
77
|
+
expect(balanceOf({ ...base, explained: 5 })).toContain('behavioral')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('rule-sweep balance is found equals requirements plus queued', () => {
|
|
81
|
+
const base: Census = {
|
|
82
|
+
kind: 'rule-sweep',
|
|
83
|
+
subject: 'user-management',
|
|
84
|
+
phase: 'extract',
|
|
85
|
+
probes: 4,
|
|
86
|
+
found: 2,
|
|
87
|
+
as_requirements: 2,
|
|
88
|
+
queued: [],
|
|
89
|
+
batch: 'b-3',
|
|
90
|
+
}
|
|
91
|
+
expect(balanceOf(base)).toBeNull()
|
|
92
|
+
expect(balanceOf({ ...base, found: 3 })).toContain('found')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('closer balance is findings equals fixed plus queued', () => {
|
|
96
|
+
const base: Census = {
|
|
97
|
+
kind: 'closer',
|
|
98
|
+
closer: 'read-write-symmetry',
|
|
99
|
+
phase: 'extract',
|
|
100
|
+
checked: 34,
|
|
101
|
+
findings: 3,
|
|
102
|
+
fixed: 2,
|
|
103
|
+
queued: ['q-sym-001'],
|
|
104
|
+
batch: 'b-4',
|
|
105
|
+
}
|
|
106
|
+
expect(balanceOf(base)).toBeNull()
|
|
107
|
+
expect(balanceOf({ ...base, fixed: 1 })).toContain('findings')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('runCensus refuses to record an unbalanced record', async () => {
|
|
111
|
+
const file = join(root, 'c.json')
|
|
112
|
+
await writeFile(file, JSON.stringify({ ...LENS, total: 99 }))
|
|
113
|
+
expect(await runCensus({ root, file })).toBe(1)
|
|
114
|
+
expect(await readRows(storePaths(root).census)).toEqual([])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('runCensus records a balanced record', async () => {
|
|
118
|
+
const file = join(root, 'c.json')
|
|
119
|
+
await writeFile(file, JSON.stringify(LENS))
|
|
120
|
+
expect(await runCensus({ root, file })).toBe(0)
|
|
121
|
+
expect(await readRows<Census>(storePaths(root).census)).toHaveLength(1)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('re-recording the same subject replaces its record rather than stacking', async () => {
|
|
125
|
+
const file = join(root, 'c.json')
|
|
126
|
+
await writeFile(file, JSON.stringify(LENS))
|
|
127
|
+
expect(await runCensus({ root, file })).toBe(0)
|
|
128
|
+
const updated: Census = { ...LENS, in_ledger: 43, added: 2 }
|
|
129
|
+
await writeFile(file, JSON.stringify(updated))
|
|
130
|
+
expect(await runCensus({ root, file })).toBe(0)
|
|
131
|
+
const rows = await readRows<Census>(storePaths(root).census)
|
|
132
|
+
expect(rows).toHaveLength(1)
|
|
133
|
+
expect(rows[0]).toEqual(updated)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
// Correction 1: a null, non-object, string, number or array row must come
|
|
137
|
+
// back from validateCensus as a normal { ok: false, errors } result and must
|
|
138
|
+
// never throw. Task 7's validate.ts hit this exact defect with a null row
|
|
139
|
+
// dumping a raw stack trace; census.ts must not repeat it.
|
|
140
|
+
|
|
141
|
+
test('validateCensus rejects a null row instead of throwing', () => {
|
|
142
|
+
const result = validateCensus(null)
|
|
143
|
+
expect(result.ok).toBe(false)
|
|
144
|
+
if (!result.ok) expect(result.errors.join(' ')).toContain('object')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('validateCensus rejects a string row instead of throwing', () => {
|
|
148
|
+
const result = validateCensus('not a row')
|
|
149
|
+
expect(result.ok).toBe(false)
|
|
150
|
+
if (!result.ok) expect(result.errors.join(' ')).toContain('object')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('validateCensus rejects a number row instead of throwing', () => {
|
|
154
|
+
const result = validateCensus(42)
|
|
155
|
+
expect(result.ok).toBe(false)
|
|
156
|
+
if (!result.ok) expect(result.errors.join(' ')).toContain('object')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('validateCensus rejects an array row instead of throwing', () => {
|
|
160
|
+
const result = validateCensus([1, 2, 3])
|
|
161
|
+
expect(result.ok).toBe(false)
|
|
162
|
+
if (!result.ok) expect(result.errors.join(' ')).toContain('object')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('runCensus refuses a file whose top-level JSON is null, not thrown', async () => {
|
|
166
|
+
const file = join(root, 'null.json')
|
|
167
|
+
await writeFile(file, 'null')
|
|
168
|
+
// A null row is not shaped like a census record at all (no envelope exists
|
|
169
|
+
// to distinguish this from the balance check the way import-cmd.ts's
|
|
170
|
+
// {batch, phase, rows} envelope does), so this is a usage error (2), not
|
|
171
|
+
// an operation failure (1).
|
|
172
|
+
expect(await runCensus({ root, file })).toBe(2)
|
|
173
|
+
expect(await readRows(storePaths(root).census)).toEqual([])
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// Correction 2: a missing or unparseable census file must produce a clean
|
|
177
|
+
// diagnostic and exit code 2 via store.ts's readJsonFile, never an uncaught
|
|
178
|
+
// JSON.parse/readFile stack trace.
|
|
179
|
+
|
|
180
|
+
test('runCensus reports a missing file as a clean usage error', async () => {
|
|
181
|
+
const file = join(root, 'does-not-exist.json')
|
|
182
|
+
expect(await runCensus({ root, file })).toBe(2)
|
|
183
|
+
expect(await readRows(storePaths(root).census)).toEqual([])
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('runCensus reports a malformed JSON file as a clean usage error', async () => {
|
|
187
|
+
const file = join(root, 'malformed.json')
|
|
188
|
+
await writeFile(file, '{ this is not json')
|
|
189
|
+
expect(await runCensus({ root, file })).toBe(2)
|
|
190
|
+
expect(await readRows(storePaths(root).census)).toEqual([])
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// Finding 1: skipped and queued are counted by .length, so a duplicate entry
|
|
194
|
+
// pads the balance arithmetic without changing anything real. A duplicate
|
|
195
|
+
// must be refused, not silently deduped, and never allowed to make an
|
|
196
|
+
// otherwise-imbalanced record look balanced.
|
|
197
|
+
|
|
198
|
+
test('a duplicate queued id is rejected even though it would make the sum balance', () => {
|
|
199
|
+
// in_ledger 44 + added 1 + skipped 0 + queued.length 2 = 47 = total: this
|
|
200
|
+
// would pass balanceOf's arithmetic if the duplicate were silently counted.
|
|
201
|
+
const padded: Census = { ...LENS, total: 47, queued: ['q-1', 'q-1'] }
|
|
202
|
+
const result = validateCensus(padded)
|
|
203
|
+
expect(result.ok).toBe(false)
|
|
204
|
+
if (!result.ok) expect(result.errors.join(' ')).toContain('q-1')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('a duplicate skipped element is rejected even though it would make the sum balance', () => {
|
|
208
|
+
const padded: Census = {
|
|
209
|
+
...LENS,
|
|
210
|
+
total: 47,
|
|
211
|
+
skipped: [
|
|
212
|
+
{ element: 'X', reason: 'a' },
|
|
213
|
+
{ element: 'X', reason: 'b' },
|
|
214
|
+
],
|
|
215
|
+
}
|
|
216
|
+
const result = validateCensus(padded)
|
|
217
|
+
expect(result.ok).toBe(false)
|
|
218
|
+
if (!result.ok) expect(result.errors.join(' ')).toContain('X')
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('runCensus refuses a record padded with a duplicate queued id', async () => {
|
|
222
|
+
const file = join(root, 'padded.json')
|
|
223
|
+
const padded = { ...LENS, total: 47, queued: ['q-1', 'q-1'] }
|
|
224
|
+
await writeFile(file, JSON.stringify(padded))
|
|
225
|
+
expect(await runCensus({ root, file })).toBe(2)
|
|
226
|
+
expect(await readRows(storePaths(root).census)).toEqual([])
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// Finding 2: list() only checked that skipped/queued were arrays, never that
|
|
230
|
+
// their elements matched Skipped / string. Each element must be validated,
|
|
231
|
+
// naming the offending index.
|
|
232
|
+
|
|
233
|
+
test('a skipped entry that is not an object is rejected, naming the index', () => {
|
|
234
|
+
const bad = { ...LENS, skipped: ['not-an-object'] }
|
|
235
|
+
const result = validateCensus(bad)
|
|
236
|
+
expect(result.ok).toBe(false)
|
|
237
|
+
if (!result.ok) expect(result.errors.join(' ')).toContain('skipped[0]')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('a skipped entry missing a reason is rejected, naming the index', () => {
|
|
241
|
+
const bad = { ...LENS, skipped: [{ element: 'foo' }] }
|
|
242
|
+
const result = validateCensus(bad)
|
|
243
|
+
expect(result.ok).toBe(false)
|
|
244
|
+
if (!result.ok) expect(result.errors.join(' ')).toContain('skipped[0]')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('a queued entry that is not a string is rejected, naming the index', () => {
|
|
248
|
+
const bad = { ...LENS, queued: [123] }
|
|
249
|
+
const result = validateCensus(bad)
|
|
250
|
+
expect(result.ok).toBe(false)
|
|
251
|
+
if (!result.ok) expect(result.errors.join(' ')).toContain('queued[0]')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
// Finding 3: a shape failure (missing/mistyped fields, unknown kind, a
|
|
255
|
+
// duplicate) is a usage error (2); only a balance failure on an otherwise
|
|
256
|
+
// well-formed record is an operation failure (1). The two must be
|
|
257
|
+
// distinguishable without parsing stderr text.
|
|
258
|
+
|
|
259
|
+
test('a record missing required fields is a usage error (2), not an operation failure', async () => {
|
|
260
|
+
const file = join(root, 'incomplete.json')
|
|
261
|
+
await writeFile(file, JSON.stringify({ kind: 'lens' }))
|
|
262
|
+
expect(await runCensus({ root, file })).toBe(2)
|
|
263
|
+
expect(await readRows(storePaths(root).census)).toEqual([])
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test('a well-formed but unbalanced record is an operation failure (1), not a usage error', async () => {
|
|
267
|
+
const file = join(root, 'unbalanced.json')
|
|
268
|
+
await writeFile(file, JSON.stringify({ ...LENS, total: 99 }))
|
|
269
|
+
expect(await runCensus({ root, file })).toBe(1)
|
|
270
|
+
expect(await readRows(storePaths(root).census)).toEqual([])
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
// Finding 4: validateCensus's own error strings must not carry a 'census: '
|
|
274
|
+
// prefix, since runCensus prepends one when printing; a message that was
|
|
275
|
+
// already prefixed produced 'census: census: ...' on a real CLI run.
|
|
276
|
+
|
|
277
|
+
test('validateCensus error messages are not pre-prefixed with census:', () => {
|
|
278
|
+
const result = validateCensus(null)
|
|
279
|
+
expect(result.ok).toBe(false)
|
|
280
|
+
if (!result.ok) {
|
|
281
|
+
for (const e of result.errors) expect(e.startsWith('census:')).toBe(false)
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
// Finding 1, round 2: exact-string uniqueness was defeated by cosmetically
|
|
286
|
+
// distinct but semantically identical entries (wrong case, a trailing
|
|
287
|
+
// space, trailing punctuation), and cross-list padding (the same value
|
|
288
|
+
// reused in both queued and skipped) was never checked at all. Queue ids
|
|
289
|
+
// are now validated against a real format (ids.ts's isValidSlug) instead of
|
|
290
|
+
// merely checked pairwise for uniqueness, so a cosmetic variant is rejected
|
|
291
|
+
// on its own merits rather than needing to be recognized as a duplicate.
|
|
292
|
+
|
|
293
|
+
test('an uppercase queued id is rejected as malformed, not treated as a fresh distinct id', () => {
|
|
294
|
+
const bad = { ...LENS, total: 46, queued: ['Q-1'] }
|
|
295
|
+
const result = validateCensus(bad)
|
|
296
|
+
expect(result.ok).toBe(false)
|
|
297
|
+
if (!result.ok) expect(result.errors.join(' ')).toContain('queued[0]')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test('a queued id with trailing whitespace is rejected, not silently trimmed and accepted', () => {
|
|
301
|
+
const bad = { ...LENS, total: 46, queued: ['q-1 '] }
|
|
302
|
+
const result = validateCensus(bad)
|
|
303
|
+
expect(result.ok).toBe(false)
|
|
304
|
+
if (!result.ok) expect(result.errors.join(' ')).toContain('queued[0]')
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test('a queued id with trailing punctuation is rejected as malformed', () => {
|
|
308
|
+
const bad = { ...LENS, total: 46, queued: ['q-1.'] }
|
|
309
|
+
const result = validateCensus(bad)
|
|
310
|
+
expect(result.ok).toBe(false)
|
|
311
|
+
if (!result.ok) expect(result.errors.join(' ')).toContain('queued[0]')
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
test('a repeated well-formed queued id is rejected as a duplicate', () => {
|
|
315
|
+
const bad = { ...LENS, total: 47, queued: ['q-table-ownership', 'q-table-ownership'] }
|
|
316
|
+
const result = validateCensus(bad)
|
|
317
|
+
expect(result.ok).toBe(false)
|
|
318
|
+
if (!result.ok) expect(result.errors.join(' ')).toContain('q-table-ownership')
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
test('cross-list padding: the same value in both queued and skipped is rejected', () => {
|
|
322
|
+
// in_ledger 44 + added 1 + skipped.length 1 + queued.length 1 = 47 = total:
|
|
323
|
+
// this would balance if the shared value 'q-1' were allowed to pad both
|
|
324
|
+
// lists at once.
|
|
325
|
+
const bad: Census = {
|
|
326
|
+
...LENS,
|
|
327
|
+
total: 47,
|
|
328
|
+
queued: ['q-1'],
|
|
329
|
+
skipped: [{ element: 'q-1', reason: 'x' }],
|
|
330
|
+
}
|
|
331
|
+
const result = validateCensus(bad)
|
|
332
|
+
expect(result.ok).toBe(false)
|
|
333
|
+
if (!result.ok) expect(result.errors.join(' ')).toContain('q-1')
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
test('queued id format validation applies to attribute, rule-sweep and closer, not only lens', () => {
|
|
337
|
+
const attribute = {
|
|
338
|
+
kind: 'attribute',
|
|
339
|
+
surface: 'tables',
|
|
340
|
+
subject: 'table-roster-days',
|
|
341
|
+
phase: 'enumerate',
|
|
342
|
+
directions: {},
|
|
343
|
+
total: 15,
|
|
344
|
+
behavioral: 7,
|
|
345
|
+
explained: 6,
|
|
346
|
+
queued: ['Q-1'],
|
|
347
|
+
batch: 'b-2',
|
|
348
|
+
}
|
|
349
|
+
const ruleSweep = {
|
|
350
|
+
kind: 'rule-sweep',
|
|
351
|
+
subject: 'user-management',
|
|
352
|
+
phase: 'extract',
|
|
353
|
+
probes: 4,
|
|
354
|
+
found: 2,
|
|
355
|
+
as_requirements: 1,
|
|
356
|
+
queued: ['q-1.'],
|
|
357
|
+
batch: 'b-3',
|
|
358
|
+
}
|
|
359
|
+
const closer = {
|
|
360
|
+
kind: 'closer',
|
|
361
|
+
closer: 'read-write-symmetry',
|
|
362
|
+
phase: 'extract',
|
|
363
|
+
checked: 34,
|
|
364
|
+
findings: 3,
|
|
365
|
+
fixed: 2,
|
|
366
|
+
queued: ['q-1 '],
|
|
367
|
+
batch: 'b-4',
|
|
368
|
+
}
|
|
369
|
+
for (const bad of [attribute, ruleSweep, closer]) {
|
|
370
|
+
const result = validateCensus(bad)
|
|
371
|
+
expect(result.ok).toBe(false)
|
|
372
|
+
}
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
// Round 3: skipped's duplicate check and the cross-list check both compared
|
|
376
|
+
// raw element-name strings, so a case or whitespace variant on the skipped
|
|
377
|
+
// side defeated both (queued was already fixed in round 2, but skipped was
|
|
378
|
+
// left alone). Fixed by normalizing (trim + case-fold) for comparison only;
|
|
379
|
+
// the stored value is always the author's original text.
|
|
380
|
+
|
|
381
|
+
test('a case-variant duplicate skipped element is rejected', () => {
|
|
382
|
+
const bad: Census = {
|
|
383
|
+
...LENS,
|
|
384
|
+
total: 47,
|
|
385
|
+
skipped: [
|
|
386
|
+
{ element: 'orders', reason: 'a' },
|
|
387
|
+
{ element: 'ORDERS', reason: 'b' },
|
|
388
|
+
],
|
|
389
|
+
}
|
|
390
|
+
const result = validateCensus(bad)
|
|
391
|
+
expect(result.ok).toBe(false)
|
|
392
|
+
if (!result.ok) expect(result.errors.join(' ')).toContain('orders')
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
test('a whitespace-variant duplicate skipped element is rejected', () => {
|
|
396
|
+
const bad: Census = {
|
|
397
|
+
...LENS,
|
|
398
|
+
total: 47,
|
|
399
|
+
skipped: [
|
|
400
|
+
{ element: 'orders', reason: 'a' },
|
|
401
|
+
{ element: ' orders ', reason: 'b' },
|
|
402
|
+
],
|
|
403
|
+
}
|
|
404
|
+
const result = validateCensus(bad)
|
|
405
|
+
expect(result.ok).toBe(false)
|
|
406
|
+
if (!result.ok) expect(result.errors.join(' ')).toContain('orders')
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
test('cross-list padding is rejected even with a case-variant on the skipped side', () => {
|
|
410
|
+
// queued is already canonicalized ('q-1'), so this specifically checks
|
|
411
|
+
// that the skipped side is normalized before the two are compared.
|
|
412
|
+
const bad: Census = {
|
|
413
|
+
...LENS,
|
|
414
|
+
total: 47,
|
|
415
|
+
queued: ['q-1'],
|
|
416
|
+
skipped: [{ element: 'Q-1', reason: 'x' }],
|
|
417
|
+
}
|
|
418
|
+
const result = validateCensus(bad)
|
|
419
|
+
expect(result.ok).toBe(false)
|
|
420
|
+
if (!result.ok) expect(result.errors.join(' ')).toContain('Q-1')
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
// Normalization must stop at trim + case-fold: it must not newly reject
|
|
424
|
+
// legitimate records, including ones whose element names differ only in
|
|
425
|
+
// punctuation (which is left alone deliberately, since punctuation can be
|
|
426
|
+
// load-bearing in a free-text element name).
|
|
427
|
+
|
|
428
|
+
test('punctuation-only variants of an element name are accepted as distinct, not deduped', () => {
|
|
429
|
+
const ok: Census = {
|
|
430
|
+
...LENS,
|
|
431
|
+
total: 48,
|
|
432
|
+
skipped: [
|
|
433
|
+
{ element: 'orders', reason: 'framework-owned' },
|
|
434
|
+
{ element: 'orders.', reason: 'legacy typo, kept verbatim' },
|
|
435
|
+
],
|
|
436
|
+
queued: ['q-table-ownership'],
|
|
437
|
+
}
|
|
438
|
+
const result = validateCensus(ok)
|
|
439
|
+
expect(result.ok).toBe(true)
|
|
440
|
+
if (result.ok) expect(result.value).toEqual(ok)
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
test('legitimate records still validate for all four kinds', () => {
|
|
444
|
+
const lens = validateCensus({
|
|
445
|
+
...LENS,
|
|
446
|
+
total: 48,
|
|
447
|
+
skipped: [
|
|
448
|
+
{ element: 'orders', reason: 'framework-owned' },
|
|
449
|
+
{ element: 'orders.', reason: 'legacy typo, kept verbatim' },
|
|
450
|
+
],
|
|
451
|
+
queued: ['q-table-ownership'],
|
|
452
|
+
})
|
|
453
|
+
expect(lens.ok).toBe(true)
|
|
454
|
+
|
|
455
|
+
const attribute = validateCensus({
|
|
456
|
+
kind: 'attribute',
|
|
457
|
+
surface: 'tables',
|
|
458
|
+
subject: 'table-roster-days',
|
|
459
|
+
phase: 'enumerate',
|
|
460
|
+
directions: {
|
|
461
|
+
ddl: { count: 14, evidence: 'rg -n "CREATE TABLE" schema.sql | wc -l' },
|
|
462
|
+
entity: { count: 13, evidence: 'rg -n "\\[Table\\(" --type cs | wc -l' },
|
|
463
|
+
},
|
|
464
|
+
total: 15,
|
|
465
|
+
behavioral: 7,
|
|
466
|
+
explained: 6,
|
|
467
|
+
queued: ['q-ros-007'],
|
|
468
|
+
batch: 'b-2',
|
|
469
|
+
})
|
|
470
|
+
expect(attribute.ok).toBe(true)
|
|
471
|
+
|
|
472
|
+
const ruleSweep = validateCensus({
|
|
473
|
+
kind: 'rule-sweep',
|
|
474
|
+
subject: 'user-management',
|
|
475
|
+
phase: 'extract',
|
|
476
|
+
probes: 4,
|
|
477
|
+
found: 2,
|
|
478
|
+
as_requirements: 2,
|
|
479
|
+
queued: [],
|
|
480
|
+
batch: 'b-3',
|
|
481
|
+
})
|
|
482
|
+
expect(ruleSweep.ok).toBe(true)
|
|
483
|
+
|
|
484
|
+
const closer = validateCensus({
|
|
485
|
+
kind: 'closer',
|
|
486
|
+
closer: 'read-write-symmetry',
|
|
487
|
+
phase: 'extract',
|
|
488
|
+
checked: 34,
|
|
489
|
+
findings: 3,
|
|
490
|
+
fixed: 2,
|
|
491
|
+
queued: ['q-sym-001'],
|
|
492
|
+
batch: 'b-4',
|
|
493
|
+
})
|
|
494
|
+
expect(closer.ok).toBe(true)
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
test('a rule-sweep census without a phase is rejected by name', () => {
|
|
498
|
+
const result = validateCensus({
|
|
499
|
+
kind: 'rule-sweep',
|
|
500
|
+
subject: 'pricing',
|
|
501
|
+
batch: 'b-rules-001',
|
|
502
|
+
probes: 4,
|
|
503
|
+
found: 2,
|
|
504
|
+
as_requirements: 2,
|
|
505
|
+
queued: [],
|
|
506
|
+
})
|
|
507
|
+
expect(result.ok).toBe(false)
|
|
508
|
+
if (!result.ok) expect(result.errors).toContain('phase is required')
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
// Fix round 1, Finding 2: `phase` was free text on every kind, but check.ts's
|
|
512
|
+
// run-state gate only ever looks for a lens census's batch in
|
|
513
|
+
// phases.enumerate.batches and a closer census's batch in
|
|
514
|
+
// phases.extract.batches, both hardcoded, never read off the record's own
|
|
515
|
+
// `phase`. A lens or closer record declaring any other phase could have
|
|
516
|
+
// census-cmd.ts commit its batch under that other phase and the gate would
|
|
517
|
+
// then report it as never committed, even though it was. validateCensus now
|
|
518
|
+
// rejects that mismatch at write time instead of leaving it for the gate to
|
|
519
|
+
// discover after the fact.
|
|
520
|
+
|
|
521
|
+
test('a lens census declaring a phase other than enumerate is rejected by name', () => {
|
|
522
|
+
const result = validateCensus(lens({ phase: 'extract' }))
|
|
523
|
+
expect(result.ok).toBe(false)
|
|
524
|
+
if (!result.ok) {
|
|
525
|
+
const joined = result.errors.join('\n')
|
|
526
|
+
expect(joined).toContain('lens census must declare phase "enumerate"')
|
|
527
|
+
expect(joined).toContain('found "extract"')
|
|
528
|
+
}
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
test('a closer census declaring a phase other than extract is rejected by name', () => {
|
|
532
|
+
const result = validateCensus({
|
|
533
|
+
kind: 'closer',
|
|
534
|
+
closer: 'read-write-symmetry',
|
|
535
|
+
phase: 'enumerate',
|
|
536
|
+
checked: 34,
|
|
537
|
+
findings: 3,
|
|
538
|
+
fixed: 2,
|
|
539
|
+
queued: ['q-sym-001'],
|
|
540
|
+
batch: 'b-4',
|
|
541
|
+
})
|
|
542
|
+
expect(result.ok).toBe(false)
|
|
543
|
+
if (!result.ok) {
|
|
544
|
+
const joined = result.errors.join('\n')
|
|
545
|
+
expect(joined).toContain('closer census must declare phase "extract"')
|
|
546
|
+
expect(joined).toContain('found "enumerate"')
|
|
547
|
+
}
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
test('an attribute census may declare any phase, since the gate never cross-checks it against a batch list', () => {
|
|
551
|
+
const result = validateCensus({
|
|
552
|
+
kind: 'attribute',
|
|
553
|
+
surface: 'tables',
|
|
554
|
+
subject: 'table-roster-days',
|
|
555
|
+
phase: 'seam',
|
|
556
|
+
directions: {
|
|
557
|
+
ddl: { count: 14, evidence: 'rg -n "CREATE TABLE" schema.sql | wc -l' },
|
|
558
|
+
entity: { count: 13, evidence: 'rg -n "\\[Table\\(" --type cs | wc -l' },
|
|
559
|
+
},
|
|
560
|
+
total: 15,
|
|
561
|
+
behavioral: 7,
|
|
562
|
+
explained: 6,
|
|
563
|
+
queued: ['q-ros-007'],
|
|
564
|
+
batch: 'b-2',
|
|
565
|
+
})
|
|
566
|
+
expect(result.ok).toBe(true)
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
// Critical finding 1 (shared with import-cmd.ts and queue-cmd.ts): a store
|
|
570
|
+
// whose configured source.path resolves to include the store's own target
|
|
571
|
+
// path must not crash. assertNotUnderSource throws by design (writeRows's
|
|
572
|
+
// own contract, see store.test.ts); census-cmd.ts must catch that throw and
|
|
573
|
+
// report a clean diagnostic with a deliberate exit code instead of letting
|
|
574
|
+
// it escape as an uncaught stack trace. See the report for the full
|
|
575
|
+
// argument for exit code 2 over 1.
|
|
576
|
+
|
|
577
|
+
test('runCensus refuses to write when the store sits inside its own configured source tree, as a clean usage error (2), not a crash', async () => {
|
|
578
|
+
await writeConfig(root, { sourcePath: root, scope: 'all', targetName: 'newapp' })
|
|
579
|
+
const file = join(root, 'c.json')
|
|
580
|
+
await writeFile(file, JSON.stringify(LENS))
|
|
581
|
+
expect(await runCensus({ root, file })).toBe(2)
|
|
582
|
+
expect(await readRows(storePaths(root).census)).toEqual([])
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
function lens(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
586
|
+
return {
|
|
587
|
+
kind: 'lens',
|
|
588
|
+
surface: 'tables',
|
|
589
|
+
phase: 'enumerate',
|
|
590
|
+
directions: {
|
|
591
|
+
ddl: { count: 43, evidence: 'rg -n "CREATE TABLE" schema.sql | wc -l' },
|
|
592
|
+
orm: { count: 40, evidence: 'rg -n "\\[Table\\(" --type cs | wc -l' },
|
|
593
|
+
},
|
|
594
|
+
total: 45,
|
|
595
|
+
in_ledger: 45,
|
|
596
|
+
added: 0,
|
|
597
|
+
skipped: [],
|
|
598
|
+
queued: [],
|
|
599
|
+
batch: 'b-tables-census-001',
|
|
600
|
+
...overrides,
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
test('a well-formed two-direction record with evidence validates', () => {
|
|
605
|
+
const result = validateCensus(lens())
|
|
606
|
+
expect(result.ok).toBe(true)
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
test('a bare-count direction is rejected with the shape it needs', () => {
|
|
610
|
+
const result = validateCensus(lens({ directions: { ddl: 43, orm: 40 } }))
|
|
611
|
+
expect(result.ok).toBe(false)
|
|
612
|
+
if (!result.ok) {
|
|
613
|
+
expect(result.errors.join('\n')).toContain('old bare-count shape')
|
|
614
|
+
expect(result.errors.join('\n')).toContain('"count": 43')
|
|
615
|
+
}
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
test('a direction with an empty evidence string is rejected by name', () => {
|
|
619
|
+
const result = validateCensus(
|
|
620
|
+
lens({
|
|
621
|
+
directions: {
|
|
622
|
+
ddl: { count: 43, evidence: 'rg CREATE TABLE' },
|
|
623
|
+
orm: { count: 40, evidence: ' ' },
|
|
624
|
+
},
|
|
625
|
+
}),
|
|
626
|
+
)
|
|
627
|
+
expect(result.ok).toBe(false)
|
|
628
|
+
if (!result.ok) expect(result.errors.join('\n')).toContain('directions.orm.evidence is required')
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
test('a single direction is rejected against the lens contract', () => {
|
|
632
|
+
const result = validateCensus(
|
|
633
|
+
lens({ directions: { ddl: { count: 45, evidence: 'rg CREATE TABLE' } } }),
|
|
634
|
+
)
|
|
635
|
+
expect(result.ok).toBe(false)
|
|
636
|
+
if (!result.ok) expect(result.errors.join('\n')).toContain('at least two independent directions')
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
test('total below the largest direction count fails bounds', () => {
|
|
640
|
+
const result = validateCensus(lens({ total: 40, in_ledger: 40 }))
|
|
641
|
+
expect(result.ok).toBe(true)
|
|
642
|
+
if (result.ok) {
|
|
643
|
+
const message = boundsOf(result.value)
|
|
644
|
+
expect(message).toContain('below the largest direction count 43')
|
|
645
|
+
}
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
test('total above the sum of direction counts fails bounds', () => {
|
|
649
|
+
const result = validateCensus(lens({ total: 90, in_ledger: 90 }))
|
|
650
|
+
expect(result.ok).toBe(true)
|
|
651
|
+
if (result.ok) {
|
|
652
|
+
const message = boundsOf(result.value)
|
|
653
|
+
expect(message).toContain('exceeds the 83 findings')
|
|
654
|
+
}
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
test('total exactly at each bound passes', () => {
|
|
658
|
+
for (const total of [43, 83]) {
|
|
659
|
+
const result = validateCensus(lens({ total, in_ledger: total }))
|
|
660
|
+
expect(result.ok).toBe(true)
|
|
661
|
+
if (result.ok) expect(boundsOf(result.value)).toBeNull()
|
|
662
|
+
}
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
test('bounds apply to attribute records too', () => {
|
|
666
|
+
const result = validateCensus({
|
|
667
|
+
kind: 'attribute',
|
|
668
|
+
surface: 'tables',
|
|
669
|
+
subject: 'users',
|
|
670
|
+
phase: 'enumerate',
|
|
671
|
+
directions: {
|
|
672
|
+
schema: { count: 12, evidence: 'psql \\d users' },
|
|
673
|
+
code: { count: 9, evidence: 'rg "users\\." --type cs' },
|
|
674
|
+
},
|
|
675
|
+
total: 30,
|
|
676
|
+
behavioral: 8,
|
|
677
|
+
explained: 8,
|
|
678
|
+
queued: [],
|
|
679
|
+
batch: 'b-users-attr-001',
|
|
680
|
+
})
|
|
681
|
+
expect(result.ok).toBe(true)
|
|
682
|
+
if (result.ok) expect(boundsOf(result.value)).toContain('exceeds the 21 findings')
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
test('bounds say nothing about kinds that carry no directions', () => {
|
|
686
|
+
const result = validateCensus({
|
|
687
|
+
kind: 'closer',
|
|
688
|
+
closer: 'scope-injection',
|
|
689
|
+
phase: 'extract',
|
|
690
|
+
batch: 'b-closer-001',
|
|
691
|
+
checked: 3,
|
|
692
|
+
findings: 0,
|
|
693
|
+
fixed: 0,
|
|
694
|
+
queued: [],
|
|
695
|
+
})
|
|
696
|
+
expect(result.ok).toBe(true)
|
|
697
|
+
if (result.ok) expect(boundsOf(result.value)).toBeNull()
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
// Review round 1, Finding 3: the two-direction minimum is counted on the raw
|
|
701
|
+
// keys of the input object, before any entry is filtered out for being
|
|
702
|
+
// malformed. Pinned here so a future refactor that switches to counting the
|
|
703
|
+
// validated output map instead cannot silently regress this: a record with
|
|
704
|
+
// one valid direction and one bare-count direction must still be rejected,
|
|
705
|
+
// and specifically for the bare-count shape, not for a spurious "needs at
|
|
706
|
+
// least two directions" message that would imply the fix is counting the
|
|
707
|
+
// wrong thing.
|
|
708
|
+
test('one valid direction plus one malformed direction is rejected for the malformed one, not treated as short of two', () => {
|
|
709
|
+
const result = validateCensus(
|
|
710
|
+
lens({
|
|
711
|
+
directions: {
|
|
712
|
+
ddl: { count: 43, evidence: 'rg -n "CREATE TABLE" schema.sql | wc -l' },
|
|
713
|
+
orm: 40,
|
|
714
|
+
},
|
|
715
|
+
}),
|
|
716
|
+
)
|
|
717
|
+
expect(result.ok).toBe(false)
|
|
718
|
+
if (!result.ok) {
|
|
719
|
+
const joined = result.errors.join('\n')
|
|
720
|
+
expect(joined).toContain('old bare-count shape')
|
|
721
|
+
expect(joined).not.toContain('at least two independent directions')
|
|
722
|
+
}
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
// Review round 1, Finding 1b: boundsOf's own type signature promises
|
|
726
|
+
// record.directions is well-formed, but a hand-edited census.jsonl can put
|
|
727
|
+
// anything there, and this function is the last line of defense once
|
|
728
|
+
// check.ts's gate-time validation exists (a future caller could always skip
|
|
729
|
+
// that and call boundsOf directly). Object.values(...).map(d => d.count) on
|
|
730
|
+
// a bare number silently produces undefined, and Math.max/reduce on
|
|
731
|
+
// undefined produce NaN, against which every comparison is false, so the
|
|
732
|
+
// bug this pins is a false negative: total 999 must never be judged "in
|
|
733
|
+
// bounds" just because one direction was malformed.
|
|
734
|
+
test('boundsOf names a malformed direction instead of silently treating it as in bounds', () => {
|
|
735
|
+
const malformed = {
|
|
736
|
+
kind: 'lens',
|
|
737
|
+
surface: 'tables',
|
|
738
|
+
phase: 'enumerate',
|
|
739
|
+
directions: { ddl: 43, orm: { count: 40, evidence: 'rg -n "\\[Table\\(" --type cs' } },
|
|
740
|
+
total: 999,
|
|
741
|
+
in_ledger: 999,
|
|
742
|
+
added: 0,
|
|
743
|
+
skipped: [],
|
|
744
|
+
queued: [],
|
|
745
|
+
batch: 'b-1',
|
|
746
|
+
} as unknown as Census
|
|
747
|
+
const message = boundsOf(malformed)
|
|
748
|
+
expect(message).toContain('ddl')
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
// Review round 1, Finding 2: all nine bounds tests above call validateCensus
|
|
752
|
+
// and boundsOf directly; none drives a bounds-violating record through the
|
|
753
|
+
// real command path, so census-cmd.ts's own
|
|
754
|
+
// `balanceOf(result.value) ?? boundsOf(result.value)` line has never been
|
|
755
|
+
// exercised end to end. This closes that gap for the exit code and the
|
|
756
|
+
// stderr message together.
|
|
757
|
+
test('runCensus refuses a record whose total is out of bounds, on stderr, as an operation failure (1)', async () => {
|
|
758
|
+
const file = join(root, 'out-of-bounds.json')
|
|
759
|
+
await writeFile(file, JSON.stringify(lens({ total: 90, in_ledger: 90 })))
|
|
760
|
+
const original = process.stderr.write.bind(process.stderr)
|
|
761
|
+
const out: string[] = []
|
|
762
|
+
process.stderr.write = ((s: string) => {
|
|
763
|
+
out.push(s)
|
|
764
|
+
return true
|
|
765
|
+
}) as typeof process.stderr.write
|
|
766
|
+
let code: number
|
|
767
|
+
try {
|
|
768
|
+
code = await runCensus({ root, file })
|
|
769
|
+
} finally {
|
|
770
|
+
process.stderr.write = original
|
|
771
|
+
}
|
|
772
|
+
expect(code).toBe(1)
|
|
773
|
+
expect(out.join('')).toContain('exceeds the 83 findings')
|
|
774
|
+
expect(await readRows(storePaths(root).census)).toEqual([])
|
|
775
|
+
})
|