@agentskit/doc-bridge 1.0.2 → 1.2.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/CONTRIBUTING.md +8 -0
  3. package/README.md +114 -17
  4. package/SECURITY.md +1 -1
  5. package/action.yml +23 -26
  6. package/dist/cli/program.js +1241 -309
  7. package/dist/cli/program.js.map +1 -1
  8. package/dist/config/index.d.ts +1 -1
  9. package/dist/config/index.js +338 -13
  10. package/dist/config/index.js.map +1 -1
  11. package/dist/{index-CPUJbTbg.d.ts → index-DGI9TBLE.d.ts} +906 -11
  12. package/dist/index.d.ts +65 -11
  13. package/dist/index.js +1084 -171
  14. package/dist/index.js.map +1 -1
  15. package/docs/DOGFOOD-ROUND2.md +5 -0
  16. package/docs/DOGFOOD-ROUND3.md +5 -0
  17. package/docs/DOGFOOD-V1.md +5 -0
  18. package/docs/DOGFOOD.md +5 -0
  19. package/docs/MARKETPLACE-ECOSYSTEM-PLAN.md +16 -0
  20. package/docs/MARKETPLACE.md +39 -0
  21. package/docs/POSITIONING.md +5 -0
  22. package/docs/RELEASE.md +26 -19
  23. package/docs/agent-corpus/INDEX.md +10 -0
  24. package/docs/agent-corpus/OVERVIEW.md +9 -0
  25. package/docs/agent-corpus/chat.md +10 -0
  26. package/docs/agent-corpus/cli.md +10 -0
  27. package/docs/agent-corpus/conformance.md +10 -0
  28. package/docs/agent-corpus/doc-bridge.md +10 -0
  29. package/docs/agent-corpus/doctor.md +10 -0
  30. package/docs/agent-corpus/gates.md +10 -0
  31. package/docs/agent-corpus/mcp.md +10 -0
  32. package/docs/agent-corpus/memory.md +10 -0
  33. package/docs/agent-corpus/query.md +10 -0
  34. package/docs/chat-and-rag.md +34 -0
  35. package/docs/examples.md +5 -0
  36. package/docs/for-agents.md +31 -0
  37. package/docs/getting-started.md +32 -2
  38. package/docs/index.md +23 -0
  39. package/docs/landing/assets/doc-bridge-hero.webp +0 -0
  40. package/docs/landing/assets/doc-bridge-surfaces.webp +0 -0
  41. package/docs/landing/assets/doc-bridge-two-way.webp +0 -0
  42. package/docs/landing/index.html +70 -10
  43. package/docs/mcp.md +5 -0
  44. package/docs/meta.json +20 -0
  45. package/docs/ollama-demo.md +6 -1
  46. package/docs/playbook/doc-bridge-pattern.md +4 -2
  47. package/docs/query.md +34 -0
  48. package/docs/recipes/index-pipeline.md +7 -2
  49. package/docs/schemas/agent-handoff-v1.md +5 -0
  50. package/docs/schemas/doc-bridge-index-v1.md +5 -0
  51. package/docs/schemas/memory-candidate-v1.md +15 -1
  52. package/docs/skills/doc-bridge.md +6 -1
  53. package/docs/spec/cli.md +6 -0
  54. package/docs/spec/config-v1.md +56 -6
  55. package/docs/spec/documentation-standard-v1.md +136 -0
  56. package/docs/spec/playbook-feedback.md +5 -0
  57. package/docs/spec/registry-agents.md +5 -0
  58. package/ecosystem-claims.json +187 -0
  59. package/ecosystem-upstream.json +9 -0
  60. package/ecosystem.json +235 -0
  61. package/examples/verify-handoff.mjs +5 -0
  62. package/package.json +46 -4
  63. package/scripts/check-ecosystem-upstream.mjs +50 -0
  64. package/src/cli/program.ts +36 -3
  65. package/src/config/index.ts +7 -1
  66. package/src/config/load-config.ts +4 -14
  67. package/src/config/schema.ts +91 -0
  68. package/src/conformance/documentation-standard-v1.ts +502 -0
  69. package/src/conformance/ecosystem-contract.ts +175 -0
  70. package/src/gates/run-gates.ts +33 -4
  71. package/src/index-builder/human-adapters/core.ts +12 -5
  72. package/src/index-builder/human-adapters/docusaurus.ts +29 -44
  73. package/src/index-builder/human-adapters/index.ts +15 -3
  74. package/src/index-builder/scan-corpus.ts +6 -6
  75. package/src/index.ts +17 -0
  76. package/src/lib/bounded-text.ts +25 -0
  77. package/src/lib/paths.ts +20 -2
  78. package/src/lib/static-js-literal.ts +261 -0
  79. package/src/lib/walk.ts +23 -4
  80. package/src/version.ts +1 -1
@@ -0,0 +1,502 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path'
3
+
4
+ import type {
5
+ DocBridgeConfigV1,
6
+ DocumentationStandardRuleId,
7
+ DocumentationStandardV1Config,
8
+ } from '../config/schema.js'
9
+ import { parseCanonicalEcosystemContract } from './ecosystem-contract.js'
10
+ import { buildDocBridgeIndex } from '../index-builder/build-index.js'
11
+ import { scanHumanDocRecords } from '../index-builder/human-adapters/index.js'
12
+ import { renderLlmsTxt } from '../index-builder/llms-txt.js'
13
+ import { toPosix } from '../lib/paths.js'
14
+
15
+ export const DOCUMENTATION_STANDARD_V1_ID = 'documentation-standard-v1' as const
16
+ export const DOCUMENTATION_STANDARD_V1_STATUS = 'stable' as const
17
+
18
+ const MAX_TEXT_EVIDENCE_BYTES = 4 * 1_024 * 1_024
19
+
20
+ export type { DocumentationStandardRuleId } from '../config/schema.js'
21
+
22
+ export type DocumentationStandardRuleLevel = 'required' | 'recommended'
23
+ export type DocumentationStandardRuleStatus = 'pass' | 'fail' | 'excepted'
24
+
25
+ export type DocumentationStandardEvidence = {
26
+ readonly path: string
27
+ readonly detail: string
28
+ }
29
+
30
+ export type DocumentationStandardRemediation = {
31
+ readonly command: string
32
+ readonly detail: string
33
+ }
34
+
35
+ export type DocumentationStandardRuleResult = {
36
+ readonly id: DocumentationStandardRuleId
37
+ readonly level: DocumentationStandardRuleLevel
38
+ readonly status: DocumentationStandardRuleStatus
39
+ readonly ok: boolean
40
+ readonly message: string
41
+ readonly evidence: readonly DocumentationStandardEvidence[]
42
+ readonly remediation: DocumentationStandardRemediation
43
+ readonly exception?: {
44
+ readonly reason: string
45
+ readonly approvedBy: string
46
+ readonly trackingUrl: string
47
+ }
48
+ }
49
+
50
+ export type DocumentationConformanceReportV1 = {
51
+ readonly schemaVersion: 1
52
+ readonly profile: {
53
+ readonly id: typeof DOCUMENTATION_STANDARD_V1_ID
54
+ readonly version: 1
55
+ readonly status: typeof DOCUMENTATION_STANDARD_V1_STATUS
56
+ }
57
+ readonly ok: boolean
58
+ readonly recommendedOk: boolean
59
+ readonly summary: {
60
+ readonly required: { readonly passed: number; readonly failed: number; readonly excepted: number }
61
+ readonly recommended: { readonly passed: number; readonly failed: number; readonly excepted: number }
62
+ }
63
+ readonly results: readonly DocumentationStandardRuleResult[]
64
+ }
65
+
66
+ type RuleDraft = Omit<DocumentationStandardRuleResult, 'status' | 'ok' | 'exception'> & {
67
+ readonly passed: boolean
68
+ }
69
+
70
+ const safePath = (root: string, path: string): string | undefined => {
71
+ const rootAbs = realpathSync.native(resolve(root))
72
+ const unresolved = resolve(rootAbs, path)
73
+ const unresolvedRel = relative(rootAbs, unresolved)
74
+ if (isAbsolute(unresolvedRel) || unresolvedRel === '..' || unresolvedRel.startsWith(`..${sep}`)) return undefined
75
+ if (!existsSync(unresolved)) return unresolved
76
+ try {
77
+ const abs = realpathSync.native(unresolved)
78
+ const rel = relative(rootAbs, abs)
79
+ return !isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`) ? abs : undefined
80
+ } catch {
81
+ return undefined
82
+ }
83
+ }
84
+
85
+ const fileEvidence = (
86
+ root: string,
87
+ path: string,
88
+ options?: { readonly readContent?: boolean },
89
+ ): { readonly exists: boolean; readonly content: string; readonly evidence: DocumentationStandardEvidence } => {
90
+ const abs = safePath(root, path)
91
+ if (!abs) {
92
+ return {
93
+ exists: false,
94
+ content: '',
95
+ evidence: { path, detail: 'Path escapes the project root.' },
96
+ }
97
+ }
98
+ if (!existsSync(abs)) {
99
+ return { exists: false, content: '', evidence: { path, detail: 'File does not exist.' } }
100
+ }
101
+ try {
102
+ const stat = statSync(abs)
103
+ if (!stat.isFile()) {
104
+ return { exists: false, content: '', evidence: { path, detail: 'Path is not a regular file.' } }
105
+ }
106
+ if (stat.size === 0) {
107
+ return { exists: false, content: '', evidence: { path, detail: 'File is empty.' } }
108
+ }
109
+ if (options?.readContent === false) {
110
+ return {
111
+ exists: true,
112
+ content: '',
113
+ evidence: { path: toPosix(relative(resolve(root), abs)) || '.', detail: 'File exists and is non-empty.' },
114
+ }
115
+ }
116
+ if (stat.size > MAX_TEXT_EVIDENCE_BYTES) {
117
+ return {
118
+ exists: false,
119
+ content: '',
120
+ evidence: { path, detail: `Text evidence exceeds ${MAX_TEXT_EVIDENCE_BYTES} bytes.` },
121
+ }
122
+ }
123
+ const content = readFileSync(abs, 'utf8')
124
+ return {
125
+ exists: content.trim().length > 0,
126
+ content,
127
+ evidence: {
128
+ path: toPosix(relative(resolve(root), abs)) || '.',
129
+ detail: content.trim().length > 0 ? 'File exists and is non-empty.' : 'File is empty.',
130
+ },
131
+ }
132
+ } catch {
133
+ return { exists: false, content: '', evidence: { path, detail: 'File is not readable text.' } }
134
+ }
135
+ }
136
+
137
+ const resultWithException = (
138
+ draft: RuleDraft,
139
+ options: DocumentationStandardV1Config,
140
+ ): DocumentationStandardRuleResult => {
141
+ if (draft.passed) {
142
+ const { passed: _passed, ...result } = draft
143
+ return { ...result, status: 'pass', ok: true }
144
+ }
145
+
146
+ const exception = options.exceptions?.find((candidate) => candidate.ruleId === draft.id)
147
+ const { passed: _passed, ...result } = draft
148
+ if (!exception) return { ...result, status: 'fail', ok: false }
149
+ return {
150
+ ...result,
151
+ status: 'excepted',
152
+ ok: true,
153
+ exception: {
154
+ reason: exception.reason,
155
+ approvedBy: exception.approvedBy,
156
+ trackingUrl: exception.trackingUrl,
157
+ },
158
+ }
159
+ }
160
+
161
+ const humanDocsRule = (root: string, config: DocBridgeConfigV1): RuleDraft => {
162
+ const docs = scanHumanDocRecords(root, config)
163
+ return {
164
+ id: 'human-docs',
165
+ level: 'required',
166
+ passed: docs.length > 0,
167
+ message: docs.length > 0 ? `Found ${docs.length} human document(s).` : 'No human documentation was discovered.',
168
+ evidence: docs.slice(0, 10).map((doc) => ({
169
+ path: toPosix(relative(resolve(root), doc.path)),
170
+ detail: `Human route: ${doc.url}`,
171
+ })),
172
+ remediation: {
173
+ command: 'edit doc-bridge.config.json',
174
+ detail: 'Configure corpus.human with a supported adapter and a non-agent documentation root.',
175
+ },
176
+ }
177
+ }
178
+
179
+ const llmsRule = (
180
+ root: string,
181
+ config: DocBridgeConfigV1,
182
+ options: DocumentationStandardV1Config,
183
+ ): RuleDraft => {
184
+ const llmsPath = config.index?.llmsTxt?.outFile ?? 'llms.txt'
185
+ const llmsKey = safePath(root, llmsPath) ?? resolve(root, llmsPath)
186
+ const rawSources = new Map<string, string>()
187
+ for (const path of options.rawSources ?? []) {
188
+ const key = safePath(root, path) ?? resolve(root, path)
189
+ if (key !== llmsKey && !rawSources.has(key)) rawSources.set(key, path)
190
+ }
191
+ const paths = [llmsPath, ...rawSources.values()]
192
+ const evidence = paths.map((path) => fileEvidence(root, path))
193
+ const generated = buildDocBridgeIndex({ root, config, write: false }).index
194
+ const expectedLlms = renderLlmsTxt(config, generated.knowledge, generated.project?.name ?? 'project')
195
+ const llmsIsFresh = evidence[0]?.content === expectedLlms
196
+ if (evidence[0]?.exists) {
197
+ evidence[0] = {
198
+ ...evidence[0],
199
+ evidence: {
200
+ ...evidence[0].evidence,
201
+ detail: llmsIsFresh
202
+ ? 'File matches the deterministic ak-docs output.'
203
+ : 'File is stale or was not generated by the current ak-docs inputs.',
204
+ },
205
+ }
206
+ }
207
+ const passed =
208
+ config.index?.llmsTxt?.enabled !== false &&
209
+ paths.length > 1 &&
210
+ llmsIsFresh &&
211
+ evidence.every((item) => item.exists)
212
+ return {
213
+ id: 'llms-and-raw-source',
214
+ level: 'required',
215
+ passed,
216
+ message: passed
217
+ ? `Resolved llms.txt and ${paths.length - 1} raw source(s).`
218
+ : 'llms.txt must be enabled, current, and accompanied by at least one readable raw source.',
219
+ evidence: evidence.map((item) => item.evidence),
220
+ remediation: {
221
+ command: 'ak-docs index',
222
+ detail: 'Generate llms.txt and configure conformance.documentationStandardV1.rawSources.',
223
+ },
224
+ }
225
+ }
226
+
227
+ const normalizedUrl = (value: string): string => value.replace(/\/$/, '')
228
+
229
+ const ecosystemContract = (
230
+ root: string,
231
+ options: DocumentationStandardV1Config,
232
+ ): {
233
+ readonly passed: boolean
234
+ readonly urls: ReadonlySet<string>
235
+ readonly evidence: readonly DocumentationStandardEvidence[]
236
+ } => {
237
+ const declaration = options.ecosystemContract
238
+ if (!declaration) {
239
+ return {
240
+ passed: false,
241
+ urls: new Set(),
242
+ evidence: [{ path: 'doc-bridge.config.json', detail: 'Canonical ecosystem contract evidence is not declared.' }],
243
+ }
244
+ }
245
+
246
+ const manifestFile = fileEvidence(root, declaration.manifest)
247
+ const claimsFile = fileEvidence(root, declaration.claims)
248
+ const evidence: DocumentationStandardEvidence[] = [manifestFile.evidence, claimsFile.evidence]
249
+ if (!manifestFile.exists || !claimsFile.exists) return { passed: false, urls: new Set(), evidence }
250
+
251
+ try {
252
+ const manifest: unknown = JSON.parse(manifestFile.content)
253
+ const claims: unknown = JSON.parse(claimsFile.content)
254
+ const contract = parseCanonicalEcosystemContract(manifest, claims)
255
+ const productIds = contract.manifest.products.map((product) => product.id)
256
+ if (!productIds.includes(declaration.productId)) throw new Error(`Manifest is missing product ${declaration.productId}.`)
257
+
258
+ const urls = new Set<string>()
259
+ for (const product of contract.manifest.products) {
260
+ for (const value of [
261
+ product.surfaces.home,
262
+ product.surfaces.docs,
263
+ product.surfaces.llms,
264
+ product.surfaces.stats,
265
+ ]) {
266
+ if (typeof value === 'string' && /^https:\/\//.test(value)) urls.add(normalizedUrl(value))
267
+ }
268
+ }
269
+ evidence[0] = { path: declaration.manifest, detail: `Validated ${productIds.length} canonical product(s), including ${declaration.productId}.` }
270
+ evidence[1] = { path: declaration.claims, detail: `Validated claim-ledger identity for ${contract.claims.products.length} product(s).` }
271
+ return { passed: urls.size > 0, urls, evidence }
272
+ } catch (error) {
273
+ evidence.push({
274
+ path: `${declaration.manifest}, ${declaration.claims}`,
275
+ detail: error instanceof Error ? error.message : 'Canonical ecosystem contract is invalid.',
276
+ })
277
+ return { passed: false, urls: new Set(), evidence }
278
+ }
279
+ }
280
+
281
+ const handoffsRule = (root: string, config: DocBridgeConfigV1): RuleDraft => {
282
+ const index = buildDocBridgeIndex({ root, config, write: false }).index
283
+ const handoffs = Object.values(index.handoffs ?? {})
284
+ const ready = handoffs.filter(
285
+ (handoff) =>
286
+ handoff.startHere.length > 0 &&
287
+ handoff.editRoots.length > 0 &&
288
+ handoff.checks.length > 0 &&
289
+ (handoff.bridge?.humanDoc === 'linked' || handoff.bridge?.humanDoc === 'external'),
290
+ )
291
+ return {
292
+ id: 'agent-handoffs',
293
+ level: 'required',
294
+ passed: ready.length > 0 && ready.length === handoffs.length,
295
+ message:
296
+ ready.length > 0 && ready.length === handoffs.length
297
+ ? `${ready.length} handoff(s) are action-ready and human-linked.`
298
+ : `${ready.length}/${handoffs.length} handoff(s) are action-ready and human-linked.`,
299
+ evidence: handoffs.map((handoff) => ({
300
+ path: handoff.startHere,
301
+ detail: `${handoff.target.id}: ${handoff.editRoots.length} edit root(s), ${handoff.checks.length} check(s), bridge=${handoff.bridge?.humanDoc ?? 'none'}`,
302
+ })),
303
+ remediation: {
304
+ command: 'ak-docs bootstrap agent-docs && ak-docs index',
305
+ detail: 'Add ownership checks and a resolvable humanDoc for every handoff.',
306
+ },
307
+ }
308
+ }
309
+
310
+ const contributionRule = (root: string, options: DocumentationStandardV1Config): RuleDraft => {
311
+ const paths = options.contributionPaths?.length ? options.contributionPaths : ['CONTRIBUTING.md']
312
+ const evidence = paths.map((path) => fileEvidence(root, path))
313
+ return {
314
+ id: 'contribution',
315
+ level: 'required',
316
+ passed: evidence.some((item) => item.exists),
317
+ message: evidence.some((item) => item.exists) ? 'Contribution guidance is available.' : 'Contribution guidance is missing.',
318
+ evidence: evidence.map((item) => item.evidence),
319
+ remediation: {
320
+ command: 'edit CONTRIBUTING.md',
321
+ detail: 'Document setup, validation commands, and the pull-request workflow.',
322
+ },
323
+ }
324
+ }
325
+
326
+ const markersRule = (
327
+ root: string,
328
+ options: DocumentationStandardV1Config,
329
+ kind: 'metadata' | 'structured-diagrams',
330
+ level: DocumentationStandardRuleLevel,
331
+ ): RuleDraft => {
332
+ const declarations = options[kind === 'metadata' ? 'metadata' : 'diagrams'] ?? []
333
+ const evidence: DocumentationStandardEvidence[] = []
334
+ let passed = declarations.length > 0
335
+ for (const declaration of declarations) {
336
+ const file = fileEvidence(root, declaration.path)
337
+ const missing = declaration.contains.filter((marker) => !file.content.includes(marker))
338
+ if (!file.exists || missing.length > 0) passed = false
339
+ evidence.push({
340
+ path: declaration.path,
341
+ detail: !file.exists
342
+ ? file.evidence.detail
343
+ : missing.length
344
+ ? `Missing marker(s): ${missing.join(', ')}`
345
+ : `Found marker(s): ${declaration.contains.join(', ')}`,
346
+ })
347
+ }
348
+ return {
349
+ id: kind,
350
+ level,
351
+ passed,
352
+ message: passed ? `${kind} evidence is complete.` : `${kind} evidence is incomplete.`,
353
+ evidence,
354
+ remediation: {
355
+ command: 'edit doc-bridge.config.json',
356
+ detail: `Declare ${kind} evidence paths and markers that exist in the repository.`,
357
+ },
358
+ }
359
+ }
360
+
361
+ const linksRule = (root: string, options: DocumentationStandardV1Config): RuleDraft => {
362
+ const links = options.links ?? []
363
+ const contract = ecosystemContract(root, options)
364
+ const evidence: DocumentationStandardEvidence[] = [...contract.evidence]
365
+ let passed = links.length > 0 && contract.passed
366
+ for (const link of links) {
367
+ const sources = link.paths.map((path) => ({ path, file: fileEvidence(root, path) }))
368
+ const matches = sources.filter(({ file }) => file.exists && file.content.includes(link.url))
369
+ const canonical = contract.urls.has(normalizedUrl(link.url))
370
+ if (matches.length === 0 || !canonical) passed = false
371
+ evidence.push({
372
+ path: sources.map((source) => source.path).join(', '),
373
+ detail:
374
+ matches.length === 0
375
+ ? `Missing ${link.url}`
376
+ : canonical
377
+ ? `Found canonical ecosystem URL ${link.url}`
378
+ : `Found ${link.url}, but it is absent from the canonical ecosystem manifest.`,
379
+ })
380
+ }
381
+ return {
382
+ id: 'cross-links',
383
+ level: 'required',
384
+ passed,
385
+ message: passed ? `${links.length} required ecosystem link(s) resolve in source.` : 'One or more required ecosystem links are missing from source.',
386
+ evidence,
387
+ remediation: {
388
+ command: 'edit README.md',
389
+ detail: 'Sync the canonical ecosystem snapshots and add each configured canonical URL to a declared documentation source.',
390
+ },
391
+ }
392
+ }
393
+
394
+ const quickstartsRule = (root: string, options: DocumentationStandardV1Config): RuleDraft => {
395
+ const quickstarts = options.quickstarts ?? []
396
+ const evidence: DocumentationStandardEvidence[] = []
397
+ let passed = quickstarts.length > 0
398
+ for (const quickstart of quickstarts) {
399
+ const doc = fileEvidence(root, quickstart.doc)
400
+ const test = fileEvidence(root, quickstart.test)
401
+ const missingMarkers = quickstart.testContains.filter((marker) => !test.content.includes(marker))
402
+ if (!doc.exists || !test.exists || missingMarkers.length > 0 || !quickstart.command.trim()) passed = false
403
+ evidence.push(
404
+ { path: quickstart.doc, detail: `${quickstart.id}: ${doc.evidence.detail}` },
405
+ {
406
+ path: quickstart.test,
407
+ detail: missingMarkers.length
408
+ ? `${quickstart.id}: missing test marker(s): ${missingMarkers.join(', ')}`
409
+ : `${quickstart.id}: test evidence; CI command: ${quickstart.command}`,
410
+ },
411
+ )
412
+ }
413
+ return {
414
+ id: 'tested-quickstarts',
415
+ level: 'required',
416
+ passed,
417
+ message: passed ? `${quickstarts.length} quickstart(s) have executable test evidence.` : 'Quickstart test evidence is incomplete.',
418
+ evidence,
419
+ remediation: {
420
+ command: 'pnpm test',
421
+ detail: 'Map every quickstart to a documentation path, test file, identifying marker, and CI command.',
422
+ },
423
+ }
424
+ }
425
+
426
+ const visualsRule = (root: string, options: DocumentationStandardV1Config): RuleDraft => {
427
+ const visuals = options.visuals ?? []
428
+ const evidence = visuals.map((path) => fileEvidence(root, path, { readContent: false }))
429
+ return {
430
+ id: 'visual-explanations',
431
+ level: 'recommended',
432
+ passed: visuals.length > 0 && evidence.every((item) => item.exists),
433
+ message: visuals.length > 0 && evidence.every((item) => item.exists) ? `${visuals.length} visual asset(s) found.` : 'Visual explanation evidence is incomplete.',
434
+ evidence: evidence.map((item) => item.evidence),
435
+ remediation: {
436
+ command: 'edit doc-bridge.config.json',
437
+ detail: 'Declare the images or animations that explain the product workflow.',
438
+ },
439
+ }
440
+ }
441
+
442
+ const count = (
443
+ results: readonly DocumentationStandardRuleResult[],
444
+ level: DocumentationStandardRuleLevel,
445
+ status: DocumentationStandardRuleStatus,
446
+ ): number => results.filter((result) => result.level === level && result.status === status).length
447
+
448
+ export const runDocumentationStandardV1 = (
449
+ root: string,
450
+ config: DocBridgeConfigV1,
451
+ ): DocumentationConformanceReportV1 => {
452
+ const options = config.conformance?.documentationStandardV1 ?? {}
453
+ const drafts: RuleDraft[] = [
454
+ humanDocsRule(root, config),
455
+ llmsRule(root, config, options),
456
+ handoffsRule(root, config),
457
+ contributionRule(root, options),
458
+ markersRule(root, options, 'metadata', 'required'),
459
+ linksRule(root, options),
460
+ quickstartsRule(root, options),
461
+ visualsRule(root, options),
462
+ markersRule(root, options, 'structured-diagrams', 'recommended'),
463
+ ]
464
+ const results = drafts.map((draft) => resultWithException(draft, options))
465
+ return {
466
+ schemaVersion: 1,
467
+ profile: { id: DOCUMENTATION_STANDARD_V1_ID, version: 1, status: DOCUMENTATION_STANDARD_V1_STATUS },
468
+ ok: results.filter((result) => result.level === 'required').every((result) => result.ok),
469
+ recommendedOk: results.filter((result) => result.level === 'recommended').every((result) => result.ok),
470
+ summary: {
471
+ required: {
472
+ passed: count(results, 'required', 'pass'),
473
+ failed: count(results, 'required', 'fail'),
474
+ excepted: count(results, 'required', 'excepted'),
475
+ },
476
+ recommended: {
477
+ passed: count(results, 'recommended', 'pass'),
478
+ failed: count(results, 'recommended', 'fail'),
479
+ excepted: count(results, 'recommended', 'excepted'),
480
+ },
481
+ },
482
+ results,
483
+ }
484
+ }
485
+
486
+ export const formatDocumentationStandardText = (
487
+ report: DocumentationConformanceReportV1,
488
+ ): string[] => [
489
+ `Documentation Standard v1 (${report.profile.status})`,
490
+ `Required: ${report.summary.required.passed} passed · ${report.summary.required.failed} failed · ${report.summary.required.excepted} excepted`,
491
+ `Recommended: ${report.summary.recommended.passed} passed · ${report.summary.recommended.failed} failed · ${report.summary.recommended.excepted} excepted`,
492
+ '',
493
+ ...report.results.flatMap((result) => [
494
+ `${result.status === 'pass' ? 'PASS' : result.status === 'excepted' ? 'EXCEPTED' : 'FAIL'} [${result.level}] ${result.id}: ${result.message}`,
495
+ ...result.evidence.map((evidence) => ` evidence: ${evidence.path} — ${evidence.detail}`),
496
+ ...(result.exception
497
+ ? [` exception: ${result.exception.reason} — ${result.exception.approvedBy} (${result.exception.trackingUrl})`]
498
+ : result.ok
499
+ ? []
500
+ : [` fix: ${result.remediation.command} — ${result.remediation.detail}`]),
501
+ ]),
502
+ ]
@@ -0,0 +1,175 @@
1
+ import { z } from 'zod'
2
+
3
+ const NonEmptyStringSchema = z.string().refine((value) => value.trim().length > 0, 'must be non-empty')
4
+ const HttpsUrlSchema = z.string().url().refine((value) => value.startsWith('https://'), 'must use https')
5
+ const RepoSchema = z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/)
6
+ const SlugSchema = z.string().regex(/^[a-z][a-z0-9-]*$/)
7
+
8
+ const SurfaceSchema = z.object({
9
+ home: HttpsUrlSchema.optional(),
10
+ docs: HttpsUrlSchema.optional(),
11
+ llms: HttpsUrlSchema.optional(),
12
+ stats: HttpsUrlSchema.optional(),
13
+ documentation: z.enum(['fumadocs', 'repository']),
14
+ chat: z.enum(['agentschat', 'custom', 'none']),
15
+ }).passthrough()
16
+
17
+ const ProductSchema = z.object({
18
+ id: SlugSchema,
19
+ name: NonEmptyStringSchema,
20
+ shortName: NonEmptyStringSchema,
21
+ kind: NonEmptyStringSchema,
22
+ role: NonEmptyStringSchema,
23
+ promise: NonEmptyStringSchema,
24
+ maturity: z.enum(['planning', 'alpha', 'beta', 'stable', 'deprecated']),
25
+ repo: RepoSchema,
26
+ accent: z.string().regex(/^#[0-9A-Fa-f]{6}$/),
27
+ surfaces: SurfaceSchema,
28
+ navigation: z.object({
29
+ showInBar: z.boolean(),
30
+ order: z.number().int().nonnegative().optional(),
31
+ next: z.array(SlugSchema),
32
+ }).passthrough(),
33
+ }).passthrough()
34
+
35
+ const LegacyPropertySchema = z.object({
36
+ id: SlugSchema,
37
+ name: NonEmptyStringSchema,
38
+ barLabel: NonEmptyStringSchema,
39
+ domain: NonEmptyStringSchema,
40
+ url: HttpsUrlSchema,
41
+ repo: RepoSchema,
42
+ tagline: NonEmptyStringSchema,
43
+ kind: NonEmptyStringSchema,
44
+ accent: z.string().regex(/^#[0-9A-Fa-f]{6}$/),
45
+ llms: HttpsUrlSchema.optional(),
46
+ stats: HttpsUrlSchema.optional(),
47
+ }).passthrough()
48
+
49
+ const ManifestSchema = z.object({
50
+ schemaVersion: z.literal(2),
51
+ parentBrand: z.object({ id: NonEmptyStringSchema, name: NonEmptyStringSchema }).passthrough(),
52
+ products: z.array(ProductSchema).min(1),
53
+ properties: z.array(LegacyPropertySchema).length(4),
54
+ builder: z.object({ id: NonEmptyStringSchema, name: NonEmptyStringSchema, url: HttpsUrlSchema }).passthrough().optional(),
55
+ }).passthrough()
56
+
57
+ const EvidenceSchema = z.discriminatedUnion('type', [
58
+ z.object({
59
+ type: z.literal('repository-derivation'),
60
+ repo: RepoSchema,
61
+ path: NonEmptyStringSchema,
62
+ summary: NonEmptyStringSchema,
63
+ }).passthrough(),
64
+ z.object({ type: z.literal('endpoint'), url: HttpsUrlSchema, summary: NonEmptyStringSchema }).passthrough(),
65
+ ])
66
+
67
+ const ClaimSchema = z.object({
68
+ id: NonEmptyStringSchema,
69
+ value: z.number().finite().nonnegative(),
70
+ noun: NonEmptyStringSchema,
71
+ conservativeFloor: z.number().int().nonnegative().optional(),
72
+ evidence: EvidenceSchema,
73
+ }).passthrough()
74
+
75
+ const ClaimProductSchema = z.object({
76
+ productId: SlugSchema,
77
+ source: z.discriminatedUnion('type', [
78
+ z.object({ type: z.literal('endpoint'), url: HttpsUrlSchema }).passthrough(),
79
+ z.object({ type: z.literal('repository'), repo: RepoSchema }).passthrough(),
80
+ ]),
81
+ verification: z.enum(['verified', 'declared']),
82
+ claims: z.array(ClaimSchema),
83
+ }).passthrough()
84
+
85
+ const ClaimsSchema = z.object({
86
+ schemaVersion: z.literal(1),
87
+ manifestSchemaVersion: z.literal(2),
88
+ products: z.array(ClaimProductSchema),
89
+ }).passthrough()
90
+
91
+ const LEGACY_PRODUCT_IDS = ['agentskit', 'akos', 'playbook', 'registry'] as const
92
+
93
+ export const parseCanonicalEcosystemContract = (
94
+ manifestInput: unknown,
95
+ claimsInput: unknown,
96
+ ): { readonly manifest: z.infer<typeof ManifestSchema>; readonly claims: z.infer<typeof ClaimsSchema> } => {
97
+ const manifest = ManifestSchema.parse(manifestInput)
98
+ const claims = ClaimsSchema.parse(claimsInput)
99
+ const products = new Map(manifest.products.map((product) => [product.id, product]))
100
+ if (products.size !== manifest.products.length) throw new Error('Manifest product IDs must be unique.')
101
+
102
+ const navigationOrders = new Set<number>()
103
+ for (const product of manifest.products) {
104
+ if (product.surfaces.documentation === 'fumadocs' && !product.surfaces.docs) {
105
+ throw new Error(`Product ${product.id} requires a docs surface for Fumadocs.`)
106
+ }
107
+ if (product.navigation.showInBar) {
108
+ if (!product.surfaces.home || product.navigation.order === undefined) {
109
+ throw new Error(`Product ${product.id} requires home and order for shared navigation.`)
110
+ }
111
+ if (navigationOrders.has(product.navigation.order)) throw new Error('Navigation orders must be unique.')
112
+ navigationOrders.add(product.navigation.order)
113
+ }
114
+ const next = new Set(product.navigation.next)
115
+ if (next.size !== product.navigation.next.length || next.has(product.id)) {
116
+ throw new Error(`Product ${product.id} has duplicate or self-referential navigation.`)
117
+ }
118
+ for (const nextId of next) {
119
+ if (!products.has(nextId)) throw new Error(`Product ${product.id} references unknown product ${nextId}.`)
120
+ }
121
+ }
122
+
123
+ for (const [index, id] of LEGACY_PRODUCT_IDS.entries()) {
124
+ const legacy = manifest.properties[index]
125
+ const product = products.get(id)
126
+ if (!legacy || !product || legacy.id !== id || !product.surfaces.home) {
127
+ throw new Error(`Legacy property ${index} must project product ${id}.`)
128
+ }
129
+ const expected = {
130
+ name: product.name,
131
+ barLabel: product.shortName,
132
+ domain: new URL(product.surfaces.home).host,
133
+ url: product.surfaces.home,
134
+ repo: product.repo,
135
+ tagline: product.promise,
136
+ kind: product.kind,
137
+ accent: product.accent,
138
+ llms: product.surfaces.llms,
139
+ stats: product.surfaces.stats,
140
+ }
141
+ for (const [key, value] of Object.entries(expected)) {
142
+ if (legacy[key as keyof typeof legacy] !== value) throw new Error(`Legacy ${id}.${key} must match v2.`)
143
+ }
144
+ }
145
+
146
+ const claimProducts = new Map(claims.products.map((product) => [product.productId, product]))
147
+ if (claimProducts.size !== claims.products.length || claimProducts.size !== products.size) {
148
+ throw new Error('Claims must include every manifest product exactly once.')
149
+ }
150
+ for (const [productId, product] of products) {
151
+ const claimProduct = claimProducts.get(productId)
152
+ if (!claimProduct) throw new Error(`Claims are missing product ${productId}.`)
153
+ if (claimProduct.source.type === 'endpoint') {
154
+ if (claimProduct.source.url !== product.surfaces.stats) throw new Error(`Claims source for ${productId} must match stats.`)
155
+ } else if (claimProduct.source.repo !== product.repo) {
156
+ throw new Error(`Claims source for ${productId} must match repo.`)
157
+ }
158
+ if (claimProduct.verification === 'declared' && claimProduct.claims.length > 0) {
159
+ throw new Error(`Declared product ${productId} cannot publish claims.`)
160
+ }
161
+ const claimIds = new Set<string>()
162
+ for (const claim of claimProduct.claims) {
163
+ if (claimIds.has(claim.id)) throw new Error(`Product ${productId} has duplicate claim ${claim.id}.`)
164
+ claimIds.add(claim.id)
165
+ if (claim.conservativeFloor !== undefined && claim.conservativeFloor > claim.value) {
166
+ throw new Error(`Claim ${productId}:${claim.id} has a floor above its value.`)
167
+ }
168
+ if (claim.evidence.type === 'repository-derivation' && claim.evidence.repo !== product.repo) {
169
+ throw new Error(`Claim ${productId}:${claim.id} evidence must match the product repo.`)
170
+ }
171
+ }
172
+ }
173
+
174
+ return { manifest, claims }
175
+ }