@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,1093 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
const CLI = join(import.meta.dir, '..', '..', 'bin', 'migrate.ts')
|
|
7
|
+
const FIXTURE = join(import.meta.dir, '..', '..', 'fixtures', 'tiny-express')
|
|
8
|
+
|
|
9
|
+
let target: string
|
|
10
|
+
let source: string
|
|
11
|
+
|
|
12
|
+
async function migrate(args: string[]): Promise<{ code: number; out: string; err: string }> {
|
|
13
|
+
const proc = Bun.spawn(['bun', CLI, ...args], { cwd: target, stdout: 'pipe', stderr: 'pipe' })
|
|
14
|
+
const [out, err] = await Promise.all([
|
|
15
|
+
new Response(proc.stdout).text(),
|
|
16
|
+
new Response(proc.stderr).text(),
|
|
17
|
+
])
|
|
18
|
+
await proc.exited
|
|
19
|
+
return { code: proc.exitCode ?? -1, out, err }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function write(name: string, value: unknown): Promise<string> {
|
|
23
|
+
const path = join(target, name)
|
|
24
|
+
await writeFile(path, JSON.stringify(value))
|
|
25
|
+
return path
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function writeText(name: string, text: string): Promise<string> {
|
|
29
|
+
const path = join(target, name)
|
|
30
|
+
await writeFile(path, text)
|
|
31
|
+
return path
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
beforeEach(async () => {
|
|
35
|
+
target = await mkdtemp(join(tmpdir(), 'migrate-e2e-express-'))
|
|
36
|
+
source = join(target, 'legacy')
|
|
37
|
+
await cp(FIXTURE, source, { recursive: true })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
afterEach(async () => {
|
|
41
|
+
await rm(target, { recursive: true, force: true })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// GROUND-TRUTH.md is the single source of what this fixture contains. Reading
|
|
45
|
+
// and parsing it here, rather than hand-copying its rows into this file, is
|
|
46
|
+
// the point: a row added to the fixture without a matching update here (or
|
|
47
|
+
// vice versa) cannot silently drift, because the batches below are built from
|
|
48
|
+
// whatever the table on disk actually says.
|
|
49
|
+
type GroundTruthRow = { surface: string; id: string; element: string }
|
|
50
|
+
|
|
51
|
+
async function parseGroundTruth(path: string): Promise<GroundTruthRow[]> {
|
|
52
|
+
const text = await readFile(path, 'utf8')
|
|
53
|
+
const rows: GroundTruthRow[] = []
|
|
54
|
+
for (const line of text.split('\n')) {
|
|
55
|
+
const trimmed = line.trim()
|
|
56
|
+
if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) continue
|
|
57
|
+
const cells = trimmed
|
|
58
|
+
.slice(1, -1)
|
|
59
|
+
.split('|')
|
|
60
|
+
.map((c) => c.trim())
|
|
61
|
+
if (cells.length !== 3) continue
|
|
62
|
+
const surface = cells[0] ?? ''
|
|
63
|
+
const id = cells[1] ?? ''
|
|
64
|
+
const element = cells[2] ?? ''
|
|
65
|
+
if (surface === 'surface' && id === 'id') continue // header row
|
|
66
|
+
if (cells.every((c) => /^-+$/.test(c))) continue // separator row
|
|
67
|
+
rows.push({ surface, id, element })
|
|
68
|
+
}
|
|
69
|
+
return rows
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function groupBySurface(rows: GroundTruthRow[]): Map<string, GroundTruthRow[]> {
|
|
73
|
+
const groups = new Map<string, GroundTruthRow[]>()
|
|
74
|
+
for (const row of rows) {
|
|
75
|
+
const list = groups.get(row.surface) ?? []
|
|
76
|
+
list.push(row)
|
|
77
|
+
groups.set(row.surface, list)
|
|
78
|
+
}
|
|
79
|
+
return groups
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Express has no recipe pack in references/recipes/ (only aspnet.md exists),
|
|
83
|
+
// so enumerate.md's contract-only mode applies: two independently-derived
|
|
84
|
+
// directions per surface, supplied by the agent instead of a recipe file.
|
|
85
|
+
// Both directions below find exactly the fixture's own elements, since this
|
|
86
|
+
// is a fixture small enough that a real agent's two directions would too.
|
|
87
|
+
// Every evidence string below names a command actually re-run against the
|
|
88
|
+
// fixture (not merely a plausible-sounding description) to confirm it
|
|
89
|
+
// produces the count this surface's rows carry; nothing downstream executes
|
|
90
|
+
// these strings, so an unverified one would sit undetected the same way a
|
|
91
|
+
// stale src citation's line range does.
|
|
92
|
+
//
|
|
93
|
+
// Six of the eight (routes, jobs, reports, screens, integrations, workflows)
|
|
94
|
+
// have both directions read the same single file; only tables and settings
|
|
95
|
+
// cross-reference a second one. That is a property of a fixture this small,
|
|
96
|
+
// not evidence of stronger independent triangulation than it actually
|
|
97
|
+
// carries, and nobody should cite it as the latter.
|
|
98
|
+
const DIRECTIONS: Record<string, [string, string]> = {
|
|
99
|
+
routes: ['grep "app.(get|post)" across app.js', 'manual walk of route registrations in app.js'],
|
|
100
|
+
tables: [
|
|
101
|
+
'grep "CREATE TABLE" across schema.sql',
|
|
102
|
+
'manual review of table names referenced in app.js, cron.js and reports/',
|
|
103
|
+
],
|
|
104
|
+
jobs: ['grep "cron.schedule" across cron.js', 'manual review of cron.js'],
|
|
105
|
+
reports: ['ls reports/*.json', 'manual review of report definitions under reports/'],
|
|
106
|
+
screens: ['ls views/*.html', 'manual review of templates under views/'],
|
|
107
|
+
integrations: ['grep "fetch(" across app.js', 'manual review of outbound HTTP calls in app.js'],
|
|
108
|
+
workflows: [
|
|
109
|
+
'manual review of state stored and later consumed across app.js handlers',
|
|
110
|
+
'grep "pendingWelcomes" across app.js',
|
|
111
|
+
],
|
|
112
|
+
settings: [
|
|
113
|
+
'grep "settings\\." across app.js, excluding the require line',
|
|
114
|
+
'manual review of settings.json keys',
|
|
115
|
+
],
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Ledger refs a lens would record per enumerate.md's Procedure, step 4:
|
|
119
|
+
// an element that touches one already in the ledger gets a
|
|
120
|
+
// {"kind": "ledger", "id": ...} entry naming it. See GROUND-TRUTH.md's
|
|
121
|
+
// "Element-to-element touches" section, which this map matches by hand,
|
|
122
|
+
// the same relationship DIRECTIONS above has to the fixture's source
|
|
123
|
+
// files: this is fixture metadata the test asserts against, not something
|
|
124
|
+
// parsed off disk.
|
|
125
|
+
type LedgerRef = { kind: 'ledger'; id: string }
|
|
126
|
+
const REFS: Record<string, LedgerRef[]> = {
|
|
127
|
+
'route-get-api-users': [{ kind: 'ledger', id: 'table-users' }],
|
|
128
|
+
'route-post-api-users': [{ kind: 'ledger', id: 'table-users' }],
|
|
129
|
+
'job-purge-audit-log': [{ kind: 'ledger', id: 'table-audit-log' }],
|
|
130
|
+
'report-daily-users': [{ kind: 'ledger', id: 'table-users' }],
|
|
131
|
+
'workflow-welcome-email': [
|
|
132
|
+
{ kind: 'ledger', id: 'route-post-api-users' },
|
|
133
|
+
{ kind: 'ledger', id: 'route-get-api-users-id-welcome' },
|
|
134
|
+
{ kind: 'ledger', id: 'setting-welcome-email-enabled' },
|
|
135
|
+
],
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Builds the same graph seam.md's surface-affinity clustering builds: one
|
|
139
|
+
// edge per {"kind": "ledger", ...} ref on any element, connecting it to the
|
|
140
|
+
// id it names. An edge is an unordered pair, stored as its two ids sorted
|
|
141
|
+
// and joined, so a-touches-b and b-touches-a (were both ever recorded)
|
|
142
|
+
// would collapse to one edge rather than count twice.
|
|
143
|
+
type StoredElement = { id: string; refs: { kind: string; id?: string }[] }
|
|
144
|
+
|
|
145
|
+
function buildAffinityEdges(elements: StoredElement[]): Set<string> {
|
|
146
|
+
const edges = new Set<string>()
|
|
147
|
+
for (const el of elements) {
|
|
148
|
+
for (const ref of el.refs) {
|
|
149
|
+
if (ref.kind === 'ledger' && ref.id) {
|
|
150
|
+
edges.add([el.id, ref.id].sort().join('|'))
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return edges
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const QUEUE_ID = 'q-tiny-express-enumerate-scaffold'
|
|
158
|
+
|
|
159
|
+
const QUEUE_ITEM = `---
|
|
160
|
+
id: ${QUEUE_ID}
|
|
161
|
+
severity: minor
|
|
162
|
+
status: open
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Evidence
|
|
166
|
+
|
|
167
|
+
This run asserts a clean \`check --phase enumerate\` before extract has run,
|
|
168
|
+
and coverage is a whole-store gate: it reads every element regardless of
|
|
169
|
+
\`--phase\`, so an element with no terminal disposition fails it at any
|
|
170
|
+
phase. No requirement exists yet at that point for an element to map to.
|
|
171
|
+
|
|
172
|
+
## Options
|
|
173
|
+
|
|
174
|
+
(a) Leave every element unaccounted and give up on asserting the enumerate
|
|
175
|
+
slice clean at all. (b) Give every element a placeholder out-of-scope
|
|
176
|
+
disposition citing this item, which extract then replaces with a real
|
|
177
|
+
\`mapped\` disposition once there is a requirement to map to.
|
|
178
|
+
|
|
179
|
+
## Recommendation
|
|
180
|
+
|
|
181
|
+
Recommend (b); it isolates the census and run-state assertions the
|
|
182
|
+
enumerate step exists to make from the coverage gate, and citing this item
|
|
183
|
+
keeps the placeholder auditable rather than silent. The item stays open
|
|
184
|
+
after extract supersedes it, because nothing in this milestone adjudicates
|
|
185
|
+
a queue item.
|
|
186
|
+
`
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Phase 2, seam. The partition surface-affinity clustering produces from the
|
|
190
|
+
// REFS above, following references/phases/seam.md's procedure rather than
|
|
191
|
+
// choosing capabilities by hand. Re-derived by running that manual's own
|
|
192
|
+
// formula over exactly the seven edges e2e asserts in step 6b:
|
|
193
|
+
//
|
|
194
|
+
// component 1 (n=2): job-purge-audit-log, table-audit-log
|
|
195
|
+
// component 2 (n=7): report-daily-users, route-get-api-users,
|
|
196
|
+
// route-get-api-users-id-welcome, route-post-api-users,
|
|
197
|
+
// setting-welcome-email-enabled, table-users, workflow-welcome-email
|
|
198
|
+
// edgeless singletons (n=3): integration-mailer, screen-users,
|
|
199
|
+
// setting-max-users-per-page
|
|
200
|
+
// m (total edges) = 7
|
|
201
|
+
// components-only Q = 0.245
|
|
202
|
+
//
|
|
203
|
+
// 0.245 is below the 0.3 floor, so the greedy refinement seam.md specifies
|
|
204
|
+
// runs on the seven-node component (every node in it starting as its own
|
|
205
|
+
// group, the two-node component left whole):
|
|
206
|
+
//
|
|
207
|
+
// starting Q (component exploded to singletons) = -0.010
|
|
208
|
+
// merge 1: {report-daily-users} + {table-users} -> Q = 0.102
|
|
209
|
+
// merge 2: {route-get-api-users-id-welcome} + {workflow-welcome-email} -> Q = 0.214
|
|
210
|
+
// merge 3: {route-get-api-users} + {report-daily-users, table-users} -> Q = 0.316
|
|
211
|
+
// merge 4: {setting-welcome-email-enabled} + {route-get-api-users-id-welcome,
|
|
212
|
+
// workflow-welcome-email} -> Q = 0.418
|
|
213
|
+
// merge 5: {route-post-api-users} + {report-daily-users, route-get-api-users,
|
|
214
|
+
// table-users} -> Q = 0.459
|
|
215
|
+
// no further join raises Q; stopping
|
|
216
|
+
//
|
|
217
|
+
// Q = 0.459 >= 0.3. Express has no schema this run can cluster, no statically
|
|
218
|
+
// parseable call graph, and the copied fixture carries no VCS history, so
|
|
219
|
+
// surface-affinity is the only validator that could run at all: the
|
|
220
|
+
// one-validator exception, not the two-agree rule, is what licenses accepting
|
|
221
|
+
// this split, exactly as seam.md's first worked example describes.
|
|
222
|
+
//
|
|
223
|
+
// The three edgeless elements are assigned by hand, per seam.md's edgeless
|
|
224
|
+
// rule, each on the source proximity that rule names, because the graph has
|
|
225
|
+
// nothing to say about a node with no edges:
|
|
226
|
+
// screen-users -> user-directory (views/users.html renders the
|
|
227
|
+
// list that capability's route serves)
|
|
228
|
+
// setting-max-users-per-page-> user-directory (app.js:8 is the only line
|
|
229
|
+
// that reads it, inside that capability's route)
|
|
230
|
+
// integration-mailer -> welcome-notification (app.js:21 is inside
|
|
231
|
+
// that capability's welcome handler)
|
|
232
|
+
type CapabilityRow = { slug: string; title: string; ns: string; elements: string[] }
|
|
233
|
+
|
|
234
|
+
const CAPABILITIES: CapabilityRow[] = [
|
|
235
|
+
{
|
|
236
|
+
slug: 'audit-retention',
|
|
237
|
+
title: 'Audit Retention',
|
|
238
|
+
ns: 'AR',
|
|
239
|
+
elements: ['job-purge-audit-log', 'table-audit-log'],
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
slug: 'user-directory',
|
|
243
|
+
title: 'User Directory',
|
|
244
|
+
ns: 'UD',
|
|
245
|
+
elements: [
|
|
246
|
+
'report-daily-users',
|
|
247
|
+
'route-get-api-users',
|
|
248
|
+
'route-post-api-users',
|
|
249
|
+
'table-users',
|
|
250
|
+
'screen-users',
|
|
251
|
+
'setting-max-users-per-page',
|
|
252
|
+
],
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
slug: 'welcome-notification',
|
|
256
|
+
title: 'Welcome Notification',
|
|
257
|
+
ns: 'WN',
|
|
258
|
+
elements: [
|
|
259
|
+
'route-get-api-users-id-welcome',
|
|
260
|
+
'setting-welcome-email-enabled',
|
|
261
|
+
'workflow-welcome-email',
|
|
262
|
+
'integration-mailer',
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
const SEAM_JSON = {
|
|
268
|
+
validators: {
|
|
269
|
+
'schema-clustering': { ran: false, reason: 'no relational schema this run can cluster' },
|
|
270
|
+
'call-graph': { ran: false, reason: 'no static call-graph tooling in this environment' },
|
|
271
|
+
'change-coupling': { ran: false, reason: 'the copied fixture carries no VCS history' },
|
|
272
|
+
'surface-affinity': { ran: true, modularity: 0.459 },
|
|
273
|
+
},
|
|
274
|
+
agreement: ['surface-affinity'],
|
|
275
|
+
modularity: 0.459,
|
|
276
|
+
status: 'accepted',
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// Phase 3, extract. One requirement per distinct behavior, at least one
|
|
281
|
+
// citation each, every citation's src path and line range real: the citations
|
|
282
|
+
// gate runs by default and resolves each one against the copied fixture, so a
|
|
283
|
+
// wrong line range here fails the run rather than sitting undetected.
|
|
284
|
+
// `parity` is null on every row, because assigning an oracle is phase 4's job;
|
|
285
|
+
// extract.md is explicit that the parity gate exempts nothing at confirmed or
|
|
286
|
+
// inferred, only at queued.
|
|
287
|
+
type Citation =
|
|
288
|
+
| { kind: 'ledger'; id: string }
|
|
289
|
+
| { kind: 'src'; path: string; lines: [number, number] }
|
|
290
|
+
|
|
291
|
+
type RequirementRow = {
|
|
292
|
+
id: string
|
|
293
|
+
cap: string
|
|
294
|
+
requirement: string
|
|
295
|
+
actors: string
|
|
296
|
+
objects: string
|
|
297
|
+
rules: string
|
|
298
|
+
origin: string
|
|
299
|
+
confidence: { kind: string; queue?: string }
|
|
300
|
+
citations: Citation[]
|
|
301
|
+
parity: null
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const REQUIREMENTS: RequirementRow[] = [
|
|
305
|
+
{
|
|
306
|
+
id: 'UD-001',
|
|
307
|
+
cap: 'user-directory',
|
|
308
|
+
requirement: 'Listing users returns at most the configured maximum number of users per page',
|
|
309
|
+
actors: 'API client',
|
|
310
|
+
objects: 'User list',
|
|
311
|
+
rules: 'The response is sliced to settings.maxUsersPerPage entries',
|
|
312
|
+
origin: 'intended',
|
|
313
|
+
confidence: { kind: 'confirmed' },
|
|
314
|
+
citations: [
|
|
315
|
+
{ kind: 'ledger', id: 'route-get-api-users' },
|
|
316
|
+
{ kind: 'src', path: 'app.js', lines: [8, 8] },
|
|
317
|
+
{ kind: 'src', path: 'settings.json', lines: [1, 4] },
|
|
318
|
+
],
|
|
319
|
+
parity: null,
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
id: 'UD-002',
|
|
323
|
+
cap: 'user-directory',
|
|
324
|
+
requirement: 'Creating a user returns a generated id and records a pending welcome against it',
|
|
325
|
+
actors: 'API client',
|
|
326
|
+
objects: 'User',
|
|
327
|
+
rules: 'The id is minted from the current clock; the submitted email is held against it',
|
|
328
|
+
origin: 'intended',
|
|
329
|
+
confidence: { kind: 'confirmed' },
|
|
330
|
+
citations: [
|
|
331
|
+
{ kind: 'ledger', id: 'route-post-api-users' },
|
|
332
|
+
{ kind: 'src', path: 'app.js', lines: [10, 14] },
|
|
333
|
+
],
|
|
334
|
+
parity: null,
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
id: 'UD-003',
|
|
338
|
+
cap: 'user-directory',
|
|
339
|
+
requirement:
|
|
340
|
+
'The users table stores an id, a required email, and an active flag defaulting to set',
|
|
341
|
+
actors: '-',
|
|
342
|
+
objects: 'User row',
|
|
343
|
+
rules: 'email is NOT NULL; is_active defaults to 1',
|
|
344
|
+
origin: 'intended',
|
|
345
|
+
confidence: { kind: 'confirmed' },
|
|
346
|
+
citations: [
|
|
347
|
+
{ kind: 'ledger', id: 'table-users' },
|
|
348
|
+
{ kind: 'src', path: 'schema.sql', lines: [1, 5] },
|
|
349
|
+
],
|
|
350
|
+
parity: null,
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
id: 'UD-004',
|
|
354
|
+
cap: 'user-directory',
|
|
355
|
+
requirement: 'The daily-users report counts only users whose active flag is set',
|
|
356
|
+
actors: 'Report consumer',
|
|
357
|
+
objects: 'Active user count',
|
|
358
|
+
rules: 'The query filters on is_active = 1 and runs once a day',
|
|
359
|
+
origin: 'intended',
|
|
360
|
+
confidence: { kind: 'confirmed' },
|
|
361
|
+
citations: [
|
|
362
|
+
{ kind: 'ledger', id: 'report-daily-users' },
|
|
363
|
+
{ kind: 'src', path: 'reports/daily-users.json', lines: [1, 6] },
|
|
364
|
+
],
|
|
365
|
+
parity: null,
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
id: 'UD-005',
|
|
369
|
+
cap: 'user-directory',
|
|
370
|
+
requirement: 'The users screen renders one list entry per user email',
|
|
371
|
+
actors: 'Operator',
|
|
372
|
+
objects: 'User list screen',
|
|
373
|
+
rules: 'Each entry is bound to the email field',
|
|
374
|
+
origin: 'intended',
|
|
375
|
+
confidence: { kind: 'confirmed' },
|
|
376
|
+
citations: [
|
|
377
|
+
{ kind: 'ledger', id: 'screen-users' },
|
|
378
|
+
{ kind: 'src', path: 'views/users.html', lines: [1, 9] },
|
|
379
|
+
],
|
|
380
|
+
parity: null,
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
// The one queued requirement in this run, and the reason it is queued is
|
|
384
|
+
// visible in the source rather than invented: app.js:8 answers the list
|
|
385
|
+
// route with a literal empty array sliced to the page cap. Nothing in the
|
|
386
|
+
// checkout shows the users table ever being read, so whether this endpoint
|
|
387
|
+
// is a stub or genuinely always empty is not decidable from here.
|
|
388
|
+
// A queued requirement is the one case the parity gate exempts, which is
|
|
389
|
+
// why this row keeps `parity: null` through phase 4 while every other row
|
|
390
|
+
// gets a plan.
|
|
391
|
+
id: 'UD-006',
|
|
392
|
+
cap: 'user-directory',
|
|
393
|
+
requirement: 'The users list endpoint reads the rows it returns from the users table',
|
|
394
|
+
actors: 'API client',
|
|
395
|
+
objects: 'User list',
|
|
396
|
+
rules: '-',
|
|
397
|
+
origin: 'intended',
|
|
398
|
+
confidence: { kind: 'queued', queue: 'q-express-user-list-source' },
|
|
399
|
+
citations: [
|
|
400
|
+
{ kind: 'ledger', id: 'route-get-api-users' },
|
|
401
|
+
{ kind: 'src', path: 'app.js', lines: [8, 8] },
|
|
402
|
+
{ kind: 'src', path: 'schema.sql', lines: [1, 5] },
|
|
403
|
+
],
|
|
404
|
+
parity: null,
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
id: 'WN-001',
|
|
408
|
+
cap: 'welcome-notification',
|
|
409
|
+
requirement:
|
|
410
|
+
'The welcome route reports a send only when the welcome setting is enabled and a pending welcome exists for that id',
|
|
411
|
+
actors: 'API client',
|
|
412
|
+
objects: 'Pending welcome',
|
|
413
|
+
rules: 'Both settings.welcomeEmailEnabled and a stored email must hold, or nothing is sent',
|
|
414
|
+
origin: 'intended',
|
|
415
|
+
confidence: { kind: 'confirmed' },
|
|
416
|
+
citations: [
|
|
417
|
+
{ kind: 'ledger', id: 'route-get-api-users-id-welcome' },
|
|
418
|
+
{ kind: 'src', path: 'app.js', lines: [18, 27] },
|
|
419
|
+
{ kind: 'src', path: 'settings.json', lines: [1, 4] },
|
|
420
|
+
],
|
|
421
|
+
parity: null,
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
id: 'WN-002',
|
|
425
|
+
cap: 'welcome-notification',
|
|
426
|
+
requirement:
|
|
427
|
+
'Signup and welcome are one two-step workflow carrying the pending email in process memory between the two requests',
|
|
428
|
+
actors: 'API client',
|
|
429
|
+
objects: 'Pending welcome state',
|
|
430
|
+
rules: 'Step one writes the map entry; step two consumes it',
|
|
431
|
+
origin: 'intended',
|
|
432
|
+
confidence: { kind: 'confirmed' },
|
|
433
|
+
citations: [
|
|
434
|
+
{ kind: 'ledger', id: 'workflow-welcome-email' },
|
|
435
|
+
{ kind: 'src', path: 'app.js', lines: [5, 6] },
|
|
436
|
+
{ kind: 'src', path: 'app.js', lines: [16, 19] },
|
|
437
|
+
],
|
|
438
|
+
parity: null,
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
id: 'WN-003',
|
|
442
|
+
cap: 'welcome-notification',
|
|
443
|
+
requirement: 'The welcome email is sent by an outbound POST to the mailer service',
|
|
444
|
+
actors: 'System',
|
|
445
|
+
objects: 'Welcome email',
|
|
446
|
+
rules: 'The recipient address is the email held against the pending welcome',
|
|
447
|
+
origin: 'intended',
|
|
448
|
+
// Inferred, not confirmed: the source shows the request being made, and
|
|
449
|
+
// nothing citable here shows what the mailer does with it. That is
|
|
450
|
+
// extract.md's own definition of inferred, a piece genuinely unobservable
|
|
451
|
+
// from a call to something outside the citable source.
|
|
452
|
+
confidence: { kind: 'inferred' },
|
|
453
|
+
citations: [
|
|
454
|
+
{ kind: 'ledger', id: 'integration-mailer' },
|
|
455
|
+
{ kind: 'src', path: 'app.js', lines: [21, 24] },
|
|
456
|
+
],
|
|
457
|
+
parity: null,
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
id: 'AR-001',
|
|
461
|
+
cap: 'audit-retention',
|
|
462
|
+
requirement: 'Audit log rows older than thirty days are purged on a nightly schedule',
|
|
463
|
+
actors: 'System',
|
|
464
|
+
objects: 'Audit log rows',
|
|
465
|
+
rules: 'Runs at 02:00 daily; the cutoff is thirty days',
|
|
466
|
+
origin: 'intended',
|
|
467
|
+
confidence: { kind: 'confirmed' },
|
|
468
|
+
citations: [
|
|
469
|
+
{ kind: 'ledger', id: 'job-purge-audit-log' },
|
|
470
|
+
{ kind: 'src', path: 'cron.js', lines: [3, 6] },
|
|
471
|
+
],
|
|
472
|
+
parity: null,
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
id: 'AR-002',
|
|
476
|
+
cap: 'audit-retention',
|
|
477
|
+
requirement: 'The audit log stores one row per user action with a creation timestamp',
|
|
478
|
+
actors: '-',
|
|
479
|
+
objects: 'Audit log row',
|
|
480
|
+
rules: 'user_id, action and created_at are all NOT NULL',
|
|
481
|
+
origin: 'intended',
|
|
482
|
+
confidence: { kind: 'confirmed' },
|
|
483
|
+
citations: [
|
|
484
|
+
{ kind: 'ledger', id: 'table-audit-log' },
|
|
485
|
+
{ kind: 'src', path: 'schema.sql', lines: [7, 12] },
|
|
486
|
+
],
|
|
487
|
+
parity: null,
|
|
488
|
+
},
|
|
489
|
+
]
|
|
490
|
+
|
|
491
|
+
// Every ground-truth element maps to one requirement. Asserted against the
|
|
492
|
+
// parsed GROUND-TRUTH.md rows below rather than trusted, so an element added
|
|
493
|
+
// to the fixture with no home here fails loudly instead of quietly ending the
|
|
494
|
+
// run unaccounted.
|
|
495
|
+
const DISPOSITIONS: Record<string, { kind: 'mapped'; fr: string }> = {
|
|
496
|
+
'route-get-api-users': { kind: 'mapped', fr: 'UD-001' },
|
|
497
|
+
'setting-max-users-per-page': { kind: 'mapped', fr: 'UD-001' },
|
|
498
|
+
'route-post-api-users': { kind: 'mapped', fr: 'UD-002' },
|
|
499
|
+
'table-users': { kind: 'mapped', fr: 'UD-003' },
|
|
500
|
+
'report-daily-users': { kind: 'mapped', fr: 'UD-004' },
|
|
501
|
+
'screen-users': { kind: 'mapped', fr: 'UD-005' },
|
|
502
|
+
'route-get-api-users-id-welcome': { kind: 'mapped', fr: 'WN-001' },
|
|
503
|
+
'setting-welcome-email-enabled': { kind: 'mapped', fr: 'WN-001' },
|
|
504
|
+
'workflow-welcome-email': { kind: 'mapped', fr: 'WN-002' },
|
|
505
|
+
'integration-mailer': { kind: 'mapped', fr: 'WN-003' },
|
|
506
|
+
'job-purge-audit-log': { kind: 'mapped', fr: 'AR-001' },
|
|
507
|
+
'table-audit-log': { kind: 'mapped', fr: 'AR-002' },
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// One rule-sweep per capability, one attribute census for the only table with
|
|
511
|
+
// columns worth explaining, and one record per declared closer. extract.md is
|
|
512
|
+
// explicit that nothing gates the first two for completeness (only lens
|
|
513
|
+
// against [surfaces].types and closer against [closers].set), so they are here
|
|
514
|
+
// as the manual's discipline, not because a gate would catch their absence.
|
|
515
|
+
const EXTRACT_CENSUS: Record<string, unknown>[] = [
|
|
516
|
+
{
|
|
517
|
+
kind: 'rule-sweep',
|
|
518
|
+
subject: 'user-directory',
|
|
519
|
+
phase: 'extract',
|
|
520
|
+
probes: 2,
|
|
521
|
+
found: 1,
|
|
522
|
+
as_requirements: 1,
|
|
523
|
+
queued: [],
|
|
524
|
+
batch: 'b-rules-user-directory-001',
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
kind: 'rule-sweep',
|
|
528
|
+
subject: 'welcome-notification',
|
|
529
|
+
phase: 'extract',
|
|
530
|
+
probes: 2,
|
|
531
|
+
found: 1,
|
|
532
|
+
as_requirements: 1,
|
|
533
|
+
queued: [],
|
|
534
|
+
batch: 'b-rules-welcome-notification-001',
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
kind: 'rule-sweep',
|
|
538
|
+
subject: 'audit-retention',
|
|
539
|
+
phase: 'extract',
|
|
540
|
+
probes: 2,
|
|
541
|
+
found: 1,
|
|
542
|
+
as_requirements: 1,
|
|
543
|
+
queued: [],
|
|
544
|
+
batch: 'b-rules-audit-retention-001',
|
|
545
|
+
},
|
|
546
|
+
{
|
|
547
|
+
// schema.sql:1-5 declares id, email and is_active; the report query at
|
|
548
|
+
// reports/daily-users.json:4 names one of them. `id` is an identity key,
|
|
549
|
+
// which extract.md's exemption list keeps out of `behavioral`, leaving
|
|
550
|
+
// email (UD-003) and is_active (UD-004) as the two that are explained.
|
|
551
|
+
kind: 'attribute',
|
|
552
|
+
surface: 'tables',
|
|
553
|
+
subject: 'table-users',
|
|
554
|
+
phase: 'extract',
|
|
555
|
+
directions: {
|
|
556
|
+
ddl: { count: 3, evidence: 'column list from CREATE TABLE users in schema.sql' },
|
|
557
|
+
report_query: {
|
|
558
|
+
count: 1,
|
|
559
|
+
evidence: 'columns named in the query in reports/daily-users.json',
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
total: 3,
|
|
563
|
+
behavioral: 2,
|
|
564
|
+
explained: 2,
|
|
565
|
+
queued: [],
|
|
566
|
+
batch: 'b-attr-table-users-001',
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
// workflow-welcome-email spans route-post-api-users (user-directory) and
|
|
570
|
+
// both route-get-api-users-id-welcome and setting-welcome-email-enabled
|
|
571
|
+
// (welcome-notification): a genuine cross-capability seam, found and
|
|
572
|
+
// covered on the spot by WN-002, which describes the whole journey rather
|
|
573
|
+
// than either half.
|
|
574
|
+
kind: 'closer',
|
|
575
|
+
closer: 'cross-capability-workflow',
|
|
576
|
+
phase: 'extract',
|
|
577
|
+
checked: 1,
|
|
578
|
+
findings: 1,
|
|
579
|
+
fixed: 1,
|
|
580
|
+
queued: [],
|
|
581
|
+
batch: 'b-closer-cross-capability-001',
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
kind: 'closer',
|
|
585
|
+
closer: 'scope-injection',
|
|
586
|
+
phase: 'extract',
|
|
587
|
+
checked: 11,
|
|
588
|
+
findings: 0,
|
|
589
|
+
fixed: 0,
|
|
590
|
+
queued: [],
|
|
591
|
+
batch: 'b-closer-scope-injection-001',
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
// A real finding this pass cannot resolve: app.js writes nothing to the
|
|
595
|
+
// users table and reads nothing from it, so the only read on record is the
|
|
596
|
+
// report's query and there is no write anywhere at all.
|
|
597
|
+
kind: 'closer',
|
|
598
|
+
closer: 'read-write-symmetry',
|
|
599
|
+
phase: 'extract',
|
|
600
|
+
checked: 11,
|
|
601
|
+
findings: 1,
|
|
602
|
+
fixed: 0,
|
|
603
|
+
queued: ['q-express-users-table-unwired'],
|
|
604
|
+
batch: 'b-closer-read-write-symmetry-001',
|
|
605
|
+
},
|
|
606
|
+
]
|
|
607
|
+
|
|
608
|
+
// ---------------------------------------------------------------------------
|
|
609
|
+
// Phase 4, parity. source.basis is source-only (init's default, and the honest
|
|
610
|
+
// call for a copied fixture with no package.json and no installed
|
|
611
|
+
// dependencies), so `differential` is unavailable: it needs a live legacy
|
|
612
|
+
// system to diff against, and there is none. `golden-master` survives only
|
|
613
|
+
// where the source itself ships the artifact that determines the output, which
|
|
614
|
+
// parity.md names as the exception -- the DDL and the report query are exactly
|
|
615
|
+
// that. Everything else is `rubric`, and every rubric below `high` carries the
|
|
616
|
+
// queue id the refs gate checks for.
|
|
617
|
+
//
|
|
618
|
+
// Each `ref` is built from target.parity_test_path read out of config.toml at
|
|
619
|
+
// run time, never from the default hardcoded here, since probe.md says an
|
|
620
|
+
// operator may have edited it and parity.md says to read it rather than assume.
|
|
621
|
+
type ParityPlan =
|
|
622
|
+
| { kind: 'golden-master'; capability: string; frSlug: string }
|
|
623
|
+
| { kind: 'rubric'; level: string; queue?: string }
|
|
624
|
+
|
|
625
|
+
const PARITY: Record<string, ParityPlan | null> = {
|
|
626
|
+
'UD-001': { kind: 'rubric', level: 'high' },
|
|
627
|
+
'UD-002': { kind: 'rubric', level: 'high' },
|
|
628
|
+
'UD-003': { kind: 'golden-master', capability: 'user-directory', frSlug: 'users-table' },
|
|
629
|
+
'UD-004': { kind: 'golden-master', capability: 'user-directory', frSlug: 'daily-users-report' },
|
|
630
|
+
'UD-005': { kind: 'rubric', level: 'high' },
|
|
631
|
+
'UD-006': null,
|
|
632
|
+
'WN-001': { kind: 'rubric', level: 'high' },
|
|
633
|
+
'WN-002': { kind: 'rubric', level: 'high' },
|
|
634
|
+
'WN-003': { kind: 'rubric', level: 'moderate', queue: 'q-express-mailer-delivery-unobservable' },
|
|
635
|
+
'AR-001': { kind: 'rubric', level: 'high' },
|
|
636
|
+
'AR-002': { kind: 'golden-master', capability: 'audit-retention', frSlug: 'audit-log-table' },
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const DELTA = {
|
|
640
|
+
id: 'delta-mailer-provider-swap',
|
|
641
|
+
scope: 'Outbound welcome email delivery (WN-003)',
|
|
642
|
+
rationale:
|
|
643
|
+
'The legacy app posts straight to mailer.example.com from inside the request handler. The target sends through its own provider, so the outbound request differs by destination and headers for reasons that have nothing to do with whether a welcome was owed.',
|
|
644
|
+
parity_exclusion:
|
|
645
|
+
'The WN-003 parity check must not assert on the mailer endpoint or the shape of the request sent to it, only that a send was attempted for an enabled, pending welcome.',
|
|
646
|
+
validation:
|
|
647
|
+
'A greenfield-only test asserts the target provider is called with the right recipient; the parity suite does not re-prove the transport.',
|
|
648
|
+
owner_signed: null as string | null,
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Every queue item this run files beyond the enumerate scaffold, each one named
|
|
652
|
+
// by something real: two by a field the refs gate checks (UD-006's
|
|
653
|
+
// confidence.queue and WN-003's parity.queue), one by a closer census's own
|
|
654
|
+
// `queued` array, which queue.md is explicit that no gate ever cross-checks.
|
|
655
|
+
// Filed anyway, because that is the manual's discipline.
|
|
656
|
+
const QUEUE_ITEMS: Record<string, string> = {
|
|
657
|
+
'q-express-user-list-source': `---
|
|
658
|
+
id: q-express-user-list-source
|
|
659
|
+
severity: moderate
|
|
660
|
+
status: open
|
|
661
|
+
---
|
|
662
|
+
|
|
663
|
+
## Evidence
|
|
664
|
+
|
|
665
|
+
\`app.js:8\` answers \`GET /api/users\` with \`[].slice(0, settings.maxUsersPerPage)\`:
|
|
666
|
+
a literal empty array, capped at the configured page size. Nothing anywhere
|
|
667
|
+
in the checkout opens a database connection or reads the \`users\` table that
|
|
668
|
+
\`schema.sql:1-5\` declares.
|
|
669
|
+
|
|
670
|
+
## Options
|
|
671
|
+
|
|
672
|
+
(a) Treat the endpoint as a stub and write the requirement against the table
|
|
673
|
+
the schema declares. (b) Treat the empty list as the real behavior and
|
|
674
|
+
record that the table is unread. (c) Ask the operator which one production
|
|
675
|
+
actually served.
|
|
676
|
+
|
|
677
|
+
## Recommendation
|
|
678
|
+
|
|
679
|
+
Recommend (c); the route and the schema disagree about whether this endpoint
|
|
680
|
+
has a data source, and nothing in the source settles it either way.
|
|
681
|
+
`,
|
|
682
|
+
'q-express-mailer-delivery-unobservable': `---
|
|
683
|
+
id: q-express-mailer-delivery-unobservable
|
|
684
|
+
severity: moderate
|
|
685
|
+
status: open
|
|
686
|
+
---
|
|
687
|
+
|
|
688
|
+
## Evidence
|
|
689
|
+
|
|
690
|
+
\`WN-003\` describes the outbound POST at \`app.js:21-24\` to
|
|
691
|
+
\`https://mailer.example.com/send\`. The basis for this run is source-only, so
|
|
692
|
+
there is no live legacy system to diff against and nothing to capture a
|
|
693
|
+
golden master from, and the call crosses a boundary neither could reach in
|
|
694
|
+
any case.
|
|
695
|
+
|
|
696
|
+
## Options
|
|
697
|
+
|
|
698
|
+
(a) Ship \`rubric:low\` and revisit if a runnable environment appears.
|
|
699
|
+
(b) Block parity on this requirement until the mailer can be observed.
|
|
700
|
+
(c) Ship \`rubric:moderate\`: the guard conditions and the recipient are both
|
|
701
|
+
readable from the source, the delivery itself is not.
|
|
702
|
+
|
|
703
|
+
## Recommendation
|
|
704
|
+
|
|
705
|
+
Recommend (c); \`rubric:moderate\` matches exactly what is observable today.
|
|
706
|
+
`,
|
|
707
|
+
'q-express-users-table-unwired': `---
|
|
708
|
+
id: q-express-users-table-unwired
|
|
709
|
+
severity: moderate
|
|
710
|
+
status: open
|
|
711
|
+
---
|
|
712
|
+
|
|
713
|
+
## Evidence
|
|
714
|
+
|
|
715
|
+
The \`read-write-symmetry\` closer checked every write path against a matching
|
|
716
|
+
read path. \`schema.sql:1-5\` declares a \`users\` table;
|
|
717
|
+
\`reports/daily-users.json:4\` reads it; nothing in \`app.js\` or \`cron.js\`
|
|
718
|
+
writes to it, and \`POST /api/users\` at \`app.js:10-14\` persists nothing at
|
|
719
|
+
all beyond an in-memory map entry.
|
|
720
|
+
|
|
721
|
+
## Options
|
|
722
|
+
|
|
723
|
+
(a) Treat the table as write-only-by-something-outside-this-checkout and
|
|
724
|
+
widen the search. (b) Treat it as a real gap for the target to fix rather
|
|
725
|
+
than replicate. (c) Ask the operator whether user rows were ever written by
|
|
726
|
+
this application.
|
|
727
|
+
|
|
728
|
+
## Recommendation
|
|
729
|
+
|
|
730
|
+
Recommend (c); a table with a reader and no writer anywhere in the checkout
|
|
731
|
+
is exactly what this closer exists to surface, and only the operator can say
|
|
732
|
+
whether the writer is missing or merely elsewhere.
|
|
733
|
+
`,
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Substitutes a parity plan into the row shape `migrate import reqs` accepts.
|
|
737
|
+
// `{capability}` and `{fr_slug}` come from target.parity_test_path as read off
|
|
738
|
+
// config.toml; parity.md is explicit that nothing in the CLI derives an
|
|
739
|
+
// fr_slug or checks a ref against the template, so this substitution is the
|
|
740
|
+
// whole of the convention.
|
|
741
|
+
function renderParity(plan: ParityPlan | null, template: string): Record<string, unknown> | null {
|
|
742
|
+
if (plan === null) return null
|
|
743
|
+
if (plan.kind === 'rubric') {
|
|
744
|
+
return { kind: 'rubric', level: plan.level, ...(plan.queue ? { queue: plan.queue } : {}) }
|
|
745
|
+
}
|
|
746
|
+
const ref = template
|
|
747
|
+
.replaceAll('{capability}', plan.capability)
|
|
748
|
+
.replaceAll('{fr_slug}', plan.frSlug)
|
|
749
|
+
return { kind: 'golden-master', ref }
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function readTomlString(text: string, key: string): string {
|
|
753
|
+
const match = text.match(new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, 'm'))
|
|
754
|
+
if (!match?.[1]) throw new Error(`config.toml has no ${key}`)
|
|
755
|
+
return match[1]
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
test('contract-only run driven probe through queue, ending green at check --phase queue', async () => {
|
|
759
|
+
const groundTruthPath = join(source, 'GROUND-TRUTH.md')
|
|
760
|
+
const rows = await parseGroundTruth(groundTruthPath)
|
|
761
|
+
expect(rows.length).toBeGreaterThan(0)
|
|
762
|
+
const groups = groupBySurface(rows)
|
|
763
|
+
|
|
764
|
+
// 1. migrate init.
|
|
765
|
+
const init = await migrate([
|
|
766
|
+
'init',
|
|
767
|
+
'--source',
|
|
768
|
+
source,
|
|
769
|
+
'--scope',
|
|
770
|
+
'the whole tiny app',
|
|
771
|
+
'--name',
|
|
772
|
+
'tiny-next',
|
|
773
|
+
'--source-stack',
|
|
774
|
+
'express',
|
|
775
|
+
])
|
|
776
|
+
expect(init.code).toBe(0)
|
|
777
|
+
|
|
778
|
+
// Both defaults init wrote are left exactly as they are: the eight surface
|
|
779
|
+
// types, because exercising all eight is this fixture's whole point, and the
|
|
780
|
+
// three closers, because this run reaches extract and therefore owes a
|
|
781
|
+
// closer census record for each one. parity_test_path is read back rather
|
|
782
|
+
// than assumed, per probe.md and parity.md, since an operator may have
|
|
783
|
+
// edited it and nothing downstream would notice.
|
|
784
|
+
const cfgPath = join(target, '.migrate', 'config.toml')
|
|
785
|
+
const cfg = await readFile(cfgPath, 'utf8')
|
|
786
|
+
const parityTemplate = readTomlString(cfg, 'parity_test_path')
|
|
787
|
+
expect(parityTemplate).toContain('{capability}')
|
|
788
|
+
expect(parityTemplate).toContain('{fr_slug}')
|
|
789
|
+
|
|
790
|
+
// 2. migrate phase probe --status done.
|
|
791
|
+
expect((await migrate(['phase', 'probe', '--status', 'done'])).code).toBe(0)
|
|
792
|
+
|
|
793
|
+
// File the queue item every element's placeholder disposition below cites.
|
|
794
|
+
// See QUEUE_ITEM for why: coverage is a whole-store gate, so a clean check
|
|
795
|
+
// bound at --phase enumerate (step 6) still needs every element disposed,
|
|
796
|
+
// even though disposing elements is ordinarily extract's job.
|
|
797
|
+
const queuePath = await writeText(`${QUEUE_ID}.md`, QUEUE_ITEM)
|
|
798
|
+
expect((await migrate(['queue', 'add', queuePath])).code).toBe(0)
|
|
799
|
+
|
|
800
|
+
// 3. migrate import elements, one batch per surface, rows from GROUND-TRUTH.md.
|
|
801
|
+
for (const [surface, surfaceRows] of groups) {
|
|
802
|
+
const batch = await write(`elements-${surface}.json`, {
|
|
803
|
+
batch: `b-${surface}-elements-001`,
|
|
804
|
+
phase: 'enumerate',
|
|
805
|
+
rows: surfaceRows.map((row) => ({
|
|
806
|
+
id: row.id,
|
|
807
|
+
surface: row.surface,
|
|
808
|
+
element: row.element,
|
|
809
|
+
found_by: ['code'],
|
|
810
|
+
disposition: { kind: 'out-of-scope', queue: QUEUE_ID },
|
|
811
|
+
refs: REFS[row.id] ?? [],
|
|
812
|
+
lens: 'code',
|
|
813
|
+
notes: '',
|
|
814
|
+
})),
|
|
815
|
+
})
|
|
816
|
+
const result = await migrate(['import', 'elements', batch])
|
|
817
|
+
expect(result.code).toBe(0)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// 4. migrate census, one balanced lens record per surface, two directions each.
|
|
821
|
+
for (const [surface, surfaceRows] of groups) {
|
|
822
|
+
const count = surfaceRows.length
|
|
823
|
+
const [a, b] = DIRECTIONS[surface] ?? ['direction a', 'direction b']
|
|
824
|
+
const record = await write(`census-${surface}.json`, {
|
|
825
|
+
kind: 'lens',
|
|
826
|
+
surface,
|
|
827
|
+
phase: 'enumerate',
|
|
828
|
+
directions: {
|
|
829
|
+
grep: { count, evidence: a },
|
|
830
|
+
manual: { count, evidence: b },
|
|
831
|
+
},
|
|
832
|
+
total: count,
|
|
833
|
+
in_ledger: 0,
|
|
834
|
+
added: count,
|
|
835
|
+
skipped: [],
|
|
836
|
+
queued: [],
|
|
837
|
+
batch: `b-${surface}-census-001`,
|
|
838
|
+
})
|
|
839
|
+
const result = await migrate(['census', record])
|
|
840
|
+
expect(result.code).toBe(0)
|
|
841
|
+
expect(result.out).toContain(`census: recorded lens:${surface}`)
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// 5. migrate phase enumerate --status done.
|
|
845
|
+
expect((await migrate(['phase', 'enumerate', '--status', 'done'])).code).toBe(0)
|
|
846
|
+
|
|
847
|
+
// 6. migrate check --phase enumerate. Every gate this phase owns is clean,
|
|
848
|
+
// and the only violations left are the three declared closers, whose
|
|
849
|
+
// records belong to extract and do not exist yet: exactly the mid-run
|
|
850
|
+
// posture enumerate.md describes, where the census gate reads the whole
|
|
851
|
+
// store regardless of --phase. Asserting the count pins that down harder
|
|
852
|
+
// than asserting cleanliness would, because it fails if anything else
|
|
853
|
+
// starts failing too.
|
|
854
|
+
//
|
|
855
|
+
// Note this holds despite the workflows batch (imported before the settings
|
|
856
|
+
// batch, in GROUND-TRUTH.md's row order) citing setting-welcome-email-enabled
|
|
857
|
+
// before that element exists yet: nothing in `import elements` or `check`
|
|
858
|
+
// resolves a `ledger` ref against the ledger, so a dangling one mid-run is
|
|
859
|
+
// silently tolerated, exactly as enumerate.md's step 4 says. It has resolved
|
|
860
|
+
// by the time this check runs, since every surface has imported by now.
|
|
861
|
+
const enumerated = await migrate(['check', '--phase', 'enumerate'])
|
|
862
|
+
expect(enumerated.out).toContain(
|
|
863
|
+
`0/${rows.length} mapped, ${rows.length} out-of-scope, 0 unaccounted`,
|
|
864
|
+
)
|
|
865
|
+
expect(enumerated.out).toContain('Violations (3):')
|
|
866
|
+
expect(enumerated.out).toContain(' census:')
|
|
867
|
+
for (const closer of ['cross-capability-workflow', 'scope-injection', 'read-write-symmetry']) {
|
|
868
|
+
expect(enumerated.out).toContain(` declared closer ${closer} has no census record`)
|
|
869
|
+
}
|
|
870
|
+
expect(enumerated.code).toBe(1)
|
|
871
|
+
|
|
872
|
+
// 6b. Surface-affinity clustering's graph, built straight from the store
|
|
873
|
+
// the same way seam.md's worked example builds it. This is the assertion
|
|
874
|
+
// that would fail if enumerate's ref-recording step were skipped: an
|
|
875
|
+
// empty REFS map above would leave every element's refs empty, and this
|
|
876
|
+
// graph would have zero edges, which is exactly the defect this task
|
|
877
|
+
// exists to close.
|
|
878
|
+
const elementsForAffinity = (await readFile(join(target, '.migrate', 'elements.jsonl'), 'utf8'))
|
|
879
|
+
.split('\n')
|
|
880
|
+
.filter((line) => line.trim().length > 0)
|
|
881
|
+
.map((line) => JSON.parse(line) as StoredElement)
|
|
882
|
+
const edges = buildAffinityEdges(elementsForAffinity)
|
|
883
|
+
expect(edges.size).toBeGreaterThan(0)
|
|
884
|
+
const expectedEdges = [
|
|
885
|
+
['route-get-api-users', 'table-users'],
|
|
886
|
+
['route-post-api-users', 'table-users'],
|
|
887
|
+
['job-purge-audit-log', 'table-audit-log'],
|
|
888
|
+
['report-daily-users', 'table-users'],
|
|
889
|
+
['workflow-welcome-email', 'route-post-api-users'],
|
|
890
|
+
['workflow-welcome-email', 'route-get-api-users-id-welcome'],
|
|
891
|
+
['workflow-welcome-email', 'setting-welcome-email-enabled'],
|
|
892
|
+
].map(([a, b]) => [a, b].sort().join('|'))
|
|
893
|
+
for (const edge of expectedEdges) {
|
|
894
|
+
expect(edges.has(edge)).toBe(true)
|
|
895
|
+
}
|
|
896
|
+
expect(edges.size).toBe(expectedEdges.length)
|
|
897
|
+
|
|
898
|
+
// 7. Phase 2, seam. No CLI verb authors any of the three artifacts, so all
|
|
899
|
+
// three are written by hand here exactly as seam.md says a real run writes
|
|
900
|
+
// them. The partition is asserted to be a partition of the ground truth
|
|
901
|
+
// first: every element in exactly one capability, nothing invented, nothing
|
|
902
|
+
// dropped. No gate checks that (nothing reads capabilities.elements at all),
|
|
903
|
+
// which is precisely why the assertion is here.
|
|
904
|
+
const assigned = CAPABILITIES.flatMap((c) => c.elements)
|
|
905
|
+
expect([...assigned].sort()).toEqual(rows.map((r) => r.id).sort())
|
|
906
|
+
expect(new Set(assigned).size).toBe(assigned.length)
|
|
907
|
+
|
|
908
|
+
const storeDir = join(target, '.migrate')
|
|
909
|
+
await writeFile(
|
|
910
|
+
join(storeDir, 'capabilities.jsonl'),
|
|
911
|
+
`${CAPABILITIES.map((c) => JSON.stringify(c)).join('\n')}\n`,
|
|
912
|
+
)
|
|
913
|
+
await writeFile(join(storeDir, 'seam.json'), `${JSON.stringify(SEAM_JSON, null, 2)}\n`)
|
|
914
|
+
await writeFile(
|
|
915
|
+
join(storeDir, 'seam.md'),
|
|
916
|
+
'# Seam evidence\n\nsurface-affinity clustering over the ledger refs, connected components then the\ngreedy modularity refinement, Q = 0.459. The other three validators could not\nrun: no relational schema, no static call graph, no VCS history in the copied\nfixture.\n',
|
|
917
|
+
)
|
|
918
|
+
expect((await migrate(['phase', 'seam', '--status', 'done'])).code).toBe(0)
|
|
919
|
+
|
|
920
|
+
// 8. Phase 3, extract. Requirements first, then the disposition write-back
|
|
921
|
+
// as its own elements batch, which extract.md calls out as the only writer
|
|
922
|
+
// of a resolved disposition.
|
|
923
|
+
for (const id of Object.keys(DISPOSITIONS)) {
|
|
924
|
+
expect(rows.some((r) => r.id === id)).toBe(true)
|
|
925
|
+
}
|
|
926
|
+
expect(Object.keys(DISPOSITIONS).length).toBe(rows.length)
|
|
927
|
+
|
|
928
|
+
const reqBatch = await write('reqs.json', {
|
|
929
|
+
batch: 'b-reqs-extract-001',
|
|
930
|
+
phase: 'extract',
|
|
931
|
+
rows: REQUIREMENTS,
|
|
932
|
+
})
|
|
933
|
+
const reqImport = await migrate(['import', 'reqs', reqBatch])
|
|
934
|
+
expect(reqImport.code).toBe(0)
|
|
935
|
+
expect(reqImport.out).toContain(
|
|
936
|
+
`import reqs: ${REQUIREMENTS.length} added, 0 updated, batch b-reqs-extract-001`,
|
|
937
|
+
)
|
|
938
|
+
|
|
939
|
+
// The queue items UD-006's confidence and the read-write-symmetry closer
|
|
940
|
+
// name, filed before anything checks them: a queue id with no file behind it
|
|
941
|
+
// is a present violation the moment `check` runs, not a future one.
|
|
942
|
+
for (const [id, body] of Object.entries(QUEUE_ITEMS)) {
|
|
943
|
+
const path = await writeText(`${id}.md`, body)
|
|
944
|
+
const added = await migrate(['queue', 'add', path])
|
|
945
|
+
expect(added.code).toBe(0)
|
|
946
|
+
expect(added.out).toContain(`queue add: ${id}`)
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const disposed = await write('elements-disposed.json', {
|
|
950
|
+
batch: 'b-elements-disposition-001',
|
|
951
|
+
phase: 'extract',
|
|
952
|
+
rows: rows.map((row) => ({
|
|
953
|
+
id: row.id,
|
|
954
|
+
surface: row.surface,
|
|
955
|
+
element: row.element,
|
|
956
|
+
found_by: ['code'],
|
|
957
|
+
disposition: DISPOSITIONS[row.id],
|
|
958
|
+
refs: REFS[row.id] ?? [],
|
|
959
|
+
lens: 'code',
|
|
960
|
+
notes: '',
|
|
961
|
+
})),
|
|
962
|
+
})
|
|
963
|
+
const disposeImport = await migrate(['import', 'elements', disposed])
|
|
964
|
+
expect(disposeImport.code).toBe(0)
|
|
965
|
+
expect(disposeImport.out).toContain(`import elements: 0 added, ${rows.length} updated`)
|
|
966
|
+
|
|
967
|
+
for (const record of EXTRACT_CENSUS) {
|
|
968
|
+
const path = await write(`census-${record.batch}.json`, record)
|
|
969
|
+
const result = await migrate(['census', path])
|
|
970
|
+
expect(result.code).toBe(0)
|
|
971
|
+
}
|
|
972
|
+
expect((await migrate(['phase', 'extract', '--status', 'done'])).code).toBe(0)
|
|
973
|
+
|
|
974
|
+
// 9. Phase 4, parity. The delta goes in unsigned first, so the deltas gate
|
|
975
|
+
// is shown failing on it before it is shown clean: an unsigned exclusion is
|
|
976
|
+
// exactly what that gate exists to stop from accreting silently.
|
|
977
|
+
const unsigned = await write('deltas.json', {
|
|
978
|
+
batch: 'b-deltas-parity-001',
|
|
979
|
+
phase: 'parity',
|
|
980
|
+
rows: [DELTA],
|
|
981
|
+
})
|
|
982
|
+
expect((await migrate(['import', 'deltas', unsigned])).code).toBe(0)
|
|
983
|
+
const withUnsignedDelta = await migrate(['check', '--phase', 'parity'])
|
|
984
|
+
expect(withUnsignedDelta.code).toBe(1)
|
|
985
|
+
expect(withUnsignedDelta.out).toContain(' deltas:')
|
|
986
|
+
expect(withUnsignedDelta.out).toContain(` ${DELTA.id} is not owner-signed`)
|
|
987
|
+
|
|
988
|
+
const signed = await write('deltas-signed.json', {
|
|
989
|
+
batch: 'b-deltas-parity-002',
|
|
990
|
+
phase: 'parity',
|
|
991
|
+
rows: [{ ...DELTA, owner_signed: '2026-08-08' }],
|
|
992
|
+
})
|
|
993
|
+
expect((await migrate(['import', 'deltas', signed])).code).toBe(0)
|
|
994
|
+
|
|
995
|
+
const parityBatch = await write('reqs-parity.json', {
|
|
996
|
+
batch: 'b-reqs-parity-001',
|
|
997
|
+
phase: 'parity',
|
|
998
|
+
rows: REQUIREMENTS.map((r) => ({
|
|
999
|
+
...r,
|
|
1000
|
+
parity: renderParity(PARITY[r.id] ?? null, parityTemplate),
|
|
1001
|
+
})),
|
|
1002
|
+
})
|
|
1003
|
+
const parityImport = await migrate(['import', 'reqs', parityBatch])
|
|
1004
|
+
expect(parityImport.code).toBe(0)
|
|
1005
|
+
// Every row counts as updated, including UD-006, whose parity stays null:
|
|
1006
|
+
// the importer stamps its own batch id onto every row it writes, so a row
|
|
1007
|
+
// whose content is otherwise unchanged still differs from the one on disk.
|
|
1008
|
+
expect(parityImport.out).toContain(
|
|
1009
|
+
`import reqs: 0 added, ${REQUIREMENTS.length} updated, batch b-reqs-parity-001`,
|
|
1010
|
+
)
|
|
1011
|
+
expect((await migrate(['phase', 'parity', '--status', 'done'])).code).toBe(0)
|
|
1012
|
+
|
|
1013
|
+
// 10. Phase 5, queue. Every item this run owed was filed in the pass that
|
|
1014
|
+
// named it, so this phase closes on the status flip, exactly as queue.md
|
|
1015
|
+
// says: closing is not "the queue is empty", since nothing in this milestone
|
|
1016
|
+
// adjudicates an item.
|
|
1017
|
+
const listed = await migrate(['queue', 'list', '--open'])
|
|
1018
|
+
expect(listed.code).toBe(0)
|
|
1019
|
+
expect(listed.out).toContain(`${Object.keys(QUEUE_ITEMS).length + 1} item(s)`)
|
|
1020
|
+
expect((await migrate(['phase', 'queue', '--status', 'done'])).code).toBe(0)
|
|
1021
|
+
|
|
1022
|
+
// 11. The terminus. Every element mapped, every gate clean, exit 0.
|
|
1023
|
+
const green = await migrate(['check', '--phase', 'queue'])
|
|
1024
|
+
expect(green.out).toContain(`${rows.length}/${rows.length} mapped, 0 out-of-scope, 0 unaccounted`)
|
|
1025
|
+
expect(green.out).not.toContain('Violations')
|
|
1026
|
+
expect(green.code).toBe(0)
|
|
1027
|
+
|
|
1028
|
+
// 12. Plain `migrate check` gates every phase through handoff, and fails on
|
|
1029
|
+
// exactly the two that have no verb in this milestone. The violation count
|
|
1030
|
+
// is what makes "exactly" an assertion rather than a hope.
|
|
1031
|
+
const full = await migrate(['check'])
|
|
1032
|
+
expect(full.code).toBe(1)
|
|
1033
|
+
expect(full.out).toContain('Violations (2):')
|
|
1034
|
+
expect(full.out).toContain(' run-state:')
|
|
1035
|
+
expect(full.out).toContain(
|
|
1036
|
+
' phase adjudicate is pending; every phase through handoff must be done',
|
|
1037
|
+
)
|
|
1038
|
+
expect(full.out).toContain(
|
|
1039
|
+
' phase handoff is pending; every phase through handoff must be done',
|
|
1040
|
+
)
|
|
1041
|
+
|
|
1042
|
+
// 13. The terminus assertion in step 11 is load-bearing, shown by mutation
|
|
1043
|
+
// rather than asserted: nulling every requirement's parity plan is a phase-4
|
|
1044
|
+
// regression and nothing else, and it must break the green check. Restoring
|
|
1045
|
+
// the file returns it to green, which is what proves the mutation was the
|
|
1046
|
+
// only cause.
|
|
1047
|
+
const requirementsPath = join(storeDir, 'requirements.jsonl')
|
|
1048
|
+
const requirementsText = await readFile(requirementsPath, 'utf8')
|
|
1049
|
+
const stripped = requirementsText
|
|
1050
|
+
.split('\n')
|
|
1051
|
+
.filter((line) => line.trim().length > 0)
|
|
1052
|
+
.map((line) => JSON.stringify({ ...(JSON.parse(line) as object), parity: null }))
|
|
1053
|
+
await writeFile(requirementsPath, `${stripped.join('\n')}\n`)
|
|
1054
|
+
|
|
1055
|
+
const noParity = await migrate(['check', '--phase', 'queue'])
|
|
1056
|
+
expect(noParity.code).toBe(1)
|
|
1057
|
+
expect(noParity.out).toContain(' parity:')
|
|
1058
|
+
expect(noParity.out).toContain(' UD-001 has no parity plan')
|
|
1059
|
+
// UD-006 is `queued`, the one confidence the parity gate exempts, so it must
|
|
1060
|
+
// not appear even now that its plan is null like everyone else's.
|
|
1061
|
+
expect(noParity.out).not.toContain('UD-006 has no parity plan')
|
|
1062
|
+
|
|
1063
|
+
await writeFile(requirementsPath, requirementsText)
|
|
1064
|
+
expect((await migrate(['check', '--phase', 'queue'])).code).toBe(0)
|
|
1065
|
+
|
|
1066
|
+
// 14. Removing one element row breaks the census reconciliation for its
|
|
1067
|
+
// surface, and only that: `in_ledger + added` on the surviving census
|
|
1068
|
+
// record still claims the pre-mutation count, but elements.jsonl now has
|
|
1069
|
+
// one fewer row for that surface. This is what makes the enumerate-phase
|
|
1070
|
+
// arithmetic load-bearing rather than vacuous: if the reconciliation check
|
|
1071
|
+
// were missing or broken, this mutation would leave the check clean too.
|
|
1072
|
+
const mutatedSurface = 'tables'
|
|
1073
|
+
const mutatedRows = groups.get(mutatedSurface)
|
|
1074
|
+
if (!mutatedRows || mutatedRows.length < 2) {
|
|
1075
|
+
throw new Error(`fixture must declare at least two ${mutatedSurface} rows to mutate one away`)
|
|
1076
|
+
}
|
|
1077
|
+
const removedId = mutatedRows[mutatedRows.length - 1]?.id
|
|
1078
|
+
const elementsPath = join(target, '.migrate', 'elements.jsonl')
|
|
1079
|
+
const elementsText = await readFile(elementsPath, 'utf8')
|
|
1080
|
+
const survivingLines = elementsText
|
|
1081
|
+
.split('\n')
|
|
1082
|
+
.filter((line) => line.trim().length > 0)
|
|
1083
|
+
.filter((line) => (JSON.parse(line) as { id: string }).id !== removedId)
|
|
1084
|
+
await writeFile(elementsPath, `${survivingLines.join('\n')}\n`)
|
|
1085
|
+
|
|
1086
|
+
const afterRemoval = await migrate(['check', '--phase', 'enumerate'])
|
|
1087
|
+
expect(afterRemoval.code).not.toBe(0)
|
|
1088
|
+
const claimed = mutatedRows.length
|
|
1089
|
+
const actual = mutatedRows.length - 1
|
|
1090
|
+
expect(afterRemoval.out).toContain(
|
|
1091
|
+
`lens census for ${mutatedSurface} claims in_ledger 0 + added ${claimed} = ${claimed} element(s) in the ledger, but elements.jsonl has ${actual}`,
|
|
1092
|
+
)
|
|
1093
|
+
})
|