@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,1276 @@
|
|
|
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-webforms')
|
|
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-webforms-'))
|
|
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, parsed
|
|
45
|
+
// here rather than hand-copied, for the same reason e2e-express.test.ts parses
|
|
46
|
+
// it: a row added to the fixture without a matching update here (or the
|
|
47
|
+
// reverse) 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
|
+
// This fixture's whole point is exercising the shipped aspnet recipe
|
|
83
|
+
// (references/recipes/aspnet.md), the one pack that ships at v1, instead of
|
|
84
|
+
// contract-only mode: `--source-stack aspnet-webforms` below is what routes
|
|
85
|
+
// enumerate.md's Inputs section at that file instead of deriving directions
|
|
86
|
+
// by hand. Every evidence string is the recipe's own literal `Probe:` (or
|
|
87
|
+
// disclosed supporting-probe) command, copied verbatim from aspnet.md, not a
|
|
88
|
+
// paraphrase of it: that is what ties this test to the recipe rather than to
|
|
89
|
+
// a generic enumeration, and it is also what a reviewer re-checks the count
|
|
90
|
+
// against. Every count below was produced by running that exact command
|
|
91
|
+
// against this fixture, not assumed from reading the recipe, and every raw
|
|
92
|
+
// match count that differs from the reported one is disclosed in a comment
|
|
93
|
+
// beside it rather than left for a reader to rediscover.
|
|
94
|
+
//
|
|
95
|
+
// **All twenty-one of aspnet.md's named directions are here**, not a chosen
|
|
96
|
+
// subset. Several surfaces therefore carry more than the two-direction floor,
|
|
97
|
+
// which is aspnet.md's own preamble ("several surfaces below name more than
|
|
98
|
+
// the two-direction floor") made concrete. Site.master gives both nav probes
|
|
99
|
+
// (reports and screens) a real master page to find real content in, not an
|
|
100
|
+
// absence to report against: a real WebForms app ships a master page, and this
|
|
101
|
+
// fixture's screens/nav direction has to discriminate a real screen link from
|
|
102
|
+
// a real report link sitting in the same file, not merely report zero because
|
|
103
|
+
// none exist.
|
|
104
|
+
//
|
|
105
|
+
// Seven directions find nothing here, and every one is recorded as a real
|
|
106
|
+
// zero rather than omitted, per enumerate.md's zero-findings rule: jobs'
|
|
107
|
+
// hangfire and osconfig, tables' orm, reports' builder, integrations' wcf and
|
|
108
|
+
// configured, and workflows' multistep. This fixture simply uses none of
|
|
109
|
+
// Hangfire, Windows Task Scheduler, EF, an .rdlc catalog, WCF, configured
|
|
110
|
+
// service endpoints, or MVC-style wizard actions. A zero costs one line and
|
|
111
|
+
// says the direction ran; leaving it out would say only that somebody chose
|
|
112
|
+
// not to look.
|
|
113
|
+
//
|
|
114
|
+
// A zero-count direction cannot break a census either way: the bound is
|
|
115
|
+
// max(directions) <= total <= sum(directions), and adding a zero moves
|
|
116
|
+
// neither end of it.
|
|
117
|
+
const DIRECTIONS: Record<string, Record<string, { count: number; evidence: string }>> = {
|
|
118
|
+
routes: {
|
|
119
|
+
// Raw match count is 7 (1 RoutePrefix + 2 lines per action x 3 actions,
|
|
120
|
+
// since every action here uses the split verb-attribute-then-Route-attribute
|
|
121
|
+
// form): excluding the RoutePrefix line (it annotates no action) leaves 6,
|
|
122
|
+
// and deduping each action's two lines down to the one action they both
|
|
123
|
+
// annotate leaves 3, the real count this direction reports.
|
|
124
|
+
attribute: {
|
|
125
|
+
count: 3,
|
|
126
|
+
evidence:
|
|
127
|
+
"rg -n -g '*.cs' '[\\[,]\\s*(RoutePrefix|Route|HttpGet|HttpPost|HttpPut|HttpDelete|HttpPatch|HttpHead|HttpOptions)\\b' <source>",
|
|
128
|
+
},
|
|
129
|
+
convention: {
|
|
130
|
+
count: 3,
|
|
131
|
+
evidence: "rg -n -g '*Controller*.cs' 'public\\s+\\S+\\s+\\w+\\s*\\(' <source>",
|
|
132
|
+
},
|
|
133
|
+
webforms: {
|
|
134
|
+
count: 2,
|
|
135
|
+
evidence: "find <source> -type f \\( -name '*.aspx' -o -name '*.ashx' \\)",
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
tables: {
|
|
139
|
+
ddl: { count: 2, evidence: "rg -n -g '*.sql' -i '^\\s*CREATE TABLE' <source>" },
|
|
140
|
+
// Raw match count is 4: a bracket-quoted [dbo].[Users] read, a plain
|
|
141
|
+
// INSERT INTO Users, a schema-qualified DELETE FROM dbo.AuditLog, and one
|
|
142
|
+
// block-comment continuation line naming a table that does not exist
|
|
143
|
+
// (LegacyUsers) which survives the probe's // -only comment filter (it
|
|
144
|
+
// starts with " * ", not "//") and must be excluded by classification,
|
|
145
|
+
// not by the regex. 2 real tables, both already known from DDL.
|
|
146
|
+
raw_ado: {
|
|
147
|
+
count: 2,
|
|
148
|
+
evidence:
|
|
149
|
+
"rg -n -g '*.cs' '(FROM|INTO|UPDATE)\\s+((\\[[^]]+\\]|\\w+)\\.)*(\\[[^]]+\\]|\\w+)' <source> | grep -Ev '^[^:]+:[0-9]+:[[:space:]]*//'",
|
|
150
|
+
},
|
|
151
|
+
// Zero: this fixture uses raw ADO throughout and has no DbContext and no
|
|
152
|
+
// [Table(...)] attribute anywhere. The recipe pairs a second probe with
|
|
153
|
+
// this direction's DDL half, for migrationBuilder.CreateTable, which finds
|
|
154
|
+
// nothing here for the same reason.
|
|
155
|
+
orm: { count: 0, evidence: "rg -n -g '*.cs' 'DbSet<|\\[Table\\(' <source>: no matches" },
|
|
156
|
+
},
|
|
157
|
+
jobs: {
|
|
158
|
+
// Raw match count is 3, all in Jobs/NightlyDigestJob.cs and all naming the
|
|
159
|
+
// same single job: the IJob class declaration at line 9, the
|
|
160
|
+
// JobBuilder.Create that builds it at 29, and the ScheduleJob that
|
|
161
|
+
// registers it at 30. Deduped to the one job they describe, that is 1.
|
|
162
|
+
quartz: {
|
|
163
|
+
count: 1,
|
|
164
|
+
evidence: "rg -n -g '*.cs' 'IJob\\b|JobBuilder\\.Create|ScheduleJob\\(' <source>",
|
|
165
|
+
},
|
|
166
|
+
hangfire: {
|
|
167
|
+
count: 0,
|
|
168
|
+
evidence: "rg -n -g '*.cs' 'RecurringJob\\.|BackgroundJob\\.' <source>: no matches",
|
|
169
|
+
},
|
|
170
|
+
osconfig: {
|
|
171
|
+
count: 0,
|
|
172
|
+
evidence:
|
|
173
|
+
"rg -n -g '*.xml' -g '*.config' -i 'cron|schedule|<Task version' <source>: no matches",
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
reports: {
|
|
177
|
+
disk: {
|
|
178
|
+
count: 1,
|
|
179
|
+
evidence: "find <source> -type f \\( -name '*.rdl' -o -name '*.rdlc' \\)",
|
|
180
|
+
},
|
|
181
|
+
// Site.master's nav carries a real "Daily active users report" link: 1
|
|
182
|
+
// real match, already known from the disk direction above.
|
|
183
|
+
nav: {
|
|
184
|
+
count: 1,
|
|
185
|
+
evidence:
|
|
186
|
+
"rg -n -g '*.sitemap' -g '_Layout.cshtml' -g '*.master' -g '*Nav*.cshtml' -g '*Menu*.cshtml' -i 'report' <source>",
|
|
187
|
+
},
|
|
188
|
+
// Zero: nothing in this fixture registers a report through code. The .rdl
|
|
189
|
+
// is picked up off disk and linked from the master page, which is the whole
|
|
190
|
+
// of how this app knows about it.
|
|
191
|
+
builder: { count: 0, evidence: "rg -n -g '*.cs' '\\.rdlc?\"' <source>: no matches" },
|
|
192
|
+
},
|
|
193
|
+
screens: {
|
|
194
|
+
filesystem: {
|
|
195
|
+
count: 2,
|
|
196
|
+
evidence: "find <source> -type f \\( -name '*.aspx' -o -name '*.cshtml' \\)",
|
|
197
|
+
},
|
|
198
|
+
// Raw match count is 3: Site.master's Home and Users NavigateUrl links
|
|
199
|
+
// (2 real screens, both already known from the filesystem direction) and
|
|
200
|
+
// one href pointing at the reports nav entry above, which names a report
|
|
201
|
+
// path, not a screen, and must be excluded by classification. 2 real
|
|
202
|
+
// screens.
|
|
203
|
+
nav: {
|
|
204
|
+
count: 2,
|
|
205
|
+
evidence:
|
|
206
|
+
"rg -n -g '*.sitemap' -g '_Layout.cshtml' -g '*.master' -g '*Nav*.cshtml' -g '*Menu*.cshtml' '(url|href|NavigateUrl)=\"~/|Html\\.ActionLink\\(|Url\\.Action\\(' <source>",
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
integrations: {
|
|
210
|
+
httpclient: {
|
|
211
|
+
count: 1,
|
|
212
|
+
evidence: "rg -n -g '*.cs' 'new HttpClient\\(|new WebClient\\(' <source>",
|
|
213
|
+
},
|
|
214
|
+
wcf: {
|
|
215
|
+
count: 0,
|
|
216
|
+
evidence:
|
|
217
|
+
"rg -n -g '*.cs' 'ClientBase<|ChannelFactory<|\\[ServiceContract\\]' <source>: no matches",
|
|
218
|
+
},
|
|
219
|
+
// Zero: web.config declares appSettings and connectionStrings, and no
|
|
220
|
+
// <endpoint> element at all. The one outbound call this app makes is
|
|
221
|
+
// hardcoded in BillingClient.cs, not configured.
|
|
222
|
+
configured: { count: 0, evidence: "rg -n -g '*.config' '<endpoint\\b' <source>: no matches" },
|
|
223
|
+
},
|
|
224
|
+
workflows: {
|
|
225
|
+
multistep: {
|
|
226
|
+
count: 0,
|
|
227
|
+
evidence: "rg -n -g '*.cs' 'ActionResult Step[0-9]+' <source>: no matches",
|
|
228
|
+
},
|
|
229
|
+
// Raw match count is 2, both in Controllers/UsersController.cs and both on
|
|
230
|
+
// the same session key: the write at line 49 and the read at 57. A write
|
|
231
|
+
// and the read that consumes it are the two ends of one workflow, not two
|
|
232
|
+
// workflows, so this reports 1.
|
|
233
|
+
statecarriers: {
|
|
234
|
+
count: 1,
|
|
235
|
+
evidence: "rg -n -g '*.cs' 'Session\\[|TempData\\[' <source>",
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
settings: {
|
|
239
|
+
storage: { count: 3, evidence: "rg -n -g '*.config' '<add (key|name)=' <source>" },
|
|
240
|
+
// Raw match count is 4: UsersController.cs:17 and NightlyDigestJob.cs:14
|
|
241
|
+
// both read DefaultConnection, UsersController.cs:58 reads
|
|
242
|
+
// WelcomeEmailEnabled, and NightlyDigestJob.cs:13 reads
|
|
243
|
+
// NightlyDigestCutoffDays. Deduped by the setting named rather than by the
|
|
244
|
+
// read site, that is 3 distinct settings, matching what storage found.
|
|
245
|
+
readsites: {
|
|
246
|
+
count: 3,
|
|
247
|
+
evidence: "rg -n -g '*.cs' 'ConfigurationManager\\.(AppSettings|ConnectionStrings)' <source>",
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Ledger refs a lens would record per enumerate.md's Procedure, step 4: an
|
|
253
|
+
// element that touches one already in the ledger gets a
|
|
254
|
+
// {"kind": "ledger", "id": ...} entry naming it. See GROUND-TRUTH.md's
|
|
255
|
+
// "Element-to-element touches" section, which this map matches by hand, the
|
|
256
|
+
// same relationship DIRECTIONS above has to aspnet.md's probes: this is
|
|
257
|
+
// fixture metadata the test asserts against, not something parsed off disk.
|
|
258
|
+
type LedgerRef = { kind: 'ledger'; id: string }
|
|
259
|
+
const REFS: Record<string, LedgerRef[]> = {
|
|
260
|
+
'route-get-api-users': [{ kind: 'ledger', id: 'table-users' }],
|
|
261
|
+
'route-post-api-users': [{ kind: 'ledger', id: 'table-users' }],
|
|
262
|
+
'route-get-api-users-id-welcome': [{ kind: 'ledger', id: 'integration-billing-sync' }],
|
|
263
|
+
'job-nightly-digest': [{ kind: 'ledger', id: 'table-audit-log' }],
|
|
264
|
+
'report-daily-users': [{ kind: 'ledger', id: 'table-users' }],
|
|
265
|
+
'workflow-signup-welcome': [
|
|
266
|
+
{ kind: 'ledger', id: 'route-post-api-users' },
|
|
267
|
+
{ kind: 'ledger', id: 'route-get-api-users-id-welcome' },
|
|
268
|
+
{ kind: 'ledger', id: 'setting-welcome-email-enabled' },
|
|
269
|
+
],
|
|
270
|
+
// enumerate.md step 4's own canonical example: "a screen that posts to a
|
|
271
|
+
// route" (route-post-api-users, wired for real via Default.aspx.cs calling
|
|
272
|
+
// UsersController.CreateUser directly) and the analogous read-side case
|
|
273
|
+
// (route-get-api-users-id-welcome, wired via Users.aspx.cs calling
|
|
274
|
+
// UsersController.GetWelcomeStatus directly).
|
|
275
|
+
'screen-default': [{ kind: 'ledger', id: 'route-post-api-users' }],
|
|
276
|
+
'screen-users': [{ kind: 'ledger', id: 'route-get-api-users-id-welcome' }],
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Builds the same graph seam.md's surface-affinity clustering builds: one
|
|
280
|
+
// edge per {"kind": "ledger", ...} ref on any element, connecting it to the
|
|
281
|
+
// id it names. An edge is an unordered pair, stored as its two ids sorted and
|
|
282
|
+
// joined, so a-touches-b and b-touches-a (were both ever recorded) would
|
|
283
|
+
// collapse to one edge rather than count twice.
|
|
284
|
+
type StoredElement = { id: string; refs: { kind: string; id?: string }[] }
|
|
285
|
+
|
|
286
|
+
function buildAffinityEdges(elements: StoredElement[]): Set<string> {
|
|
287
|
+
const edges = new Set<string>()
|
|
288
|
+
for (const el of elements) {
|
|
289
|
+
for (const ref of el.refs) {
|
|
290
|
+
if (ref.kind === 'ledger' && ref.id) {
|
|
291
|
+
edges.add([el.id, ref.id].sort().join('|'))
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return edges
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const QUEUE_ID = 'q-tiny-webforms-enumerate-scaffold'
|
|
299
|
+
|
|
300
|
+
const QUEUE_ITEM = `---
|
|
301
|
+
id: ${QUEUE_ID}
|
|
302
|
+
severity: minor
|
|
303
|
+
status: open
|
|
304
|
+
---
|
|
305
|
+
|
|
306
|
+
## Evidence
|
|
307
|
+
|
|
308
|
+
This run asserts the enumerate slice before extract has run, and coverage is
|
|
309
|
+
a whole-store gate: it reads every element regardless of \`--phase\`, so an
|
|
310
|
+
element with no terminal disposition fails it at any phase. No requirement
|
|
311
|
+
exists yet at that point for an element to map to.
|
|
312
|
+
|
|
313
|
+
## Options
|
|
314
|
+
|
|
315
|
+
(a) Leave every element unaccounted and give up on asserting the enumerate
|
|
316
|
+
slice at all. (b) Give every element a placeholder out-of-scope disposition
|
|
317
|
+
citing this item, which extract then replaces with a real disposition once
|
|
318
|
+
there is a requirement to map to.
|
|
319
|
+
|
|
320
|
+
## Recommendation
|
|
321
|
+
|
|
322
|
+
Recommend (b); it isolates the census and run-state assertions the enumerate
|
|
323
|
+
step exists to make from the coverage gate, and citing this item keeps the
|
|
324
|
+
placeholder auditable rather than silent. The item stays open after extract
|
|
325
|
+
supersedes it, because nothing in this milestone adjudicates a queue item.
|
|
326
|
+
`
|
|
327
|
+
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// Phase 2, seam. The partition is the one references/phases/seam.md's second
|
|
330
|
+
// worked example derives and prints in full, from exactly the ten edges step
|
|
331
|
+
// 7b asserts below: components-only Q = 0.180, under the 0.3 floor, so the
|
|
332
|
+
// greedy refinement runs on the ten-node component and reaches Q = 0.505. The
|
|
333
|
+
// manual carries the merge ladder; it is not repeated here, because a second
|
|
334
|
+
// copy of it is a second thing to keep true.
|
|
335
|
+
//
|
|
336
|
+
// The four edgeless elements are assigned by hand per seam.md's edgeless rule,
|
|
337
|
+
// each on the source proximity that rule names:
|
|
338
|
+
// route-default-aspx -> signup (it is Default.aspx, and
|
|
339
|
+
// screen-default is that file's
|
|
340
|
+
// rendered half)
|
|
341
|
+
// route-users-aspx -> welcome-activation (same, for
|
|
342
|
+
// Users.aspx and screen-users)
|
|
343
|
+
// setting-nightly-digest-cutoff-days -> audit-retention
|
|
344
|
+
// (Jobs/NightlyDigestJob.cs:13 is the
|
|
345
|
+
// only line that reads it)
|
|
346
|
+
// and the fourth is seam.md's own named exception: setting-default-connection
|
|
347
|
+
// is read at Controllers/UsersController.cs:17 and Jobs/NightlyDigestJob.cs:14,
|
|
348
|
+
// two lines that between them serve three of the four capabilities, so no
|
|
349
|
+
// capability has a claim the others do not. It joins none of them on purpose,
|
|
350
|
+
// gets a queue item, and extract disposes of it out-of-scope citing that item.
|
|
351
|
+
type CapabilityRow = { slug: string; title: string; ns: string; elements: string[] }
|
|
352
|
+
|
|
353
|
+
const CAPABILITIES: CapabilityRow[] = [
|
|
354
|
+
{
|
|
355
|
+
slug: 'welcome-activation',
|
|
356
|
+
title: 'Welcome Activation',
|
|
357
|
+
ns: 'WA',
|
|
358
|
+
elements: [
|
|
359
|
+
'integration-billing-sync',
|
|
360
|
+
'route-get-api-users-id-welcome',
|
|
361
|
+
'screen-users',
|
|
362
|
+
'route-users-aspx',
|
|
363
|
+
],
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
slug: 'audit-retention',
|
|
367
|
+
title: 'Audit Retention',
|
|
368
|
+
ns: 'AR',
|
|
369
|
+
elements: ['job-nightly-digest', 'table-audit-log', 'setting-nightly-digest-cutoff-days'],
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
slug: 'user-directory',
|
|
373
|
+
title: 'User Directory',
|
|
374
|
+
ns: 'UD',
|
|
375
|
+
elements: ['report-daily-users', 'route-get-api-users', 'table-users'],
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
slug: 'signup',
|
|
379
|
+
title: 'Signup',
|
|
380
|
+
ns: 'SU',
|
|
381
|
+
elements: [
|
|
382
|
+
'route-post-api-users',
|
|
383
|
+
'screen-default',
|
|
384
|
+
'setting-welcome-email-enabled',
|
|
385
|
+
'workflow-signup-welcome',
|
|
386
|
+
'route-default-aspx',
|
|
387
|
+
],
|
|
388
|
+
},
|
|
389
|
+
]
|
|
390
|
+
|
|
391
|
+
// Deliberately in no capability, per seam.md's exception. Held as its own
|
|
392
|
+
// constant so the partition assertion below has to name it explicitly rather
|
|
393
|
+
// than a missing element slipping through as an off-by-one.
|
|
394
|
+
const UNASSIGNED = ['setting-default-connection']
|
|
395
|
+
|
|
396
|
+
const SEAM_JSON = {
|
|
397
|
+
validators: {
|
|
398
|
+
'schema-clustering': { ran: false, reason: 'no live database to read foreign keys from' },
|
|
399
|
+
'call-graph': { ran: false, reason: 'no static C# call-graph tooling in this environment' },
|
|
400
|
+
'change-coupling': { ran: false, reason: 'the copied fixture carries no VCS history' },
|
|
401
|
+
'surface-affinity': { ran: true, modularity: 0.505 },
|
|
402
|
+
},
|
|
403
|
+
agreement: ['surface-affinity'],
|
|
404
|
+
modularity: 0.505,
|
|
405
|
+
status: 'accepted',
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// Phase 3, extract. Every src citation's path and line range is real: the
|
|
410
|
+
// citations gate runs by default and resolves each one against the copied
|
|
411
|
+
// fixture, so a wrong range fails the run rather than sitting undetected.
|
|
412
|
+
type Citation =
|
|
413
|
+
| { kind: 'ledger'; id: string }
|
|
414
|
+
| { kind: 'src'; path: string; lines: [number, number] }
|
|
415
|
+
|
|
416
|
+
type RequirementRow = {
|
|
417
|
+
id: string
|
|
418
|
+
cap: string
|
|
419
|
+
requirement: string
|
|
420
|
+
actors: string
|
|
421
|
+
objects: string
|
|
422
|
+
rules: string
|
|
423
|
+
origin: string
|
|
424
|
+
confidence: { kind: string; queue?: string }
|
|
425
|
+
citations: Citation[]
|
|
426
|
+
parity: null
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const REQUIREMENTS: RequirementRow[] = [
|
|
430
|
+
{
|
|
431
|
+
id: 'SU-001',
|
|
432
|
+
cap: 'signup',
|
|
433
|
+
requirement:
|
|
434
|
+
'The signup screen submits an email address to the user-creation route in process and redirects to the users page',
|
|
435
|
+
actors: 'Visitor',
|
|
436
|
+
objects: 'Signup form',
|
|
437
|
+
rules: 'The redirect target is ~/Users.aspx',
|
|
438
|
+
origin: 'intended',
|
|
439
|
+
confidence: { kind: 'confirmed' },
|
|
440
|
+
citations: [
|
|
441
|
+
{ kind: 'ledger', id: 'screen-default' },
|
|
442
|
+
{ kind: 'src', path: 'Default.aspx.cs', lines: [8, 12] },
|
|
443
|
+
{ kind: 'src', path: 'Default.aspx', lines: [1, 7] },
|
|
444
|
+
],
|
|
445
|
+
parity: null,
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
id: 'SU-002',
|
|
449
|
+
cap: 'signup',
|
|
450
|
+
requirement:
|
|
451
|
+
'Creating a user inserts an active row and stores a pending welcome in session keyed by the new id',
|
|
452
|
+
actors: 'API client',
|
|
453
|
+
objects: 'User',
|
|
454
|
+
rules: 'IsActive is set to 1 on insert; the id is minted from the current clock',
|
|
455
|
+
origin: 'intended',
|
|
456
|
+
confidence: { kind: 'confirmed' },
|
|
457
|
+
citations: [
|
|
458
|
+
{ kind: 'ledger', id: 'route-post-api-users' },
|
|
459
|
+
{ kind: 'src', path: 'Controllers/UsersController.cs', lines: [38, 51] },
|
|
460
|
+
],
|
|
461
|
+
parity: null,
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
id: 'SU-003',
|
|
465
|
+
cap: 'signup',
|
|
466
|
+
requirement:
|
|
467
|
+
'Signup then welcome is a two-page workflow carrying the pending email in session between the two pages',
|
|
468
|
+
actors: 'Visitor',
|
|
469
|
+
objects: 'Pending welcome state',
|
|
470
|
+
rules: 'The session key is PendingWelcome_ plus the new user id',
|
|
471
|
+
origin: 'intended',
|
|
472
|
+
confidence: { kind: 'confirmed' },
|
|
473
|
+
citations: [
|
|
474
|
+
{ kind: 'ledger', id: 'workflow-signup-welcome' },
|
|
475
|
+
{ kind: 'src', path: 'Controllers/UsersController.cs', lines: [49, 49] },
|
|
476
|
+
{ kind: 'src', path: 'Users.aspx.cs', lines: [8, 12] },
|
|
477
|
+
],
|
|
478
|
+
parity: null,
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
id: 'SU-004',
|
|
482
|
+
cap: 'signup',
|
|
483
|
+
requirement: 'WelcomeEmailEnabled decides whether a welcome check notifies anything at all',
|
|
484
|
+
actors: '-',
|
|
485
|
+
objects: 'Welcome setting',
|
|
486
|
+
rules: 'The value is compared against the literal string "true"',
|
|
487
|
+
origin: 'intended',
|
|
488
|
+
confidence: { kind: 'confirmed' },
|
|
489
|
+
citations: [
|
|
490
|
+
{ kind: 'ledger', id: 'setting-welcome-email-enabled' },
|
|
491
|
+
{ kind: 'src', path: 'web.config', lines: [3, 6] },
|
|
492
|
+
{ kind: 'src', path: 'Controllers/UsersController.cs', lines: [58, 59] },
|
|
493
|
+
],
|
|
494
|
+
parity: null,
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
id: 'WA-001',
|
|
498
|
+
cap: 'welcome-activation',
|
|
499
|
+
requirement:
|
|
500
|
+
'Checking a welcome status notifies the billing system when the welcome setting is enabled and a pending welcome exists for that id',
|
|
501
|
+
actors: 'API client',
|
|
502
|
+
objects: 'Activation notification',
|
|
503
|
+
rules: 'Both the setting and a stored email must hold, or nothing is notified',
|
|
504
|
+
origin: 'intended',
|
|
505
|
+
// Inferred rather than confirmed: the source shows the POST being made and
|
|
506
|
+
// nothing citable here shows what the billing system does with it, which
|
|
507
|
+
// is extract.md's own definition of a piece unobservable from the source.
|
|
508
|
+
confidence: { kind: 'inferred' },
|
|
509
|
+
citations: [
|
|
510
|
+
{ kind: 'ledger', id: 'route-get-api-users-id-welcome' },
|
|
511
|
+
{ kind: 'src', path: 'Controllers/UsersController.cs', lines: [53, 61] },
|
|
512
|
+
{ kind: 'src', path: 'Integrations/BillingClient.cs', lines: [10, 14] },
|
|
513
|
+
],
|
|
514
|
+
parity: null,
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
id: 'WA-002',
|
|
518
|
+
cap: 'welcome-activation',
|
|
519
|
+
requirement: 'The users page checks the welcome status of the id in its query string on load',
|
|
520
|
+
actors: 'Visitor',
|
|
521
|
+
objects: 'Users page',
|
|
522
|
+
rules: 'A missing id parameter is read as 0',
|
|
523
|
+
origin: 'intended',
|
|
524
|
+
confidence: { kind: 'confirmed' },
|
|
525
|
+
citations: [
|
|
526
|
+
{ kind: 'ledger', id: 'screen-users' },
|
|
527
|
+
{ kind: 'src', path: 'Users.aspx.cs', lines: [8, 12] },
|
|
528
|
+
{ kind: 'src', path: 'Users.aspx', lines: [1, 8] },
|
|
529
|
+
],
|
|
530
|
+
parity: null,
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
// The one queued requirement in this run, and the ambiguity is in the
|
|
534
|
+
// source rather than invented: GetUsers returns the reader itself out of a
|
|
535
|
+
// `using` block that disposes the connection on the way out, so whether
|
|
536
|
+
// this endpoint ever served rows is not decidable from the checkout.
|
|
537
|
+
// A queued requirement is the one case the parity gate exempts, which is
|
|
538
|
+
// why this row keeps `parity: null` through phase 4.
|
|
539
|
+
id: 'UD-001',
|
|
540
|
+
cap: 'user-directory',
|
|
541
|
+
requirement: 'Listing users returns the id and email of active users only',
|
|
542
|
+
actors: 'API client',
|
|
543
|
+
objects: 'User list',
|
|
544
|
+
rules: 'The query filters on IsActive = 1',
|
|
545
|
+
origin: 'intended',
|
|
546
|
+
confidence: { kind: 'queued', queue: 'q-webforms-getusers-reader-lifetime' },
|
|
547
|
+
citations: [
|
|
548
|
+
{ kind: 'ledger', id: 'route-get-api-users' },
|
|
549
|
+
{ kind: 'src', path: 'Controllers/UsersController.cs', lines: [23, 32] },
|
|
550
|
+
],
|
|
551
|
+
parity: null,
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
id: 'UD-002',
|
|
555
|
+
cap: 'user-directory',
|
|
556
|
+
requirement:
|
|
557
|
+
'The Users table stores an id, a required email, and an active flag defaulting to set',
|
|
558
|
+
actors: '-',
|
|
559
|
+
objects: 'User row',
|
|
560
|
+
rules: 'Email is NOT NULL; IsActive defaults to 1',
|
|
561
|
+
origin: 'intended',
|
|
562
|
+
confidence: { kind: 'confirmed' },
|
|
563
|
+
citations: [
|
|
564
|
+
{ kind: 'ledger', id: 'table-users' },
|
|
565
|
+
{ kind: 'src', path: 'Schema.sql', lines: [1, 5] },
|
|
566
|
+
],
|
|
567
|
+
parity: null,
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
id: 'UD-003',
|
|
571
|
+
cap: 'user-directory',
|
|
572
|
+
requirement: 'The daily users report counts only users whose active flag is set',
|
|
573
|
+
actors: 'Report consumer',
|
|
574
|
+
objects: 'Active user count',
|
|
575
|
+
rules: 'The dataset query filters on IsActive = 1',
|
|
576
|
+
origin: 'intended',
|
|
577
|
+
confidence: { kind: 'confirmed' },
|
|
578
|
+
citations: [
|
|
579
|
+
{ kind: 'ledger', id: 'report-daily-users' },
|
|
580
|
+
{ kind: 'src', path: 'Reports/DailyUsers.rdl', lines: [3, 9] },
|
|
581
|
+
],
|
|
582
|
+
parity: null,
|
|
583
|
+
},
|
|
584
|
+
{
|
|
585
|
+
id: 'AR-001',
|
|
586
|
+
cap: 'audit-retention',
|
|
587
|
+
requirement:
|
|
588
|
+
'Audit log rows older than the configured cutoff are deleted on a nightly schedule',
|
|
589
|
+
actors: 'System',
|
|
590
|
+
objects: 'Audit log rows',
|
|
591
|
+
rules: 'The Quartz trigger fires at 02:00 daily; the cutoff comes from configuration',
|
|
592
|
+
origin: 'intended',
|
|
593
|
+
confidence: { kind: 'confirmed' },
|
|
594
|
+
citations: [
|
|
595
|
+
{ kind: 'ledger', id: 'job-nightly-digest' },
|
|
596
|
+
{ kind: 'src', path: 'Jobs/NightlyDigestJob.cs', lines: [11, 22] },
|
|
597
|
+
{ kind: 'src', path: 'Jobs/NightlyDigestJob.cs', lines: [27, 31] },
|
|
598
|
+
],
|
|
599
|
+
parity: null,
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
id: 'AR-002',
|
|
603
|
+
cap: 'audit-retention',
|
|
604
|
+
requirement: 'The AuditLog table stores one row per user action with a creation timestamp',
|
|
605
|
+
actors: '-',
|
|
606
|
+
objects: 'Audit log row',
|
|
607
|
+
rules: 'UserId, Action and CreatedAt are all NOT NULL',
|
|
608
|
+
origin: 'intended',
|
|
609
|
+
confidence: { kind: 'confirmed' },
|
|
610
|
+
citations: [
|
|
611
|
+
{ kind: 'ledger', id: 'table-audit-log' },
|
|
612
|
+
{ kind: 'src', path: 'Schema.sql', lines: [7, 12] },
|
|
613
|
+
],
|
|
614
|
+
parity: null,
|
|
615
|
+
},
|
|
616
|
+
{
|
|
617
|
+
id: 'AR-003',
|
|
618
|
+
cap: 'audit-retention',
|
|
619
|
+
requirement:
|
|
620
|
+
'NightlyDigestCutoffDays sets how old an audit row must be before the nightly purge deletes it',
|
|
621
|
+
actors: '-',
|
|
622
|
+
objects: 'Retention cutoff',
|
|
623
|
+
rules: 'The value is parsed as an integer number of days and subtracted from the current time',
|
|
624
|
+
origin: 'intended',
|
|
625
|
+
confidence: { kind: 'confirmed' },
|
|
626
|
+
citations: [
|
|
627
|
+
{ kind: 'ledger', id: 'setting-nightly-digest-cutoff-days' },
|
|
628
|
+
{ kind: 'src', path: 'web.config', lines: [3, 6] },
|
|
629
|
+
{ kind: 'src', path: 'Jobs/NightlyDigestJob.cs', lines: [13, 13] },
|
|
630
|
+
],
|
|
631
|
+
parity: null,
|
|
632
|
+
},
|
|
633
|
+
]
|
|
634
|
+
|
|
635
|
+
// Fifteen elements map to a requirement; the sixteenth is the seam exception,
|
|
636
|
+
// carried forward unmapped on purpose with the queue id the refs gate checks.
|
|
637
|
+
type Disposition = { kind: 'mapped'; fr: string } | { kind: 'out-of-scope'; queue: string }
|
|
638
|
+
|
|
639
|
+
const DISPOSITIONS: Record<string, Disposition> = {
|
|
640
|
+
'screen-default': { kind: 'mapped', fr: 'SU-001' },
|
|
641
|
+
'route-default-aspx': { kind: 'mapped', fr: 'SU-001' },
|
|
642
|
+
'route-post-api-users': { kind: 'mapped', fr: 'SU-002' },
|
|
643
|
+
'workflow-signup-welcome': { kind: 'mapped', fr: 'SU-003' },
|
|
644
|
+
'setting-welcome-email-enabled': { kind: 'mapped', fr: 'SU-004' },
|
|
645
|
+
'route-get-api-users-id-welcome': { kind: 'mapped', fr: 'WA-001' },
|
|
646
|
+
'integration-billing-sync': { kind: 'mapped', fr: 'WA-001' },
|
|
647
|
+
'screen-users': { kind: 'mapped', fr: 'WA-002' },
|
|
648
|
+
'route-users-aspx': { kind: 'mapped', fr: 'WA-002' },
|
|
649
|
+
'route-get-api-users': { kind: 'mapped', fr: 'UD-001' },
|
|
650
|
+
'table-users': { kind: 'mapped', fr: 'UD-002' },
|
|
651
|
+
'report-daily-users': { kind: 'mapped', fr: 'UD-003' },
|
|
652
|
+
'job-nightly-digest': { kind: 'mapped', fr: 'AR-001' },
|
|
653
|
+
'table-audit-log': { kind: 'mapped', fr: 'AR-002' },
|
|
654
|
+
'setting-nightly-digest-cutoff-days': { kind: 'mapped', fr: 'AR-003' },
|
|
655
|
+
'setting-default-connection': {
|
|
656
|
+
kind: 'out-of-scope',
|
|
657
|
+
queue: 'q-webforms-default-connection-ownership',
|
|
658
|
+
},
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const EXTRACT_CENSUS: Record<string, unknown>[] = [
|
|
662
|
+
{
|
|
663
|
+
kind: 'rule-sweep',
|
|
664
|
+
subject: 'signup',
|
|
665
|
+
phase: 'extract',
|
|
666
|
+
probes: 2,
|
|
667
|
+
found: 1,
|
|
668
|
+
as_requirements: 1,
|
|
669
|
+
queued: [],
|
|
670
|
+
batch: 'b-rules-signup-001',
|
|
671
|
+
},
|
|
672
|
+
{
|
|
673
|
+
kind: 'rule-sweep',
|
|
674
|
+
subject: 'welcome-activation',
|
|
675
|
+
phase: 'extract',
|
|
676
|
+
probes: 2,
|
|
677
|
+
found: 1,
|
|
678
|
+
as_requirements: 1,
|
|
679
|
+
queued: [],
|
|
680
|
+
batch: 'b-rules-welcome-activation-001',
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
kind: 'rule-sweep',
|
|
684
|
+
subject: 'user-directory',
|
|
685
|
+
phase: 'extract',
|
|
686
|
+
probes: 2,
|
|
687
|
+
found: 1,
|
|
688
|
+
as_requirements: 1,
|
|
689
|
+
queued: [],
|
|
690
|
+
batch: 'b-rules-user-directory-001',
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
kind: 'rule-sweep',
|
|
694
|
+
subject: 'audit-retention',
|
|
695
|
+
phase: 'extract',
|
|
696
|
+
probes: 2,
|
|
697
|
+
found: 1,
|
|
698
|
+
as_requirements: 1,
|
|
699
|
+
queued: [],
|
|
700
|
+
batch: 'b-rules-audit-retention-001',
|
|
701
|
+
},
|
|
702
|
+
{
|
|
703
|
+
// Schema.sql:1-5 declares Id, Email and IsActive; the SELECT at
|
|
704
|
+
// Controllers/UsersController.cs:29 names two of them. `Id` is an identity
|
|
705
|
+
// key, which extract.md's exemption list keeps out of `behavioral`, leaving
|
|
706
|
+
// Email (UD-002) and IsActive (UD-003) as the two that are explained.
|
|
707
|
+
kind: 'attribute',
|
|
708
|
+
surface: 'tables',
|
|
709
|
+
subject: 'table-users',
|
|
710
|
+
phase: 'extract',
|
|
711
|
+
directions: {
|
|
712
|
+
ddl: { count: 3, evidence: 'column list from CREATE TABLE Users in Schema.sql' },
|
|
713
|
+
reader: {
|
|
714
|
+
count: 2,
|
|
715
|
+
evidence: 'columns named in the SELECT at Controllers/UsersController.cs:29',
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
total: 3,
|
|
719
|
+
behavioral: 2,
|
|
720
|
+
explained: 2,
|
|
721
|
+
queued: [],
|
|
722
|
+
batch: 'b-attr-table-users-001',
|
|
723
|
+
},
|
|
724
|
+
{
|
|
725
|
+
// workflow-signup-welcome spans signup (route-post-api-users,
|
|
726
|
+
// setting-welcome-email-enabled) and welcome-activation
|
|
727
|
+
// (route-get-api-users-id-welcome): a genuine cross-capability seam, found
|
|
728
|
+
// and covered on the spot by SU-003, which describes the whole journey
|
|
729
|
+
// rather than either half of it.
|
|
730
|
+
kind: 'closer',
|
|
731
|
+
closer: 'cross-capability-workflow',
|
|
732
|
+
phase: 'extract',
|
|
733
|
+
checked: 1,
|
|
734
|
+
findings: 1,
|
|
735
|
+
fixed: 1,
|
|
736
|
+
queued: [],
|
|
737
|
+
batch: 'b-closer-cross-capability-001',
|
|
738
|
+
},
|
|
739
|
+
{
|
|
740
|
+
kind: 'closer',
|
|
741
|
+
closer: 'scope-injection',
|
|
742
|
+
phase: 'extract',
|
|
743
|
+
checked: 12,
|
|
744
|
+
findings: 0,
|
|
745
|
+
fixed: 0,
|
|
746
|
+
queued: [],
|
|
747
|
+
batch: 'b-closer-scope-injection-001',
|
|
748
|
+
},
|
|
749
|
+
{
|
|
750
|
+
// A real finding this pass cannot resolve: NightlyDigestJob deletes
|
|
751
|
+
// AuditLog rows, and nothing anywhere in the checkout ever inserts one.
|
|
752
|
+
kind: 'closer',
|
|
753
|
+
closer: 'read-write-symmetry',
|
|
754
|
+
phase: 'extract',
|
|
755
|
+
checked: 12,
|
|
756
|
+
findings: 1,
|
|
757
|
+
fixed: 0,
|
|
758
|
+
queued: ['q-webforms-auditlog-never-written'],
|
|
759
|
+
batch: 'b-closer-read-write-symmetry-001',
|
|
760
|
+
},
|
|
761
|
+
]
|
|
762
|
+
|
|
763
|
+
// ---------------------------------------------------------------------------
|
|
764
|
+
// Phase 4, parity. source.basis is source-only (init's default, and the honest
|
|
765
|
+
// call for a checkout with no .csproj and no SDK to build it), so
|
|
766
|
+
// `differential` is unavailable: it needs a live legacy system to diff
|
|
767
|
+
// against. `golden-master` survives only where the source itself ships the
|
|
768
|
+
// artifact that determines the output, which is parity.md's stated exception
|
|
769
|
+
// and covers the DDL and the report definition. Everything else is `rubric`,
|
|
770
|
+
// and every rubric below `high` carries the queue id the refs gate checks.
|
|
771
|
+
type ParityPlan =
|
|
772
|
+
| { kind: 'golden-master'; capability: string; frSlug: string }
|
|
773
|
+
| { kind: 'rubric'; level: string; queue?: string }
|
|
774
|
+
|
|
775
|
+
const PARITY: Record<string, ParityPlan | null> = {
|
|
776
|
+
'SU-001': { kind: 'rubric', level: 'high' },
|
|
777
|
+
'SU-002': { kind: 'rubric', level: 'high' },
|
|
778
|
+
'SU-003': { kind: 'rubric', level: 'high' },
|
|
779
|
+
'SU-004': { kind: 'rubric', level: 'high' },
|
|
780
|
+
'WA-001': { kind: 'rubric', level: 'moderate', queue: 'q-webforms-billing-notify-unobservable' },
|
|
781
|
+
'WA-002': { kind: 'rubric', level: 'high' },
|
|
782
|
+
'UD-001': null,
|
|
783
|
+
'UD-002': { kind: 'golden-master', capability: 'user-directory', frSlug: 'users-table' },
|
|
784
|
+
'UD-003': { kind: 'golden-master', capability: 'user-directory', frSlug: 'daily-users-report' },
|
|
785
|
+
'AR-001': { kind: 'rubric', level: 'high' },
|
|
786
|
+
'AR-002': { kind: 'golden-master', capability: 'audit-retention', frSlug: 'audit-log-table' },
|
|
787
|
+
'AR-003': { kind: 'rubric', level: 'high' },
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const DELTA = {
|
|
791
|
+
id: 'delta-in-process-controller-call',
|
|
792
|
+
scope: 'The signup screen reaching the user-creation route (SU-001)',
|
|
793
|
+
rationale:
|
|
794
|
+
'Default.aspx.cs:10 constructs UsersController directly and calls CreateUser in process, with no HTTP request between the two. The target reaches the same route over HTTP, so status codes, model binding and error surfacing all differ for reasons that have nothing to do with whether a user was created.',
|
|
795
|
+
parity_exclusion:
|
|
796
|
+
'The SU-001 parity check must not assert on transport, status code or model-binding behavior, only on the user row that results and the redirect that follows it.',
|
|
797
|
+
validation:
|
|
798
|
+
"A greenfield-only test covers the target's HTTP error surface for this route; the parity suite does not re-prove it.",
|
|
799
|
+
owner_signed: null as string | null,
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Four queue items beyond the enumerate scaffold. Three are named by a field
|
|
803
|
+
// the refs gate actually checks (UD-001's confidence.queue, WA-001's
|
|
804
|
+
// parity.queue, setting-default-connection's disposition.queue); the fourth is
|
|
805
|
+
// named only by a closer census's own `queued` array, which queue.md is
|
|
806
|
+
// explicit no gate ever cross-checks. Filed anyway, as the manual's discipline.
|
|
807
|
+
const QUEUE_ITEMS: Record<string, string> = {
|
|
808
|
+
'q-webforms-getusers-reader-lifetime': `---
|
|
809
|
+
id: q-webforms-getusers-reader-lifetime
|
|
810
|
+
severity: moderate
|
|
811
|
+
status: open
|
|
812
|
+
---
|
|
813
|
+
|
|
814
|
+
## Evidence
|
|
815
|
+
|
|
816
|
+
\`Controllers/UsersController.cs:25-32\` returns \`Ok(cmd.ExecuteReader())\` from
|
|
817
|
+
inside a \`using (var conn = OpenConnection())\` block. The connection is
|
|
818
|
+
disposed as the method returns, before anything can read from the reader it
|
|
819
|
+
just handed back.
|
|
820
|
+
|
|
821
|
+
## Options
|
|
822
|
+
|
|
823
|
+
(a) Write the requirement against the query's evident intent, active users
|
|
824
|
+
only, and treat the lifetime bug as a defect the target should not
|
|
825
|
+
replicate. (b) Treat the endpoint as non-functional and record that. (c) Ask
|
|
826
|
+
the operator whether this endpoint ever returned rows in production.
|
|
827
|
+
|
|
828
|
+
## Recommendation
|
|
829
|
+
|
|
830
|
+
Recommend (c); the query says one thing and the disposal order says another,
|
|
831
|
+
and only production behavior settles which one this run should carry forward.
|
|
832
|
+
`,
|
|
833
|
+
'q-webforms-billing-notify-unobservable': `---
|
|
834
|
+
id: q-webforms-billing-notify-unobservable
|
|
835
|
+
severity: moderate
|
|
836
|
+
status: open
|
|
837
|
+
---
|
|
838
|
+
|
|
839
|
+
## Evidence
|
|
840
|
+
|
|
841
|
+
\`WA-001\` describes the outbound POST at \`Integrations/BillingClient.cs:10-14\`
|
|
842
|
+
to \`https://billing.example.com/activations\`. The basis for this run is
|
|
843
|
+
source-only, so there is no live legacy system to diff against and nothing to
|
|
844
|
+
capture a golden master from, and the call crosses a boundary neither could
|
|
845
|
+
reach in any case.
|
|
846
|
+
|
|
847
|
+
## Options
|
|
848
|
+
|
|
849
|
+
(a) Ship \`rubric:low\` and revisit if a runnable environment appears.
|
|
850
|
+
(b) Block parity on this requirement until the billing system can be
|
|
851
|
+
observed. (c) Ship \`rubric:moderate\`: the guard conditions and the payload
|
|
852
|
+
are both readable from the source, the delivery itself is not.
|
|
853
|
+
|
|
854
|
+
## Recommendation
|
|
855
|
+
|
|
856
|
+
Recommend (c); \`rubric:moderate\` matches exactly what is observable today.
|
|
857
|
+
`,
|
|
858
|
+
'q-webforms-default-connection-ownership': `---
|
|
859
|
+
id: q-webforms-default-connection-ownership
|
|
860
|
+
severity: moderate
|
|
861
|
+
status: open
|
|
862
|
+
---
|
|
863
|
+
|
|
864
|
+
## Evidence
|
|
865
|
+
|
|
866
|
+
\`setting-default-connection\` (\`web.config:8\`) is read at
|
|
867
|
+
\`Controllers/UsersController.cs:17\` and \`Jobs/NightlyDigestJob.cs:14\`. Those
|
|
868
|
+
two lines serve three of this run's four capabilities between them, so no
|
|
869
|
+
capability has a claim on it the others do not, and surface-affinity
|
|
870
|
+
clustering has nothing to say about it either: it carries no ledger ref and
|
|
871
|
+
is named by none, so it is an edgeless singleton.
|
|
872
|
+
|
|
873
|
+
## Options
|
|
874
|
+
|
|
875
|
+
(a) File it under \`user-directory\`, which owns the users table it mostly
|
|
876
|
+
reaches. (b) Declare a fifth, infrastructure-shaped capability to hold shared
|
|
877
|
+
configuration. (c) Leave it out of the partition and carry it forward
|
|
878
|
+
out-of-scope until an owner decides where shared infrastructure belongs in
|
|
879
|
+
the target.
|
|
880
|
+
|
|
881
|
+
## Recommendation
|
|
882
|
+
|
|
883
|
+
Recommend (c) for now; (a) would hide a shared dependency inside one
|
|
884
|
+
capability's fanout, and (b) is a partition decision worth an owner's
|
|
885
|
+
signature rather than this pass's guess.
|
|
886
|
+
`,
|
|
887
|
+
'q-webforms-auditlog-never-written': `---
|
|
888
|
+
id: q-webforms-auditlog-never-written
|
|
889
|
+
severity: moderate
|
|
890
|
+
status: open
|
|
891
|
+
---
|
|
892
|
+
|
|
893
|
+
## Evidence
|
|
894
|
+
|
|
895
|
+
The \`read-write-symmetry\` closer checked every write path against a matching
|
|
896
|
+
read path. \`Jobs/NightlyDigestJob.cs:17\` deletes from \`dbo.AuditLog\`, and
|
|
897
|
+
\`Schema.sql:7-12\` declares the table, but nothing anywhere in the checkout
|
|
898
|
+
ever inserts a row into it: the only two SQL writes in the source are the
|
|
899
|
+
\`INSERT INTO Users\` at \`Controllers/UsersController.cs:44\` and that delete.
|
|
900
|
+
|
|
901
|
+
## Options
|
|
902
|
+
|
|
903
|
+
(a) Widen the search; the writer may live outside this checkout, in a trigger
|
|
904
|
+
or a separate service. (b) Treat it as a real gap for the target to fix
|
|
905
|
+
rather than replicate. (c) Ask the operator whether audit rows were ever
|
|
906
|
+
written by this application.
|
|
907
|
+
|
|
908
|
+
## Recommendation
|
|
909
|
+
|
|
910
|
+
Recommend (c); a table with a purge job and no writer anywhere is exactly
|
|
911
|
+
what this closer exists to surface, and only the operator can say whether the
|
|
912
|
+
writer is missing or merely elsewhere.
|
|
913
|
+
`,
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// Substitutes a parity plan into the row shape `migrate import reqs` accepts.
|
|
917
|
+
// `{capability}` and `{fr_slug}` come from target.parity_test_path as read off
|
|
918
|
+
// config.toml; parity.md is explicit that nothing in the CLI derives an
|
|
919
|
+
// fr_slug or checks a ref against the template, so this substitution is the
|
|
920
|
+
// whole of the convention.
|
|
921
|
+
function renderParity(plan: ParityPlan | null, template: string): Record<string, unknown> | null {
|
|
922
|
+
if (plan === null) return null
|
|
923
|
+
if (plan.kind === 'rubric') {
|
|
924
|
+
return { kind: 'rubric', level: plan.level, ...(plan.queue ? { queue: plan.queue } : {}) }
|
|
925
|
+
}
|
|
926
|
+
const ref = template
|
|
927
|
+
.replaceAll('{capability}', plan.capability)
|
|
928
|
+
.replaceAll('{fr_slug}', plan.frSlug)
|
|
929
|
+
return { kind: 'golden-master', ref }
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function readTomlString(text: string, key: string): string {
|
|
933
|
+
const match = text.match(new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, 'm'))
|
|
934
|
+
if (!match?.[1]) throw new Error(`config.toml has no ${key}`)
|
|
935
|
+
return match[1]
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
test('aspnet recipe run driven probe through queue, ending green at check --phase queue', async () => {
|
|
939
|
+
const groundTruthPath = join(source, 'GROUND-TRUTH.md')
|
|
940
|
+
const rows = await parseGroundTruth(groundTruthPath)
|
|
941
|
+
expect(rows.length).toBeGreaterThan(0)
|
|
942
|
+
const groups = groupBySurface(rows)
|
|
943
|
+
|
|
944
|
+
// 1. migrate init, with --source-stack aspnet-webforms so enumerate.md
|
|
945
|
+
// reads references/recipes/aspnet.md instead of falling into contract-only
|
|
946
|
+
// mode: that recipe path, never exercised against a committed fixture
|
|
947
|
+
// before this test, is the one this whole fixture exists to prove out.
|
|
948
|
+
const init = await migrate([
|
|
949
|
+
'init',
|
|
950
|
+
'--source',
|
|
951
|
+
source,
|
|
952
|
+
'--scope',
|
|
953
|
+
'the whole tiny webforms app',
|
|
954
|
+
'--name',
|
|
955
|
+
'webforms-next',
|
|
956
|
+
'--source-stack',
|
|
957
|
+
'aspnet-webforms',
|
|
958
|
+
])
|
|
959
|
+
expect(init.code).toBe(0)
|
|
960
|
+
|
|
961
|
+
// Both defaults init wrote are left exactly as they are: the eight surface
|
|
962
|
+
// types, because exercising all eight is this fixture's whole point, and the
|
|
963
|
+
// three closers, because this run reaches extract and therefore owes a
|
|
964
|
+
// closer census record for each one. parity_test_path is read back rather
|
|
965
|
+
// than assumed, per probe.md and parity.md, since an operator may have
|
|
966
|
+
// edited it and nothing downstream would notice.
|
|
967
|
+
const cfgPath = join(target, '.migrate', 'config.toml')
|
|
968
|
+
const cfg = await readFile(cfgPath, 'utf8')
|
|
969
|
+
const parityTemplate = readTomlString(cfg, 'parity_test_path')
|
|
970
|
+
expect(parityTemplate).toContain('{capability}')
|
|
971
|
+
expect(parityTemplate).toContain('{fr_slug}')
|
|
972
|
+
|
|
973
|
+
// 2. migrate phase probe --status done.
|
|
974
|
+
expect((await migrate(['phase', 'probe', '--status', 'done'])).code).toBe(0)
|
|
975
|
+
|
|
976
|
+
// File the queue item every element's placeholder disposition below cites.
|
|
977
|
+
const queuePath = await writeText(`${QUEUE_ID}.md`, QUEUE_ITEM)
|
|
978
|
+
expect((await migrate(['queue', 'add', queuePath])).code).toBe(0)
|
|
979
|
+
|
|
980
|
+
// 4. migrate import elements, one batch per surface, rows from GROUND-TRUTH.md.
|
|
981
|
+
for (const [surface, surfaceRows] of groups) {
|
|
982
|
+
const batch = await write(`elements-${surface}.json`, {
|
|
983
|
+
batch: `b-${surface}-elements-001`,
|
|
984
|
+
phase: 'enumerate',
|
|
985
|
+
rows: surfaceRows.map((row) => ({
|
|
986
|
+
id: row.id,
|
|
987
|
+
surface: row.surface,
|
|
988
|
+
element: row.element,
|
|
989
|
+
found_by: ['code'],
|
|
990
|
+
disposition: { kind: 'out-of-scope', queue: QUEUE_ID },
|
|
991
|
+
refs: REFS[row.id] ?? [],
|
|
992
|
+
lens: 'code',
|
|
993
|
+
notes: '',
|
|
994
|
+
})),
|
|
995
|
+
})
|
|
996
|
+
const result = await migrate(['import', 'elements', batch])
|
|
997
|
+
expect(result.code).toBe(0)
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// aspnet.md names twenty-one directions across these eight surfaces, and
|
|
1001
|
+
// seven of them find nothing on this fixture. Both numbers are counted here
|
|
1002
|
+
// rather than claimed in a comment, so dropping a direction or quietly
|
|
1003
|
+
// omitting a zero is a test failure rather than something a reader has to
|
|
1004
|
+
// notice.
|
|
1005
|
+
const declaredDirections = Object.values(DIRECTIONS).flatMap((d) => Object.values(d))
|
|
1006
|
+
expect(declaredDirections.length).toBe(21)
|
|
1007
|
+
expect(declaredDirections.filter((d) => d.count === 0).length).toBe(7)
|
|
1008
|
+
|
|
1009
|
+
// 5. migrate census, one balanced lens record per surface, directions named
|
|
1010
|
+
// by aspnet.md for that surface, evidence set to the recipe's own probe
|
|
1011
|
+
// command, total within the bounds those directions imply.
|
|
1012
|
+
for (const [surface, surfaceRows] of groups) {
|
|
1013
|
+
const count = surfaceRows.length
|
|
1014
|
+
const directions = DIRECTIONS[surface]
|
|
1015
|
+
if (!directions) throw new Error(`no aspnet.md directions declared for surface ${surface}`)
|
|
1016
|
+
const record = await write(`census-${surface}.json`, {
|
|
1017
|
+
kind: 'lens',
|
|
1018
|
+
surface,
|
|
1019
|
+
phase: 'enumerate',
|
|
1020
|
+
directions,
|
|
1021
|
+
total: count,
|
|
1022
|
+
in_ledger: 0,
|
|
1023
|
+
added: count,
|
|
1024
|
+
skipped: [],
|
|
1025
|
+
queued: [],
|
|
1026
|
+
batch: `b-${surface}-census-001`,
|
|
1027
|
+
})
|
|
1028
|
+
const result = await migrate(['census', record])
|
|
1029
|
+
expect(result.code).toBe(0)
|
|
1030
|
+
expect(result.out).toContain(`census: recorded lens:${surface}`)
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// 6. migrate phase enumerate --status done.
|
|
1034
|
+
expect((await migrate(['phase', 'enumerate', '--status', 'done'])).code).toBe(0)
|
|
1035
|
+
|
|
1036
|
+
// 7. migrate check --phase enumerate. Every gate this phase owns is clean,
|
|
1037
|
+
// and the only violations left are the three declared closers, whose records
|
|
1038
|
+
// belong to extract and do not exist yet: exactly the mid-run posture
|
|
1039
|
+
// enumerate.md describes, where the census gate reads the whole store
|
|
1040
|
+
// regardless of --phase. Asserting the count pins that down harder than
|
|
1041
|
+
// asserting cleanliness would, because it fails if anything else starts
|
|
1042
|
+
// failing too.
|
|
1043
|
+
const enumerated = await migrate(['check', '--phase', 'enumerate'])
|
|
1044
|
+
expect(enumerated.out).toContain(
|
|
1045
|
+
`0/${rows.length} mapped, ${rows.length} out-of-scope, 0 unaccounted`,
|
|
1046
|
+
)
|
|
1047
|
+
expect(enumerated.out).toContain('Violations (3):')
|
|
1048
|
+
expect(enumerated.out).toContain(' census:')
|
|
1049
|
+
for (const closer of ['cross-capability-workflow', 'scope-injection', 'read-write-symmetry']) {
|
|
1050
|
+
expect(enumerated.out).toContain(` declared closer ${closer} has no census record`)
|
|
1051
|
+
}
|
|
1052
|
+
expect(enumerated.code).toBe(1)
|
|
1053
|
+
|
|
1054
|
+
// 7b. Surface-affinity clustering's graph, built straight from the store
|
|
1055
|
+
// the same way seam.md's worked example builds it. This is the assertion
|
|
1056
|
+
// that would fail if enumerate's ref-recording step (step 4) were skipped:
|
|
1057
|
+
// an empty REFS map above would leave every element's refs empty, and this
|
|
1058
|
+
// graph would have zero edges, which is exactly the defect this task
|
|
1059
|
+
// exists to close.
|
|
1060
|
+
const elementsForAffinity = (await readFile(join(target, '.migrate', 'elements.jsonl'), 'utf8'))
|
|
1061
|
+
.split('\n')
|
|
1062
|
+
.filter((line) => line.trim().length > 0)
|
|
1063
|
+
.map((line) => JSON.parse(line) as StoredElement)
|
|
1064
|
+
const edges = buildAffinityEdges(elementsForAffinity)
|
|
1065
|
+
expect(edges.size).toBeGreaterThan(0)
|
|
1066
|
+
const expectedEdges = [
|
|
1067
|
+
['route-get-api-users', 'table-users'],
|
|
1068
|
+
['route-post-api-users', 'table-users'],
|
|
1069
|
+
['route-get-api-users-id-welcome', 'integration-billing-sync'],
|
|
1070
|
+
['job-nightly-digest', 'table-audit-log'],
|
|
1071
|
+
['report-daily-users', 'table-users'],
|
|
1072
|
+
['workflow-signup-welcome', 'route-post-api-users'],
|
|
1073
|
+
['workflow-signup-welcome', 'route-get-api-users-id-welcome'],
|
|
1074
|
+
['workflow-signup-welcome', 'setting-welcome-email-enabled'],
|
|
1075
|
+
['screen-default', 'route-post-api-users'],
|
|
1076
|
+
['screen-users', 'route-get-api-users-id-welcome'],
|
|
1077
|
+
].map(([a, b]) => [a, b].sort().join('|'))
|
|
1078
|
+
for (const edge of expectedEdges) {
|
|
1079
|
+
expect(edges.has(edge)).toBe(true)
|
|
1080
|
+
}
|
|
1081
|
+
expect(edges.size).toBe(expectedEdges.length)
|
|
1082
|
+
|
|
1083
|
+
// 8. Phase 2, seam. No CLI verb authors any of the three artifacts, so all
|
|
1084
|
+
// three are written by hand here exactly as seam.md says a real run writes
|
|
1085
|
+
// them. The partition is checked against the ground truth first: fifteen
|
|
1086
|
+
// elements in exactly one capability each, and the sixteenth deliberately in
|
|
1087
|
+
// none. No gate checks any of that (nothing reads capabilities.elements at
|
|
1088
|
+
// all), which is precisely why the assertion is here.
|
|
1089
|
+
const assigned = CAPABILITIES.flatMap((c) => c.elements)
|
|
1090
|
+
expect(new Set(assigned).size).toBe(assigned.length)
|
|
1091
|
+
expect([...assigned, ...UNASSIGNED].sort()).toEqual(rows.map((r) => r.id).sort())
|
|
1092
|
+
expect(UNASSIGNED).toEqual(['setting-default-connection'])
|
|
1093
|
+
|
|
1094
|
+
const storeDir = join(target, '.migrate')
|
|
1095
|
+
await writeFile(
|
|
1096
|
+
join(storeDir, 'capabilities.jsonl'),
|
|
1097
|
+
`${CAPABILITIES.map((c) => JSON.stringify(c)).join('\n')}\n`,
|
|
1098
|
+
)
|
|
1099
|
+
await writeFile(join(storeDir, 'seam.json'), `${JSON.stringify(SEAM_JSON, null, 2)}\n`)
|
|
1100
|
+
await writeFile(
|
|
1101
|
+
join(storeDir, 'seam.md'),
|
|
1102
|
+
'# Seam evidence\n\nsurface-affinity clustering over the ledger refs, connected components then the\ngreedy modularity refinement, Q = 0.505. The other three validators could not\nrun: no live database, no static C# call graph, no VCS history in the copied\nfixture. setting-default-connection is left out of every capability on purpose;\nsee q-webforms-default-connection-ownership.\n',
|
|
1103
|
+
)
|
|
1104
|
+
expect((await migrate(['phase', 'seam', '--status', 'done'])).code).toBe(0)
|
|
1105
|
+
|
|
1106
|
+
// 9. Phase 3, extract.
|
|
1107
|
+
for (const id of Object.keys(DISPOSITIONS)) {
|
|
1108
|
+
expect(rows.some((r) => r.id === id)).toBe(true)
|
|
1109
|
+
}
|
|
1110
|
+
expect(Object.keys(DISPOSITIONS).length).toBe(rows.length)
|
|
1111
|
+
|
|
1112
|
+
const reqBatch = await write('reqs.json', {
|
|
1113
|
+
batch: 'b-reqs-extract-001',
|
|
1114
|
+
phase: 'extract',
|
|
1115
|
+
rows: REQUIREMENTS,
|
|
1116
|
+
})
|
|
1117
|
+
const reqImport = await migrate(['import', 'reqs', reqBatch])
|
|
1118
|
+
expect(reqImport.code).toBe(0)
|
|
1119
|
+
expect(reqImport.out).toContain(
|
|
1120
|
+
`import reqs: ${REQUIREMENTS.length} added, 0 updated, batch b-reqs-extract-001`,
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
for (const [id, body] of Object.entries(QUEUE_ITEMS)) {
|
|
1124
|
+
const path = await writeText(`${id}.md`, body)
|
|
1125
|
+
const added = await migrate(['queue', 'add', path])
|
|
1126
|
+
expect(added.code).toBe(0)
|
|
1127
|
+
expect(added.out).toContain(`queue add: ${id}`)
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const disposed = await write('elements-disposed.json', {
|
|
1131
|
+
batch: 'b-elements-disposition-001',
|
|
1132
|
+
phase: 'extract',
|
|
1133
|
+
rows: rows.map((row) => ({
|
|
1134
|
+
id: row.id,
|
|
1135
|
+
surface: row.surface,
|
|
1136
|
+
element: row.element,
|
|
1137
|
+
found_by: ['code'],
|
|
1138
|
+
disposition: DISPOSITIONS[row.id],
|
|
1139
|
+
refs: REFS[row.id] ?? [],
|
|
1140
|
+
lens: 'code',
|
|
1141
|
+
notes: '',
|
|
1142
|
+
})),
|
|
1143
|
+
})
|
|
1144
|
+
const disposeImport = await migrate(['import', 'elements', disposed])
|
|
1145
|
+
expect(disposeImport.code).toBe(0)
|
|
1146
|
+
expect(disposeImport.out).toContain(`import elements: 0 added, ${rows.length} updated`)
|
|
1147
|
+
|
|
1148
|
+
for (const record of EXTRACT_CENSUS) {
|
|
1149
|
+
const path = await write(`census-${record.batch}.json`, record)
|
|
1150
|
+
const result = await migrate(['census', path])
|
|
1151
|
+
expect(result.code).toBe(0)
|
|
1152
|
+
}
|
|
1153
|
+
expect((await migrate(['phase', 'extract', '--status', 'done'])).code).toBe(0)
|
|
1154
|
+
|
|
1155
|
+
// 10. Phase 4, parity. The delta goes in unsigned first, so the deltas gate
|
|
1156
|
+
// is shown failing on it before it is shown clean.
|
|
1157
|
+
const unsigned = await write('deltas.json', {
|
|
1158
|
+
batch: 'b-deltas-parity-001',
|
|
1159
|
+
phase: 'parity',
|
|
1160
|
+
rows: [DELTA],
|
|
1161
|
+
})
|
|
1162
|
+
expect((await migrate(['import', 'deltas', unsigned])).code).toBe(0)
|
|
1163
|
+
const withUnsignedDelta = await migrate(['check', '--phase', 'parity'])
|
|
1164
|
+
expect(withUnsignedDelta.code).toBe(1)
|
|
1165
|
+
expect(withUnsignedDelta.out).toContain(' deltas:')
|
|
1166
|
+
expect(withUnsignedDelta.out).toContain(` ${DELTA.id} is not owner-signed`)
|
|
1167
|
+
|
|
1168
|
+
const signed = await write('deltas-signed.json', {
|
|
1169
|
+
batch: 'b-deltas-parity-002',
|
|
1170
|
+
phase: 'parity',
|
|
1171
|
+
rows: [{ ...DELTA, owner_signed: '2026-08-08' }],
|
|
1172
|
+
})
|
|
1173
|
+
expect((await migrate(['import', 'deltas', signed])).code).toBe(0)
|
|
1174
|
+
|
|
1175
|
+
const parityBatch = await write('reqs-parity.json', {
|
|
1176
|
+
batch: 'b-reqs-parity-001',
|
|
1177
|
+
phase: 'parity',
|
|
1178
|
+
rows: REQUIREMENTS.map((r) => ({
|
|
1179
|
+
...r,
|
|
1180
|
+
parity: renderParity(PARITY[r.id] ?? null, parityTemplate),
|
|
1181
|
+
})),
|
|
1182
|
+
})
|
|
1183
|
+
const parityImport = await migrate(['import', 'reqs', parityBatch])
|
|
1184
|
+
expect(parityImport.code).toBe(0)
|
|
1185
|
+
// Every row counts as updated, including UD-001, whose parity stays null:
|
|
1186
|
+
// the importer stamps its own batch id onto every row it writes, so a row
|
|
1187
|
+
// whose content is otherwise unchanged still differs from the one on disk.
|
|
1188
|
+
expect(parityImport.out).toContain(
|
|
1189
|
+
`import reqs: 0 added, ${REQUIREMENTS.length} updated, batch b-reqs-parity-001`,
|
|
1190
|
+
)
|
|
1191
|
+
expect((await migrate(['phase', 'parity', '--status', 'done'])).code).toBe(0)
|
|
1192
|
+
|
|
1193
|
+
// 11. Phase 5, queue. Every item this run owed was filed in the pass that
|
|
1194
|
+
// named it, so this phase closes on the status flip, exactly as queue.md
|
|
1195
|
+
// says: closing is not "the queue is empty", since nothing in this milestone
|
|
1196
|
+
// adjudicates an item.
|
|
1197
|
+
const listed = await migrate(['queue', 'list', '--open'])
|
|
1198
|
+
expect(listed.code).toBe(0)
|
|
1199
|
+
expect(listed.out).toContain(`${Object.keys(QUEUE_ITEMS).length + 1} item(s)`)
|
|
1200
|
+
expect((await migrate(['phase', 'queue', '--status', 'done'])).code).toBe(0)
|
|
1201
|
+
|
|
1202
|
+
// 12. The terminus. Fifteen of sixteen mapped, the sixteenth out-of-scope by
|
|
1203
|
+
// the seam decision, no violations, exit 0.
|
|
1204
|
+
const green = await migrate(['check', '--phase', 'queue'])
|
|
1205
|
+
expect(green.out).toContain(
|
|
1206
|
+
`${rows.length - UNASSIGNED.length}/${rows.length} mapped, ${UNASSIGNED.length} out-of-scope, 0 unaccounted`,
|
|
1207
|
+
)
|
|
1208
|
+
expect(green.out).not.toContain('Violations')
|
|
1209
|
+
expect(green.code).toBe(0)
|
|
1210
|
+
|
|
1211
|
+
// 13. Plain `migrate check` gates every phase through handoff, and fails on
|
|
1212
|
+
// exactly the two that have no verb in this milestone. The violation count
|
|
1213
|
+
// is what makes "exactly" an assertion rather than a hope.
|
|
1214
|
+
const full = await migrate(['check'])
|
|
1215
|
+
expect(full.code).toBe(1)
|
|
1216
|
+
expect(full.out).toContain('Violations (2):')
|
|
1217
|
+
expect(full.out).toContain(' run-state:')
|
|
1218
|
+
expect(full.out).toContain(
|
|
1219
|
+
' phase adjudicate is pending; every phase through handoff must be done',
|
|
1220
|
+
)
|
|
1221
|
+
expect(full.out).toContain(
|
|
1222
|
+
' phase handoff is pending; every phase through handoff must be done',
|
|
1223
|
+
)
|
|
1224
|
+
|
|
1225
|
+
// 14. The terminus assertion in step 12 is load-bearing, shown by mutation
|
|
1226
|
+
// rather than asserted: nulling every requirement's parity plan is a phase-4
|
|
1227
|
+
// regression and nothing else, and it must break the green check. Restoring
|
|
1228
|
+
// the file returns it to green, which is what proves the mutation was the
|
|
1229
|
+
// only cause.
|
|
1230
|
+
const requirementsPath = join(storeDir, 'requirements.jsonl')
|
|
1231
|
+
const requirementsText = await readFile(requirementsPath, 'utf8')
|
|
1232
|
+
const stripped = requirementsText
|
|
1233
|
+
.split('\n')
|
|
1234
|
+
.filter((line) => line.trim().length > 0)
|
|
1235
|
+
.map((line) => JSON.stringify({ ...(JSON.parse(line) as object), parity: null }))
|
|
1236
|
+
await writeFile(requirementsPath, `${stripped.join('\n')}\n`)
|
|
1237
|
+
|
|
1238
|
+
const noParity = await migrate(['check', '--phase', 'queue'])
|
|
1239
|
+
expect(noParity.code).toBe(1)
|
|
1240
|
+
expect(noParity.out).toContain(' parity:')
|
|
1241
|
+
expect(noParity.out).toContain(' SU-001 has no parity plan')
|
|
1242
|
+
// UD-001 is `queued`, the one confidence the parity gate exempts, so it must
|
|
1243
|
+
// not appear even now that its plan is null like everyone else's.
|
|
1244
|
+
expect(noParity.out).not.toContain('UD-001 has no parity plan')
|
|
1245
|
+
|
|
1246
|
+
await writeFile(requirementsPath, requirementsText)
|
|
1247
|
+
expect((await migrate(['check', '--phase', 'queue'])).code).toBe(0)
|
|
1248
|
+
|
|
1249
|
+
// 15. Removing one element row breaks the census reconciliation for its
|
|
1250
|
+
// surface, and only that: `in_ledger + added` on the surviving census
|
|
1251
|
+
// record still claims the pre-mutation count, but elements.jsonl now has
|
|
1252
|
+
// one fewer row for that surface. This is what makes the enumerate-phase
|
|
1253
|
+
// arithmetic load-bearing rather than vacuous: if the reconciliation check
|
|
1254
|
+
// were missing or broken, this mutation would leave the check clean too.
|
|
1255
|
+
const mutatedSurface = 'tables'
|
|
1256
|
+
const mutatedRows = groups.get(mutatedSurface)
|
|
1257
|
+
if (!mutatedRows || mutatedRows.length < 2) {
|
|
1258
|
+
throw new Error(`fixture must declare at least two ${mutatedSurface} rows to mutate one away`)
|
|
1259
|
+
}
|
|
1260
|
+
const removedId = mutatedRows[mutatedRows.length - 1]?.id
|
|
1261
|
+
const elementsPath = join(target, '.migrate', 'elements.jsonl')
|
|
1262
|
+
const elementsText = await readFile(elementsPath, 'utf8')
|
|
1263
|
+
const survivingLines = elementsText
|
|
1264
|
+
.split('\n')
|
|
1265
|
+
.filter((line) => line.trim().length > 0)
|
|
1266
|
+
.filter((line) => (JSON.parse(line) as { id: string }).id !== removedId)
|
|
1267
|
+
await writeFile(elementsPath, `${survivingLines.join('\n')}\n`)
|
|
1268
|
+
|
|
1269
|
+
const afterRemoval = await migrate(['check', '--phase', 'enumerate'])
|
|
1270
|
+
expect(afterRemoval.code).not.toBe(0)
|
|
1271
|
+
const claimed = mutatedRows.length
|
|
1272
|
+
const actual = mutatedRows.length - 1
|
|
1273
|
+
expect(afterRemoval.out).toContain(
|
|
1274
|
+
`lens census for ${mutatedSurface} claims in_ledger 0 + added ${claimed} = ${claimed} element(s) in the ledger, but elements.jsonl has ${actual}`,
|
|
1275
|
+
)
|
|
1276
|
+
})
|