@iceinvein/agent-skills 0.2.0 → 0.4.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 +2 -2
- package/dist/cli/index.js +14 -10
- package/package.json +1 -1
- package/skills/index.json +4 -4
- package/skills/migrate/README.md +35 -23
- package/skills/migrate/SKILL.md +75 -15
- package/skills/migrate/bin/migrate.ts +90 -0
- package/skills/migrate/docs/architecture.md +61 -26
- package/skills/migrate/docs/reference.md +53 -8
- package/skills/migrate/fixtures/fake-gh.ts +113 -0
- package/skills/migrate/fixtures/flow-target/docs/WORK.md +12 -0
- package/skills/migrate/fixtures/flow-target/docs/modernisation/capability-map/.gitkeep +0 -0
- package/skills/migrate/fixtures/flow-target/tools/flow/src/cli.ts +156 -0
- package/skills/migrate/package.json +1 -1
- package/skills/migrate/references/phases/adjudicate.md +161 -0
- package/skills/migrate/references/phases/handoff.md +220 -0
- package/skills/migrate/references/phases/probe.md +2 -2
- package/skills/migrate/references/phases/queue.md +21 -14
- package/skills/migrate/references/run-ops.md +17 -13
- package/skills/migrate/scripts/__tests__/adapter-flow.test.ts +290 -0
- package/skills/migrate/scripts/__tests__/adapter-github.test.ts +232 -0
- package/skills/migrate/scripts/__tests__/adapter-markdown.test.ts +183 -0
- package/skills/migrate/scripts/__tests__/adjudicate.test.ts +332 -0
- package/skills/migrate/scripts/__tests__/assumptions.test.ts +179 -0
- package/skills/migrate/scripts/__tests__/coverage.test.ts +192 -0
- package/skills/migrate/scripts/__tests__/e2e-express.test.ts +167 -7
- package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +9 -4
- package/skills/migrate/scripts/__tests__/forecast.test.ts +280 -0
- package/skills/migrate/scripts/__tests__/gates-handoff.test.ts +309 -0
- package/skills/migrate/scripts/__tests__/handoff-cmd.test.ts +308 -0
- package/skills/migrate/scripts/__tests__/handoff-order.test.ts +156 -0
- package/skills/migrate/scripts/adapters/flow.ts +280 -0
- package/skills/migrate/scripts/adapters/github.ts +260 -0
- package/skills/migrate/scripts/adapters/markdown.ts +175 -0
- package/skills/migrate/scripts/adjudicate-cmd.ts +243 -0
- package/skills/migrate/scripts/assumptions.ts +188 -0
- package/skills/migrate/scripts/check.ts +119 -320
- package/skills/migrate/scripts/coverage-cmd.ts +86 -0
- package/skills/migrate/scripts/coverage.ts +151 -0
- package/skills/migrate/scripts/dates.ts +17 -0
- package/skills/migrate/scripts/forecast-cmd.ts +124 -0
- package/skills/migrate/scripts/forecast.ts +264 -0
- package/skills/migrate/scripts/gates/adjudication.ts +30 -0
- package/skills/migrate/scripts/gates/census.ts +107 -0
- package/skills/migrate/scripts/gates/citations.ts +11 -0
- package/skills/migrate/scripts/gates/context.ts +76 -0
- package/skills/migrate/scripts/gates/coverage.ts +22 -0
- package/skills/migrate/scripts/gates/deltas.ts +15 -0
- package/skills/migrate/scripts/gates/handoff.ts +145 -0
- package/skills/migrate/scripts/gates/leaks.ts +11 -0
- package/skills/migrate/scripts/gates/parity.ts +15 -0
- package/skills/migrate/scripts/gates/queue.ts +9 -0
- package/skills/migrate/scripts/gates/refs.ts +97 -0
- package/skills/migrate/scripts/gates/run-state.ts +67 -0
- package/skills/migrate/scripts/gates/source.ts +28 -0
- package/skills/migrate/scripts/handoff-cmd.ts +186 -0
- package/skills/migrate/scripts/handoff.ts +330 -0
- package/skills/migrate/scripts/paths.ts +4 -0
- package/skills/migrate/scripts/types.ts +43 -0
- package/skills/migrate/scripts/validate.ts +12 -0
- package/skills/migrate/skill.json +2 -2
- package/skills/migrate/templates/forecast-assumptions.md +59 -0
- package/skills/sluice/SKILL.md +20 -7
- package/skills/sluice/references/deep-channel.md +20 -0
- package/skills/sluice/references/finish.md +4 -2
- package/skills/sluice/references/meter.md +38 -0
- package/skills/sluice/scripts/run-stats.sh +236 -0
- package/skills/sluice/skill.json +4 -3
|
@@ -1,35 +1,24 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { balanceOf, boundsOf, censusKey, validateCensus } from './census.ts'
|
|
3
|
-
import { resolveCitations } from './citations.ts'
|
|
1
|
+
import { validateCensus } from './census.ts'
|
|
4
2
|
import { loadConfig } from './config.ts'
|
|
5
|
-
import {
|
|
3
|
+
import { gate as adjudicationGate } from './gates/adjudication.ts'
|
|
4
|
+
import { gate as censusGate } from './gates/census.ts'
|
|
5
|
+
import { gate as citationsGate } from './gates/citations.ts'
|
|
6
|
+
import type { CensusRow, Gate, GateContext } from './gates/context.ts'
|
|
7
|
+
import { gate as coverageGate } from './gates/coverage.ts'
|
|
8
|
+
import { gate as deltasGate } from './gates/deltas.ts'
|
|
9
|
+
import { gate as handoffGate } from './gates/handoff.ts'
|
|
10
|
+
import { gate as leaksGate } from './gates/leaks.ts'
|
|
11
|
+
import { gate as parityGate } from './gates/parity.ts'
|
|
12
|
+
import { gate as queueGate } from './gates/queue.ts'
|
|
13
|
+
import { gate as refsGate } from './gates/refs.ts'
|
|
14
|
+
import { gate as runStateGate } from './gates/run-state.ts'
|
|
15
|
+
import { gate as sourceGate } from './gates/source.ts'
|
|
16
|
+
import { loadHandoff } from './handoff.ts'
|
|
6
17
|
import { storePaths } from './paths.ts'
|
|
7
18
|
import { loadPhases, PHASES, type Phase } from './phases.ts'
|
|
8
19
|
import { loadQueue } from './queue.ts'
|
|
9
20
|
import { readRawRows, readRows } from './store.ts'
|
|
10
|
-
import type { Capability,
|
|
11
|
-
import { isRecord } from './validate.ts'
|
|
12
|
-
|
|
13
|
-
// A hand-edited census.jsonl never passes through census-cmd.ts's
|
|
14
|
-
// validateCensus call, so this is the only label available for a row that
|
|
15
|
-
// fails the check below: its 1-based position in the file, plus (when kind
|
|
16
|
-
// and the field censusKey needs both parse as strings) the same key
|
|
17
|
-
// census-cmd.ts uses to identify a record, so the message points at
|
|
18
|
-
// something the reader can find rather than an opaque line number alone.
|
|
19
|
-
function censusRowLabel(raw: unknown, line: number): string {
|
|
20
|
-
const base = `census.jsonl line ${line}`
|
|
21
|
-
if (!isRecord(raw)) return base
|
|
22
|
-
const kind = raw.kind
|
|
23
|
-
if (kind === 'lens' && typeof raw.surface === 'string')
|
|
24
|
-
return `${base} (${censusKey(raw as Census)})`
|
|
25
|
-
if (kind === 'attribute' && typeof raw.subject === 'string')
|
|
26
|
-
return `${base} (${censusKey(raw as Census)})`
|
|
27
|
-
if (kind === 'rule-sweep' && typeof raw.subject === 'string')
|
|
28
|
-
return `${base} (${censusKey(raw as Census)})`
|
|
29
|
-
if (kind === 'closer' && typeof raw.closer === 'string')
|
|
30
|
-
return `${base} (${censusKey(raw as Census)})`
|
|
31
|
-
return base
|
|
32
|
-
}
|
|
21
|
+
import type { Capability, Delta, Element, Requirement, Violation } from './types.ts'
|
|
33
22
|
|
|
34
23
|
export type CheckResult = { summary: string; violations: Violation[] }
|
|
35
24
|
|
|
@@ -44,18 +33,56 @@ export const GATE_ORDER = [
|
|
|
44
33
|
'leaks',
|
|
45
34
|
'source',
|
|
46
35
|
'run-state',
|
|
36
|
+
'adjudication',
|
|
37
|
+
'handoff',
|
|
47
38
|
] as const
|
|
48
39
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
40
|
+
// A gate may declare the earliest phase at which it becomes applicable, and is
|
|
41
|
+
// skipped when the checked terminus has not reached it.
|
|
42
|
+
//
|
|
43
|
+
// Every other gate reads the whole store regardless of --phase, which is right
|
|
44
|
+
// for them: a coverage or census gap at phase 5 is a real failure whenever it
|
|
45
|
+
// is found. These two describe phases 6 and 7, so firing them unconditionally
|
|
46
|
+
// would make `migrate check --phase queue` red for an entire mid-run campaign,
|
|
47
|
+
// which is the exact failure the posture split exists to prevent. The rule is
|
|
48
|
+
// deliberately narrow: a gate is skipped only when it describes work the
|
|
49
|
+
// checked terminus has not reached.
|
|
50
|
+
const GATE_PHASE: Partial<Record<(typeof GATE_ORDER)[number], Phase>> = {
|
|
51
|
+
adjudication: 'adjudicate',
|
|
52
|
+
handoff: 'handoff',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const GATES: Record<(typeof GATE_ORDER)[number], Gate> = {
|
|
56
|
+
coverage: coverageGate,
|
|
57
|
+
census: censusGate,
|
|
58
|
+
refs: refsGate,
|
|
59
|
+
queue: queueGate,
|
|
60
|
+
deltas: deltasGate,
|
|
61
|
+
parity: parityGate,
|
|
62
|
+
citations: citationsGate,
|
|
63
|
+
leaks: leaksGate,
|
|
64
|
+
source: sourceGate,
|
|
65
|
+
'run-state': runStateGate,
|
|
66
|
+
adjudication: adjudicationGate,
|
|
67
|
+
handoff: handoffGate,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// census.jsonl is the one store file that cannot be assumed to have been
|
|
71
|
+
// written by census-cmd.ts, since nothing stops a hand edit, and readRows only
|
|
72
|
+
// asserts a type onto whatever JSON.parse returns rather than checking it. So
|
|
73
|
+
// every row goes through the same validateCensus every real write goes
|
|
74
|
+
// through, once, here. Two gates consume the result and neither re-derives it:
|
|
75
|
+
// gate 2 reports the shape failures and does the arithmetic, gate 10
|
|
76
|
+
// cross-checks the valid records' batch fields. Doing it in one place is also
|
|
77
|
+
// what keeps a gate from depending on another gate having run first. File
|
|
78
|
+
// order is preserved, because gate 2's messages follow it.
|
|
79
|
+
function validateRows(rows: { line: number; raw: unknown }[]): CensusRow[] {
|
|
80
|
+
return rows.map(({ line, raw }) => {
|
|
81
|
+
const result = validateCensus(raw)
|
|
82
|
+
return result.ok
|
|
83
|
+
? ({ ok: true, line, record: result.value } as const)
|
|
84
|
+
: ({ ok: false, line, raw, errors: result.errors } as const)
|
|
55
85
|
})
|
|
56
|
-
const out = await new Response(proc.stdout).text()
|
|
57
|
-
await proc.exited
|
|
58
|
-
return out.trim().length > 0
|
|
59
86
|
}
|
|
60
87
|
|
|
61
88
|
export async function runCheck(opts: {
|
|
@@ -73,302 +100,74 @@ export async function runCheck(opts: {
|
|
|
73
100
|
const requirements = await readRows<Requirement>(paths.requirements)
|
|
74
101
|
const capabilities = await readRows<Capability>(paths.capabilities)
|
|
75
102
|
const deltas = await readRows<Delta>(paths.deltas)
|
|
76
|
-
|
|
77
|
-
// gate cannot assume was ever written by census-cmd.ts, since nothing
|
|
78
|
-
// stops a hand edit, and readRows only asserts a type onto whatever
|
|
79
|
-
// JSON.parse returns rather than checking it. Gate 2 below runs the same
|
|
80
|
-
// validateCensus every real write goes through, so a shape that only
|
|
81
|
-
// TypeScript ever believed in gets caught here instead of quietly reaching
|
|
82
|
-
// balanceOf and boundsOf, both of which assume a well-formed record.
|
|
83
|
-
const censusRows = await readRawRows(paths.census)
|
|
103
|
+
const censusRows = validateRows(await readRawRows(paths.census))
|
|
84
104
|
const { items: queueItems, errors: queueErrors } = await loadQueue(paths.queueDir)
|
|
105
|
+
const phases = await loadPhases(opts.root)
|
|
85
106
|
|
|
86
|
-
const
|
|
107
|
+
const terminusIndex = opts.phase ? PHASES.indexOf(opts.phase) : PHASES.length - 1
|
|
108
|
+
// A gate is skipped only when NOTHING claims its phase was reached. Keyed on
|
|
109
|
+
// the furthest phase `phases.json` marks done as well as on the requested
|
|
110
|
+
// terminus, because a store whose own state file says it reached handoff,
|
|
111
|
+
// with nothing emitted, must not be able to hide that by being checked at an
|
|
112
|
+
// earlier terminus. This mirrors run-state's done-over-pending check, which
|
|
113
|
+
// deliberately looks across all eight phases regardless of terminus on the
|
|
114
|
+
// grounds that hand-edited state is worth naming whether or not the caller
|
|
115
|
+
// asked about it.
|
|
116
|
+
let furthestDone = -1
|
|
117
|
+
PHASES.forEach((phase, i) => {
|
|
118
|
+
if (phases[phase].status === 'done') furthestDone = i
|
|
119
|
+
})
|
|
120
|
+
const scopeIndex = Math.max(terminusIndex, furthestDone)
|
|
121
|
+
|
|
122
|
+
const ctx: GateContext = {
|
|
123
|
+
root: opts.root,
|
|
124
|
+
cfg,
|
|
125
|
+
paths,
|
|
126
|
+
elements,
|
|
127
|
+
requirements,
|
|
128
|
+
capabilities,
|
|
129
|
+
deltas,
|
|
130
|
+
censusRows,
|
|
131
|
+
handoff: undefined,
|
|
132
|
+
queueItems,
|
|
133
|
+
queueErrors,
|
|
134
|
+
phases,
|
|
135
|
+
terminusIndex,
|
|
136
|
+
citations: opts.citations !== false,
|
|
137
|
+
leaks: opts.leaks === true,
|
|
138
|
+
gitBin,
|
|
139
|
+
}
|
|
87
140
|
|
|
88
|
-
|
|
141
|
+
const violations: Violation[] = []
|
|
142
|
+
for (const name of GATE_ORDER) {
|
|
143
|
+
const from = GATE_PHASE[name]
|
|
144
|
+
if (from && PHASES.indexOf(from) > scopeIndex) continue
|
|
145
|
+
// Loaded here rather than with the rest of the store: the only consumer is
|
|
146
|
+
// the handoff gate, and reading it eagerly meant a corrupt handoff.json
|
|
147
|
+
// killed every bounded check, plus `status` and `report`, which call
|
|
148
|
+
// runCheck purely for the summary and never look at this file.
|
|
149
|
+
if (name === 'handoff' && ctx.handoff === undefined) ctx.handoff = await loadHandoff(opts.root)
|
|
150
|
+
violations.push(...(await GATES[name](ctx)))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Built here rather than inside the coverage gate: report-cmd.ts reads this
|
|
154
|
+
// out of CheckResult on a store that is expected to still have violations,
|
|
155
|
+
// so it is a property of the run rather than something a gate returns.
|
|
89
156
|
let mapped = 0
|
|
90
157
|
let outOfScope = 0
|
|
91
158
|
let unaccounted = 0
|
|
92
159
|
for (const el of elements) {
|
|
93
160
|
if (el.disposition.kind === 'mapped') mapped++
|
|
94
161
|
else if (el.disposition.kind === 'out-of-scope') outOfScope++
|
|
95
|
-
else
|
|
96
|
-
unaccounted++
|
|
97
|
-
violations.push({
|
|
98
|
-
gate: 'coverage',
|
|
99
|
-
message: `${el.id} (${el.surface}) is still unaccounted`,
|
|
100
|
-
})
|
|
101
|
-
}
|
|
162
|
+
else unaccounted++
|
|
102
163
|
}
|
|
103
164
|
const summary = `${mapped}/${elements.length} mapped, ${outOfScope} out-of-scope, ${unaccounted} unaccounted`
|
|
104
165
|
|
|
105
|
-
//
|
|
106
|
-
// not
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
// boundsOf, and the in_ledger + added reconciliation below: all three
|
|
111
|
-
// assume a well-formed record, so running them on one that already failed
|
|
112
|
-
// shape validation would add confusing noise on top of the real defect
|
|
113
|
-
// rather than a second independent fact.
|
|
114
|
-
const surfacesWithCensus = new Set<string>()
|
|
115
|
-
const closersWithCensus = new Set<string>()
|
|
116
|
-
const census: Census[] = []
|
|
117
|
-
for (const { line, raw } of censusRows) {
|
|
118
|
-
const result = validateCensus(raw)
|
|
119
|
-
if (!result.ok) {
|
|
120
|
-
const label = censusRowLabel(raw, line)
|
|
121
|
-
for (const error of result.errors) {
|
|
122
|
-
violations.push({ gate: 'census', message: `${label}: ${error}` })
|
|
123
|
-
}
|
|
124
|
-
// A row that fails validation still ran and still named a surface or
|
|
125
|
-
// closer it claims to cover; only its shape is defective, not its
|
|
126
|
-
// existence. Registered defensively here (guarded the same way
|
|
127
|
-
// censusRowLabel is, since the row is not a trustworthy Census) so
|
|
128
|
-
// gate 2 does not also claim that surface or closer has no census
|
|
129
|
-
// record at all, which is a different and wrong accusation: that
|
|
130
|
-
// message means the lens never ran or never closed, not that it ran
|
|
131
|
-
// and produced something malformed. A row whose kind or identity
|
|
132
|
-
// field does not even parse as a string cannot make this claim, so it
|
|
133
|
-
// falls through to the genuinely-missing check below unregistered.
|
|
134
|
-
if (isRecord(raw)) {
|
|
135
|
-
if (raw.kind === 'lens' && typeof raw.surface === 'string') {
|
|
136
|
-
surfacesWithCensus.add(raw.surface)
|
|
137
|
-
} else if (raw.kind === 'closer' && typeof raw.closer === 'string') {
|
|
138
|
-
closersWithCensus.add(raw.closer)
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
continue
|
|
142
|
-
}
|
|
143
|
-
const record = result.value
|
|
144
|
-
census.push(record)
|
|
145
|
-
const imbalance = balanceOf(record)
|
|
146
|
-
if (imbalance) violations.push({ gate: 'census', message: imbalance })
|
|
147
|
-
const outOfBounds = boundsOf(record)
|
|
148
|
-
if (outOfBounds) violations.push({ gate: 'census', message: outOfBounds })
|
|
149
|
-
if (record.kind === 'lens') {
|
|
150
|
-
surfacesWithCensus.add(record.surface)
|
|
151
|
-
// balanceOf only checks that the record's own numbers add up
|
|
152
|
-
// internally; nothing before this ties in_ledger + added to anything
|
|
153
|
-
// outside the record itself, so a lens census can balance perfectly
|
|
154
|
-
// while claiming a headcount elements.jsonl never received (total is
|
|
155
|
-
// self-reported and cannot be checked against anything, but in_ledger
|
|
156
|
-
// + added claims a specific number of rows now exist in the ledger
|
|
157
|
-
// for this surface, and that claim is directly countable).
|
|
158
|
-
const claimed = record.in_ledger + record.added
|
|
159
|
-
const actual = elements.filter((e) => e.surface === record.surface).length
|
|
160
|
-
if (actual !== claimed) {
|
|
161
|
-
violations.push({
|
|
162
|
-
gate: 'census',
|
|
163
|
-
message: `lens census for ${record.surface} claims in_ledger ${record.in_ledger} + added ${record.added} = ${claimed} element(s) in the ledger, but elements.jsonl has ${actual}`,
|
|
164
|
-
})
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
if (record.kind === 'closer') closersWithCensus.add(record.closer)
|
|
168
|
-
}
|
|
169
|
-
for (const surface of cfg.surfaces) {
|
|
170
|
-
if (!surfacesWithCensus.has(surface)) {
|
|
171
|
-
violations.push({
|
|
172
|
-
gate: 'census',
|
|
173
|
-
message: `declared surface ${surface} has no lens census record; the lens did not run or did not close`,
|
|
174
|
-
})
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
for (const closer of cfg.closers) {
|
|
178
|
-
if (!closersWithCensus.has(closer)) {
|
|
179
|
-
violations.push({
|
|
180
|
-
gate: 'census',
|
|
181
|
-
message: `declared closer ${closer} has no census record`,
|
|
182
|
-
})
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Gate 3: referential integrity.
|
|
187
|
-
const reqIds = new Set(requirements.map((r) => r.id))
|
|
188
|
-
const elementIds = new Set(elements.map((e) => e.id))
|
|
189
|
-
const capSlugs = new Set(capabilities.map((c) => c.slug))
|
|
190
|
-
const queueIds = new Set(queueItems.map((q) => q.id))
|
|
191
|
-
|
|
192
|
-
// A duplicate id or slug within one store file is a real defect the refs
|
|
193
|
-
// gate must catch on its own, not something it can assume another gate or
|
|
194
|
-
// command already ruled out: `import reqs` upserts by id, but
|
|
195
|
-
// capabilities.jsonl has no import path at all today, so hand-editing is
|
|
196
|
-
// currently the only way a row lands there, and nothing stops two rows
|
|
197
|
-
// from hand-editing into the same identity with different content. A
|
|
198
|
-
// gate whose soundness depends on another gate having run first is not
|
|
199
|
-
// independently a gate (see Task 10's inverted citation range for the
|
|
200
|
-
// same reasoning). Counted off the raw row arrays, not the `Set`s above:
|
|
201
|
-
// a `Set` collapses duplicates by construction, which is exactly the
|
|
202
|
-
// evidence (which id, how many rows) this check exists to preserve.
|
|
203
|
-
function duplicatesOf(values: string[]): Map<string, number> {
|
|
204
|
-
const counts = new Map<string, number>()
|
|
205
|
-
for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1)
|
|
206
|
-
const dups = new Map<string, number>()
|
|
207
|
-
for (const [v, count] of counts) {
|
|
208
|
-
if (count > 1) dups.set(v, count)
|
|
209
|
-
}
|
|
210
|
-
return dups
|
|
211
|
-
}
|
|
212
|
-
for (const [id, count] of duplicatesOf(requirements.map((r) => r.id))) {
|
|
213
|
-
violations.push({
|
|
214
|
-
gate: 'refs',
|
|
215
|
-
message: `requirement id ${id} appears ${count} times in requirements.jsonl`,
|
|
216
|
-
})
|
|
217
|
-
}
|
|
218
|
-
for (const [slug, count] of duplicatesOf(capabilities.map((c) => c.slug))) {
|
|
219
|
-
violations.push({
|
|
220
|
-
gate: 'refs',
|
|
221
|
-
message: `capability slug ${slug} appears ${count} times in capabilities.jsonl`,
|
|
222
|
-
})
|
|
223
|
-
}
|
|
224
|
-
for (const [id, count] of duplicatesOf(elements.map((e) => e.id))) {
|
|
225
|
-
violations.push({
|
|
226
|
-
gate: 'refs',
|
|
227
|
-
message: `element id ${id} appears ${count} times in elements.jsonl`,
|
|
228
|
-
})
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// `field` names where the queue reference came from (`disposition.queue`,
|
|
232
|
-
// `confidence.queue`, `parity.queue`) so that one requirement citing the
|
|
233
|
-
// same missing queue id from two different fields produces two
|
|
234
|
-
// violations that read as two distinct citations to fix, not one
|
|
235
|
-
// ambiguous duplicate-looking line.
|
|
236
|
-
const needQueue = (id: string, owner: string, field: string): void => {
|
|
237
|
-
if (!queueIds.has(id)) {
|
|
238
|
-
violations.push({
|
|
239
|
-
gate: 'refs',
|
|
240
|
-
message: `${owner} references queue item ${id} via ${field}, which does not exist`,
|
|
241
|
-
})
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
for (const el of elements) {
|
|
245
|
-
if (el.disposition.kind === 'mapped' && !reqIds.has(el.disposition.fr)) {
|
|
246
|
-
violations.push({
|
|
247
|
-
gate: 'refs',
|
|
248
|
-
message: `${el.id} is mapped to ${el.disposition.fr}, which is not in the registry`,
|
|
249
|
-
})
|
|
250
|
-
}
|
|
251
|
-
if (el.disposition.kind === 'out-of-scope') {
|
|
252
|
-
needQueue(el.disposition.queue, el.id, 'disposition.queue')
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
for (const req of requirements) {
|
|
256
|
-
if (!capSlugs.has(req.cap)) {
|
|
257
|
-
violations.push({
|
|
258
|
-
gate: 'refs',
|
|
259
|
-
message: `${req.id} names capability ${req.cap}, which is not in the partition`,
|
|
260
|
-
})
|
|
261
|
-
}
|
|
262
|
-
if (req.confidence.kind === 'queued')
|
|
263
|
-
needQueue(req.confidence.queue, req.id, 'confidence.queue')
|
|
264
|
-
if (req.parity?.kind === 'rubric' && req.parity.level !== 'high') {
|
|
265
|
-
needQueue(req.parity.queue, req.id, 'parity.queue')
|
|
266
|
-
}
|
|
267
|
-
for (const ref of req.citations) {
|
|
268
|
-
if (ref.kind === 'ledger' && !elementIds.has(ref.id)) {
|
|
269
|
-
violations.push({
|
|
270
|
-
gate: 'refs',
|
|
271
|
-
message: `${req.id} cites ledger id ${ref.id}, which is not in the ledger`,
|
|
272
|
-
})
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Gate 4: queue grammar.
|
|
278
|
-
for (const message of queueErrors) violations.push({ gate: 'queue', message })
|
|
279
|
-
|
|
280
|
-
// Gate 5: deltas.
|
|
281
|
-
for (const delta of deltas) {
|
|
282
|
-
if (!delta.owner_signed) {
|
|
283
|
-
violations.push({ gate: 'deltas', message: `${delta.id} is not owner-signed` })
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// Gate 6: parity coverage.
|
|
288
|
-
for (const req of requirements) {
|
|
289
|
-
if (req.confidence.kind !== 'queued' && req.parity === null) {
|
|
290
|
-
violations.push({ gate: 'parity', message: `${req.id} has no parity plan` })
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Gate 7: citations. Citations are on unless explicitly disabled. An FR
|
|
295
|
-
// citing a path that does not exist is the never-fabricate rule's only
|
|
296
|
-
// mechanical expression, so it should not be something a run has to
|
|
297
|
-
// remember to ask for.
|
|
298
|
-
if (opts.citations !== false) {
|
|
299
|
-
violations.push(...(await resolveCitations(requirements, cfg.source.path)))
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// Gate 8: leaks, opt-in.
|
|
303
|
-
if (opts.leaks) {
|
|
304
|
-
violations.push(...(await scanLeaks({ root: opts.root, gitBin })))
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// Gate 9: source integrity.
|
|
308
|
-
if (await sourceIsDirty(cfg.source.path, gitBin)) {
|
|
309
|
-
violations.push({
|
|
310
|
-
gate: 'source',
|
|
311
|
-
message: `the source checkout at ${cfg.source.path} has uncommitted changes; it must stay read-only`,
|
|
312
|
-
})
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// Gate 10: run-state. Every other gate proves the store is internally
|
|
316
|
-
// consistent, which an empty store satisfies. This one asks whether the run
|
|
317
|
-
// that was supposed to fill it actually happened, which is the only reason
|
|
318
|
-
// exit 0 can mean "complete" rather than "nothing contradicts anything".
|
|
319
|
-
const phases = await loadPhases(opts.root)
|
|
320
|
-
const terminus = opts.phase ? PHASES.indexOf(opts.phase) : PHASES.length - 1
|
|
321
|
-
const terminusName = PHASES[terminus]
|
|
322
|
-
for (let i = 0; i <= terminus; i++) {
|
|
323
|
-
const p = PHASES[i]
|
|
324
|
-
if (!p) continue
|
|
325
|
-
const status = phases[p].status
|
|
326
|
-
if (status !== 'done') {
|
|
327
|
-
violations.push({
|
|
328
|
-
gate: 'run-state',
|
|
329
|
-
message: `phase ${p} is ${status}; every phase through ${terminusName} must be done`,
|
|
330
|
-
})
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
// Checked across all eight phases, not just up to the terminus: a later
|
|
334
|
-
// phase marked done over a pending predecessor is hand-edited state, and it
|
|
335
|
-
// is worth naming whether or not the caller asked about that phase.
|
|
336
|
-
for (let i = 1; i < PHASES.length; i++) {
|
|
337
|
-
const current = PHASES[i]
|
|
338
|
-
const previous = PHASES[i - 1]
|
|
339
|
-
if (!current || !previous) continue
|
|
340
|
-
if (phases[current].status === 'done' && phases[previous].status === 'pending') {
|
|
341
|
-
violations.push({
|
|
342
|
-
gate: 'run-state',
|
|
343
|
-
message: `phase ${current} is done while ${previous} is still pending`,
|
|
344
|
-
})
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
// A census record naming a batch phases.json never committed means the two
|
|
348
|
-
// disagree about what happened. Only checked when the record exists, since
|
|
349
|
-
// gate 2 already names a declared surface or closer that has none.
|
|
350
|
-
const committedIn = (phase: Phase): Set<string> => new Set(phases[phase].batches.map((b) => b.id))
|
|
351
|
-
const enumerateBatches = committedIn('enumerate')
|
|
352
|
-
for (const surface of cfg.surfaces) {
|
|
353
|
-
const record = census.find((r) => r.kind === 'lens' && r.surface === surface)
|
|
354
|
-
if (record && !enumerateBatches.has(record.batch)) {
|
|
355
|
-
violations.push({
|
|
356
|
-
gate: 'run-state',
|
|
357
|
-
message: `lens census for ${surface} names batch ${record.batch}, which phases.json has no record of committing in enumerate`,
|
|
358
|
-
})
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
const extractBatches = committedIn('extract')
|
|
362
|
-
for (const closer of cfg.closers) {
|
|
363
|
-
const record = census.find((r) => r.kind === 'closer' && r.closer === closer)
|
|
364
|
-
if (record && !extractBatches.has(record.batch)) {
|
|
365
|
-
violations.push({
|
|
366
|
-
gate: 'run-state',
|
|
367
|
-
message: `closer census for ${closer} names batch ${record.batch}, which phases.json has no record of committing in extract`,
|
|
368
|
-
})
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
166
|
+
// The gates already run in GATE_ORDER, so this sort is only load-bearing for
|
|
167
|
+
// violations a gate did not label with its own name. Kept because the gate
|
|
168
|
+
// field is what check-cmd.ts groups its output by, and a mislabelled
|
|
169
|
+
// violation should still land in a predictable place rather than wherever
|
|
170
|
+
// its producing gate happened to sit.
|
|
372
171
|
const order = new Map(GATE_ORDER.map((g, i) => [g as string, i]))
|
|
373
172
|
violations.sort((a, b) => (order.get(a.gate) ?? 99) - (order.get(b.gate) ?? 99))
|
|
374
173
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { flow } from './adapters/flow.ts'
|
|
2
|
+
import { github } from './adapters/github.ts'
|
|
3
|
+
import { markdown } from './adapters/markdown.ts'
|
|
4
|
+
import { loadConfig } from './config.ts'
|
|
5
|
+
import { computeCoverage, renderCoverage } from './coverage.ts'
|
|
6
|
+
import { type Adapter, type HandoffInput, loadHandoff } from './handoff.ts'
|
|
7
|
+
import { storePaths } from './paths.ts'
|
|
8
|
+
import { readRows } from './store.ts'
|
|
9
|
+
import type { Capability, Delta, Requirement } from './types.ts'
|
|
10
|
+
|
|
11
|
+
const ADAPTERS: Record<string, Adapter> = { markdown, github, flow }
|
|
12
|
+
|
|
13
|
+
export async function runCoverage(opts: {
|
|
14
|
+
root: string
|
|
15
|
+
adapter?: string
|
|
16
|
+
gitBin?: string
|
|
17
|
+
ghBin?: string
|
|
18
|
+
}): Promise<number> {
|
|
19
|
+
const cfg = await loadConfig(opts.root)
|
|
20
|
+
const name = opts.adapter ?? cfg.handoff.adapter
|
|
21
|
+
const adapter = ADAPTERS[name]
|
|
22
|
+
if (!adapter) {
|
|
23
|
+
process.stderr.write(
|
|
24
|
+
`coverage: unknown adapter ${name}; want one of ${Object.keys(ADAPTERS).sort().join(', ')}\n`,
|
|
25
|
+
)
|
|
26
|
+
return 2
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const loaded = await loadHandoff(opts.root)
|
|
30
|
+
if (loaded.kind === 'invalid') {
|
|
31
|
+
for (const e of loaded.errors) process.stderr.write(`coverage: handoff.json ${e}\n`)
|
|
32
|
+
return 1
|
|
33
|
+
}
|
|
34
|
+
const handoff = loaded.kind === 'ok' ? loaded.value : null
|
|
35
|
+
if (!handoff) {
|
|
36
|
+
// Not zero built. There is no denominator at all, because nothing has been
|
|
37
|
+
// emitted, and reporting 0/0 would read as a measurement.
|
|
38
|
+
process.stderr.write(
|
|
39
|
+
'coverage: no handoff.json in the store; run `migrate handoff` before reading progress back\n',
|
|
40
|
+
)
|
|
41
|
+
return 1
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!adapter.throughput) {
|
|
45
|
+
// Named rather than reported as zero built: "this adapter cannot tell you"
|
|
46
|
+
// and "nothing has been delivered" are very different claims.
|
|
47
|
+
process.stderr.write(
|
|
48
|
+
`coverage: adapter ${name} reports no throughput, so built-versus-total cannot be read back through it\n`,
|
|
49
|
+
)
|
|
50
|
+
return 1
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const paths = storePaths(opts.root)
|
|
54
|
+
const input: HandoffInput = {
|
|
55
|
+
requirements: await readRows<Requirement>(paths.requirements),
|
|
56
|
+
capabilities: await readRows<Capability>(paths.capabilities),
|
|
57
|
+
deltas: await readRows<Delta>(paths.deltas),
|
|
58
|
+
config: cfg,
|
|
59
|
+
root: opts.root,
|
|
60
|
+
gitBin: opts.gitBin ?? 'git',
|
|
61
|
+
ghBin: opts.ghBin ?? 'gh',
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let throughput: Awaited<ReturnType<NonNullable<Adapter['throughput']>>>
|
|
65
|
+
try {
|
|
66
|
+
throughput = await adapter.throughput(input)
|
|
67
|
+
} catch (e) {
|
|
68
|
+
process.stderr.write(`coverage: ${(e as Error).message}\n`)
|
|
69
|
+
return 1
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const report = computeCoverage({ requirements: input.requirements, handoff, throughput })
|
|
73
|
+
process.stdout.write(`${renderCoverage(report)}\n`)
|
|
74
|
+
|
|
75
|
+
if (report.unknown.length > 0) {
|
|
76
|
+
// The emitted work and the store have diverged: something out there is
|
|
77
|
+
// reporting progress on a requirement this store has never heard of.
|
|
78
|
+
for (const fr of report.unknown) {
|
|
79
|
+
process.stderr.write(
|
|
80
|
+
`coverage: ${fr} was reported complete but is not in the registry; the emitted work and the store have diverged\n`,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
return 1
|
|
84
|
+
}
|
|
85
|
+
return 0
|
|
86
|
+
}
|