@agentskit/doc-bridge 1.4.3 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,320 @@
1
+ import { contentHashForArtifactV1 } from '../index-builder/content-hash.js'
2
+ import {
3
+ DiscoverySnapshotV1Schema,
4
+ type DiscoverySnapshotV1,
5
+ type Evidence,
6
+ type KnowledgeEntity,
7
+ type KnowledgeRelation,
8
+ } from '../schemas/knowledge.js'
9
+
10
+ export type DocumentationDiagnostic = {
11
+ readonly code: string
12
+ readonly message: string
13
+ readonly path: string
14
+ readonly evidence: Evidence
15
+ }
16
+
17
+ export type DocumentationDeclarationInput = {
18
+ readonly path: string
19
+ readonly content: string
20
+ }
21
+
22
+ export type DocumentationDeclarationOptions = {
23
+ readonly snapshot: Pick<DiscoverySnapshotV1, 'entities'>
24
+ readonly documentId?: string
25
+ }
26
+
27
+ export type DocumentationDeclarationResult = {
28
+ readonly hasDocbridge: boolean
29
+ readonly entities: readonly KnowledgeEntity[]
30
+ readonly relations: readonly KnowledgeRelation[]
31
+ readonly diagnostics: readonly DocumentationDiagnostic[]
32
+ }
33
+
34
+ export type DocumentationAnalysisResult = {
35
+ readonly snapshot: DiscoverySnapshotV1
36
+ readonly diagnostics: readonly DocumentationDiagnostic[]
37
+ }
38
+
39
+ type RelationFields = {
40
+ from?: string
41
+ to?: string
42
+ kind?: string
43
+ detection?: string
44
+ startLine: number
45
+ endLine: number
46
+ fields: Set<string>
47
+ }
48
+
49
+ const detectionValues = new Set(['static', 'dynamic', 'external'])
50
+
51
+ const evidence = (path: string, lineStart: number, lineEnd = lineStart): Evidence => ({
52
+ source: 'documentation',
53
+ path,
54
+ lineStart,
55
+ lineEnd,
56
+ })
57
+
58
+ const diagnostic = (
59
+ path: string,
60
+ code: string,
61
+ message: string,
62
+ lineStart: number,
63
+ lineEnd = lineStart,
64
+ ): DocumentationDiagnostic => ({ code, message, path: 'docbridge', evidence: evidence(path, lineStart, lineEnd) })
65
+
66
+ const scalar = (value: string): string => {
67
+ const trimmed = value.trim()
68
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
69
+ return trimmed.slice(1, -1)
70
+ }
71
+ return trimmed
72
+ }
73
+
74
+ const list = (value: string): string[] | undefined => {
75
+ const trimmed = value.trim()
76
+ if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return undefined
77
+ const body = trimmed.slice(1, -1).trim()
78
+ return body ? body.split(',').map(scalar).filter(Boolean) : []
79
+ }
80
+
81
+ const findFrontmatter = (content: string): { readonly lines: readonly string[]; readonly end: number } | undefined => {
82
+ const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/)
83
+ if (lines[0] !== '---') return undefined
84
+ const end = lines.findIndex((line, index) => index > 0 && line === '---')
85
+ return end < 0 ? undefined : { lines, end }
86
+ }
87
+
88
+ const addDiagnostic = (
89
+ diagnostics: DocumentationDiagnostic[],
90
+ input: DocumentationDeclarationInput,
91
+ code: string,
92
+ message: string,
93
+ line: number,
94
+ endLine = line,
95
+ ): void => {
96
+ diagnostics.push(diagnostic(input.path, code, message, line, endLine))
97
+ }
98
+
99
+ const resolveEntity = (
100
+ reference: string,
101
+ entities: readonly KnowledgeEntity[],
102
+ input: DocumentationDeclarationInput,
103
+ lineStart: number,
104
+ unresolved: Map<string, KnowledgeEntity>,
105
+ ): KnowledgeEntity => {
106
+ const resolved = entities.find((entity) => entity.id === reference || entity.aliases?.includes(reference))
107
+ if (resolved) return resolved
108
+ const id = `unresolved:${reference}`
109
+ const existing = unresolved.get(id)
110
+ if (existing) return existing
111
+ const entity: KnowledgeEntity = {
112
+ id,
113
+ kind: 'unresolved-reference',
114
+ name: reference,
115
+ provenance: 'declared',
116
+ evidence: [evidence(input.path, lineStart)],
117
+ }
118
+ unresolved.set(id, entity)
119
+ return entity
120
+ }
121
+
122
+ const relationKey = (from: string, to: string, kind: string): string => `${from}\u0000${to}\u0000${kind}`
123
+
124
+ const parseBlock = (
125
+ input: DocumentationDeclarationInput,
126
+ ): { readonly covers: readonly { value: string; line: number }[]; readonly relations: readonly RelationFields[]; readonly diagnostics: readonly DocumentationDiagnostic[]; readonly hasDocbridge: boolean } => {
127
+ const frontmatter = findFrontmatter(input.content)
128
+ if (!frontmatter) {
129
+ if (input.content.replace(/^\uFEFF/, '').startsWith('---')) {
130
+ return {
131
+ covers: [],
132
+ relations: [],
133
+ hasDocbridge: true,
134
+ diagnostics: [diagnostic(input.path, 'DOCBRIDGE_FRONTMATTER_MALFORMED', 'Frontmatter must close with a line containing only ---.', 1)],
135
+ }
136
+ }
137
+ return { covers: [], relations: [], diagnostics: [], hasDocbridge: false }
138
+ }
139
+
140
+ const { lines, end } = frontmatter
141
+ const docbridgeLine = lines.findIndex((line, index) => index > 0 && index < end && /^docbridge\s*:/.test(line))
142
+ if (docbridgeLine < 0) return { covers: [], relations: [], diagnostics: [], hasDocbridge: false }
143
+
144
+ const diagnostics: DocumentationDiagnostic[] = []
145
+ const covers: { value: string; line: number }[] = []
146
+ const relations: RelationFields[] = []
147
+ const inline = lines[docbridgeLine]?.slice('docbridge:'.length).trim() ?? ''
148
+ if (inline && inline !== '{}') addDiagnostic(diagnostics, input, 'DOCBRIDGE_BLOCK_MALFORMED', 'docbridge must be a nested frontmatter object.', docbridgeLine + 1)
149
+
150
+ let section: 'covers' | 'relations' | undefined
151
+ let current: RelationFields | undefined
152
+ const finishRelation = (): void => {
153
+ if (current) relations.push(current)
154
+ current = undefined
155
+ }
156
+
157
+ for (let index = docbridgeLine + 1; index < end; index += 1) {
158
+ const raw = lines[index] ?? ''
159
+ const trimmed = raw.trim()
160
+ const line = index + 1
161
+ if (!trimmed || trimmed.startsWith('#')) continue
162
+ if (/\t/.test(raw)) {
163
+ addDiagnostic(diagnostics, input, 'DOCBRIDGE_INDENTATION_INVALID', 'docbridge indentation must use spaces.', line)
164
+ continue
165
+ }
166
+ if (!raw.startsWith(' ')) {
167
+ finishRelation()
168
+ section = undefined
169
+ continue
170
+ }
171
+ if (/^ {2}[A-Za-z][A-Za-z0-9_-]*\s*:/.test(raw)) {
172
+ finishRelation()
173
+ const match = /^ {2}([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(raw)
174
+ const key = match?.[1]
175
+ const value = match?.[2] ?? ''
176
+ if (key !== 'covers' && key !== 'relations') {
177
+ addDiagnostic(diagnostics, input, 'DOCBRIDGE_FIELD_UNKNOWN', `Unknown docbridge field: ${key ?? '(missing)'}.`, line)
178
+ section = undefined
179
+ } else if (key === 'covers') {
180
+ section = 'covers'
181
+ if (value) {
182
+ const values = list(value)
183
+ if (!values) addDiagnostic(diagnostics, input, 'DOCBRIDGE_COVERS_INVALID', 'covers must be a list of entity references.', line)
184
+ else values.forEach((item) => covers.push({ value: item, line }))
185
+ }
186
+ } else {
187
+ section = 'relations'
188
+ if (value) addDiagnostic(diagnostics, input, 'DOCBRIDGE_RELATIONS_INVALID', 'relations must be a list of relation objects.', line)
189
+ }
190
+ continue
191
+ }
192
+ if (section === 'covers' && /^ {4}-\s*/.test(raw)) {
193
+ const value = scalar(raw.replace(/^ {4}-\s*/, ''))
194
+ if (!value) addDiagnostic(diagnostics, input, 'DOCBRIDGE_REFERENCE_MISSING', 'covers entries must not be empty.', line)
195
+ else covers.push({ value, line })
196
+ continue
197
+ }
198
+ if (section === 'relations' && /^ {4}-\s*/.test(raw)) {
199
+ finishRelation()
200
+ const firstField = /^ {4}-\s*([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(raw)
201
+ current = { startLine: line, endLine: line, fields: new Set() }
202
+ if (firstField?.[1]) {
203
+ current.fields.add(firstField[1])
204
+ current[firstField[1] as 'from' | 'to' | 'kind' | 'detection'] = scalar(firstField[2] ?? '')
205
+ } else if (raw.replace(/^ {4}-\s*/, '').trim()) {
206
+ addDiagnostic(diagnostics, input, 'DOCBRIDGE_RELATION_INVALID', 'Relation entries must be field mappings.', line)
207
+ }
208
+ continue
209
+ }
210
+ if (section === 'relations' && current && /^ {6}[A-Za-z][A-Za-z0-9_-]*\s*:/.test(raw)) {
211
+ const field = /^ {6}([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(raw)
212
+ const key = field?.[1]
213
+ current.endLine = line
214
+ if (!key || !['from', 'to', 'kind', 'detection'].includes(key)) {
215
+ addDiagnostic(diagnostics, input, 'DOCBRIDGE_FIELD_UNKNOWN', `Unknown relation field: ${key ?? '(missing)'}.`, line)
216
+ } else if (current.fields.has(key)) {
217
+ addDiagnostic(diagnostics, input, 'DOCBRIDGE_FIELD_DUPLICATE', `Duplicate relation field: ${key}.`, line)
218
+ } else {
219
+ current.fields.add(key)
220
+ current[key as 'from' | 'to' | 'kind' | 'detection'] = scalar(field?.[2] ?? '')
221
+ }
222
+ continue
223
+ }
224
+ addDiagnostic(diagnostics, input, 'DOCBRIDGE_STRUCTURE_INVALID', `Invalid docbridge structure at line ${line}.`, line)
225
+ }
226
+ finishRelation()
227
+ return { covers, relations, diagnostics, hasDocbridge: true }
228
+ }
229
+
230
+ export const parseDocumentationDeclarations = (
231
+ input: DocumentationDeclarationInput,
232
+ options: DocumentationDeclarationOptions,
233
+ ): DocumentationDeclarationResult => {
234
+ const parsed = parseBlock(input)
235
+ if (!parsed.hasDocbridge) return { hasDocbridge: false, entities: [], relations: [], diagnostics: [] }
236
+ const diagnostics = [...parsed.diagnostics]
237
+ const unresolved = new Map<string, KnowledgeEntity>()
238
+ const relations: KnowledgeRelation[] = []
239
+ const documentId = options.documentId ?? `document:${input.path}`
240
+ const relationClaims = new Map<string, string>()
241
+
242
+ if (!parsed.covers.length && !parsed.relations.length) {
243
+ addDiagnostic(diagnostics, input, 'DOCBRIDGE_CONTENT_MISSING', 'docbridge must declare covers or relations.', 1)
244
+ }
245
+
246
+ for (const [index, cover] of parsed.covers.entries()) {
247
+ const target = resolveEntity(cover.value, options.snapshot.entities, input, cover.line, unresolved)
248
+ relations.push({
249
+ id: `relation:declared:${input.path}:covers:${index}`,
250
+ kind: 'covers',
251
+ from: documentId,
252
+ to: target.id,
253
+ provenance: 'declared',
254
+ evidence: [evidence(input.path, cover.line)],
255
+ })
256
+ }
257
+
258
+ for (const [index, declaration] of parsed.relations.entries()) {
259
+ const basePath = `relations[${index}]`
260
+ const missing = (['from', 'to', 'kind', 'detection'] as const).filter((field) => !declaration[field])
261
+ if (missing.length) {
262
+ addDiagnostic(diagnostics, input, 'DOCBRIDGE_RELATION_FIELD_MISSING', `Relation is missing required field(s): ${missing.join(', ')}.`, declaration.startLine, declaration.endLine)
263
+ continue
264
+ }
265
+ const from = declaration.from as string
266
+ const to = declaration.to as string
267
+ const kind = declaration.kind as string
268
+ const detection = declaration.detection as string
269
+ if (!detectionValues.has(detection)) {
270
+ addDiagnostic(diagnostics, input, 'DOCBRIDGE_DETECTION_INVALID', `Invalid relation detection: ${detection}.`, declaration.startLine, declaration.endLine)
271
+ continue
272
+ }
273
+ const fromEntity = resolveEntity(from, options.snapshot.entities, input, declaration.startLine, unresolved)
274
+ const toEntity = resolveEntity(to, options.snapshot.entities, input, declaration.startLine, unresolved)
275
+ const key = relationKey(fromEntity.id, toEntity.id, kind)
276
+ const previousDetection = relationClaims.get(key)
277
+ if (previousDetection === detection) addDiagnostic(diagnostics, input, 'DOCBRIDGE_DECLARATION_DUPLICATE', 'Duplicate relation declaration.', declaration.startLine, declaration.endLine)
278
+ if (previousDetection && previousDetection !== detection) addDiagnostic(diagnostics, input, 'DOCBRIDGE_DECLARATION_CONFLICT', 'Conflicting relation declarations use different detection values.', declaration.startLine, declaration.endLine)
279
+ relationClaims.set(key, previousDetection ?? detection)
280
+ relations.push({
281
+ id: `relation:declared:${input.path}:${basePath}`,
282
+ kind,
283
+ from: fromEntity.id,
284
+ to: toEntity.id,
285
+ discriminator: detection,
286
+ provenance: 'declared',
287
+ evidence: [evidence(input.path, declaration.startLine, declaration.endLine)],
288
+ metadata: { detection },
289
+ })
290
+ }
291
+
292
+ return {
293
+ hasDocbridge: true,
294
+ entities: [...unresolved.values()].sort((a, b) => a.id.localeCompare(b.id)),
295
+ relations,
296
+ diagnostics,
297
+ }
298
+ }
299
+
300
+ export const applyDocumentationDeclarations = (
301
+ snapshot: DiscoverySnapshotV1,
302
+ documents: readonly DocumentationDeclarationInput[],
303
+ ): DocumentationAnalysisResult => {
304
+ const entities = new Map(snapshot.entities.map((entity) => [entity.id, entity]))
305
+ const relations = new Map(snapshot.relations.map((relation) => [relation.id, relation]))
306
+ const diagnostics: DocumentationDiagnostic[] = []
307
+
308
+ for (const document of documents) {
309
+ const result = parseDocumentationDeclarations(document, { snapshot })
310
+ diagnostics.push(...result.diagnostics)
311
+ for (const entity of result.entities) entities.set(entity.id, entity)
312
+ for (const relation of result.relations) relations.set(relation.id, relation)
313
+ }
314
+
315
+ const base = { ...snapshot, contentHash: '0'.repeat(64), entities: [...entities.values()], relations: [...relations.values()] }
316
+ return {
317
+ snapshot: DiscoverySnapshotV1Schema.parse({ ...base, contentHash: contentHashForArtifactV1(base) }),
318
+ diagnostics,
319
+ }
320
+ }