@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.
@@ -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 }
@@ -3,7 +3,7 @@
3
3
  import { spawnSync } from 'node:child_process'
4
4
  import { isAbsolute } from 'node:path'
5
5
 
6
- const VERSION = '1.6.2'
6
+ const VERSION = '1.6.4'
7
7
  const kinds = new Set(['package', 'ownership'])
8
8
  const args = process.argv.slice(2)
9
9
  const id = args[0]
@@ -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 { renderOfflineReport } from '../report/html.js'
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
- writeFileSync(htmlPath, renderOfflineReport({ snapshot, report }), 'utf8')
541
- output.htmlPath = htmlPath
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)}`])
@@ -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'
@@ -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 { renderOfflineReport, type OfflineReportInput, type OfflineReportOptions } from './report/html.js'
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,
@@ -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) })