@agentskit/doc-bridge 1.6.4 → 1.7.44

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 (38) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/action.yml +1 -1
  3. package/dist/cli/program.js +793 -137
  4. package/dist/cli/program.js.map +1 -1
  5. package/dist/config/index.d.ts +1 -1
  6. package/dist/config/index.js +38 -2
  7. package/dist/config/index.js.map +1 -1
  8. package/dist/{index-DudNuwI5.d.ts → index-C2PCQSrB.d.ts} +216 -25
  9. package/dist/index.d.ts +837 -67
  10. package/dist/index.js +858 -127
  11. package/dist/index.js.map +1 -1
  12. package/docs/PRD-enterprise-hardening.md +288 -0
  13. package/docs/adr/0001-enterprise-verification-contract.md +35 -0
  14. package/docs/knowledge-engine-runbook.md +18 -2
  15. package/docs/spec/analyzer-plugin-v1.md +24 -0
  16. package/docs/spec/benchmark-v1.md +30 -0
  17. package/docs/spec/config-v1.md +111 -0
  18. package/docs/validation-cycle-plan.md +236 -0
  19. package/docs/verification-harness.md +33 -4
  20. package/mcpb/manifest.json +1 -1
  21. package/package.json +1 -1
  22. package/scripts/report-visual-check.mjs +45 -10
  23. package/scripts/verification-harness.mjs +216 -13
  24. package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
  25. package/src/agents/registry-adapter.ts +31 -7
  26. package/src/cli/program.ts +44 -11
  27. package/src/config/index.ts +2 -0
  28. package/src/config/schema.ts +56 -0
  29. package/src/discovery/documentation.ts +46 -5
  30. package/src/discovery/repository.ts +95 -16
  31. package/src/index.ts +29 -0
  32. package/src/metrics/benchmark.ts +176 -0
  33. package/src/plugins/contract.ts +89 -0
  34. package/src/reconciliation/reconcile.ts +137 -3
  35. package/src/report/html.ts +302 -78
  36. package/src/schemas/knowledge.ts +16 -1
  37. package/src/version.ts +1 -1
  38. package/src/workflow/engine.ts +65 -9
@@ -0,0 +1,89 @@
1
+ import { z } from 'zod'
2
+
3
+ import { CoverageSchema, DiagnosticSchema, EntitySchema, RelationSchema, type Coverage, type KnowledgeDiagnostic, type KnowledgeEntity, type KnowledgeRelation } from '../schemas/knowledge.js'
4
+
5
+ export const ANALYZER_PLUGIN_CONTRACT_VERSION = 1 as const
6
+
7
+ export const AnalyzerPluginManifestSchema = z.object({
8
+ id: z.string().regex(/^[a-z][a-z0-9-]*$/).max(128),
9
+ version: z.string().min(1).max(64),
10
+ languages: z.array(z.string().min(1).max(64)).min(1).max(32),
11
+ frameworks: z.array(z.string().min(1).max(128)).max(32).default([]),
12
+ capabilities: z.array(z.string().min(1).max(128)).min(1).max(32),
13
+ knowledgeSchemaVersion: z.literal(1),
14
+ compatibility: z.object({ pipelineMajor: z.number().int().nonnegative() }).strict(),
15
+ unsupportedConstructs: z.array(z.string().min(1).max(256)).max(128).default([]),
16
+ resourceLimits: z.object({ maxFiles: z.number().int().positive().optional(), maxBytes: z.number().int().positive().optional() }).strict().default({}),
17
+ }).strict()
18
+
19
+ export type AnalyzerPluginManifest = z.infer<typeof AnalyzerPluginManifestSchema>
20
+
21
+ export const AnalyzerPluginOutputSchema = z.object({
22
+ entities: z.array(EntitySchema).max(50_000).default([]),
23
+ relations: z.array(RelationSchema).max(100_000).default([]),
24
+ coverage: z.array(CoverageSchema).max(1_000).default([]),
25
+ diagnostics: z.array(DiagnosticSchema).max(100_000).default([]),
26
+ }).strict()
27
+
28
+ export type AnalyzerPluginOutput = z.infer<typeof AnalyzerPluginOutputSchema>
29
+
30
+ export type AnalyzerPluginInput = {
31
+ readonly language: string
32
+ readonly framework?: string
33
+ readonly files: readonly { readonly path: string; readonly bytes: number }[]
34
+ readonly value?: unknown
35
+ }
36
+
37
+ export type AnalyzerPlugin = {
38
+ readonly manifest: AnalyzerPluginManifest
39
+ readonly analyze: (input: AnalyzerPluginInput) => Promise<unknown> | unknown
40
+ }
41
+
42
+ export type AnalyzerRegistry = {
43
+ readonly register: (plugin: AnalyzerPlugin) => void
44
+ readonly list: () => readonly AnalyzerPluginManifest[]
45
+ readonly analyze: (id: string, input: AnalyzerPluginInput) => Promise<AnalyzerPluginOutput>
46
+ }
47
+
48
+ const pipelineMajor = (version: string): number => Number.parseInt(version.split('.')[0] ?? '', 10)
49
+
50
+ export const createAnalyzerRegistry = (options: { readonly pipelineVersion?: string; readonly maxPlugins?: number } = {}): AnalyzerRegistry => {
51
+ const plugins = new Map<string, AnalyzerPlugin>()
52
+ const pipeline = pipelineMajor(options.pipelineVersion ?? '1.0.0')
53
+ return {
54
+ register(plugin) {
55
+ const manifest = AnalyzerPluginManifestSchema.parse(plugin.manifest)
56
+ if (manifest.compatibility.pipelineMajor !== pipeline) throw new Error(`Analyzer plugin "${manifest.id}" requires pipeline major ${manifest.compatibility.pipelineMajor}; current pipeline is ${pipeline}.`)
57
+ if (plugins.has(manifest.id)) throw new Error(`Analyzer plugin "${manifest.id}" is already registered.`)
58
+ if (options.maxPlugins !== undefined && plugins.size >= options.maxPlugins) throw new Error(`Analyzer plugin limit ${options.maxPlugins} exceeded.`)
59
+ plugins.set(manifest.id, { ...plugin, manifest })
60
+ },
61
+ list() {
62
+ return [...plugins.values()].map((plugin) => plugin.manifest).sort((a, b) => a.id.localeCompare(b.id))
63
+ },
64
+ async analyze(id, input) {
65
+ const plugin = plugins.get(id)
66
+ if (!plugin) throw new Error(`Analyzer plugin "${id}" is not registered.`)
67
+ const { maxFiles, maxBytes } = plugin.manifest.resourceLimits
68
+ const bytes = input.files.reduce((total, file) => total + file.bytes, 0)
69
+ if (maxFiles !== undefined && input.files.length > maxFiles) throw new Error(`Analyzer plugin "${id}" file limit ${maxFiles} exceeded.`)
70
+ if (maxBytes !== undefined && bytes > maxBytes) throw new Error(`Analyzer plugin "${id}" byte limit ${maxBytes} exceeded.`)
71
+ try {
72
+ const output = AnalyzerPluginOutputSchema.parse(await plugin.analyze(input))
73
+ return {
74
+ ...output,
75
+ coverage: output.coverage.map((entry) => ({ ...entry, analyzer: plugin.manifest.id, analyzerVersion: plugin.manifest.version })),
76
+ }
77
+ } catch (error) {
78
+ return {
79
+ entities: [],
80
+ relations: [],
81
+ diagnostics: [],
82
+ coverage: [{ analyzer: plugin.manifest.id, analyzerVersion: plugin.manifest.version, scope: 'plugin', status: 'not-analyzed', reason: `Plugin failed safely: ${error instanceof Error ? error.message : String(error)}` }],
83
+ }
84
+ }
85
+ },
86
+ }
87
+ }
88
+
89
+ export type { Coverage, KnowledgeDiagnostic, KnowledgeEntity, KnowledgeRelation }
@@ -11,8 +11,12 @@ import {
11
11
  type EntityResolver = (reference: string) => string
12
12
 
13
13
  export type ReconciliationOptions = {
14
+ /** Compare declarations at a semantic level while retaining raw discovery evidence. */
15
+ readonly scope?: 'file' | 'module' | 'package'
14
16
  /** Omit for backwards-compatible all-relation checking; [] disables missing-declaration findings. */
15
17
  readonly requiredRelationKinds?: readonly string[]
18
+ /** Emit one bounded finding for each observed Markdown document without declarations. */
19
+ readonly includeOrphanedDocuments?: boolean
16
20
  }
17
21
 
18
22
  const ignoredDocumentationRelations = new Set(['covers'])
@@ -51,6 +55,14 @@ const mergeEvidence = (...relations: readonly KnowledgeRelation[]): Evidence[] =
51
55
  return [...merged.values()].sort((a, b) => evidenceKey(a).localeCompare(evidenceKey(b)))
52
56
  }
53
57
 
58
+ const boundedEvidence = (evidence: readonly Evidence[]): Evidence[] => [...evidence].slice(0, 64)
59
+
60
+ const diagnosticCounts = (values: readonly string[]): Record<string, number> => {
61
+ const counts = new Map<string, number>()
62
+ for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1)
63
+ return Object.fromEntries([...counts.entries()].sort(([a], [b]) => a.localeCompare(b)))
64
+ }
65
+
54
66
  const diagnosticId = (code: string, value: unknown): string =>
55
67
  `reconciliation:${code}:${sha256NormalizedV1(value).slice(0, 32)}`
56
68
 
@@ -72,6 +84,80 @@ const entityById = (snapshots: readonly DiscoverySnapshotV1[]): ReadonlyMap<stri
72
84
  const isUnresolved = (id: string, entities: ReadonlyMap<string, KnowledgeEntity>): boolean =>
73
85
  id.startsWith('unresolved:') || entities.get(id)?.kind === 'unresolved-reference'
74
86
 
87
+ const normalizedPath = (path: string): string => path.replaceAll('\\', '/').replace(/^\.\//, '')
88
+
89
+ const semanticEntityId = (
90
+ id: string,
91
+ scope: NonNullable<ReconciliationOptions['scope']>,
92
+ entities: ReadonlyMap<string, KnowledgeEntity>,
93
+ packageByModule: ReadonlyMap<string, string>,
94
+ ): string => {
95
+ if (scope === 'file' || id.startsWith('external:') || id.startsWith('unresolved:')) return id
96
+ const entity = entities.get(id)
97
+ if (!entity || entity.kind !== 'module' || !entity.path) return id
98
+ if (scope === 'module') return id
99
+ return packageByModule.get(id) ?? id
100
+ }
101
+
102
+ const packageLookup = (scope: NonNullable<ReconciliationOptions['scope']>, entities: ReadonlyMap<string, KnowledgeEntity>): Map<string, string> => {
103
+ if (scope !== 'package') return new Map()
104
+ const packages = [...entities.values()]
105
+ .filter((entity) => entity.kind === 'package' && entity.path)
106
+ .map((entity) => ({ id: entity.id, path: normalizedPath(entity.path as string) }))
107
+ .sort((a, b) => b.path.length - a.path.length || a.id.localeCompare(b.id))
108
+ const result = new Map<string, string>()
109
+ for (const entity of entities.values()) {
110
+ if (entity.kind !== 'module' || !entity.path) continue
111
+ const modulePath = normalizedPath(entity.path)
112
+ const packageEntity = packages.find(({ path }) => path === '.' || modulePath === path || modulePath.startsWith(`${path}/`))
113
+ if (packageEntity) result.set(entity.id, packageEntity.id)
114
+ }
115
+ return result
116
+ }
117
+
118
+ const aggregatedRelations = (
119
+ relations: readonly KnowledgeRelation[],
120
+ scope: NonNullable<ReconciliationOptions['scope']>,
121
+ entities: ReadonlyMap<string, KnowledgeEntity>,
122
+ packageByModule: ReadonlyMap<string, string>,
123
+ ): KnowledgeRelation[] => {
124
+ if (scope === 'file') return [...relations]
125
+ const groups = new Map<string, KnowledgeRelation[]>()
126
+ for (const relation of relations) {
127
+ const from = semanticEntityId(relation.from, scope, entities, packageByModule)
128
+ const to = semanticEntityId(relation.to, scope, entities, packageByModule)
129
+ const detection = relationDetection(relation)
130
+ const key = `${from}\u0000${to}\u0000${relation.kind}\u0000${detection}`
131
+ const group = groups.get(key) ?? []
132
+ group.push(relation)
133
+ groups.set(key, group)
134
+ }
135
+ return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key, group]) => {
136
+ const first = group[0] as KnowledgeRelation
137
+ const parts = key.split('\u0000')
138
+ const from = parts[0] ?? first.from
139
+ const to = parts[1] ?? first.to
140
+ const kind = parts[2] ?? first.kind
141
+ const detection = parts[3] ?? relationDetection(first)
142
+ const mergedEvidence = mergeEvidence(...group)
143
+ return {
144
+ ...first,
145
+ id: `relation:aggregated:${scope}:${sha256NormalizedV1(key).slice(0, 32)}`,
146
+ from,
147
+ to,
148
+ ...(detection === 'static' && !first.discriminator ? {} : { discriminator: detection }),
149
+ evidence: boundedEvidence(mergedEvidence),
150
+ metadata: {
151
+ ...(first.metadata ?? {}),
152
+ aggregationScope: scope,
153
+ aggregatedRelationCount: group.length,
154
+ aggregatedEvidenceCount: mergedEvidence.length,
155
+ ...(mergedEvidence.length > 64 ? { evidenceTruncated: true } : {}),
156
+ },
157
+ }
158
+ })
159
+ }
160
+
75
161
  const relationMatches = (observed: KnowledgeRelation, declared: KnowledgeRelation, resolveEntity: EntityResolver): boolean => {
76
162
  if (relationBase(observed, resolveEntity) !== relationBase(declared, resolveEntity)) return false
77
163
  return relationDetection(declared) === relationDetection(observed)
@@ -93,7 +179,7 @@ const reportDiagnostic = (
93
179
  status,
94
180
  severity,
95
181
  message,
96
- evidence: [...evidence],
182
+ evidence: boundedEvidence(evidence),
97
183
  ...(entityIds?.length ? { entityIds: [...entityIds] } : {}),
98
184
  ...(relationIds?.length ? { relationIds: [...relationIds] } : {}),
99
185
  ...(remediation ? { remediation } : {}),
@@ -106,11 +192,32 @@ export const reconcileKnowledge = (
106
192
  ): ReconciliationReportV1 => {
107
193
  const resolveEntity = entityResolver([observed, declared])
108
194
  const entities = entityById([observed, declared])
109
- const observedRelations = observed.relations.filter((relation) => relation.provenance === 'observed' && !ignoredDocumentationRelations.has(relation.kind))
110
- const declaredRelations = declared.relations.filter((relation) => relation.provenance === 'declared' && !ignoredDocumentationRelations.has(relation.kind))
195
+ const scope = options.scope ?? 'file'
196
+ const packageByModule = packageLookup(scope, entities)
197
+ const allDeclaredRelations = declared.relations.filter((relation) => relation.provenance === 'declared')
198
+ const observedRelations = aggregatedRelations(observed.relations.filter((relation) => relation.provenance === 'observed' && !ignoredDocumentationRelations.has(relation.kind)), scope, entities, packageByModule)
199
+ const declaredRelations = aggregatedRelations(declared.relations.filter((relation) => relation.provenance === 'declared' && !ignoredDocumentationRelations.has(relation.kind)), scope, entities, packageByModule)
111
200
  const requiredRelationKinds = options.requiredRelationKinds === undefined ? undefined : new Set(options.requiredRelationKinds)
112
201
  const diagnostics: ReconciliationReportV1['diagnostics'][number][] = []
113
202
 
203
+ if (options.includeOrphanedDocuments) {
204
+ const declaredDocumentIds = new Set(allDeclaredRelations.map((relation) => relation.from).filter((id) => id.startsWith('document:')))
205
+ for (const document of observed.entities.filter((entity) => entity.kind === 'document').sort((a, b) => a.id.localeCompare(b.id))) {
206
+ if (declaredDocumentIds.has(document.id)) continue
207
+ diagnostics.push(reportDiagnostic(
208
+ 'DOCUMENTATION_ORPHANED',
209
+ 'undocumented',
210
+ 'info',
211
+ 'Markdown document has no Doc Bridge declaration linking it to observed knowledge.',
212
+ document.evidence,
213
+ document.id,
214
+ [document.id],
215
+ undefined,
216
+ 'Add a docbridge declaration or exclude this document from the documentation comparison scope.',
217
+ ))
218
+ }
219
+ }
220
+
114
221
  for (const entity of declared.entities.filter((item) => isUnresolved(item.id, entities)).sort((a, b) => a.id.localeCompare(b.id))) {
115
222
  diagnostics.push(reportDiagnostic(
116
223
  'UNRESOLVED_ENTITY_REFERENCE',
@@ -211,6 +318,29 @@ export const reconcileKnowledge = (
211
318
  }
212
319
 
213
320
  const sortedDiagnostics = [...diagnostics].sort((a, b) => a.id.localeCompare(b.id))
321
+ const packageEntities = observed.entities.filter((entity) => entity.kind === 'package')
322
+ const packageStatus = { fresh: 0, stale: 0, missing: 0, unverified: 0 }
323
+ const diagnosticStatusByEntity = new Map<string, Set<ReconciliationReportV1['diagnostics'][number]['status']>>()
324
+ const relationEndpoints = new Map([...observedRelations, ...declaredRelations].map((relation) => [relation.id, [relation.from, relation.to]]))
325
+ for (const diagnostic of sortedDiagnostics) for (const id of [...(diagnostic.entityIds ?? []), ...(diagnostic.relationIds ?? []).flatMap((relationId) => relationEndpoints.get(relationId) ?? [])]) {
326
+ const statuses = diagnosticStatusByEntity.get(id) ?? new Set()
327
+ statuses.add(diagnostic.status)
328
+ diagnosticStatusByEntity.set(id, statuses)
329
+ }
330
+ for (const packageEntity of packageEntities) {
331
+ const docs = allDeclaredRelations.filter((relation) => relation.from.startsWith('document:') && relation.to === packageEntity.id)
332
+ const statuses = diagnosticStatusByEntity.get(packageEntity.id) ?? new Set()
333
+ if (!docs.length) packageStatus.missing += 1
334
+ else if (statuses.has('conflict') || statuses.has('stale-or-unverified')) packageStatus.stale += 1
335
+ else if (statuses.has('undocumented') || statuses.has('unresolved') || statuses.has('not-analyzed')) packageStatus.unverified += 1
336
+ else packageStatus.fresh += 1
337
+ }
338
+ const documentation = {
339
+ documentCount: observed.entities.filter((entity) => entity.kind === 'document').length,
340
+ documentedDocumentCount: new Set(allDeclaredRelations.filter((relation) => relation.from.startsWith('document:')).map((relation) => relation.from)).size,
341
+ packageCount: packageEntities.length,
342
+ packageStatus,
343
+ }
214
344
  const base = {
215
345
  type: 'reconciliation-report' as const,
216
346
  schemaVersion: 1 as const,
@@ -228,7 +358,11 @@ export const reconcileKnowledge = (
228
358
  entityCount: observed.entities.length,
229
359
  relationCount: observedRelations.length,
230
360
  diagnosticCount: sortedDiagnostics.length,
361
+ ...(options.scope === undefined ? {} : { scope: options.scope }),
231
362
  ...(options.requiredRelationKinds === undefined ? {} : { requiredRelationKinds: [...new Set(options.requiredRelationKinds)].sort() }),
363
+ diagnosticsByCode: diagnosticCounts(sortedDiagnostics.map((diagnostic) => diagnostic.code)),
364
+ diagnosticsByStatus: diagnosticCounts(sortedDiagnostics.map((diagnostic) => diagnostic.status)),
365
+ documentation,
232
366
  },
233
367
  }
234
368
  return ReconciliationReportV1Schema.parse({ ...base, contentHash: contentHashForArtifactV1(base) })