@agentskit/doc-bridge 1.4.1 → 1.5.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/action.yml +1 -1
  3. package/dist/cli/program.js +2461 -274
  4. package/dist/cli/program.js.map +1 -1
  5. package/dist/config/index.d.ts +1 -1
  6. package/dist/config/index.js +77 -4
  7. package/dist/config/index.js.map +1 -1
  8. package/dist/{index-DAeq_OIi.d.ts → index-Di7PkJuf.d.ts} +209 -8
  9. package/dist/index.d.ts +1942 -4
  10. package/dist/index.js +2190 -193
  11. package/dist/index.js.map +1 -1
  12. package/docs/PRD-doc-bridge-knowledge-engine.md +338 -0
  13. package/docs/knowledge-engine-runbook.md +44 -0
  14. package/ecosystem-claims.json +19 -19
  15. package/ecosystem-upstream.json +2 -2
  16. package/ecosystem.json +117 -109
  17. package/mcpb/manifest.json +25 -1
  18. package/package.json +2 -2
  19. package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
  20. package/src/agents/registry-adapter.ts +97 -0
  21. package/src/cli/program.ts +285 -3
  22. package/src/config/defaults.ts +14 -1
  23. package/src/config/schema.ts +69 -0
  24. package/src/conformance/ecosystem-contract.ts +6 -3
  25. package/src/discovery/documentation.ts +320 -0
  26. package/src/discovery/repository.ts +514 -0
  27. package/src/fixes/proposals.ts +165 -0
  28. package/src/index-builder/content-hash.ts +9 -2
  29. package/src/index-builder/human-adapters/plain-markdown.ts +17 -1
  30. package/src/index-builder/llms-txt.ts +22 -2
  31. package/src/index-builder/scan-corpus.ts +17 -1
  32. package/src/index.ts +99 -2
  33. package/src/mcp/server.ts +178 -8
  34. package/src/reconciliation/reconcile.ts +227 -0
  35. package/src/report/html.ts +74 -0
  36. package/src/rules/engine.ts +180 -0
  37. package/src/safety/repository.ts +84 -0
  38. package/src/schemas/knowledge.ts +315 -0
  39. package/src/validate.ts +24 -0
  40. package/src/version.ts +1 -1
  41. package/src/workflow/engine.ts +238 -0
  42. package/tsup.config.ts +2 -1
package/src/mcp/server.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { readFileSync, realpathSync } from 'node:fs'
2
- import { relative, resolve } from 'node:path'
1
+ import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
2
+ import { join, relative, resolve } from 'node:path'
3
3
 
4
4
  import { z, ZodError } from 'zod'
5
5
 
@@ -13,6 +13,14 @@ import { runQuery } from '../query/query.js'
13
13
  import { searchIndex } from '../query/search.js'
14
14
  import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
15
15
  import { PACKAGE_VERSION } from '../version.js'
16
+ import { loadWorkflowManifest, loadWorkflowStepOutput } from '../workflow/engine.js'
17
+ import { parseDiscoverySnapshot, parseReconciliationReport } from '../validate.js'
18
+ import { applyFixProposal, approveFixProposal, createArtifactNormalizationProposal, createMarkdownLinkFixProposal } from '../fixes/proposals.js'
19
+ import { createRegistryAgentAdapter, loadRegistryAgentRunner, persistRegistryAgentProposal } from '../agents/registry-adapter.js'
20
+ import { sha256NormalizedV1 } from '../index-builder/content-hash.js'
21
+ import { discoverRepository } from '../discovery/repository.js'
22
+ import { FixProposalV1Schema, type DiscoverySnapshotV1, type ReconciliationReportV1, type FixProposalV1 } from '../schemas/knowledge.js'
23
+ import { redactValue } from '../safety/repository.js'
16
24
 
17
25
  type JsonRpcRequest = {
18
26
  readonly jsonrpc?: '2.0'
@@ -99,6 +107,47 @@ export const MCP_TOOLS = [
99
107
  annotations: { readOnlyHint: true },
100
108
  inputSchema: { type: 'object', properties: {} },
101
109
  },
110
+ {
111
+ name: 'docbridge.snapshot',
112
+ title: 'Read the latest discovery snapshot',
113
+ description: 'Read the bounded canonical repository snapshot from the latest workflow run.',
114
+ annotations: { readOnlyHint: true },
115
+ inputSchema: { type: 'object', properties: { runId: { type: 'string' } } },
116
+ },
117
+ {
118
+ name: 'docbridge.report',
119
+ title: 'Read the latest reconciliation report',
120
+ description: 'Read the canonical reconciliation report from the latest workflow run.',
121
+ annotations: { readOnlyHint: true },
122
+ inputSchema: { type: 'object', properties: { runId: { type: 'string' } } },
123
+ },
124
+ {
125
+ name: 'docbridge.diagnostics',
126
+ title: 'Read reconciliation diagnostics',
127
+ description: 'Read bounded diagnostics from the latest canonical reconciliation report.',
128
+ annotations: { readOnlyHint: true },
129
+ inputSchema: { type: 'object', properties: { status: { type: 'string' }, severity: { type: 'string' } } },
130
+ },
131
+ {
132
+ name: 'docbridge.relations',
133
+ title: 'Read architecture relations',
134
+ description: 'Read bounded observed and declared relations from the latest canonical snapshot.',
135
+ annotations: { readOnlyHint: true },
136
+ inputSchema: { type: 'object', properties: { kind: { type: 'string' }, limit: { type: 'number' } } },
137
+ },
138
+ {
139
+ name: 'docbridge.run',
140
+ title: 'Read workflow state',
141
+ description: 'Read the latest resumable workflow state and artifact references.',
142
+ annotations: { readOnlyHint: true },
143
+ inputSchema: { type: 'object', properties: {} },
144
+ },
145
+ {
146
+ name: 'docbridge.proposals',
147
+ title: 'Read or approve proposals',
148
+ description: 'Create, inspect, approve and apply deterministic proposals through the shared human-gated workflow.',
149
+ inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['list', 'propose-links', 'propose-normalize', 'suggest', 'approve', 'apply'] }, proposalHash: { type: 'string' }, artifactPath: { type: 'string' }, approvedBy: { type: 'string' }, proposal: { type: 'object' } } },
150
+ },
102
151
  ] as const
103
152
 
104
153
  const asRecord = (value: unknown): Record<string, unknown> =>
@@ -126,6 +175,11 @@ const DocGetArgsSchema = z
126
175
  })
127
176
  .refine((args) => args.id || args.path, 'doc.get requires id or path')
128
177
 
178
+ const WorkflowRunArgsSchema = z.object({ runId: z.string().min(1).optional() })
179
+ const DiagnosticsArgsSchema = z.object({ status: z.string().min(1).optional(), severity: z.string().min(1).optional() })
180
+ const RelationsArgsSchema = z.object({ kind: z.string().min(1).optional(), limit: z.number().int().positive().max(500).optional() })
181
+ const ProposalsArgsSchema = z.object({ action: z.enum(['list', 'propose-links', 'propose-normalize', 'suggest', 'approve', 'apply']).optional(), proposalHash: z.string().min(1).optional(), artifactPath: z.string().min(1).optional(), approvedBy: z.string().min(1).optional(), proposal: z.unknown().optional() })
182
+
129
183
  const parseToolArgs = <T>(tool: string, schema: z.ZodType<T>, value: unknown): T => {
130
184
  try {
131
185
  return schema.parse(value)
@@ -169,6 +223,45 @@ const resolveDocPath = (root: string, relPath: string): string => {
169
223
  return abs
170
224
  }
171
225
 
226
+ const workflowStateDir = (ctx: McpContext): string => resolve(ctx.root, ctx.config.workflow?.stateDir ?? '.doc-bridge/workflow')
227
+
228
+ const workflowRun = (ctx: McpContext) => loadWorkflowManifest(workflowStateDir(ctx))
229
+
230
+ const ensureLatestRun = (ctx: McpContext, runId?: string) => {
231
+ const run = workflowRun(ctx)
232
+ if (runId && run.runId !== runId) throw new Error(`Unknown workflow run "${runId}"`)
233
+ if (run.state === 'stale' || run.state === 'failed') throw new Error(`Workflow run is ${run.state}; resume or create a valid run before reading artifacts.`)
234
+ return run
235
+ }
236
+
237
+ const workflowSnapshot = (ctx: McpContext, runId?: string): DiscoverySnapshotV1 => {
238
+ ensureLatestRun(ctx, runId)
239
+ return parseDiscoverySnapshot(loadWorkflowStepOutput(workflowStateDir(ctx), 'normalize'))
240
+ }
241
+
242
+ const workflowReport = (ctx: McpContext, runId?: string): ReconciliationReportV1 => {
243
+ ensureLatestRun(ctx, runId)
244
+ return parseReconciliationReport(loadWorkflowStepOutput(workflowStateDir(ctx), 'reconcile'))
245
+ }
246
+
247
+ const proposalPath = (ctx: McpContext): string => join(ctx.root, '.doc-bridge', 'proposal.json')
248
+ const readSavedProposal = (ctx: McpContext, input: unknown): FixProposalV1 => {
249
+ if (input !== undefined) return FixProposalV1Schema.parse(input)
250
+ try { return FixProposalV1Schema.parse(JSON.parse(readFileSync(proposalPath(ctx), 'utf8')) as unknown) } catch { throw new Error(`No saved fix proposal at ${proposalPath(ctx)}.`) }
251
+ }
252
+ const saveProposal = (ctx: McpContext, proposal: FixProposalV1): void => { mkdirSync(join(ctx.root, '.doc-bridge'), { recursive: true }); writeFileSync(proposalPath(ctx), `${JSON.stringify(proposal, null, 2)}\n`, 'utf8') }
253
+
254
+ const enabledMcpTools = (ctx: McpContext) => {
255
+ const configured = ctx.config.surfaces?.mcp?.tools
256
+ if (!configured || configured.length === MCP_TOOLS.length && MCP_TOOLS.every((tool) => configured.includes(tool.name as typeof configured[number]))) return MCP_TOOLS
257
+ return MCP_TOOLS.filter((tool) => configured.includes(tool.name as typeof configured[number]))
258
+ }
259
+
260
+ const assertMcpToolEnabled = (ctx: McpContext, name: string): void => {
261
+ if (!MCP_TOOLS.some((tool) => tool.name === name)) throw new Error(`Unknown tool "${name}"`)
262
+ if (!enabledMcpTools(ctx).some((tool) => tool.name === name)) throw new Error(`MCP tool "${name}" is disabled by configuration.`)
263
+ }
264
+
172
265
  export const handleMcpRequest = (ctx: McpContext, request: JsonRpcRequest): unknown => {
173
266
  if (request.method === 'initialize') {
174
267
  return {
@@ -178,12 +271,14 @@ export const handleMcpRequest = (ctx: McpContext, request: JsonRpcRequest): unkn
178
271
  }
179
272
  }
180
273
 
181
- if (request.method === 'tools/list') return { tools: MCP_TOOLS }
274
+ if (request.method === 'tools/list') return { tools: enabledMcpTools(ctx) }
182
275
 
183
276
  if (request.method === 'tools/call') {
184
277
  const params = asRecord(request.params)
185
278
  const name = params.name
186
279
  const args = asRecord(params.arguments)
280
+ if (typeof name !== 'string') throw new Error('MCP tools/call requires a tool name.')
281
+ assertMcpToolEnabled(ctx, name)
187
282
  const index = () => ctx.loadIndex?.() ?? loadDocBridgeIndex(ctx.root, ctx.config)
188
283
 
189
284
  if (name === 'handoff.resolve') {
@@ -232,6 +327,77 @@ export const handleMcpRequest = (ctx: McpContext, request: JsonRpcRequest): unkn
232
327
  })
233
328
  }
234
329
 
330
+ if (name === 'docbridge.snapshot') {
331
+ const parsed = parseToolArgs('docbridge.snapshot', WorkflowRunArgsSchema, args)
332
+ return textResult(redactValue(workflowSnapshot(ctx, parsed.runId)))
333
+ }
334
+
335
+ if (name === 'docbridge.report') {
336
+ const parsed = parseToolArgs('docbridge.report', WorkflowRunArgsSchema, args)
337
+ return textResult(redactValue(workflowReport(ctx, parsed.runId)))
338
+ }
339
+
340
+ if (name === 'docbridge.diagnostics') {
341
+ const parsed = parseToolArgs('docbridge.diagnostics', DiagnosticsArgsSchema, args)
342
+ const diagnostics = workflowReport(ctx).diagnostics.filter((diagnostic) =>
343
+ (!parsed.status || diagnostic.status === parsed.status) && (!parsed.severity || diagnostic.severity === parsed.severity),
344
+ )
345
+ return textResult(redactValue({ reportHash: workflowReport(ctx).contentHash, diagnostics }))
346
+ }
347
+
348
+ if (name === 'docbridge.relations') {
349
+ const parsed = parseToolArgs('docbridge.relations', RelationsArgsSchema, args)
350
+ const snapshot = workflowSnapshot(ctx)
351
+ return textResult({ snapshotHash: snapshot.contentHash, relations: snapshot.relations.filter((relation) => !parsed.kind || relation.kind === parsed.kind).slice(0, parsed.limit ?? 100) })
352
+ }
353
+
354
+ if (name === 'docbridge.run') {
355
+ parseToolArgs('docbridge.run', z.object({}), args)
356
+ return textResult(workflowRun(ctx))
357
+ }
358
+
359
+ if (name === 'docbridge.proposals') {
360
+ const parsed = parseToolArgs('docbridge.proposals', ProposalsArgsSchema, args)
361
+ const run = (() => { try { return workflowRun(ctx) } catch { return undefined } })()
362
+ if (!parsed.action || parsed.action === 'list') {
363
+ let proposal: FixProposalV1 | undefined
364
+ try { proposal = readSavedProposal(ctx, undefined) } catch { proposal = undefined }
365
+ return textResult(redactValue({ ...(run ? { runId: run.runId } : {}), proposals: proposal ? [proposal] : [] }))
366
+ }
367
+ if (parsed.action === 'suggest') {
368
+ return loadRegistryAgentRunner(ctx.root, ctx.config).then(async (runner) => {
369
+ const snapshot = workflowSnapshot(ctx)
370
+ const report = workflowReport(ctx)
371
+ const adapter = createRegistryAgentAdapter(ctx.root, ctx.config, runner)
372
+ const proposal = await adapter.run(snapshot, report)
373
+ const savedPath = persistRegistryAgentProposal(workflowStateDir(ctx), proposal)
374
+ return textResult(redactValue({ ...(run ? { runId: run.runId } : {}), proposal, proposalPath: savedPath }))
375
+ })
376
+ }
377
+ const discovered = discoverRepository({ root: ctx.root, config: ctx.config })
378
+ const options = { baseRevision: discovered.sourceRevision, configurationHash: sha256NormalizedV1(ctx.config), ...(ctx.config.project?.name ? { projectName: ctx.config.project.name } : {}) }
379
+ if (parsed.action === 'propose-links') {
380
+ const proposal = createMarkdownLinkFixProposal(ctx.root, options)
381
+ if (proposal) saveProposal(ctx, proposal)
382
+ return textResult(redactValue({ ...(run ? { runId: run.runId } : {}), proposal: proposal ?? null }))
383
+ }
384
+ if (parsed.action === 'propose-normalize') {
385
+ if (!parsed.artifactPath) throw new Error('docbridge.proposals propose-normalize requires artifactPath')
386
+ const proposal = createArtifactNormalizationProposal(ctx.root, parsed.artifactPath, options)
387
+ if (proposal) saveProposal(ctx, proposal)
388
+ return textResult(redactValue({ ...(run ? { runId: run.runId } : {}), proposal: proposal ?? null }))
389
+ }
390
+ if (parsed.action === 'approve') {
391
+ const proposal = approveFixProposal(readSavedProposal(ctx, parsed.proposal), parsed.approvedBy ?? 'human')
392
+ if (parsed.proposalHash && proposal.approval?.proposalHash !== parsed.proposalHash) throw new Error('proposalHash does not match the saved proposal')
393
+ saveProposal(ctx, proposal)
394
+ return textResult(redactValue({ ...(run ? { runId: run.runId } : {}), proposal }))
395
+ }
396
+ const proposal = applyFixProposal(ctx.root, readSavedProposal(ctx, parsed.proposal), { currentRevision: discovered.sourceRevision })
397
+ saveProposal(ctx, proposal)
398
+ return textResult(redactValue({ ...(run ? { runId: run.runId } : {}), proposal }))
399
+ }
400
+
235
401
  throw new Error(`Unknown tool "${String(name)}"`)
236
402
  }
237
403
 
@@ -246,10 +412,10 @@ const writeFrame = (payload: unknown, framing: StdioFraming): void => {
246
412
  process.stdout.write(framing === 'json-line' ? `${body}\n` : `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`)
247
413
  }
248
414
 
249
- const respond = (ctx: McpContext, request: JsonRpcRequest, framing: StdioFraming): void => {
415
+ export const respondMcpRequest = async (ctx: McpContext, request: JsonRpcRequest, framing: StdioFraming): Promise<void> => {
250
416
  if (request.id === undefined) {
251
417
  try {
252
- handleMcpRequest(ctx, request)
418
+ await handleMcpRequest(ctx, request)
253
419
  } catch {
254
420
  // Notifications do not get responses.
255
421
  }
@@ -257,7 +423,7 @@ const respond = (ctx: McpContext, request: JsonRpcRequest, framing: StdioFraming
257
423
  }
258
424
 
259
425
  try {
260
- const result = handleMcpRequest(ctx, request)
426
+ const result = await handleMcpRequest(ctx, request)
261
427
  writeFrame({ jsonrpc: '2.0', id: request.id, result: result ?? {} }, framing)
262
428
  } catch (error) {
263
429
  writeFrame({
@@ -270,6 +436,10 @@ const respond = (ctx: McpContext, request: JsonRpcRequest, framing: StdioFraming
270
436
 
271
437
  export const startMcpStdioServer = (ctx: McpContext): void => {
272
438
  let buffer = Buffer.alloc(0)
439
+ let responseChain = Promise.resolve()
440
+ const enqueueResponse = (request: JsonRpcRequest, framing: StdioFraming): void => {
441
+ responseChain = responseChain.then(() => respondMcpRequest(ctx, request, framing))
442
+ }
273
443
  process.stdin.on('data', (chunk: Buffer) => {
274
444
  buffer = Buffer.concat([buffer, chunk])
275
445
  while (true) {
@@ -288,7 +458,7 @@ export const startMcpStdioServer = (ctx: McpContext): void => {
288
458
  if (buffer.length < bodyEnd) return
289
459
  const raw = buffer.subarray(bodyStart, bodyEnd).toString('utf8')
290
460
  buffer = buffer.subarray(bodyEnd)
291
- respond(ctx, JSON.parse(raw) as JsonRpcRequest, 'content-length')
461
+ enqueueResponse(JSON.parse(raw) as JsonRpcRequest, 'content-length')
292
462
  continue
293
463
  }
294
464
 
@@ -298,7 +468,7 @@ export const startMcpStdioServer = (ctx: McpContext): void => {
298
468
  buffer = buffer.subarray(lineEnd + 1)
299
469
  if (!raw) continue
300
470
  try {
301
- respond(ctx, JSON.parse(raw) as JsonRpcRequest, 'json-line')
471
+ enqueueResponse(JSON.parse(raw) as JsonRpcRequest, 'json-line')
302
472
  } catch {
303
473
  writeFrame({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }, 'json-line')
304
474
  }
@@ -0,0 +1,227 @@
1
+ import { contentHashForArtifactV1, sha256NormalizedV1 } from '../index-builder/content-hash.js'
2
+ import {
3
+ ReconciliationReportV1Schema,
4
+ type DiscoverySnapshotV1,
5
+ type Evidence,
6
+ type KnowledgeEntity,
7
+ type KnowledgeRelation,
8
+ type ReconciliationReportV1,
9
+ } from '../schemas/knowledge.js'
10
+
11
+ type EntityResolver = (reference: string) => string
12
+
13
+ const ignoredDocumentationRelations = new Set(['covers'])
14
+
15
+ const metadataDetection = (relation: KnowledgeRelation): string | undefined => {
16
+ const detection = relation.metadata?.detection
17
+ return typeof detection === 'string' ? detection : undefined
18
+ }
19
+
20
+ const relationDetection = (relation: KnowledgeRelation): string =>
21
+ relation.discriminator ?? metadataDetection(relation) ?? 'static'
22
+
23
+ const entityResolver = (snapshots: readonly DiscoverySnapshotV1[]): EntityResolver => {
24
+ const references = new Map<string, string>()
25
+ const entities = snapshots.flatMap((snapshot) => snapshot.entities).sort((a, b) => a.id.localeCompare(b.id))
26
+ for (const entity of entities) {
27
+ references.set(entity.id, entity.id)
28
+ for (const alias of entity.aliases ?? []) {
29
+ if (!references.has(alias)) references.set(alias, entity.id)
30
+ }
31
+ }
32
+ return (reference) => references.get(reference) ?? reference
33
+ }
34
+
35
+ const relationBase = (relation: KnowledgeRelation, resolveEntity: EntityResolver): string =>
36
+ `${resolveEntity(relation.from)}\u0000${resolveEntity(relation.to)}\u0000${relation.kind}`
37
+
38
+ const evidenceKey = (item: Evidence): string =>
39
+ `${item.source}:${item.path}:${item.lineStart ?? ''}:${item.lineEnd ?? ''}:${item.context ?? ''}`
40
+
41
+ const mergeEvidence = (...relations: readonly KnowledgeRelation[]): Evidence[] => {
42
+ const merged = new Map<string, Evidence>()
43
+ for (const relation of relations) {
44
+ for (const item of relation.evidence) merged.set(evidenceKey(item), item)
45
+ }
46
+ return [...merged.values()].sort((a, b) => evidenceKey(a).localeCompare(evidenceKey(b)))
47
+ }
48
+
49
+ const diagnosticId = (code: string, value: unknown): string =>
50
+ `reconciliation:${code}:${sha256NormalizedV1(value).slice(0, 32)}`
51
+
52
+ const coverageAvailable = (snapshot: DiscoverySnapshotV1, relation: KnowledgeRelation): boolean => {
53
+ const status = snapshot.coverage.find((entry) =>
54
+ entry.scope === 'static-imports-and-exports' && (relation.kind === 'imports' || relation.kind === 're-exports'),
55
+ )?.status
56
+ return status === undefined || status === 'complete'
57
+ }
58
+
59
+ const entityById = (snapshots: readonly DiscoverySnapshotV1[]): ReadonlyMap<string, KnowledgeEntity> => {
60
+ const entities = new Map<string, KnowledgeEntity>()
61
+ for (const snapshot of snapshots) {
62
+ for (const entity of snapshot.entities) if (!entities.has(entity.id)) entities.set(entity.id, entity)
63
+ }
64
+ return entities
65
+ }
66
+
67
+ const isUnresolved = (id: string, entities: ReadonlyMap<string, KnowledgeEntity>): boolean =>
68
+ id.startsWith('unresolved:') || entities.get(id)?.kind === 'unresolved-reference'
69
+
70
+ const relationMatches = (observed: KnowledgeRelation, declared: KnowledgeRelation, resolveEntity: EntityResolver): boolean => {
71
+ if (relationBase(observed, resolveEntity) !== relationBase(declared, resolveEntity)) return false
72
+ return relationDetection(declared) === relationDetection(observed)
73
+ }
74
+
75
+ const reportDiagnostic = (
76
+ code: string,
77
+ status: ReconciliationReportV1['diagnostics'][number]['status'],
78
+ severity: ReconciliationReportV1['diagnostics'][number]['severity'],
79
+ message: string,
80
+ evidence: readonly Evidence[],
81
+ value: unknown,
82
+ entityIds?: readonly string[],
83
+ relationIds?: readonly string[],
84
+ remediation?: string,
85
+ ): ReconciliationReportV1['diagnostics'][number] => ({
86
+ id: diagnosticId(code, value),
87
+ code,
88
+ status,
89
+ severity,
90
+ message,
91
+ evidence: [...evidence],
92
+ ...(entityIds?.length ? { entityIds: [...entityIds] } : {}),
93
+ ...(relationIds?.length ? { relationIds: [...relationIds] } : {}),
94
+ ...(remediation ? { remediation } : {}),
95
+ })
96
+
97
+ export const reconcileKnowledge = (
98
+ observed: DiscoverySnapshotV1,
99
+ declared: DiscoverySnapshotV1,
100
+ ): ReconciliationReportV1 => {
101
+ const resolveEntity = entityResolver([observed, declared])
102
+ const entities = entityById([observed, declared])
103
+ const observedRelations = observed.relations.filter((relation) => relation.provenance === 'observed' && !ignoredDocumentationRelations.has(relation.kind))
104
+ const declaredRelations = declared.relations.filter((relation) => relation.provenance === 'declared' && !ignoredDocumentationRelations.has(relation.kind))
105
+ const diagnostics: ReconciliationReportV1['diagnostics'][number][] = []
106
+
107
+ for (const entity of declared.entities.filter((item) => isUnresolved(item.id, entities)).sort((a, b) => a.id.localeCompare(b.id))) {
108
+ diagnostics.push(reportDiagnostic(
109
+ 'UNRESOLVED_ENTITY_REFERENCE',
110
+ 'unresolved',
111
+ 'error',
112
+ `Declared reference could not be resolved: ${entity.name}.`,
113
+ entity.evidence,
114
+ entity.id,
115
+ [entity.id],
116
+ undefined,
117
+ 'Resolve the reference to an observed entity ID or configured alias.',
118
+ ))
119
+ }
120
+
121
+ const declaredByBase = new Map<string, KnowledgeRelation[]>()
122
+ for (const relation of declaredRelations) {
123
+ const base = relationBase(relation, resolveEntity)
124
+ const group = declaredByBase.get(base) ?? []
125
+ group.push(relation)
126
+ declaredByBase.set(base, group)
127
+ }
128
+
129
+ for (const group of declaredByBase.values()) {
130
+ const detections = new Set(group.map(relationDetection))
131
+ if (detections.size < 2) continue
132
+ diagnostics.push(reportDiagnostic(
133
+ 'CONFLICTING_DECLARATIONS',
134
+ 'conflict',
135
+ 'error',
136
+ 'Declarations for the same semantic relation disagree on detection.',
137
+ mergeEvidence(...group),
138
+ [relationBase(group[0] as KnowledgeRelation, resolveEntity), [...detections].sort()],
139
+ undefined,
140
+ group.map((relation) => relation.id),
141
+ 'Keep one detection value for this relation or document the intended distinction with a different relation kind.',
142
+ ))
143
+ }
144
+
145
+ for (const relation of observedRelations) {
146
+ const candidates = declaredByBase.get(relationBase(relation, resolveEntity)) ?? []
147
+ const match = candidates.find((candidate) => relationMatches(relation, candidate, resolveEntity))
148
+ if (match) {
149
+ diagnostics.push(reportDiagnostic(
150
+ 'RELATION_CONFIRMED',
151
+ 'confirmed',
152
+ 'info',
153
+ 'Observed relation is covered by a matching declaration.',
154
+ mergeEvidence(relation, match),
155
+ relation.id,
156
+ undefined,
157
+ [relation.id, match.id],
158
+ ))
159
+ } else if (coverageAvailable(observed, relation)) {
160
+ diagnostics.push(reportDiagnostic(
161
+ 'RELATION_UNDOCUMENTED',
162
+ 'undocumented',
163
+ 'warn',
164
+ 'Observed relation has no matching documentation declaration.',
165
+ relation.evidence,
166
+ relation.id,
167
+ undefined,
168
+ [relation.id],
169
+ 'Add a matching relation declaration or configure this relation kind as intentionally undocumented.',
170
+ ))
171
+ }
172
+ }
173
+
174
+ for (const relation of declaredRelations) {
175
+ const candidates = observedRelations.filter((candidate) => relationBase(candidate, resolveEntity) === relationBase(relation, resolveEntity))
176
+ const detection = relationDetection(relation)
177
+ if (candidates.some((candidate) => relationMatches(candidate, relation, resolveEntity))) continue
178
+ if (isUnresolved(resolveEntity(relation.from), entities) || isUnresolved(resolveEntity(relation.to), entities)) continue
179
+ if (detection === 'dynamic' || detection === 'external') {
180
+ diagnostics.push(reportDiagnostic(
181
+ 'RELATION_NOT_ANALYZED',
182
+ 'not-analyzed',
183
+ 'info',
184
+ `Declared ${detection} relation cannot be verified by the current static analyzer.`,
185
+ relation.evidence,
186
+ relation.id,
187
+ undefined,
188
+ [relation.id],
189
+ 'Enable a compatible analyzer or provide explicit observed evidence before treating this relation as confirmed.',
190
+ ))
191
+ } else if (coverageAvailable(observed, relation)) {
192
+ diagnostics.push(reportDiagnostic(
193
+ 'DECLARED_RELATION_STALE',
194
+ 'stale-or-unverified',
195
+ 'warn',
196
+ candidates.length ? 'Declared relation has incompatible observed evidence.' : 'Declared static relation was not observed.',
197
+ candidates.length ? mergeEvidence(relation, ...candidates) : relation.evidence,
198
+ relation.id,
199
+ undefined,
200
+ [relation.id, ...candidates.map((candidate) => candidate.id)],
201
+ 'Update the declaration or the implementation so both graphs describe the same relation.',
202
+ ))
203
+ }
204
+ }
205
+
206
+ const sortedDiagnostics = [...diagnostics].sort((a, b) => a.id.localeCompare(b.id))
207
+ const base = {
208
+ type: 'reconciliation-report' as const,
209
+ schemaVersion: 1 as const,
210
+ contentHash: '0'.repeat(64),
211
+ contentHashAlgo: observed.contentHashAlgo,
212
+ project: observed.project,
213
+ sourceRevision: observed.sourceRevision,
214
+ sourceRevisionKind: observed.sourceRevisionKind,
215
+ configurationHash: observed.configurationHash,
216
+ pipelineVersion: observed.pipelineVersion,
217
+ analyzerVersions: observed.analyzerVersions,
218
+ snapshotHash: observed.contentHash,
219
+ diagnostics: sortedDiagnostics,
220
+ summary: {
221
+ entityCount: observed.entities.length,
222
+ relationCount: observedRelations.length,
223
+ diagnosticCount: sortedDiagnostics.length,
224
+ },
225
+ }
226
+ return ReconciliationReportV1Schema.parse({ ...base, contentHash: contentHashForArtifactV1(base) })
227
+ }
@@ -0,0 +1,74 @@
1
+ import { DiscoverySnapshotV1Schema, ReconciliationReportV1Schema, type DiscoverySnapshotV1, type ReconciliationReportV1 } from '../schemas/knowledge.js'
2
+ import { redactSecrets } from '../safety/repository.js'
3
+
4
+ export type OfflineReportInput = {
5
+ readonly snapshot: DiscoverySnapshotV1
6
+ readonly report: ReconciliationReportV1
7
+ }
8
+
9
+ export type OfflineReportOptions = {
10
+ readonly includeSnippets?: boolean
11
+ }
12
+
13
+ const escapeHtml = (value: unknown): string => String(value)
14
+ .replaceAll('&', '&amp;')
15
+ .replaceAll('<', '&lt;')
16
+ .replaceAll('>', '&gt;')
17
+ .replaceAll('"', '&quot;')
18
+ .replaceAll("'", '&#39;')
19
+
20
+ const anchor = (prefix: string, value: string): string => `${prefix}-${value.replace(/[^A-Za-z0-9_-]+/g, '-')}`
21
+
22
+ const evidenceText = (evidence: ReconciliationReportV1['diagnostics'][number]['evidence'][number], includeSnippets: boolean): string => {
23
+ const location = `${evidence.path}${evidence.lineStart ? `:${evidence.lineStart}${evidence.lineEnd && evidence.lineEnd !== evidence.lineStart ? `-${evidence.lineEnd}` : ''}` : ''}`
24
+ return `${location}${includeSnippets && evidence.context ? ` — ${redactSecrets(evidence.context)}` : ''}`
25
+ }
26
+
27
+ const errorPage = (message: string): string => `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Doc Bridge report error</title><style>body{font:16px system-ui;margin:3rem;color:#311}main{max-width:60rem;margin:auto;border:1px solid #d99;padding:2rem;border-radius:8px;background:#fff8f8}code{white-space:pre-wrap}</style></head><body><main><h1>Doc Bridge report unavailable</h1><p>The saved snapshot/report could not be rendered.</p><code>${escapeHtml(message)}</code><p>Run <code>ak-docs check</code> to regenerate valid artifacts.</p></main></body></html>`
28
+
29
+ const embeddedJson = (value: unknown): string => JSON.stringify(value).replaceAll('<', '\\u003c')
30
+
31
+ const controls = (statusOptions: string[]): string => `<section id="controls"><label>Search <input id="search" type="search" placeholder="entity, relation, diagnostic"></label><label>Severity <select id="severity"><option value="">Any</option><option>error</option><option>warn</option><option>info</option></select></label><label>Provenance <select id="provenance"><option value="">Any</option><option>observed</option><option>declared</option><option>proposed</option></select></label><label>Status <select id="status"><option value="">Any</option>${statusOptions.map((value) => `<option>${value}</option>`).join('')}</select></label><label>Analyzer <input id="analyzer" type="search" placeholder="js-ts, report"></label><label>Entity <input id="entity" type="search" placeholder="entity id"></label><label>Relation <input id="relation" type="search" placeholder="relation id"></label><button id="reset">Reset filters</button></section>`
32
+
33
+ const renderCompact = (snapshot: DiscoverySnapshotV1, report: ReconciliationReportV1, entityAnchors: ReadonlyMap<string, string>, includeSnippets: boolean): string => {
34
+ const entities = snapshot.entities.map((entity) => ({ id: entity.id, name: entity.name, kind: entity.kind, anchor: entityAnchors.get(entity.id) ?? anchor('entity', entity.id) }))
35
+ const entityIndexes = new Map(entities.map((entity, index) => [entity.id, index]))
36
+ const relations = snapshot.relations.map((relation) => ({ id: relation.id, from: entityIndexes.get(relation.from), to: entityIndexes.get(relation.to), kind: relation.kind, provenance: relation.provenance }))
37
+ const diagnostics = report.diagnostics.map((diagnostic) => ({
38
+ ...diagnostic,
39
+ evidence: diagnostic.evidence.map(({ context, ...evidence }) => includeSnippets && context ? { ...evidence, context: redactSecrets(context) } : evidence),
40
+ }))
41
+ const data = embeddedJson({ entities, relations, diagnostics, coverage: snapshot.coverage })
42
+ const script = `const data=${data};const esc=(v)=>String(v).replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;').replaceAll("'",'&#39;');const entityById=new Map(data.entities.map((e)=>[e.id,e]));const entityLink=(id)=>{const e=entityById.get(id);return '<a href="#'+esc(e?.anchor??'entity-'+id)+'">'+esc(e?.name??id)+'</a>'};const evidenceText=(e)=>esc(e.path+(e.lineStart?':'+e.lineStart+(e.lineEnd&&e.lineEnd!==e.lineStart?'-'+e.lineEnd:''):'')+(e.context?' — '+e.context:''));const render=()=>{document.querySelector('#map').innerHTML=data.entities.map((e)=>'<div class="node" id="'+esc(e.anchor)+'" data-node="'+esc(e.anchor)+'" data-search="'+esc([e.id,e.name,e.kind].join(' '))+'"><a href="#'+esc(e.anchor)+'">'+esc(e.name)+'</a><br><span class="muted">'+esc(e.kind)+'</span></div>').join('');document.querySelector('#relations').innerHTML=data.relations.map((r)=>{const from=data.entities[r.from],to=data.entities[r.to];return '<tr id="relation-'+esc(r.id)+'" data-relation="'+esc(r.id)+'" data-from="'+esc(from?.anchor??'')+'" data-to="'+esc(to?.anchor??'')+'" data-kind="'+esc(r.kind)+'" data-provenance="'+esc(r.provenance)+'" data-analyzer="snapshot" data-search="'+esc([r.id,from?.id,to?.id,r.kind,r.provenance].join(' '))+'"><td>'+esc(r.kind)+'</td><td>'+entityLink(from?.id??'')+'</td><td>'+entityLink(to?.id??'')+'</td><td>'+esc(r.provenance)+'</td></tr>'}).join('');document.querySelector('#diagnostics').innerHTML=data.diagnostics.length?data.diagnostics.map((d)=>'<article id="diagnostic-'+esc(d.id)+'" class="diagnostic" data-status="'+esc(d.status)+'" data-severity="'+esc(d.severity)+'" data-analyzer="report" data-entity="'+esc((d.entityIds??[]).join(' '))+'" data-relation="'+esc((d.relationIds??[]).join(' '))+'" data-search="'+esc([d.id,d.code,d.message,...(d.entityIds??[]),...(d.relationIds??[])].join(' '))+'"><h3><a href="#diagnostic-'+esc(d.id)+'">'+esc(d.code)+'</a> <span class="badge '+esc(d.severity)+'">'+esc(d.severity)+'</span></h3><p>'+esc(d.message)+'</p><p>Status: <b>'+esc(d.status)+'</b></p><ul>'+d.evidence.map((e)=>'<li>'+evidenceText(e)+'</li>').join('')+'</ul>'+(d.entityIds?.length?'<p>Entities: '+d.entityIds.map(entityLink).join(', ')+'</p>':'')+'</article>').join(''):'<p class="muted">No diagnostics.</p>';document.querySelector('#coverage').innerHTML=data.coverage.length?data.coverage.map((c)=>'<li data-status="'+esc(c.status)+'" data-analyzer="'+esc(c.analyzer)+'" data-search="'+esc([c.analyzer,c.scope,c.status,c.reason??''].join(' '))+'"><b>'+esc(c.analyzer)+'</b> / '+esc(c.scope)+': '+esc(c.status)+(c.reason?' — '+esc(c.reason):'')+'</li>').join(''):'<li class="muted">No coverage metadata.</li>';document.querySelectorAll('[data-node]').forEach((node)=>node.onclick=()=>{const id=node.dataset.node;document.querySelectorAll('[data-from],[data-to]').forEach((edge)=>edge.classList.toggle('hidden',edge.dataset.from!==id&&edge.dataset.to!==id))});};const apply=()=>{const q=document.querySelector('#search').value.toLowerCase(),severity=document.querySelector('#severity').value,provenance=document.querySelector('#provenance').value,status=document.querySelector('#status').value,analyzer=document.querySelector('#analyzer').value.toLowerCase(),entity=document.querySelector('#entity').value.toLowerCase(),relation=document.querySelector('#relation').value.toLowerCase();document.querySelectorAll('[data-search]').forEach((el)=>el.classList.toggle('hidden',Boolean(q&&!el.dataset.search.toLowerCase().includes(q)||severity&&el.dataset.severity!==severity||provenance&&el.dataset.provenance!==provenance||status&&el.dataset.status!==status||analyzer&&!el.dataset.analyzer?.toLowerCase().includes(analyzer)||entity&&!el.dataset.entity?.toLowerCase().includes(entity)||relation&&!el.dataset.relation?.toLowerCase().includes(relation))));};render();for(const id of ['search','analyzer','entity','relation'])document.querySelector('#'+id).oninput=apply;for(const id of ['severity','provenance','status'])document.querySelector('#'+id).onchange=apply;document.querySelector('#reset').onclick=()=>{for(const id of ['search','analyzer','entity','relation'])document.querySelector('#'+id).value='';for(const id of ['severity','provenance','status'])document.querySelector('#'+id).value='';apply()};`
43
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Doc Bridge — ${escapeHtml(snapshot.project.name)}</title><style>body{font:14px system-ui;margin:0;color:#18202a;background:#f5f7fa}header,main{max-width:1200px;margin:auto;padding:1.25rem}header{background:#18202a;color:white;max-width:none;padding-left:calc((100% - 1200px)/2);padding-right:calc((100% - 1200px)/2)}main{background:white}section{margin:1.5rem 0;border-top:1px solid #d7dde5;padding-top:1rem}table{border-collapse:collapse;width:100%;margin-top:.75rem}td,th{border-bottom:1px solid #e5e9ef;text-align:left;padding:.45rem;vertical-align:top}input,select,button{padding:.45rem;margin:.15rem;border:1px solid #b9c3d0;border-radius:4px;background:white}.badge{border-radius:1rem;padding:.15rem .5rem;background:#dce4ee}.error{background:#ffd9d9}.warn{background:#fff0c2}.info{background:#dcecff}.diagnostic{border:1px solid #d7dde5;border-left:4px solid #9aa7b5;padding:.75rem;margin:.75rem 0}.diagnostic:target,tr:target{background:#fff8cf}.muted{color:#5d6a78}a{color:#0b5cad}#map{display:flex;gap:1rem;flex-wrap:wrap}.node{border:1px solid #9aa7b5;padding:.5rem;border-radius:4px}.hidden{display:none}</style></head><body><header><h1>Doc Bridge: ${escapeHtml(snapshot.project.name)}</h1><p>Offline architecture and documentation reconciliation report</p><p class="muted">Snapshot ${escapeHtml(snapshot.contentHash)} · Report ${escapeHtml(report.contentHash)} · Revision ${escapeHtml(snapshot.sourceRevision)}</p></header><main>${controls(['confirmed', 'undocumented', 'stale-or-unverified', 'conflict', 'unresolved', 'not-analyzed'])}<section id="architecture"><h2>Architecture map</h2><p>Large snapshots are rendered from compact canonical data after load to keep the offline report bounded.</p><div id="map"></div><h3>Relations</h3><table><thead><tr><th>Kind</th><th>From</th><th>To</th><th>Provenance</th></tr></thead><tbody id="relations"></tbody></table></section><section id="diagnostic-lens"><h2>Diagnostic lens</h2><p>Findings: ${report.diagnostics.length}</p><div id="diagnostics"></div></section><section id="coverage"><h2>Coverage and unsupported areas</h2><ul></ul></section><section id="metadata"><h2>Run metadata</h2><dl><dt>Source revision</dt><dd>${escapeHtml(snapshot.sourceRevision)} (${escapeHtml(snapshot.sourceRevisionKind)})</dd><dt>Configuration hash</dt><dd>${escapeHtml(snapshot.configurationHash)}</dd><dt>Pipeline</dt><dd>${escapeHtml(snapshot.pipelineVersion)}</dd></dl></section></main><script>${script}</script></body></html>`
44
+ }
45
+
46
+ const render = (input: OfflineReportInput, options: OfflineReportOptions): string => {
47
+ const { snapshot, report } = input
48
+ const includeSnippets = options.includeSnippets === true
49
+ const entityAnchors = new Map(snapshot.entities.map((entity, index) => [entity.id, entity.id.length > 64 ? `entity-n${index}` : anchor('entity', entity.id)]))
50
+ if (snapshot.entities.length > 500 || report.diagnostics.length > 1_000) return renderCompact(snapshot, report, entityAnchors, includeSnippets)
51
+ const entityNames = new Map(snapshot.entities.map((entity) => [entity.id, entity.name]))
52
+ const entityAnchor = (id: string): string => entityAnchors.get(id) ?? anchor('entity', id)
53
+ const entityLabel = (id: string): string => entityNames.get(id) ?? id
54
+ const entityLink = (id: string): string => `<a href="#${entityAnchor(id)}">${escapeHtml(entityLabel(id))}</a>`
55
+ const relations = snapshot.relations.map((relation) => `<tr id="${anchor('relation', relation.id)}" data-relation="${escapeHtml(relation.id)}" data-from="${entityAnchor(relation.from)}" data-to="${entityAnchor(relation.to)}" data-kind="${escapeHtml(relation.kind)}" data-provenance="${escapeHtml(relation.provenance)}" data-analyzer="snapshot" data-search="${escapeHtml(`${relation.id} ${relation.from} ${relation.to} ${relation.kind} ${relation.provenance}`)}"><td>${escapeHtml(relation.kind)}</td><td>${entityLink(relation.from)}</td><td>${entityLink(relation.to)}</td><td>${escapeHtml(relation.provenance)}</td></tr>`).join('')
56
+ const diagnostics = report.diagnostics.map((diagnostic) => `<article id="${anchor('diagnostic', diagnostic.id)}" class="diagnostic" data-status="${escapeHtml(diagnostic.status)}" data-severity="${escapeHtml(diagnostic.severity)}" data-analyzer="report" data-entity="${escapeHtml(diagnostic.entityIds?.join(' ') ?? '')}" data-relation="${escapeHtml(diagnostic.relationIds?.join(' ') ?? '')}" data-search="${escapeHtml(`${diagnostic.id} ${diagnostic.code} ${diagnostic.message} ${diagnostic.entityIds?.join(' ') ?? ''} ${diagnostic.relationIds?.join(' ') ?? ''}`)}"><h3><a href="#${anchor('diagnostic', diagnostic.id)}">${escapeHtml(diagnostic.code)}</a> <span class="badge ${escapeHtml(diagnostic.severity)}">${escapeHtml(diagnostic.severity)}</span></h3><p>${escapeHtml(diagnostic.message)}</p><p>Status: <b>${escapeHtml(diagnostic.status)}</b></p><ul>${diagnostic.evidence.map((item) => `<li>${escapeHtml(evidenceText(item, includeSnippets))}</li>`).join('')}</ul>${diagnostic.entityIds?.length ? `<p>Entities: ${diagnostic.entityIds.map((id) => entityLink(id)).join(', ')}</p>` : ''}</article>`).join('')
57
+ const coverage = snapshot.coverage.map((entry) => `<li data-status="${escapeHtml(entry.status)}" data-analyzer="${escapeHtml(entry.analyzer)}" data-search="${escapeHtml(`${entry.analyzer} ${entry.scope} ${entry.status} ${entry.reason ?? ''}`)}"><b>${escapeHtml(entry.analyzer)}</b> / ${escapeHtml(entry.scope)}: ${escapeHtml(entry.status)}${entry.reason ? ` — ${escapeHtml(entry.reason)}` : ''}</li>`).join('')
58
+ const nodes = snapshot.entities.map((entity) => `<div class="node" id="${entityAnchor(entity.id)}" data-node="${entityAnchor(entity.id)}"><a href="#${entityAnchor(entity.id)}">${escapeHtml(entity.name)}</a><br><span class="muted">${escapeHtml(entity.kind)}</span></div>`).join('')
59
+ const script = `const q=document.querySelector('#search'),severity=document.querySelector('#severity'),provenance=document.querySelector('#provenance'),status=document.querySelector('#status'),analyzer=document.querySelector('#analyzer'),entity=document.querySelector('#entity'),relation=document.querySelector('#relation');function apply(){const term=q.value.toLowerCase(),entityTerm=entity.value.toLowerCase(),relationTerm=relation.value.toLowerCase();document.querySelectorAll('[data-search]').forEach((el)=>{const match=(!term||el.dataset.search.toLowerCase().includes(term))&&(!severity.value||el.dataset.severity===severity.value)&&(!provenance.value||el.dataset.provenance===provenance.value)&&(!status.value||el.dataset.status===status.value)&&(!analyzer.value||el.dataset.analyzer?.toLowerCase().includes(analyzer.value.toLowerCase()))&&(!entityTerm||el.dataset.entity?.toLowerCase().includes(entityTerm))&&(!relationTerm||el.dataset.relation?.toLowerCase().includes(relationTerm));el.classList.toggle('hidden',!match)});}q.oninput=apply;severity.onchange=apply;provenance.onchange=apply;status.onchange=apply;analyzer.oninput=apply;entity.oninput=apply;relation.oninput=apply;document.querySelector('#reset').onclick=()=>{q.value='';severity.value='';provenance.value='';status.value='';analyzer.value='';entity.value='';relation.value='';apply()};document.querySelectorAll('[data-node]').forEach((node)=>node.onclick=()=>{const id=node.dataset.node;document.querySelectorAll('[data-from],[data-to]').forEach((edge)=>edge.classList.toggle('hidden',edge.dataset.from!==id&&edge.dataset.to!==id));});`
60
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Doc Bridge — ${escapeHtml(snapshot.project.name)}</title><style>body{font:14px system-ui;margin:0;color:#18202a;background:#f5f7fa}header,main{max-width:1200px;margin:auto;padding:1.25rem}header{background:#18202a;color:white;max-width:none;padding-left:calc((100% - 1200px)/2);padding-right:calc((100% - 1200px)/2)}main{background:white}section{margin:1.5rem 0;border-top:1px solid #d7dde5;padding-top:1rem}table{border-collapse:collapse;width:100%;margin-top:.75rem}td,th{border-bottom:1px solid #e5e9ef;text-align:left;padding:.45rem;vertical-align:top}input,select,button{padding:.45rem;margin:.15rem;border:1px solid #b9c3d0;border-radius:4px;background:white}.badge{border-radius:1rem;padding:.15rem .5rem;background:#dce4ee}.error{background:#ffd9d9}.warn{background:#fff0c2}.info{background:#dcecff}.diagnostic{border:1px solid #d7dde5;border-left:4px solid #9aa7b5;padding:.75rem;margin:.75rem 0}.diagnostic:target,tr:target{background:#fff8cf}.muted{color:#5d6a78}a{color:#0b5cad}#map{display:flex;gap:1rem;flex-wrap:wrap}.node{border:1px solid #9aa7b5;padding:.5rem;border-radius:4px}.hidden{display:none}</style></head><body><header><h1>Doc Bridge: ${escapeHtml(snapshot.project.name)}</h1><p>Offline architecture and documentation reconciliation report</p><p class="muted">Snapshot ${escapeHtml(snapshot.contentHash)} · Report ${escapeHtml(report.contentHash)} · Revision ${escapeHtml(snapshot.sourceRevision)}</p></header><main><section id="controls"><label>Search <input id="search" type="search" placeholder="entity, relation, diagnostic"></label><label>Severity <select id="severity"><option value="">Any</option><option>error</option><option>warn</option><option>info</option></select></label><label>Provenance <select id="provenance"><option value="">Any</option><option>observed</option><option>declared</option><option>proposed</option></select></label><label>Status <select id="status"><option value="">Any</option>${['confirmed', 'undocumented', 'stale-or-unverified', 'conflict', 'unresolved', 'not-analyzed'].map((value) => `<option>${value}</option>`).join('')}</select></label><label>Analyzer <input id="analyzer" type="search" placeholder="js-ts, report"></label><label>Entity <input id="entity" type="search" placeholder="entity id"></label><label>Relation <input id="relation" type="search" placeholder="relation id"></label><button id="reset">Reset filters</button></section><section id="architecture"><h2>Architecture map</h2><p>Observed and declared relations are rendered from the canonical snapshot; browser code does not infer edges.</p><div id="map">${nodes}</div><h3>Relations</h3><table><thead><tr><th>Kind</th><th>From</th><th>To</th><th>Provenance</th></tr></thead><tbody id="relations">${relations}</tbody></table></section><section id="diagnostic-lens"><h2>Diagnostic lens</h2><p>Findings: ${report.diagnostics.length}</p><div id="diagnostics">${diagnostics || '<p class="muted">No diagnostics.</p>'}</div></section><section id="coverage"><h2>Coverage and unsupported areas</h2><ul>${coverage || '<li>No coverage metadata.</li>'}</ul></section><section id="metadata"><h2>Run metadata</h2><dl><dt>Source revision</dt><dd>${escapeHtml(snapshot.sourceRevision)} (${escapeHtml(snapshot.sourceRevisionKind)})</dd><dt>Configuration hash</dt><dd>${escapeHtml(snapshot.configurationHash)}</dd><dt>Pipeline</dt><dd>${escapeHtml(snapshot.pipelineVersion)}</dd></dl></section></main><script>${script}</script></body></html>`
61
+ }
62
+
63
+ export const renderOfflineReport = (input: unknown, options: OfflineReportOptions = {}): string => {
64
+ try {
65
+ if (!input || typeof input !== 'object') throw new Error('Input must contain snapshot and report artifacts.')
66
+ const value = input as Partial<OfflineReportInput>
67
+ const snapshot = DiscoverySnapshotV1Schema.parse(value.snapshot)
68
+ const report = ReconciliationReportV1Schema.parse(value.report)
69
+ if (report.snapshotHash !== snapshot.contentHash) throw new Error('Report snapshotHash does not match snapshot contentHash.')
70
+ return render({ snapshot, report }, options)
71
+ } catch (error) {
72
+ return errorPage(error instanceof Error ? error.message : String(error))
73
+ }
74
+ }