@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,376 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { balanceOf, boundsOf, censusKey, validateCensus } from './census.ts'
|
|
3
|
+
import { resolveCitations } from './citations.ts'
|
|
4
|
+
import { loadConfig } from './config.ts'
|
|
5
|
+
import { scanLeaks } from './leaks.ts'
|
|
6
|
+
import { storePaths } from './paths.ts'
|
|
7
|
+
import { loadPhases, PHASES, type Phase } from './phases.ts'
|
|
8
|
+
import { loadQueue } from './queue.ts'
|
|
9
|
+
import { readRawRows, readRows } from './store.ts'
|
|
10
|
+
import type { Capability, Census, Delta, Element, Requirement, Violation } from './types.ts'
|
|
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
|
+
}
|
|
33
|
+
|
|
34
|
+
export type CheckResult = { summary: string; violations: Violation[] }
|
|
35
|
+
|
|
36
|
+
export const GATE_ORDER = [
|
|
37
|
+
'coverage',
|
|
38
|
+
'census',
|
|
39
|
+
'refs',
|
|
40
|
+
'queue',
|
|
41
|
+
'deltas',
|
|
42
|
+
'parity',
|
|
43
|
+
'citations',
|
|
44
|
+
'leaks',
|
|
45
|
+
'source',
|
|
46
|
+
'run-state',
|
|
47
|
+
] as const
|
|
48
|
+
|
|
49
|
+
async function sourceIsDirty(sourcePath: string, gitBin: string): Promise<boolean> {
|
|
50
|
+
if (!existsSync(`${sourcePath}/.git`)) return false
|
|
51
|
+
const proc = Bun.spawn([gitBin, 'status', '--porcelain'], {
|
|
52
|
+
cwd: sourcePath,
|
|
53
|
+
stdout: 'pipe',
|
|
54
|
+
stderr: 'ignore',
|
|
55
|
+
})
|
|
56
|
+
const out = await new Response(proc.stdout).text()
|
|
57
|
+
await proc.exited
|
|
58
|
+
return out.trim().length > 0
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function runCheck(opts: {
|
|
62
|
+
root: string
|
|
63
|
+
citations?: boolean
|
|
64
|
+
leaks?: boolean
|
|
65
|
+
gitBin?: string
|
|
66
|
+
phase?: Phase
|
|
67
|
+
}): Promise<CheckResult> {
|
|
68
|
+
const cfg = await loadConfig(opts.root)
|
|
69
|
+
const paths = storePaths(opts.root)
|
|
70
|
+
const gitBin = opts.gitBin ?? 'git'
|
|
71
|
+
|
|
72
|
+
const elements = await readRows<Element>(paths.elements)
|
|
73
|
+
const requirements = await readRows<Requirement>(paths.requirements)
|
|
74
|
+
const capabilities = await readRows<Capability>(paths.capabilities)
|
|
75
|
+
const deltas = await readRows<Delta>(paths.deltas)
|
|
76
|
+
// Read raw, not readRows<Census>: census.jsonl is the one store file this
|
|
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)
|
|
84
|
+
const { items: queueItems, errors: queueErrors } = await loadQueue(paths.queueDir)
|
|
85
|
+
|
|
86
|
+
const violations: Violation[] = []
|
|
87
|
+
|
|
88
|
+
// Gate 1: coverage.
|
|
89
|
+
let mapped = 0
|
|
90
|
+
let outOfScope = 0
|
|
91
|
+
let unaccounted = 0
|
|
92
|
+
for (const el of elements) {
|
|
93
|
+
if (el.disposition.kind === 'mapped') mapped++
|
|
94
|
+
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
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const summary = `${mapped}/${elements.length} mapped, ${outOfScope} out-of-scope, ${unaccounted} unaccounted`
|
|
104
|
+
|
|
105
|
+
// Gate 2: census shape, balance and presence. Each row is validated here,
|
|
106
|
+
// not merely read, because census.jsonl is a store file that census-cmd.ts
|
|
107
|
+
// does not own exclusively: a hand edit reaches this gate without ever
|
|
108
|
+
// passing through validateCensus first. A row that fails is named by line
|
|
109
|
+
// (and by censusKey when its identity parses) and excluded from balanceOf,
|
|
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
|
+
|
|
372
|
+
const order = new Map(GATE_ORDER.map((g, i) => [g as string, i]))
|
|
373
|
+
violations.sort((a, b) => (order.get(a.gate) ?? 99) - (order.get(b.gate) ?? 99))
|
|
374
|
+
|
|
375
|
+
return { summary, violations }
|
|
376
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { readFile, realpath } from 'node:fs/promises'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
import { isContained } from './paths.ts'
|
|
4
|
+
import type { Requirement, Violation } from './types.ts'
|
|
5
|
+
|
|
6
|
+
async function lineCount(path: string, cache: Map<string, number>): Promise<number> {
|
|
7
|
+
const cached = cache.get(path)
|
|
8
|
+
if (cached !== undefined) return cached
|
|
9
|
+
const text = await readFile(path, 'utf8')
|
|
10
|
+
// A trailing newline does not add a line: "a\nb\n" is two lines.
|
|
11
|
+
const count = text.length === 0 ? 0 : text.replace(/\n$/, '').split('\n').length
|
|
12
|
+
cache.set(path, count)
|
|
13
|
+
return count
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function resolveCitations(
|
|
17
|
+
reqs: Requirement[],
|
|
18
|
+
sourcePath: string,
|
|
19
|
+
): Promise<Violation[]> {
|
|
20
|
+
const violations: Violation[] = []
|
|
21
|
+
const cache = new Map<string, number>()
|
|
22
|
+
const sourceRoot = resolve(sourcePath)
|
|
23
|
+
let realSourceRoot: string
|
|
24
|
+
try {
|
|
25
|
+
realSourceRoot = await realpath(sourceRoot)
|
|
26
|
+
} catch {
|
|
27
|
+
// If sourceRoot itself cannot be resolved, fail cleanly
|
|
28
|
+
return [{ gate: 'citations', message: 'source path does not exist' }]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const req of reqs) {
|
|
32
|
+
for (const ref of req.citations) {
|
|
33
|
+
if (ref.kind !== 'src') continue
|
|
34
|
+
|
|
35
|
+
// Reject absolute paths
|
|
36
|
+
if (ref.path.startsWith('/') || /^[a-z]:/i.test(ref.path)) {
|
|
37
|
+
violations.push({
|
|
38
|
+
gate: 'citations',
|
|
39
|
+
message: `${req.id} cites ${ref.path}, which is an absolute path; citations must use relative paths`,
|
|
40
|
+
})
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const abs = resolve(join(sourceRoot, ref.path))
|
|
45
|
+
if (!isContained(abs, sourceRoot)) {
|
|
46
|
+
violations.push({
|
|
47
|
+
gate: 'citations',
|
|
48
|
+
message: `${req.id} cites ${ref.path}, which is outside the source tree`,
|
|
49
|
+
})
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Resolve real path (following symlinks, detecting broken ones)
|
|
54
|
+
let realPath: string
|
|
55
|
+
try {
|
|
56
|
+
realPath = await realpath(abs)
|
|
57
|
+
} catch {
|
|
58
|
+
violations.push({
|
|
59
|
+
gate: 'citations',
|
|
60
|
+
message: `${req.id} cites ${ref.path}, which does not exist in the source tree`,
|
|
61
|
+
})
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check containment of the real path against the real sourceRoot
|
|
66
|
+
if (!isContained(realPath, realSourceRoot)) {
|
|
67
|
+
violations.push({
|
|
68
|
+
gate: 'citations',
|
|
69
|
+
message: `${req.id} cites ${ref.path}, which is outside the source tree`,
|
|
70
|
+
})
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (ref.lines) {
|
|
75
|
+
const total = await lineCount(realPath, cache)
|
|
76
|
+
const [start, end] = ref.lines
|
|
77
|
+
if (start > end) {
|
|
78
|
+
violations.push({
|
|
79
|
+
gate: 'citations',
|
|
80
|
+
message: `${req.id} cites ${ref.path}:${start}-${end} but the range is inverted`,
|
|
81
|
+
})
|
|
82
|
+
} else if (start < 1 || end > total) {
|
|
83
|
+
violations.push({
|
|
84
|
+
gate: 'citations',
|
|
85
|
+
message: `${req.id} cites ${ref.path}:${start}-${end} but the file has ${total} line(s)`,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return violations
|
|
92
|
+
}
|