@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,98 @@
|
|
|
1
|
+
import { copyFile, mkdir } from 'node:fs/promises'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
import { loadConfig } from './config.ts'
|
|
4
|
+
import { assertNotUnderSource, storePaths } from './paths.ts'
|
|
5
|
+
import { isMissingFrontmatter, loadQueue, parseQueueItem } from './queue.ts'
|
|
6
|
+
import { readTextFile } from './store.ts'
|
|
7
|
+
|
|
8
|
+
export async function runQueue(opts: { root: string; args: string[] }): Promise<number> {
|
|
9
|
+
const [verb, ...rest] = opts.args
|
|
10
|
+
const cfg = await loadConfig(opts.root)
|
|
11
|
+
const queueDir = storePaths(opts.root).queueDir
|
|
12
|
+
|
|
13
|
+
if (verb === 'add') {
|
|
14
|
+
const file = rest[0]
|
|
15
|
+
if (!file) {
|
|
16
|
+
process.stderr.write('queue add: missing <file.md>\n')
|
|
17
|
+
return 2
|
|
18
|
+
}
|
|
19
|
+
let text: string
|
|
20
|
+
try {
|
|
21
|
+
text = await readTextFile(file)
|
|
22
|
+
} catch (e) {
|
|
23
|
+
// Missing file, a directory passed as a file, or an unreadable file:
|
|
24
|
+
// the request can never be serviced, the same usage-error class as
|
|
25
|
+
// import-cmd.ts's and census-cmd.ts's readJsonFile catch.
|
|
26
|
+
process.stderr.write(`queue add: ${(e as Error).message}\n`)
|
|
27
|
+
return 2
|
|
28
|
+
}
|
|
29
|
+
const dest = join(queueDir, basename(file))
|
|
30
|
+
const parsed = parseQueueItem(text, dest)
|
|
31
|
+
if (!parsed.ok) {
|
|
32
|
+
for (const e of parsed.errors) process.stderr.write(`queue add: ${e}\n`)
|
|
33
|
+
// No frontmatter at all belongs to the same usage-error class as the
|
|
34
|
+
// read failure above; every other grammar violation (bad severity, a
|
|
35
|
+
// missing or empty section, a filename/id mismatch, an adjudicated
|
|
36
|
+
// item with no ruling) is a well-formed file with bad content -- a
|
|
37
|
+
// content failure instead.
|
|
38
|
+
return isMissingFrontmatter(parsed.errors) ? 2 : 1
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
await assertNotUnderSource(dest, cfg.source.path)
|
|
42
|
+
} catch (e) {
|
|
43
|
+
// A store whose configured source.path resolves to include its own
|
|
44
|
+
// queue directory (e.g. source.path: '.') can never be written to,
|
|
45
|
+
// for any well-formed item whatsoever: an environment/config
|
|
46
|
+
// problem, invariant across every possible file, not a property of
|
|
47
|
+
// this item's own content, so it is a usage error (2), not the
|
|
48
|
+
// content failure (1) returned just above for a bad grammar.
|
|
49
|
+
process.stderr.write(`queue add: ${(e as Error).message}\n`)
|
|
50
|
+
return 2
|
|
51
|
+
}
|
|
52
|
+
await mkdir(queueDir, { recursive: true })
|
|
53
|
+
await copyFile(file, dest)
|
|
54
|
+
process.stdout.write(`queue add: ${parsed.value.id} [${parsed.value.severity}]\n`)
|
|
55
|
+
return 0
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (verb === 'list') {
|
|
59
|
+
const openOnly = rest.includes('--open')
|
|
60
|
+
const { items, errors } = await loadQueue(queueDir)
|
|
61
|
+
for (const e of errors) process.stderr.write(`queue: ${e}\n`)
|
|
62
|
+
const shown = openOnly ? items.filter((i) => i.status === 'open') : items
|
|
63
|
+
for (const item of shown) {
|
|
64
|
+
process.stdout.write(`${item.id}\t${item.severity}\t${item.status}\n`)
|
|
65
|
+
}
|
|
66
|
+
process.stdout.write(`${shown.length} item(s)\n`)
|
|
67
|
+
return errors.length > 0 ? 1 : 0
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (verb === 'show') {
|
|
71
|
+
const id = rest[0]
|
|
72
|
+
if (!id) {
|
|
73
|
+
process.stderr.write('queue show: missing <id>\n')
|
|
74
|
+
return 2
|
|
75
|
+
}
|
|
76
|
+
const { items } = await loadQueue(queueDir)
|
|
77
|
+
const item = items.find((i) => i.id === id)
|
|
78
|
+
if (!item) {
|
|
79
|
+
process.stderr.write(`queue show: no item ${id}\n`)
|
|
80
|
+
return 1
|
|
81
|
+
}
|
|
82
|
+
let text: string
|
|
83
|
+
try {
|
|
84
|
+
text = await readTextFile(item.path)
|
|
85
|
+
} catch (e) {
|
|
86
|
+
// The item was in the index a moment ago; its file vanishing or
|
|
87
|
+
// becoming unreadable between load and show is a domain failure on an
|
|
88
|
+
// otherwise well-formed request, not a malformed one.
|
|
89
|
+
process.stderr.write(`queue show: ${(e as Error).message}\n`)
|
|
90
|
+
return 1
|
|
91
|
+
}
|
|
92
|
+
process.stdout.write(text)
|
|
93
|
+
return 0
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
process.stderr.write('queue: want add <file.md> | list [--open] | show <id>\n')
|
|
97
|
+
return 2
|
|
98
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readdir } from 'node:fs/promises'
|
|
3
|
+
import { basename, join } from 'node:path'
|
|
4
|
+
import { isValidSlug } from './ids.ts'
|
|
5
|
+
import { readTextFile } from './store.ts'
|
|
6
|
+
import type { QueueItem, Severity } from './types.ts'
|
|
7
|
+
|
|
8
|
+
const SEVERITIES: Severity[] = ['critical', 'moderate', 'minor']
|
|
9
|
+
const SECTIONS = ['Evidence', 'Options', 'Recommendation'] as const
|
|
10
|
+
|
|
11
|
+
// A file with no --- frontmatter block at all never gets far enough to be
|
|
12
|
+
// treated as a queue item; the message below is the sole diagnostic on that
|
|
13
|
+
// path (see parseQueueItem). isMissingFrontmatter lets callers tell that
|
|
14
|
+
// case apart from every other grammar violation.
|
|
15
|
+
const NO_FRONTMATTER = 'missing --- frontmatter block'
|
|
16
|
+
|
|
17
|
+
export type ParsedQueue = { ok: true; value: QueueItem } | { ok: false; errors: string[] }
|
|
18
|
+
|
|
19
|
+
// Files authored on Windows commonly carry a leading byte-order mark and/or
|
|
20
|
+
// CRLF line endings; neither changes the file's actual content, so both are
|
|
21
|
+
// normalized away before any grammar check runs. Nothing further: no other
|
|
22
|
+
// encoding normalization is in scope.
|
|
23
|
+
function normalizeText(text: string): string {
|
|
24
|
+
const noBom = text.startsWith('\uFEFF') ? text.slice(1) : text
|
|
25
|
+
return noBom.replace(/\r\n/g, '\n')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function splitFrontmatter(text: string): { fm: Record<string, string>; body: string } | null {
|
|
29
|
+
if (!text.startsWith('---\n')) return null
|
|
30
|
+
const end = text.indexOf('\n---', 3)
|
|
31
|
+
if (end === -1) return null
|
|
32
|
+
const fmText = text.slice(4, end)
|
|
33
|
+
const body = text.slice(end + 4).replace(/^\n/, '')
|
|
34
|
+
const fm: Record<string, string> = {}
|
|
35
|
+
for (const line of fmText.split('\n')) {
|
|
36
|
+
const at = line.indexOf(':')
|
|
37
|
+
if (at === -1) continue
|
|
38
|
+
fm[line.slice(0, at).trim()] = line.slice(at + 1).trim()
|
|
39
|
+
}
|
|
40
|
+
return { fm, body }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Heading = { name: string; start: number; end: number }
|
|
44
|
+
|
|
45
|
+
// Only a line that starts with exactly '## ' (two hashes, one space, at the
|
|
46
|
+
// very start of a line) counts as a section heading. Requiring exactly two
|
|
47
|
+
// hashes at the true start of the line means '### Options' (one hash too
|
|
48
|
+
// many) or '#Options' (too few), or an indented/blockquoted/tab-indented
|
|
49
|
+
// '##' line, are ordinary body text, not headings: a level typo can no
|
|
50
|
+
// longer be mistaken for a real heading, corrupt a neighboring section's
|
|
51
|
+
// content, or silently stand in for a missing one.
|
|
52
|
+
const HEADING_LINE = /^## (.+)$/
|
|
53
|
+
|
|
54
|
+
// A fenced code block (three or more backticks, or three or more tildes, at
|
|
55
|
+
// the start of a line once surrounding whitespace is trimmed) hides
|
|
56
|
+
// everything inside it from heading detection: a pasted shell script's
|
|
57
|
+
// '## Section' comments, a quoted markdown file, or a diff are Evidence
|
|
58
|
+
// content, not document structure, and must not be mistaken for one. This
|
|
59
|
+
// is deliberately not a conforming CommonMark fence parser -- no info
|
|
60
|
+
// string handling, no requirement that a closing fence be at least as long
|
|
61
|
+
// as the one that opened it -- it only needs to stop quoted code from
|
|
62
|
+
// reading as a heading, which a same-character open/close toggle already
|
|
63
|
+
// does; a fence line of the other character (e.g. a '~~~' divider inside a
|
|
64
|
+
// backtick-fenced block) does not close it.
|
|
65
|
+
const FENCE_LINE = /^(`{3,}|~{3,})/
|
|
66
|
+
|
|
67
|
+
type HeadingScan = {
|
|
68
|
+
headings: Heading[]
|
|
69
|
+
// The 1-indexed body line where an opening fence was never matched by a
|
|
70
|
+
// closing one, or null if every fence (if any) closed. A null-vs-number
|
|
71
|
+
// result, not a boolean, so the error message can point at exactly where
|
|
72
|
+
// the unterminated fence began.
|
|
73
|
+
unclosedFenceAt: number | null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function findHeadings(body: string): HeadingScan {
|
|
77
|
+
const headings: Heading[] = []
|
|
78
|
+
const lines = body.split('\n')
|
|
79
|
+
let fenceChar: string | null = null
|
|
80
|
+
let fenceStartLine: number | null = null
|
|
81
|
+
let offset = 0
|
|
82
|
+
for (let i = 0; i < lines.length; i++) {
|
|
83
|
+
const line = lines[i] ?? ''
|
|
84
|
+
const fenceMatch = FENCE_LINE.exec(line.trim())
|
|
85
|
+
if (fenceMatch) {
|
|
86
|
+
const ch = fenceMatch[1]?.[0] ?? ''
|
|
87
|
+
if (fenceChar === null) {
|
|
88
|
+
fenceChar = ch
|
|
89
|
+
fenceStartLine = i + 1
|
|
90
|
+
} else if (ch === fenceChar) {
|
|
91
|
+
fenceChar = null
|
|
92
|
+
fenceStartLine = null
|
|
93
|
+
}
|
|
94
|
+
// A fence line of the other character while already inside a fence
|
|
95
|
+
// is just content (e.g. a '~~~' rule inside a backtick block): no
|
|
96
|
+
// state change, and it can never look like a heading anyway.
|
|
97
|
+
} else if (fenceChar === null) {
|
|
98
|
+
const m = HEADING_LINE.exec(line)
|
|
99
|
+
if (m) {
|
|
100
|
+
headings.push({ name: (m[1] ?? '').trim(), start: offset, end: offset + line.length })
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
offset += line.length + 1
|
|
104
|
+
}
|
|
105
|
+
return { headings, unclosedFenceAt: fenceChar === null ? null : fenceStartLine }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type SectionResult = { kind: 'ok'; text: string } | { kind: 'missing' } | { kind: 'duplicate' }
|
|
109
|
+
|
|
110
|
+
// A section is identified by an exact, case-sensitive, line-anchored
|
|
111
|
+
// '## <name>' heading. Zero matches is a missing section; more than one is
|
|
112
|
+
// a duplicate, reported as an error rather than silently taking the first
|
|
113
|
+
// (which used to truncate the section and drop everything after the
|
|
114
|
+
// second heading with no diagnostic). Content runs from the end of the
|
|
115
|
+
// matched heading's line to the start of the next heading of any name, or
|
|
116
|
+
// to the end of the document if there is none.
|
|
117
|
+
function sectionBody(headings: Heading[], body: string, name: string): SectionResult {
|
|
118
|
+
const matches = headings.filter((h) => h.name === name)
|
|
119
|
+
if (matches.length === 0) return { kind: 'missing' }
|
|
120
|
+
if (matches.length > 1) return { kind: 'duplicate' }
|
|
121
|
+
const match = matches[0]
|
|
122
|
+
if (!match) return { kind: 'missing' }
|
|
123
|
+
const at = headings.indexOf(match)
|
|
124
|
+
const next = headings[at + 1]
|
|
125
|
+
const contentEnd = next ? next.start : body.length
|
|
126
|
+
return { kind: 'ok', text: body.slice(match.end, contentEnd).trim() }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// A queue file that cannot be parsed at all (no --- frontmatter block found)
|
|
130
|
+
// is a usage error: the request never resolved to a queue item in the first
|
|
131
|
+
// place. Every other error below comes from a file that did parse, so it is
|
|
132
|
+
// a well-formed file with invalid content -- a content failure instead.
|
|
133
|
+
// Callers (queue-cmd.ts) use this to pick the right exit code without
|
|
134
|
+
// re-parsing the file themselves.
|
|
135
|
+
export function isMissingFrontmatter(errors: string[]): boolean {
|
|
136
|
+
return errors.length === 1 && (errors[0]?.includes(NO_FRONTMATTER) ?? false)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function parseQueueItem(rawText: string, path: string): ParsedQueue {
|
|
140
|
+
const errors: string[] = []
|
|
141
|
+
const text = normalizeText(rawText)
|
|
142
|
+
const split = splitFrontmatter(text)
|
|
143
|
+
if (!split) return { ok: false, errors: [`${path}: ${NO_FRONTMATTER}`] }
|
|
144
|
+
const { fm, body } = split
|
|
145
|
+
|
|
146
|
+
const id = fm.id ?? ''
|
|
147
|
+
if (!id) errors.push(`${path}: frontmatter is missing id`)
|
|
148
|
+
const stem = basename(path).replace(/\.md$/, '')
|
|
149
|
+
if (id && stem !== id) errors.push(`${path}: filename ${stem} does not match id ${id}`)
|
|
150
|
+
if (id && !(id.startsWith('q-') && isValidSlug(id.slice(2)))) {
|
|
151
|
+
errors.push(`${path}: id ${id} must be q- followed by a lowercase kebab-case slug`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const severity = fm.severity ?? ''
|
|
155
|
+
if (!SEVERITIES.includes(severity as Severity)) {
|
|
156
|
+
errors.push(
|
|
157
|
+
`${path}: severity must be one of ${SEVERITIES.join(', ')}, got ${severity || '<none>'}`,
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
const status = fm.status ?? ''
|
|
161
|
+
if (status !== 'open' && status !== 'adjudicated') {
|
|
162
|
+
errors.push(`${path}: status must be open or adjudicated, got ${status || '<none>'}`)
|
|
163
|
+
}
|
|
164
|
+
if (status === 'adjudicated' && !fm.ruling) {
|
|
165
|
+
errors.push(`${path}: an adjudicated item needs a ruling`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const { headings, unclosedFenceAt } = findHeadings(body)
|
|
169
|
+
const found: Record<string, string> = {}
|
|
170
|
+
if (unclosedFenceAt !== null) {
|
|
171
|
+
// The document's structure past an unterminated fence cannot be
|
|
172
|
+
// trusted -- any '##' line after it might belong inside the fence or
|
|
173
|
+
// outside it, and there is no reliable way to tell. Rather than guess
|
|
174
|
+
// (which risks exactly the silent corruption Finding 2 fixed) or pile
|
|
175
|
+
// on confusing per-section "missing"/"duplicate" noise that would
|
|
176
|
+
// misdirect the author toward the wrong section entirely, this is one
|
|
177
|
+
// clear, specific, loud error naming the real problem, and every
|
|
178
|
+
// per-section check below is skipped.
|
|
179
|
+
errors.push(
|
|
180
|
+
`${path}: unclosed code fence starting at body line ${unclosedFenceAt} (add a matching closing fence; headings after this point cannot be recognized)`,
|
|
181
|
+
)
|
|
182
|
+
} else {
|
|
183
|
+
for (const name of SECTIONS) {
|
|
184
|
+
const section = sectionBody(headings, body, name)
|
|
185
|
+
if (section.kind === 'missing') {
|
|
186
|
+
// States the grammar explicitly (line-anchored, exactly two hashes,
|
|
187
|
+
// case-sensitive) so a level typo or a wrong-case heading (which also
|
|
188
|
+
// lands here, since neither is recognized as this heading) reads as
|
|
189
|
+
// a grammar mismatch rather than "you forgot this entirely".
|
|
190
|
+
errors.push(
|
|
191
|
+
`${path}: missing ## ${name} section (a line reading exactly "## ${name}", case-sensitive)`,
|
|
192
|
+
)
|
|
193
|
+
} else if (section.kind === 'duplicate') {
|
|
194
|
+
errors.push(
|
|
195
|
+
`${path}: duplicate ## ${name} section (the heading "## ${name}" appears more than once)`,
|
|
196
|
+
)
|
|
197
|
+
} else if (section.text.length === 0) {
|
|
198
|
+
errors.push(`${path}: ## ${name} section is empty`)
|
|
199
|
+
} else {
|
|
200
|
+
found[name] = section.text
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (errors.length > 0) return { ok: false, errors }
|
|
206
|
+
return {
|
|
207
|
+
ok: true,
|
|
208
|
+
value: {
|
|
209
|
+
id,
|
|
210
|
+
severity: severity as Severity,
|
|
211
|
+
status: status as QueueItem['status'],
|
|
212
|
+
...(fm.ruling ? { ruling: fm.ruling } : {}),
|
|
213
|
+
...(fm.adjudicated ? { adjudicated: fm.adjudicated } : {}),
|
|
214
|
+
evidence: found.Evidence ?? '',
|
|
215
|
+
options: found.Options ?? '',
|
|
216
|
+
recommendation: found.Recommendation ?? '',
|
|
217
|
+
path,
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function loadQueue(
|
|
223
|
+
queueDir: string,
|
|
224
|
+
): Promise<{ items: QueueItem[]; errors: string[] }> {
|
|
225
|
+
if (!existsSync(queueDir)) return { items: [], errors: [] }
|
|
226
|
+
let names: string[]
|
|
227
|
+
try {
|
|
228
|
+
names = (await readdir(queueDir)).filter((n) => n.endsWith('.md')).sort()
|
|
229
|
+
} catch {
|
|
230
|
+
// existsSync just confirmed the path exists, so a readdir failure here
|
|
231
|
+
// means it is not a readable directory (e.g. a file sits at the queue
|
|
232
|
+
// path). One clean diagnostic naming the path, not an uncaught throw.
|
|
233
|
+
return { items: [], errors: [`${queueDir}: cannot be read as a directory`] }
|
|
234
|
+
}
|
|
235
|
+
const items: QueueItem[] = []
|
|
236
|
+
const errors: string[] = []
|
|
237
|
+
for (const name of names) {
|
|
238
|
+
const path = join(queueDir, name)
|
|
239
|
+
let text: string
|
|
240
|
+
try {
|
|
241
|
+
text = await readTextFile(path)
|
|
242
|
+
} catch (e) {
|
|
243
|
+
// One unreadable entry (permission denied, a directory named *.md, or
|
|
244
|
+
// a file removed between readdir and read) must not hide the rest of
|
|
245
|
+
// the directory: report it and keep going.
|
|
246
|
+
errors.push((e as Error).message)
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
const parsed = parseQueueItem(text, path)
|
|
250
|
+
if (parsed.ok) items.push(parsed.value)
|
|
251
|
+
else errors.push(...parsed.errors)
|
|
252
|
+
}
|
|
253
|
+
items.sort((a, b) => {
|
|
254
|
+
const bySeverity = SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity)
|
|
255
|
+
return bySeverity !== 0 ? bySeverity : a.id.localeCompare(b.id)
|
|
256
|
+
})
|
|
257
|
+
return { items, errors }
|
|
258
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { runCheck } from './check.ts'
|
|
4
|
+
import { loadConfig } from './config.ts'
|
|
5
|
+
import { assertNotUnderSource, storePaths } from './paths.ts'
|
|
6
|
+
import { loadQueue } from './queue.ts'
|
|
7
|
+
import { renderLedger, renderQueueReport, renderRequirements } from './report.ts'
|
|
8
|
+
import { readRows, writeAtomically } from './store.ts'
|
|
9
|
+
import type { Element, Requirement } from './types.ts'
|
|
10
|
+
|
|
11
|
+
export async function runReport(opts: { root: string; outDir?: string }): Promise<number> {
|
|
12
|
+
const cfg = await loadConfig(opts.root)
|
|
13
|
+
const p = storePaths(opts.root)
|
|
14
|
+
const outDir = opts.outDir ?? join(opts.root, 'docs', 'migrate')
|
|
15
|
+
await assertNotUnderSource(outDir, cfg.source.path)
|
|
16
|
+
await mkdir(outDir, { recursive: true })
|
|
17
|
+
|
|
18
|
+
const elements = await readRows<Element>(p.elements)
|
|
19
|
+
const requirements = await readRows<Requirement>(p.requirements)
|
|
20
|
+
const { items, errors } = await loadQueue(p.queueDir)
|
|
21
|
+
// A queue item the report cannot parse is not represented in queue.md at
|
|
22
|
+
// all: silently emitting a file that only covers the parseable items,
|
|
23
|
+
// under a banner claiming it was generated from the store, would let an
|
|
24
|
+
// owner reading it conclude there is nothing else to adjudicate. Surfaced
|
|
25
|
+
// the same way every other command in this CLI surfaces a domain problem
|
|
26
|
+
// on an otherwise well-formed request: named on stderr, non-zero exit.
|
|
27
|
+
for (const e of errors) process.stderr.write(`report: ${e}\n`)
|
|
28
|
+
const { summary } = await runCheck({ root: opts.root })
|
|
29
|
+
|
|
30
|
+
// Through writeAtomically rather than writeFile, so the three rendered views
|
|
31
|
+
// get temp-plus-rename like every store file: a report is what a human opens
|
|
32
|
+
// to read the run, and a truncated one is worse than a stale one. The
|
|
33
|
+
// explicit assertNotUnderSource on outDir above is still needed regardless,
|
|
34
|
+
// because the mkdir happens before any of these three run.
|
|
35
|
+
const src = cfg.source.path
|
|
36
|
+
await writeAtomically(join(outDir, 'ledger.md'), `${renderLedger(elements)}\n${summary}\n`, src)
|
|
37
|
+
await writeAtomically(join(outDir, 'requirements.md'), renderRequirements(requirements), src)
|
|
38
|
+
await writeAtomically(join(outDir, 'queue.md'), renderQueueReport(items), src)
|
|
39
|
+
|
|
40
|
+
process.stdout.write(`report: wrote ledger.md, requirements.md, queue.md to ${outDir}\n`)
|
|
41
|
+
// A malformed queue item is a content failure on an otherwise well-formed
|
|
42
|
+
// request (the store exists and could be read), the same class `queue
|
|
43
|
+
// list` already reports as exit 1 for the identical condition -- not a
|
|
44
|
+
// usage error (2, this request was perfectly serviceable) and not success
|
|
45
|
+
// (0, the report is incomplete).
|
|
46
|
+
return errors.length > 0 ? 1 : 0
|
|
47
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Confidence,
|
|
3
|
+
Disposition,
|
|
4
|
+
Element,
|
|
5
|
+
Parity,
|
|
6
|
+
QueueItem,
|
|
7
|
+
Ref,
|
|
8
|
+
Requirement,
|
|
9
|
+
} from './types.ts'
|
|
10
|
+
|
|
11
|
+
const BANNER = '<!-- generated by `migrate report`; edit the store, not this file -->'
|
|
12
|
+
|
|
13
|
+
// A cell's content is free text an agent wrote while reading a legacy
|
|
14
|
+
// codebase, so it can contain anything. Two things actually break a GFM
|
|
15
|
+
// table's structure (change the column count a row parses to, or split one
|
|
16
|
+
// row into several): an unescaped '|', which reads as a new column
|
|
17
|
+
// boundary, and a line break, which ends the row outright. Both are
|
|
18
|
+
// neutralized here: '|' is escaped to '\|', and every line-break variant
|
|
19
|
+
// (bare '\n', '\r\n', and a lone '\r' from old Mac-authored text) is
|
|
20
|
+
// flattened to a space, not just '\n' alone, so a stray '\r' can't survive
|
|
21
|
+
// as an unflattened character in the middle of a row.
|
|
22
|
+
//
|
|
23
|
+
// A run of backticks is deliberately left alone. It cannot change the
|
|
24
|
+
// column count or split the row: GFM splits a table row into cells by its
|
|
25
|
+
// literal, unescaped '|' characters first, and only afterward parses each
|
|
26
|
+
// cell's inline content (code spans, emphasis, ...) independently, so an
|
|
27
|
+
// unbalanced backtick run is confined to the cell it's in and renders as
|
|
28
|
+
// literal backticks at worst. Wrapping such a cell in a matching fence was
|
|
29
|
+
// considered, but it would have to run after the '|' escaping above, and a
|
|
30
|
+
// backtick code span does not itself interpret backslash escapes, so an
|
|
31
|
+
// escaped pipe inside it would render as the literal two characters '\|'
|
|
32
|
+
// instead of a pipe: a cosmetic regression in exchange for guarding against
|
|
33
|
+
// a break that was never structural in the first place.
|
|
34
|
+
function cell(text: string): string {
|
|
35
|
+
return text.replaceAll('|', '\\|').replaceAll(/\r\n|\r|\n/g, ' ')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function refToText(ref: Ref): string {
|
|
39
|
+
if (ref.kind === 'src')
|
|
40
|
+
return ref.lines ? `src:${ref.path}:${ref.lines[0]}-${ref.lines[1]}` : `src:${ref.path}`
|
|
41
|
+
if (ref.kind === 'ledger') return ref.id
|
|
42
|
+
if (ref.kind === 'doc') return `doc:${ref.path}`
|
|
43
|
+
return `observed:${ref.host}${ref.path}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function dispositionToText(d: Disposition): string {
|
|
47
|
+
if (d.kind === 'mapped') return `mapped:${d.fr}`
|
|
48
|
+
if (d.kind === 'out-of-scope') return `out-of-scope:${d.queue}`
|
|
49
|
+
return 'unaccounted'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function confidenceToText(c: Confidence): string {
|
|
53
|
+
return c.kind === 'queued' ? `queued:${c.queue}` : c.kind
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function parityToText(p: Parity | null): string {
|
|
57
|
+
if (p === null) return '-'
|
|
58
|
+
if (p.kind === 'rubric')
|
|
59
|
+
return p.level === 'high' ? 'rubric:high' : `rubric:${p.level}:${p.queue}`
|
|
60
|
+
return `${p.kind}:${p.ref}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function renderLedger(elements: Element[]): string {
|
|
64
|
+
const lines = [BANNER, '', '# Surface ledger', '']
|
|
65
|
+
if (elements.length === 0) {
|
|
66
|
+
lines.push('No surface elements recorded yet.', '')
|
|
67
|
+
return lines.join('\n')
|
|
68
|
+
}
|
|
69
|
+
const bySurface = new Map<string, Element[]>()
|
|
70
|
+
for (const el of elements) {
|
|
71
|
+
const list = bySurface.get(el.surface) ?? []
|
|
72
|
+
list.push(el)
|
|
73
|
+
bySurface.set(el.surface, list)
|
|
74
|
+
}
|
|
75
|
+
for (const surface of [...bySurface.keys()].sort()) {
|
|
76
|
+
lines.push(`## ${surface}`, '')
|
|
77
|
+
lines.push('| id | element | found-by | disposition | refs | notes |')
|
|
78
|
+
lines.push('| --- | --- | --- | --- | --- | --- |')
|
|
79
|
+
for (const el of bySurface.get(surface) ?? []) {
|
|
80
|
+
lines.push(
|
|
81
|
+
`| ${cell(el.id)} | ${cell(el.element)} | ${el.found_by.join(',')} | ${dispositionToText(el.disposition)} | ${cell(el.refs.map(refToText).join(', '))} | ${cell(el.notes)} |`,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
lines.push('')
|
|
85
|
+
}
|
|
86
|
+
return lines.join('\n')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function renderRequirements(reqs: Requirement[]): string {
|
|
90
|
+
const lines = [BANNER, '', '# Functional requirements', '']
|
|
91
|
+
if (reqs.length === 0) {
|
|
92
|
+
lines.push('No requirements recorded yet.', '')
|
|
93
|
+
return lines.join('\n')
|
|
94
|
+
}
|
|
95
|
+
const byCap = new Map<string, Requirement[]>()
|
|
96
|
+
for (const req of reqs) {
|
|
97
|
+
const list = byCap.get(req.cap) ?? []
|
|
98
|
+
list.push(req)
|
|
99
|
+
byCap.set(req.cap, list)
|
|
100
|
+
}
|
|
101
|
+
for (const cap of [...byCap.keys()].sort()) {
|
|
102
|
+
lines.push(`## ${cap}`, '')
|
|
103
|
+
lines.push(
|
|
104
|
+
'| id | requirement | actors | objects | rules | confidence | origin | citations | parity |',
|
|
105
|
+
)
|
|
106
|
+
lines.push('| --- | --- | --- | --- | --- | --- | --- | --- | --- |')
|
|
107
|
+
for (const req of byCap.get(cap) ?? []) {
|
|
108
|
+
lines.push(
|
|
109
|
+
`| ${cell(req.id)} | ${cell(req.requirement)} | ${cell(req.actors)} | ${cell(req.objects)} | ${cell(req.rules)} | ${confidenceToText(req.confidence)} | ${req.origin} | ${cell(req.citations.map(refToText).join(', '))} | ${cell(parityToText(req.parity))} |`,
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
lines.push('')
|
|
113
|
+
}
|
|
114
|
+
return lines.join('\n')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function renderQueueReport(items: QueueItem[]): string {
|
|
118
|
+
const lines = [BANNER, '', '# Decision queue', '']
|
|
119
|
+
if (items.length === 0) {
|
|
120
|
+
lines.push('No queue items.', '')
|
|
121
|
+
return lines.join('\n')
|
|
122
|
+
}
|
|
123
|
+
lines.push('| id | severity | status | recommendation |')
|
|
124
|
+
lines.push('| --- | --- | --- | --- |')
|
|
125
|
+
for (const item of items) {
|
|
126
|
+
const first = item.recommendation.split('\n')[0] ?? ''
|
|
127
|
+
lines.push(`| ${cell(item.id)} | ${item.severity} | ${item.status} | ${cell(first)} |`)
|
|
128
|
+
}
|
|
129
|
+
lines.push('')
|
|
130
|
+
return lines.join('\n')
|
|
131
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { rm } from 'node:fs/promises'
|
|
2
|
+
import { loadConfig } from './config.ts'
|
|
3
|
+
import { LockError, withStoreLock } from './lock.ts'
|
|
4
|
+
import { type StorePaths, storePaths } from './paths.ts'
|
|
5
|
+
import { isPhase, loadPhases, type Phase, savePhases } from './phases.ts'
|
|
6
|
+
import { readRows, writeRows } from './store.ts'
|
|
7
|
+
import type { Census, Element, Requirement } from './types.ts'
|
|
8
|
+
|
|
9
|
+
export async function runReset(opts: {
|
|
10
|
+
root: string
|
|
11
|
+
phase: string
|
|
12
|
+
forceUnlock?: boolean
|
|
13
|
+
}): Promise<number> {
|
|
14
|
+
if (!isPhase(opts.phase)) {
|
|
15
|
+
process.stderr.write(`reset: unknown phase ${opts.phase}\n`)
|
|
16
|
+
return 2
|
|
17
|
+
}
|
|
18
|
+
const phase = opts.phase
|
|
19
|
+
const cfg = await loadConfig(opts.root)
|
|
20
|
+
const p = storePaths(opts.root)
|
|
21
|
+
const src = cfg.source.path
|
|
22
|
+
|
|
23
|
+
// reset performs the same read-modify-write over whole store files that
|
|
24
|
+
// import and census do -- it reads census.jsonl and elements.jsonl, filters
|
|
25
|
+
// or rewrites every row, and writes the file back, then does the same to
|
|
26
|
+
// phases.json -- and it was the one such command with no lock at all.
|
|
27
|
+
// Concurrently with an importer that is mid-critical-section, either
|
|
28
|
+
// rewrite can discard every row the other wrote, which is exactly the loss
|
|
29
|
+
// the lock exists to stop. Nothing inside this critical section takes the
|
|
30
|
+
// lock again: writeRows, readRows, loadPhases and savePhases are all
|
|
31
|
+
// lock-free, and savePhases is deliberately called below instead of
|
|
32
|
+
// setPhaseStatus, which is the one phases.ts helper that does take it.
|
|
33
|
+
// withStoreLock is not reentrant, so that distinction is load-bearing
|
|
34
|
+
// rather than incidental.
|
|
35
|
+
let cleared: string[]
|
|
36
|
+
try {
|
|
37
|
+
cleared = await withStoreLock(opts.root, () => clearPhase(opts.root, phase, p, src), {
|
|
38
|
+
cmd: 'reset',
|
|
39
|
+
...(opts.forceUnlock ? { force: true } : {}),
|
|
40
|
+
onWait: (m) => process.stderr.write(`reset: ${m}\n`),
|
|
41
|
+
})
|
|
42
|
+
} catch (e) {
|
|
43
|
+
// Same classification import, census and phase --status already use: a
|
|
44
|
+
// lock failure is neither an unknown phase (2) nor a content failure (1);
|
|
45
|
+
// the request is fine and would likely succeed on retry.
|
|
46
|
+
if (e instanceof LockError) {
|
|
47
|
+
process.stderr.write(`reset: ${e.message}\n`)
|
|
48
|
+
return 3
|
|
49
|
+
}
|
|
50
|
+
throw e
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const what = cleared.length > 0 ? `: cleared ${cleared.join(', ')}` : ' (state only)'
|
|
54
|
+
process.stdout.write(`reset ${phase}${what}\n`)
|
|
55
|
+
return 0
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The whole mutation, extracted so the critical section above is one call and
|
|
59
|
+
// the lock's extent is impossible to misread. Returns what it cleared, which
|
|
60
|
+
// is used only to build the stdout line, after the lock has been released.
|
|
61
|
+
async function clearPhase(
|
|
62
|
+
root: string,
|
|
63
|
+
phase: Phase,
|
|
64
|
+
p: StorePaths,
|
|
65
|
+
src: string,
|
|
66
|
+
): Promise<string[]> {
|
|
67
|
+
const cleared: string[] = []
|
|
68
|
+
|
|
69
|
+
if (phase === 'enumerate') {
|
|
70
|
+
await writeRows(p.elements, [], src)
|
|
71
|
+
const census = await readRows<Census>(p.census)
|
|
72
|
+
await writeRows(
|
|
73
|
+
p.census,
|
|
74
|
+
census.filter((c) => c.kind !== 'lens'),
|
|
75
|
+
src,
|
|
76
|
+
)
|
|
77
|
+
cleared.push('elements', 'lens census')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (phase === 'seam') {
|
|
81
|
+
await writeRows(p.capabilities, [], src)
|
|
82
|
+
await rm(p.seamJson, { force: true })
|
|
83
|
+
await rm(p.seamMd, { force: true })
|
|
84
|
+
cleared.push('capabilities', 'seam')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (phase === 'extract') {
|
|
88
|
+
await writeRows(p.requirements, [], src)
|
|
89
|
+
const census = await readRows<Census>(p.census)
|
|
90
|
+
await writeRows(
|
|
91
|
+
p.census,
|
|
92
|
+
census.filter((c) => c.kind === 'lens'),
|
|
93
|
+
src,
|
|
94
|
+
)
|
|
95
|
+
const elements = await readRows<Element>(p.elements)
|
|
96
|
+
await writeRows(
|
|
97
|
+
p.elements,
|
|
98
|
+
elements.map((e) => ({ ...e, disposition: { kind: 'unaccounted' as const } })),
|
|
99
|
+
src,
|
|
100
|
+
)
|
|
101
|
+
cleared.push('requirements', 'attribute/rule-sweep/closer census', 'element dispositions')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (phase === 'parity') {
|
|
105
|
+
await writeRows(p.deltas, [], src)
|
|
106
|
+
const reqs = await readRows<Requirement>(p.requirements)
|
|
107
|
+
await writeRows(
|
|
108
|
+
p.requirements,
|
|
109
|
+
reqs.map((r) => ({ ...r, parity: null })),
|
|
110
|
+
src,
|
|
111
|
+
)
|
|
112
|
+
cleared.push('deltas', 'parity plans')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const phases = await loadPhases(root)
|
|
116
|
+
phases[phase] = { status: 'pending', batches: [], pending: [] }
|
|
117
|
+
await savePhases(root, phases, src)
|
|
118
|
+
|
|
119
|
+
return cleared
|
|
120
|
+
}
|