@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.
- package/CHANGELOG.md +19 -0
- package/action.yml +1 -1
- package/dist/cli/program.js +2461 -274
- package/dist/cli/program.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +77 -4
- package/dist/config/index.js.map +1 -1
- package/dist/{index-DAeq_OIi.d.ts → index-Di7PkJuf.d.ts} +209 -8
- package/dist/index.d.ts +1942 -4
- package/dist/index.js +2190 -193
- package/dist/index.js.map +1 -1
- package/docs/PRD-doc-bridge-knowledge-engine.md +338 -0
- package/docs/knowledge-engine-runbook.md +44 -0
- package/ecosystem-claims.json +19 -19
- package/ecosystem-upstream.json +2 -2
- package/ecosystem.json +117 -109
- package/mcpb/manifest.json +25 -1
- package/package.json +2 -2
- package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
- package/src/agents/registry-adapter.ts +97 -0
- package/src/cli/program.ts +285 -3
- package/src/config/defaults.ts +14 -1
- package/src/config/schema.ts +69 -0
- package/src/conformance/ecosystem-contract.ts +6 -3
- package/src/discovery/documentation.ts +320 -0
- package/src/discovery/repository.ts +514 -0
- package/src/fixes/proposals.ts +165 -0
- package/src/index-builder/content-hash.ts +9 -2
- package/src/index-builder/human-adapters/plain-markdown.ts +17 -1
- package/src/index-builder/llms-txt.ts +22 -2
- package/src/index-builder/scan-corpus.ts +17 -1
- package/src/index.ts +99 -2
- package/src/mcp/server.ts +178 -8
- package/src/reconciliation/reconcile.ts +227 -0
- package/src/report/html.ts +74 -0
- package/src/rules/engine.ts +180 -0
- package/src/safety/repository.ts +84 -0
- package/src/schemas/knowledge.ts +315 -0
- package/src/validate.ts +24 -0
- package/src/version.ts +1 -1
- package/src/workflow/engine.ts +238 -0
- 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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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('&', '&')
|
|
15
|
+
.replaceAll('<', '<')
|
|
16
|
+
.replaceAll('>', '>')
|
|
17
|
+
.replaceAll('"', '"')
|
|
18
|
+
.replaceAll("'", ''')
|
|
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('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"').replaceAll("'",''');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
|
+
}
|