@agentskit/doc-bridge 1.6.2 → 1.6.4
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/CHANGELOG.md +12 -0
- package/action.yml +1 -1
- package/bin/ak-verify.js +9 -0
- package/dist/cli/program.js +303 -55
- package/dist/cli/program.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +6 -0
- package/dist/config/index.js.map +1 -1
- package/dist/{index-Di7PkJuf.d.ts → index-DudNuwI5.d.ts} +24 -1
- package/dist/index.d.ts +24 -5
- package/dist/index.js +249 -43
- package/dist/index.js.map +1 -1
- package/docs/knowledge-engine-runbook.md +14 -0
- package/docs/verification-harness.md +36 -0
- package/mcpb/manifest.json +1 -1
- package/package.json +52 -44
- package/scripts/report-visual-check.mjs +250 -0
- package/scripts/verification-harness.mjs +280 -0
- package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
- package/src/cli/program.ts +64 -7
- package/src/config/index.ts +2 -0
- package/src/config/schema.ts +9 -0
- package/src/index.ts +8 -1
- package/src/lib/markdown.ts +5 -0
- package/src/reconciliation/reconcile.ts +9 -1
- package/src/report/html.ts +292 -40
- package/src/schemas/knowledge.ts +1 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execFileSync, spawn } from 'node:child_process'
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
import {
|
|
6
|
+
appendFileSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
renameSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
statSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from 'node:fs'
|
|
16
|
+
import { dirname, join, relative, resolve, sep } from 'node:path'
|
|
17
|
+
|
|
18
|
+
const VERSION = '1.1.0'
|
|
19
|
+
const STATES = new Set(['PLANNED', 'VERIFYING', 'AWAITING_HUMAN_APPROVAL', 'AWAITING_AUTHORIZATION', 'COMPLETE', 'BLOCKED', 'FAILED'])
|
|
20
|
+
const PROFILES = new Set(['strict', 'poc', 'custom'])
|
|
21
|
+
const CATEGORIES = new Set(['build', 'test', 'lint', 'logic', 'endpoint', 'database', 'cli', 'mcp', 'ui', 'docs', 'custom'])
|
|
22
|
+
const INTENTS = new Set(['ok', 'certo', 'certa', 'aprovado', 'aprovada', 'approve', 'approved', 'confirmo', 'confirmado', 'confirmed', 'yes'])
|
|
23
|
+
const DEFAULT_CONFIG = '.codex/verification.json'
|
|
24
|
+
|
|
25
|
+
const fail = (message) => { throw new Error(message) }
|
|
26
|
+
const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
|
27
|
+
const now = () => new Date().toISOString()
|
|
28
|
+
const readJson = (path) => JSON.parse(readFileSync(path, 'utf8'))
|
|
29
|
+
const writeAtomic = (path, value) => {
|
|
30
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
31
|
+
const temp = `${path}.tmp-${process.pid}`
|
|
32
|
+
writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
|
33
|
+
renameSync(temp, path)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const parseArgs = (argv) => {
|
|
37
|
+
const flags = new Set()
|
|
38
|
+
const values = new Map()
|
|
39
|
+
const positional = []
|
|
40
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
41
|
+
const arg = argv[i]
|
|
42
|
+
if (arg === '--config' || arg === '--run-id' || arg === '--by') {
|
|
43
|
+
const value = argv[++i]
|
|
44
|
+
if (!value) fail(`${arg} requires a value.`)
|
|
45
|
+
values.set(arg.slice(2), value)
|
|
46
|
+
} else if (arg?.startsWith('--')) flags.add(arg.slice(2))
|
|
47
|
+
else if (arg) positional.push(arg)
|
|
48
|
+
}
|
|
49
|
+
return { flags, values, positional }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const projectRoot = (configPath, raw) => resolve(dirname(resolve(configPath)), raw.root ?? '.')
|
|
53
|
+
const inside = (root, path) => {
|
|
54
|
+
const rel = relative(root, path)
|
|
55
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !rel.startsWith(sep))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const surfaceRequired = (value, name) => {
|
|
59
|
+
if (typeof value === 'boolean') return { required: value, reason: value ? undefined : `${name} is not part of this verification target.` }
|
|
60
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) fail(`surfaces.${name} must be a boolean or { required, reason }.`)
|
|
61
|
+
if (typeof value.required !== 'boolean') fail(`surfaces.${name}.required must be boolean.`)
|
|
62
|
+
if (!value.required && typeof value.reason !== 'string') fail(`surfaces.${name}.reason is required when the surface is not required.`)
|
|
63
|
+
return { required: value.required, ...(value.reason ? { reason: value.reason } : {}) }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const validateConfig = (raw, configPath) => {
|
|
67
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) fail(`Invalid verification config: ${configPath}`)
|
|
68
|
+
if (raw.schemaVersion !== 1) fail('verification config schemaVersion must be 1.')
|
|
69
|
+
if (typeof raw.project !== 'string' || !raw.project) fail('verification config project is required.')
|
|
70
|
+
if (raw.root !== undefined && typeof raw.root !== 'string') fail('verification config root must be a string.')
|
|
71
|
+
if (!PROFILES.has(raw.profile)) fail(`verification config profile must be one of: ${[...PROFILES].join(', ')}.`)
|
|
72
|
+
if (!Array.isArray(raw.checks) || raw.checks.length === 0) fail('verification config requires at least one check.')
|
|
73
|
+
const checks = raw.checks.map((check, index) => {
|
|
74
|
+
if (!check || typeof check !== 'object' || Array.isArray(check)) fail(`checks[${index}] must be an object.`)
|
|
75
|
+
if (typeof check.id !== 'string' || !check.id) fail(`checks[${index}].id is required.`)
|
|
76
|
+
if (typeof check.command !== 'string' || !check.command) fail(`checks[${index}].command is required.`)
|
|
77
|
+
if (!CATEGORIES.has(check.category)) fail(`checks[${index}].category is invalid.`)
|
|
78
|
+
if (check.required !== undefined && typeof check.required !== 'boolean') fail(`checks[${index}].required must be boolean.`)
|
|
79
|
+
if (check.timeoutMs !== undefined && (!Number.isInteger(check.timeoutMs) || check.timeoutMs < 1)) fail(`checks[${index}].timeoutMs must be a positive integer.`)
|
|
80
|
+
if (['endpoint', 'database', 'cli', 'mcp', 'ui'].includes(check.category) && check.execution !== 'real') fail(`checks[${index}] requires execution: "real".`)
|
|
81
|
+
return { required: true, timeoutMs: 120_000, ...check }
|
|
82
|
+
})
|
|
83
|
+
const surfaces = {}
|
|
84
|
+
for (const name of ['logic', 'endpoint', 'database', 'cli', 'mcp', 'ui', 'docs']) surfaces[name] = surfaceRequired(raw.surfaces?.[name] ?? (name === 'logic'), name)
|
|
85
|
+
for (const [name, surface] of Object.entries(surfaces)) {
|
|
86
|
+
const matching = checks.some((check) => check.category === name && check.required)
|
|
87
|
+
if (surface.required && !matching) fail(`Required surface "${name}" has no required check.`)
|
|
88
|
+
}
|
|
89
|
+
if (raw.profile !== 'strict' && (!Array.isArray(raw.exemptions) || raw.exemptions.length === 0)) fail('Non-strict profiles require explicit exemptions.')
|
|
90
|
+
if (raw.exemptions && (!Array.isArray(raw.exemptions) || raw.exemptions.some((item) => typeof item !== 'string' || !item.trim()))) fail('exemptions must be non-empty strings.')
|
|
91
|
+
const tracking = raw.tracking ?? { required: true, authorization: 'ask' }
|
|
92
|
+
if (typeof tracking.required !== 'boolean') fail('tracking.required must be boolean.')
|
|
93
|
+
if (tracking.required && tracking.authorization !== 'ask') fail('tracking.authorization must be "ask".')
|
|
94
|
+
if (tracking.required && typeof tracking.target !== 'string') fail('tracking.target is required when tracking is required.')
|
|
95
|
+
if (!tracking.required && typeof tracking.reason !== 'string') fail('tracking.reason is required when tracking is not required.')
|
|
96
|
+
return { ...raw, checks, surfaces, tracking, configPath }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const sourceRevision = (root) => {
|
|
100
|
+
try {
|
|
101
|
+
const gitOptions = { cwd: root, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }
|
|
102
|
+
const head = execFileSync('git', ['rev-parse', 'HEAD'], gitOptions).trim()
|
|
103
|
+
const diff = execFileSync('git', ['diff', '--no-ext-diff', '--binary', 'HEAD'], gitOptions)
|
|
104
|
+
const status = execFileSync('git', ['status', '--porcelain=v1', '--untracked-files=all'], gitOptions)
|
|
105
|
+
const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '-z'], gitOptions)
|
|
106
|
+
.split('\0').filter(Boolean).map((path) => ({ path, contentHash: hash(readFileSync(resolve(root, path), 'utf8')) }))
|
|
107
|
+
return hash({ head, diff, status, untracked })
|
|
108
|
+
} catch {
|
|
109
|
+
return hash({ files: readdirSync(root).sort() })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const machineStatusFrom = (stdout) => {
|
|
114
|
+
const lines = String(stdout ?? '').trim().split('\n').reverse()
|
|
115
|
+
for (const line of lines) {
|
|
116
|
+
try {
|
|
117
|
+
const value = JSON.parse(line)
|
|
118
|
+
if (value && ['failed', 'passed', 'pending-human-review'].includes(value.status)) return value
|
|
119
|
+
} catch {}
|
|
120
|
+
}
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const commandResult = (root, check) => new Promise((resolveResult) => {
|
|
125
|
+
const started = Date.now()
|
|
126
|
+
const child = spawn(check.command, { cwd: root, shell: true, env: { ...process.env, CI: process.env.CI ?? '1' } })
|
|
127
|
+
let stdout = ''
|
|
128
|
+
let stderr = ''
|
|
129
|
+
const append = (current, chunk) => `${current}${chunk.toString()}`.slice(-12_000)
|
|
130
|
+
child.stdout.on('data', (chunk) => { stdout = append(stdout, chunk) })
|
|
131
|
+
child.stderr.on('data', (chunk) => { stderr = append(stderr, chunk) })
|
|
132
|
+
const timer = setTimeout(() => child.kill('SIGTERM'), check.timeoutMs)
|
|
133
|
+
child.on('error', (error) => resolveResult({ status: 'failed', exitCode: null, durationMs: Date.now() - started, stdout, stderr: `${stderr}${error.message}`.slice(-12_000) }))
|
|
134
|
+
child.on('close', (exitCode, signal) => {
|
|
135
|
+
clearTimeout(timer)
|
|
136
|
+
const machineResult = machineStatusFrom(stdout)
|
|
137
|
+
const status = exitCode !== 0 || machineResult?.status === 'failed'
|
|
138
|
+
? 'failed'
|
|
139
|
+
: machineResult?.status === 'pending-human-review'
|
|
140
|
+
? 'awaiting-human-approval'
|
|
141
|
+
: 'passed'
|
|
142
|
+
resolveResult({ status, exitCode, signal, durationMs: Date.now() - started, stdout, stderr, ...(machineResult ? { verificationStatus: machineResult.status, verification: machineResult } : {}) })
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const stateDirFor = (root) => join(root, '.codex', 'verification')
|
|
147
|
+
const runDirFor = (root, runId) => join(stateDirFor(root), 'runs', runId)
|
|
148
|
+
const latestPathFor = (root) => join(stateDirFor(root), 'latest.json')
|
|
149
|
+
const loadLatest = (root) => existsSync(latestPathFor(root)) ? readJson(latestPathFor(root)) : undefined
|
|
150
|
+
const saveRun = (root, run) => {
|
|
151
|
+
const dir = runDirFor(root, run.runId)
|
|
152
|
+
writeAtomic(join(dir, 'run.json'), run)
|
|
153
|
+
writeAtomic(latestPathFor(root), { runId: run.runId, state: run.state, path: relative(root, join(dir, 'run.json')), updatedAt: now() })
|
|
154
|
+
}
|
|
155
|
+
const transition = (run, state, reason) => ({ ...run, state, transitions: [...run.transitions, { from: run.state, to: state, at: now(), ...(reason ? { reason } : {}) }] })
|
|
156
|
+
|
|
157
|
+
const runVerification = async (root, config, runId) => {
|
|
158
|
+
const source = sourceRevision(root)
|
|
159
|
+
const inputHash = hash({ source, config: hash(config), version: VERSION })
|
|
160
|
+
const previous = loadLatest(root)
|
|
161
|
+
if (previous?.runId) {
|
|
162
|
+
const previousRunPath = join(root, previous.path)
|
|
163
|
+
if (existsSync(previousRunPath)) {
|
|
164
|
+
const previousRun = readJson(previousRunPath)
|
|
165
|
+
if (previousRun.inputHash === inputHash && ['AWAITING_HUMAN_APPROVAL', 'AWAITING_AUTHORIZATION', 'COMPLETE'].includes(previousRun.state)) return previousRun
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const id = runId ?? `${Date.now()}-${process.pid}`
|
|
169
|
+
let run = {
|
|
170
|
+
schemaVersion: 1,
|
|
171
|
+
type: 'verification-run',
|
|
172
|
+
harnessVersion: VERSION,
|
|
173
|
+
runId: id,
|
|
174
|
+
project: config.project,
|
|
175
|
+
profile: config.profile,
|
|
176
|
+
sourceRevision: source,
|
|
177
|
+
inputHash,
|
|
178
|
+
state: 'PLANNED',
|
|
179
|
+
configPath: relative(root, config.configPath),
|
|
180
|
+
checks: config.checks.map(({ id: checkId, category, command, required, timeoutMs }) => ({ id: checkId, category, command, required, timeoutMs, status: 'pending' })),
|
|
181
|
+
surfaces: config.surfaces,
|
|
182
|
+
tracking: config.tracking,
|
|
183
|
+
exemptions: config.exemptions ?? [],
|
|
184
|
+
transitions: [{ from: null, to: 'PLANNED', at: now() }],
|
|
185
|
+
}
|
|
186
|
+
saveRun(root, run)
|
|
187
|
+
run = transition(run, 'VERIFYING')
|
|
188
|
+
saveRun(root, run)
|
|
189
|
+
for (const [index, check] of config.checks.entries()) {
|
|
190
|
+
const result = await commandResult(root, check)
|
|
191
|
+
run = { ...run, checks: run.checks.map((item, itemIndex) => itemIndex === index ? { ...item, ...result } : item) }
|
|
192
|
+
saveRun(root, run)
|
|
193
|
+
}
|
|
194
|
+
const failed = run.checks.filter((check) => check.required && !['passed', 'awaiting-human-approval'].includes(check.status))
|
|
195
|
+
if (failed.length) run = transition(run, 'BLOCKED', `Required checks failed: ${failed.map((check) => check.id).join(', ')}`)
|
|
196
|
+
else if (config.surfaces.ui.required) run = transition(run, 'AWAITING_HUMAN_APPROVAL', 'Visual UI approval is required.')
|
|
197
|
+
else if (config.tracking.required) run = transition(run, 'AWAITING_AUTHORIZATION', `Tracking authorization is required for ${config.tracking.target}.`)
|
|
198
|
+
else run = transition(run, 'COMPLETE', 'All configured verification gates passed.')
|
|
199
|
+
saveRun(root, run)
|
|
200
|
+
return run
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const intent = (value) => {
|
|
204
|
+
const normalized = String(value ?? '').trim().toLowerCase()
|
|
205
|
+
if (!INTENTS.has(normalized)) fail(`Invalid human intent "${value}". Use an explicit approval word: ok, aprovado, approved, confirmo.`)
|
|
206
|
+
return normalized
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const updateApproval = (root, run, type, rawIntent, by) => {
|
|
210
|
+
const expectedState = type === 'human-approval' ? 'AWAITING_HUMAN_APPROVAL' : 'AWAITING_AUTHORIZATION'
|
|
211
|
+
if (run.state !== expectedState) fail(`Cannot record ${type} while run is ${run.state}.`)
|
|
212
|
+
const approvedIntent = intent(rawIntent)
|
|
213
|
+
const dir = runDirFor(root, run.runId)
|
|
214
|
+
writeAtomic(join(dir, `${type}.json`), { type, runId: run.runId, runInputHash: run.inputHash, intent: approvedIntent, by: by ?? 'human', at: now() })
|
|
215
|
+
if (type === 'human-approval' && run.state === 'AWAITING_HUMAN_APPROVAL') run = run.tracking.required ? transition(run, 'AWAITING_AUTHORIZATION', `Human approval recorded for ${run.runId}.`) : transition(run, 'COMPLETE', 'Human approval recorded and all gates passed.')
|
|
216
|
+
if (type === 'tracking-authorization' && run.state === 'AWAITING_AUTHORIZATION') run = transition(run, 'COMPLETE', `Tracking authorization recorded for ${run.tracking.target}.`)
|
|
217
|
+
saveRun(root, run)
|
|
218
|
+
return run
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const clean = (root, config, periodic) => {
|
|
222
|
+
const manifestPath = join(stateDirFor(root), 'owned-artifacts.json')
|
|
223
|
+
if (!existsSync(manifestPath)) return { removed: [], skipped: [], periodic, message: 'No task-owned artifacts are registered.' }
|
|
224
|
+
const manifest = readJson(manifestPath)
|
|
225
|
+
if (!Array.isArray(manifest)) fail('owned-artifacts.json must contain an array.')
|
|
226
|
+
const removed = []
|
|
227
|
+
const skipped = []
|
|
228
|
+
for (const entry of manifest) {
|
|
229
|
+
const path = resolve(root, entry.path)
|
|
230
|
+
const allowedRoots = (config.cleanup?.roots ?? ['.codex/verification/tmp']).map((item) => resolve(root, item))
|
|
231
|
+
if (!entry.taskOwned || !inside(root, path) || !allowedRoots.some((allowed) => inside(allowed, path))) { skipped.push({ path: entry.path, reason: 'not task-owned or outside cleanup roots' }); continue }
|
|
232
|
+
if (existsSync(path)) { rmSync(path, { recursive: true, force: true }); removed.push(entry.path) }
|
|
233
|
+
}
|
|
234
|
+
return { removed, skipped, periodic }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const output = (value, json) => process.stdout.write(json ? `${JSON.stringify(value, null, 2)}\n` : `Run: ${value.runId ?? '-'}\nState: ${value.state ?? value.status ?? '-'}\n${value.reason ? `Reason: ${value.reason}\n` : ''}`)
|
|
238
|
+
|
|
239
|
+
const main = async (argv) => {
|
|
240
|
+
const { flags, values, positional } = parseArgs(argv)
|
|
241
|
+
const command = positional[0] ?? 'help'
|
|
242
|
+
if (flags.has('help') || command === 'help') {
|
|
243
|
+
process.stdout.write('ak-verify run|status|approve <run-id> <intent>|authorize <run-id> <intent>|clean [--periodic] [--config <path>] [--json]\n')
|
|
244
|
+
return 0
|
|
245
|
+
}
|
|
246
|
+
const configPath = resolve(values.get('config') ?? DEFAULT_CONFIG)
|
|
247
|
+
if (!existsSync(configPath)) fail(`Verification contract not found: ${configPath}`)
|
|
248
|
+
const root = projectRoot(configPath, readJson(configPath))
|
|
249
|
+
const config = validateConfig(readJson(configPath), configPath)
|
|
250
|
+
if (command === 'run') {
|
|
251
|
+
const run = await runVerification(root, config, values.get('run-id'))
|
|
252
|
+
output(run, flags.has('json'))
|
|
253
|
+
return ['COMPLETE', 'AWAITING_HUMAN_APPROVAL', 'AWAITING_AUTHORIZATION'].includes(run.state) ? 0 : 1
|
|
254
|
+
}
|
|
255
|
+
if (command === 'status') {
|
|
256
|
+
const latest = loadLatest(root)
|
|
257
|
+
if (!latest) fail('No verification run exists.')
|
|
258
|
+
const run = readJson(join(root, latest.path))
|
|
259
|
+
output(run, flags.has('json'))
|
|
260
|
+
return run.state === 'COMPLETE' ? 0 : 1
|
|
261
|
+
}
|
|
262
|
+
if (command === 'clean') { output(clean(root, config, flags.has('periodic')), flags.has('json')); return 0 }
|
|
263
|
+
const latest = loadLatest(root)
|
|
264
|
+
if (!latest) fail('No verification run exists.')
|
|
265
|
+
let run = readJson(join(root, latest.path))
|
|
266
|
+
const targetRunId = positional[1]
|
|
267
|
+
if (targetRunId !== run.runId) fail(`Run id mismatch. Latest run is ${run.runId}.`)
|
|
268
|
+
if (command === 'approve') run = updateApproval(root, run, 'human-approval', positional[2], values.get('by'))
|
|
269
|
+
else if (command === 'authorize') run = updateApproval(root, run, 'tracking-authorization', positional[2], values.get('by'))
|
|
270
|
+
else fail(`Unknown command "${command}".`)
|
|
271
|
+
output(run, flags.has('json'))
|
|
272
|
+
return run.state === 'COMPLETE' ? 0 : 1
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
276
|
+
try { process.exitCode = await main(process.argv.slice(2)) }
|
|
277
|
+
catch (error) { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 2 }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export { INTENTS, main, validateConfig }
|
package/src/cli/program.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import { dirname, resolve } from 'node:path'
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, relative, resolve } from 'node:path'
|
|
3
3
|
import { createInterface } from 'node:readline/promises'
|
|
4
4
|
|
|
5
5
|
import { ConfigNotFoundError, loadConfig, projectRootFromConfigPath } from '../config/load-config.js'
|
|
@@ -46,7 +46,7 @@ import type { DiscoverySnapshotV1, ReconciliationReportV1 } from '../schemas/kno
|
|
|
46
46
|
import { sha256NormalizedV1 } from '../index-builder/content-hash.js'
|
|
47
47
|
import { applyFixProposal, approveFixProposal, createArtifactNormalizationProposal, createMarkdownLinkFixProposal } from '../fixes/proposals.js'
|
|
48
48
|
import { createRegistryAgentAdapter, loadRegistryAgentRunner, persistRegistryAgentProposal } from '../agents/registry-adapter.js'
|
|
49
|
-
import {
|
|
49
|
+
import { renderOfflineReportArtifact } from '../report/html.js'
|
|
50
50
|
import { PACKAGE_VERSION } from '../version.js'
|
|
51
51
|
|
|
52
52
|
type Command =
|
|
@@ -89,7 +89,7 @@ Core (no API key):
|
|
|
89
89
|
ak-docs doctor [--text] [--badge] [--write-badge]
|
|
90
90
|
ak-docs index [--watch]
|
|
91
91
|
ak-docs discover [--text|--json]
|
|
92
|
-
ak-docs scan | reconcile | check | map [--text|--json] [--html]
|
|
92
|
+
ak-docs scan | reconcile | check | map [--text|--json] [--html] [--report-threshold <bytes>]
|
|
93
93
|
ak-docs fix propose links|normalize <artifact> [--output <file>]
|
|
94
94
|
ak-docs fix approve|apply <proposal.json> [--by <name>]
|
|
95
95
|
ak-docs suggest [--json|--text] run the configured local Registry agent
|
|
@@ -468,6 +468,7 @@ const workflowOptions = (
|
|
|
468
468
|
...(config.workflow?.stateDir ? { stateDir: config.workflow.stateDir } : {}),
|
|
469
469
|
sourceRevision,
|
|
470
470
|
configurationHash: sha256NormalizedV1(config),
|
|
471
|
+
toolVersion: '1.0.1',
|
|
471
472
|
stage,
|
|
472
473
|
handlers,
|
|
473
474
|
})
|
|
@@ -486,7 +487,9 @@ const reconcileWorkflow = (root: string, config: DocBridgeConfigV1): WorkflowExe
|
|
|
486
487
|
const scanned = scanWorkflow(root, config)
|
|
487
488
|
const snapshot = parseDiscoverySnapshot(loadWorkflowStepOutput(scanned.stateDir, 'normalize'))
|
|
488
489
|
const declared = applyDocumentationDeclarations(snapshot, documentationInputs(root, snapshot)).snapshot
|
|
489
|
-
const report = reconcileKnowledge(snapshot, declared
|
|
490
|
+
const report = reconcileKnowledge(snapshot, declared, config.reconciliation?.requiredRelationKinds === undefined
|
|
491
|
+
? {}
|
|
492
|
+
: { requiredRelationKinds: config.reconciliation.requiredRelationKinds })
|
|
490
493
|
return runWorkflow(workflowOptions(root, config, snapshot.sourceRevision, 'reconcile', { reconcile: () => report }))
|
|
491
494
|
}
|
|
492
495
|
|
|
@@ -520,6 +523,56 @@ const workflowOutput = (result: WorkflowExecutionResult): Record<string, unknown
|
|
|
520
523
|
}
|
|
521
524
|
}
|
|
522
525
|
|
|
526
|
+
const writeAtomicFile = (path: string, content: string): void => {
|
|
527
|
+
const temporaryPath = `${path}.tmp-${process.pid}`
|
|
528
|
+
writeFileSync(temporaryPath, content, 'utf8')
|
|
529
|
+
renameSync(temporaryPath, path)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const writeReportArtifact = (htmlPath: string, artifact: ReturnType<typeof renderOfflineReportArtifact>): string => {
|
|
533
|
+
mkdirSync(dirname(htmlPath), { recursive: true })
|
|
534
|
+
if (artifact.mode === 'single-file') {
|
|
535
|
+
writeAtomicFile(htmlPath, artifact.indexHtml)
|
|
536
|
+
const artifactDir = htmlPath.replace(/\.html?$/i, '')
|
|
537
|
+
if (existsSync(artifactDir)) rmSync(artifactDir, { recursive: true, force: true })
|
|
538
|
+
return htmlPath
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const artifactDir = htmlPath.replace(/\.html?$/i, '')
|
|
542
|
+
const temporaryDir = `${artifactDir}.tmp-${process.pid}`
|
|
543
|
+
const backupDir = `${artifactDir}.previous-${process.pid}`
|
|
544
|
+
const launcherTemp = `${htmlPath}.tmp-${process.pid}`
|
|
545
|
+
rmSync(temporaryDir, { recursive: true, force: true })
|
|
546
|
+
rmSync(backupDir, { recursive: true, force: true })
|
|
547
|
+
mkdirSync(temporaryDir, { recursive: true })
|
|
548
|
+
for (const [file, content] of Object.entries(artifact.files)) {
|
|
549
|
+
const filePath = resolve(temporaryDir, file)
|
|
550
|
+
mkdirSync(dirname(filePath), { recursive: true })
|
|
551
|
+
writeFileSync(filePath, content, 'utf8')
|
|
552
|
+
}
|
|
553
|
+
writeFileSync(resolve(temporaryDir, 'manifest.json'), artifact.manifest, 'utf8')
|
|
554
|
+
const frameSource = `${relative(dirname(htmlPath), artifactDir)}/index.html`
|
|
555
|
+
const launcher = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Doc Bridge report</title></head><body style="margin:0"><iframe title="Doc Bridge report" src="${frameSource}" style="border:0;width:100vw;height:100vh"></iframe></body></html>`
|
|
556
|
+
writeFileSync(launcherTemp, launcher, 'utf8')
|
|
557
|
+
try {
|
|
558
|
+
if (existsSync(artifactDir)) renameSync(artifactDir, backupDir)
|
|
559
|
+
renameSync(temporaryDir, artifactDir)
|
|
560
|
+
renameSync(launcherTemp, htmlPath)
|
|
561
|
+
if (existsSync(backupDir)) rmSync(backupDir, { recursive: true, force: true })
|
|
562
|
+
} catch (error) {
|
|
563
|
+
if (existsSync(backupDir)) {
|
|
564
|
+
const failedDir = `${artifactDir}.failed-${process.pid}`
|
|
565
|
+
if (existsSync(artifactDir)) renameSync(artifactDir, failedDir)
|
|
566
|
+
renameSync(backupDir, artifactDir)
|
|
567
|
+
if (existsSync(failedDir)) rmSync(failedDir, { recursive: true, force: true })
|
|
568
|
+
} else if (existsSync(artifactDir)) rmSync(artifactDir, { recursive: true, force: true })
|
|
569
|
+
if (existsSync(temporaryDir)) rmSync(temporaryDir, { recursive: true, force: true })
|
|
570
|
+
if (existsSync(launcherTemp)) rmSync(launcherTemp, { force: true })
|
|
571
|
+
throw error
|
|
572
|
+
}
|
|
573
|
+
return artifactDir
|
|
574
|
+
}
|
|
575
|
+
|
|
523
576
|
const runWorkflowCommand = (
|
|
524
577
|
command: 'scan' | 'reconcile' | 'check' | 'map',
|
|
525
578
|
flags: ReadonlySet<string>,
|
|
@@ -537,8 +590,12 @@ const runWorkflowCommand = (
|
|
|
537
590
|
const outputPath = optionValues(argv, '--output')[0] ?? '.doc-bridge/report.html'
|
|
538
591
|
const htmlPath = resolve(root, outputPath)
|
|
539
592
|
mkdirSync(dirname(htmlPath), { recursive: true })
|
|
540
|
-
|
|
541
|
-
|
|
593
|
+
const thresholdValue = optionValues(argv, '--report-threshold')[0]
|
|
594
|
+
const thresholdBytes = thresholdValue === undefined ? undefined : Number(thresholdValue)
|
|
595
|
+
if (thresholdBytes !== undefined && (!Number.isSafeInteger(thresholdBytes) || thresholdBytes < 1)) throw new Error('--report-threshold must be a positive integer.')
|
|
596
|
+
const artifact = renderOfflineReportArtifact({ snapshot, report }, { ...(thresholdBytes === undefined ? {} : { thresholdBytes }) })
|
|
597
|
+
output.htmlPath = writeReportArtifact(htmlPath, artifact)
|
|
598
|
+
output.htmlMode = artifact.mode
|
|
542
599
|
}
|
|
543
600
|
if (wantsTextOutput(flags, config)) {
|
|
544
601
|
writeLines([`Run: ${String(output.runId)}`, `State: ${String(output.state)}`, ...(output.snapshotHash ? [`Snapshot: ${String(output.snapshotHash)}`] : []), ...(output.reportHash ? [`Report: ${String(output.reportHash)}`] : []), `Artifacts: ${String((output.artifactRefs as unknown[]).length)}`])
|
package/src/config/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export {
|
|
|
8
8
|
} from './load-config.js'
|
|
9
9
|
export {
|
|
10
10
|
DocBridgeConfigV1Schema,
|
|
11
|
+
ReconciliationConfigSchema,
|
|
11
12
|
AgentCorpusConfigSchema,
|
|
12
13
|
HumanCorpusConfigSchema,
|
|
13
14
|
DocumentationStandardRuleIdSchema,
|
|
@@ -18,4 +19,5 @@ export {
|
|
|
18
19
|
type AgentCorpusConfig,
|
|
19
20
|
type DocumentationStandardRuleId,
|
|
20
21
|
type DocumentationStandardV1Config,
|
|
22
|
+
type ReconciliationConfig,
|
|
21
23
|
} from './schema.js'
|
package/src/config/schema.ts
CHANGED
|
@@ -179,6 +179,13 @@ export const RulesConfigSchema = z
|
|
|
179
179
|
})
|
|
180
180
|
.strict()
|
|
181
181
|
|
|
182
|
+
export const ReconciliationConfigSchema = z
|
|
183
|
+
.object({
|
|
184
|
+
/** Relation kinds that must have documentation declarations. Omit to require all observed kinds; [] disables this signal. */
|
|
185
|
+
requiredRelationKinds: z.array(z.string().min(1).max(128)).max(128).optional(),
|
|
186
|
+
})
|
|
187
|
+
.strict()
|
|
188
|
+
|
|
182
189
|
export const WorkflowConfigSchema = z
|
|
183
190
|
.object({
|
|
184
191
|
stateDir: z.string().min(1).max(512).optional(),
|
|
@@ -434,6 +441,7 @@ export const DocBridgeConfigV1Schema = z
|
|
|
434
441
|
index: IndexConfigSchema.optional(),
|
|
435
442
|
routing: RoutingConfigSchema.optional(),
|
|
436
443
|
gates: GatesConfigSchema.optional(),
|
|
444
|
+
reconciliation: ReconciliationConfigSchema.optional(),
|
|
437
445
|
rules: RulesConfigSchema.optional(),
|
|
438
446
|
workflow: WorkflowConfigSchema.optional(),
|
|
439
447
|
safety: RepositorySafetyConfigSchema.optional(),
|
|
@@ -449,6 +457,7 @@ export type AgentCorpusConfig = z.infer<typeof AgentCorpusConfigSchema>
|
|
|
449
457
|
export type HumanCorpusConfig = z.infer<typeof HumanCorpusConfigSchema>
|
|
450
458
|
export type DocumentationStandardV1Config = z.infer<typeof DocumentationStandardV1ConfigSchema>
|
|
451
459
|
export type DocumentationStandardRuleId = z.infer<typeof DocumentationStandardRuleIdSchema>
|
|
460
|
+
export type ReconciliationConfig = z.infer<typeof ReconciliationConfigSchema>
|
|
452
461
|
export type RuleId = z.infer<typeof RuleIdSchema>
|
|
453
462
|
export type RuleSeverity = z.infer<typeof RuleSeveritySchema>
|
|
454
463
|
export type RulesConfig = z.infer<typeof RulesConfigSchema>
|
package/src/index.ts
CHANGED
|
@@ -93,7 +93,14 @@ export {
|
|
|
93
93
|
type DocumentationDiagnostic,
|
|
94
94
|
} from './discovery/documentation.js'
|
|
95
95
|
export { reconcileKnowledge } from './reconciliation/reconcile.js'
|
|
96
|
-
export {
|
|
96
|
+
export {
|
|
97
|
+
DEFAULT_LARGE_REPORT_THRESHOLD_BYTES,
|
|
98
|
+
renderOfflineReport,
|
|
99
|
+
renderOfflineReportArtifact,
|
|
100
|
+
type OfflineReportArtifact,
|
|
101
|
+
type OfflineReportInput,
|
|
102
|
+
type OfflineReportOptions,
|
|
103
|
+
} from './report/html.js'
|
|
97
104
|
export {
|
|
98
105
|
applyFixProposal,
|
|
99
106
|
approveFixProposal,
|
package/src/lib/markdown.ts
CHANGED
|
@@ -82,6 +82,11 @@ export const firstParagraph = (markdown: string, maxLen = 400): string | undefin
|
|
|
82
82
|
if (buf.length) break
|
|
83
83
|
continue
|
|
84
84
|
}
|
|
85
|
+
// Navigation-only links are useful in the document, but not as its summary.
|
|
86
|
+
if (/^(?:!?)\[[^\]]*\]\([^)]*\)$/.test(t)) {
|
|
87
|
+
if (buf.length) break
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
85
90
|
buf.push(t)
|
|
86
91
|
if (buf.join(' ').length >= maxLen) break
|
|
87
92
|
}
|
|
@@ -10,6 +10,11 @@ import {
|
|
|
10
10
|
|
|
11
11
|
type EntityResolver = (reference: string) => string
|
|
12
12
|
|
|
13
|
+
export type ReconciliationOptions = {
|
|
14
|
+
/** Omit for backwards-compatible all-relation checking; [] disables missing-declaration findings. */
|
|
15
|
+
readonly requiredRelationKinds?: readonly string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
13
18
|
const ignoredDocumentationRelations = new Set(['covers'])
|
|
14
19
|
|
|
15
20
|
const metadataDetection = (relation: KnowledgeRelation): string | undefined => {
|
|
@@ -97,11 +102,13 @@ const reportDiagnostic = (
|
|
|
97
102
|
export const reconcileKnowledge = (
|
|
98
103
|
observed: DiscoverySnapshotV1,
|
|
99
104
|
declared: DiscoverySnapshotV1,
|
|
105
|
+
options: ReconciliationOptions = {},
|
|
100
106
|
): ReconciliationReportV1 => {
|
|
101
107
|
const resolveEntity = entityResolver([observed, declared])
|
|
102
108
|
const entities = entityById([observed, declared])
|
|
103
109
|
const observedRelations = observed.relations.filter((relation) => relation.provenance === 'observed' && !ignoredDocumentationRelations.has(relation.kind))
|
|
104
110
|
const declaredRelations = declared.relations.filter((relation) => relation.provenance === 'declared' && !ignoredDocumentationRelations.has(relation.kind))
|
|
111
|
+
const requiredRelationKinds = options.requiredRelationKinds === undefined ? undefined : new Set(options.requiredRelationKinds)
|
|
105
112
|
const diagnostics: ReconciliationReportV1['diagnostics'][number][] = []
|
|
106
113
|
|
|
107
114
|
for (const entity of declared.entities.filter((item) => isUnresolved(item.id, entities)).sort((a, b) => a.id.localeCompare(b.id))) {
|
|
@@ -156,7 +163,7 @@ export const reconcileKnowledge = (
|
|
|
156
163
|
undefined,
|
|
157
164
|
[relation.id, match.id],
|
|
158
165
|
))
|
|
159
|
-
} else if (coverageAvailable(observed, relation)) {
|
|
166
|
+
} else if (coverageAvailable(observed, relation) && (requiredRelationKinds === undefined || requiredRelationKinds.has(relation.kind))) {
|
|
160
167
|
diagnostics.push(reportDiagnostic(
|
|
161
168
|
'RELATION_UNDOCUMENTED',
|
|
162
169
|
'undocumented',
|
|
@@ -221,6 +228,7 @@ export const reconcileKnowledge = (
|
|
|
221
228
|
entityCount: observed.entities.length,
|
|
222
229
|
relationCount: observedRelations.length,
|
|
223
230
|
diagnosticCount: sortedDiagnostics.length,
|
|
231
|
+
...(options.requiredRelationKinds === undefined ? {} : { requiredRelationKinds: [...new Set(options.requiredRelationKinds)].sort() }),
|
|
224
232
|
},
|
|
225
233
|
}
|
|
226
234
|
return ReconciliationReportV1Schema.parse({ ...base, contentHash: contentHashForArtifactV1(base) })
|