@agentskit/doc-bridge 1.2.6 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +32 -6
  3. package/action.yml +1 -1
  4. package/dist/cli/program.js +344 -130
  5. package/dist/cli/program.js.map +1 -1
  6. package/dist/config/index.d.ts +1 -1
  7. package/dist/config/index.js +3 -1
  8. package/dist/config/index.js.map +1 -1
  9. package/dist/{index-DGI9TBLE.d.ts → index-DAeq_OIi.d.ts} +22 -22
  10. package/dist/index.d.ts +7 -4
  11. package/dist/index.js +320 -105
  12. package/dist/index.js.map +1 -1
  13. package/docs/MARKETPLACE.md +1 -1
  14. package/docs/POSITIONING.md +1 -1
  15. package/docs/examples.md +4 -0
  16. package/docs/getting-started.md +1 -1
  17. package/docs/guides/choose-context-layer.md +129 -0
  18. package/docs/guides/gate-ci.md +1 -1
  19. package/docs/guides/meta.json +1 -0
  20. package/docs/landing/index.html +2 -2
  21. package/docs/playbook/doc-bridge-pattern.md +1 -1
  22. package/docs/qa/vitepress-starlight-adapters.md +19 -0
  23. package/docs/recipes/index-pipeline.md +1 -1
  24. package/docs/spec/config-v1.md +33 -2
  25. package/examples/nextra-only.config.ts +17 -0
  26. package/examples/nx-monorepo.config.ts +11 -0
  27. package/examples/starlight-only.config.ts +17 -0
  28. package/examples/vitepress-only.config.ts +19 -0
  29. package/mcpb/manifest.json +1 -1
  30. package/package.json +15 -4
  31. package/skills/doc-bridge-handoff/SKILL.md +38 -0
  32. package/skills/doc-bridge-handoff/fixtures/synthetic-repo/doc-bridge.config.json +23 -0
  33. package/skills/doc-bridge-handoff/fixtures/synthetic-repo/docs/for-agents/packages/payments.md +5 -0
  34. package/skills/doc-bridge-handoff/fixtures/synthetic-repo/packages/payments/package.json +4 -0
  35. package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +70 -0
  36. package/src/config/schema.ts +3 -1
  37. package/src/index-builder/build-handoffs.ts +2 -0
  38. package/src/index-builder/build-index.ts +7 -1
  39. package/src/index-builder/human-adapters/index.ts +6 -0
  40. package/src/index-builder/human-adapters/nextra.ts +43 -0
  41. package/src/index-builder/human-adapters/starlight.ts +40 -0
  42. package/src/index-builder/human-adapters/vitepress.ts +43 -0
  43. package/src/index-builder/plugins/nx.ts +161 -0
  44. package/src/index-builder/plugins/pnpm-monorepo.ts +1 -0
  45. package/src/index-builder/watch-index.ts +11 -2
  46. package/src/index.ts +1 -0
  47. package/src/version.ts +1 -1
@@ -9,6 +9,7 @@ import { renderCapabilitiesJson } from './capabilities.js'
9
9
  import { sha256NormalizedV1 } from './content-hash.js'
10
10
  import { renderLlmsTxt } from './llms-txt.js'
11
11
  import { scanHumanDocs } from './human-adapters/index.js'
12
+ import { discoverNxProjects } from './plugins/nx.js'
12
13
  import { discoverPnpmPackages } from './plugins/pnpm-monorepo.js'
13
14
  import { scanAgentCorpus } from './scan-corpus.js'
14
15
 
@@ -66,7 +67,12 @@ export const buildDocBridgeIndex = (opts: BuildIndexOptions): BuildIndexResult =
66
67
  config.routing?.plugin === 'npm-workspaces' ||
67
68
  config.routing?.plugin === 'yarn-workspaces'
68
69
 
69
- const discovered = shouldDiscover ? discoverPnpmPackages(root, config) : []
70
+ const discovered =
71
+ config.routing?.plugin === 'nx'
72
+ ? discoverNxProjects(root, config)
73
+ : shouldDiscover
74
+ ? discoverPnpmPackages(root, config)
75
+ : []
70
76
  const packages = collectPackages(config, discovered, corpus)
71
77
  const humanDocs = scanHumanDocs(root, config)
72
78
 
@@ -4,8 +4,11 @@ import { resolve, sep } from 'node:path'
4
4
  import type { DocBridgeConfigV1, HumanCorpusConfig } from '../../config/schema.js'
5
5
  import { docusaurusAdapter } from './docusaurus.js'
6
6
  import { fumadocsAdapter } from './fumadocs.js'
7
+ import { nextraAdapter } from './nextra.js'
7
8
  import type { HumanAdapter, HumanDocMap, HumanDocRecord } from './core.js'
8
9
  import { plainMarkdownAdapter } from './plain-markdown.js'
10
+ import { starlightAdapter } from './starlight.js'
11
+ import { vitepressAdapter } from './vitepress.js'
9
12
 
10
13
  export type { HumanAdapter, HumanDocMap, HumanDocRecord } from './core.js'
11
14
 
@@ -13,6 +16,9 @@ const ADAPTERS: readonly HumanAdapter[] = [
13
16
  plainMarkdownAdapter,
14
17
  fumadocsAdapter,
15
18
  docusaurusAdapter,
19
+ vitepressAdapter,
20
+ starlightAdapter,
21
+ nextraAdapter,
16
22
  ]
17
23
 
18
24
  const humanConfigs = (config: DocBridgeConfigV1): HumanCorpusConfig[] => {
@@ -0,0 +1,43 @@
1
+ import {
2
+ optionString,
3
+ parseFrontmatter,
4
+ routeSlug,
5
+ scanMarkdownDocs,
6
+ type HumanAdapter,
7
+ type HumanDocRecord,
8
+ } from './core.js'
9
+
10
+ const nextraRecordId = (relPath: string, raw: string): string => {
11
+ const frontmatter = parseFrontmatter(raw)
12
+ return frontmatter.package ?? frontmatter.module ?? frontmatter.id ?? (routeSlug(relPath) || 'index')
13
+ }
14
+
15
+ const isIndexPage = (path: string): boolean => /(^|[/\\])index\.mdx?$/i.test(path)
16
+
17
+ const resolveRouteCollisions = (records: readonly HumanDocRecord[]): HumanDocRecord[] => {
18
+ const byUrl = new Map<string, { readonly record: HumanDocRecord; readonly indexPage: boolean }>()
19
+ for (const record of records) {
20
+ const normalized = { ...record, url: record.url || '/' }
21
+ const indexPage = isIndexPage(record.path)
22
+ const existing = byUrl.get(normalized.url)
23
+ if (!existing || indexPage || !existing.indexPage) {
24
+ byUrl.set(normalized.url, { record: normalized, indexPage })
25
+ }
26
+ }
27
+ return [...byUrl.values()].map(({ record }) => record)
28
+ }
29
+
30
+ export const nextraAdapter: HumanAdapter = {
31
+ plugin: 'nextra',
32
+ scan: ({ root, config }) => {
33
+ const contentDir = optionString(config.options, ['contentDir', 'docsDir', 'root'])
34
+ if (!contentDir) return []
35
+
36
+ return resolveRouteCollisions(
37
+ scanMarkdownDocs(root, contentDir, {
38
+ idForDoc: nextraRecordId,
39
+ urlPrefix: optionString(config.options, ['urlPrefix', 'contentDirBasePath']) ?? '/',
40
+ }),
41
+ )
42
+ },
43
+ }
@@ -0,0 +1,40 @@
1
+ import { slug as githubSlug } from 'github-slugger'
2
+
3
+ import {
4
+ optionString,
5
+ parseFrontmatter,
6
+ routeSlug,
7
+ scanMarkdownDocs,
8
+ type HumanAdapter,
9
+ } from './core.js'
10
+
11
+ const isStarlightPage = (relPath: string, raw: string): boolean => {
12
+ if (relPath.split('/').some((part) => part.startsWith('.') || part.startsWith('_'))) return false
13
+ return parseFrontmatter(raw).draft !== 'true'
14
+ }
15
+
16
+ const starlightFileSlug = (relPath: string): string =>
17
+ routeSlug(relPath)
18
+ .split('/')
19
+ .map((part) => githubSlug(part))
20
+ .filter(Boolean)
21
+ .join('/')
22
+
23
+ const starlightSlug = (relPath: string, raw: string): string => {
24
+ const slug = parseFrontmatter(raw).slug
25
+ return slug ? slug.replace(/^\/+|\/+$/g, '') : starlightFileSlug(relPath)
26
+ }
27
+
28
+ export const starlightAdapter: HumanAdapter = {
29
+ plugin: 'starlight',
30
+ scan: ({ root, config }) => {
31
+ const contentDir = optionString(config.options, ['contentDir', 'docsDir', 'root'])
32
+ if (!contentDir) return []
33
+
34
+ return scanMarkdownDocs(root, contentDir, {
35
+ includeRelPath: isStarlightPage,
36
+ slugForDoc: starlightSlug,
37
+ urlPrefix: config.options?.urlPrefix,
38
+ })
39
+ },
40
+ }
@@ -0,0 +1,43 @@
1
+ import { minimatch } from 'minimatch'
2
+
3
+ import { optionString, routeSlug, scanMarkdownDocs, type HumanAdapter } from './core.js'
4
+
5
+ const isVitePressPage = (relPath: string): boolean =>
6
+ !relPath.split('/').some((part) => part === '.vitepress' || part.startsWith('.'))
7
+
8
+ const srcExcludePatterns = (value: unknown): readonly string[] => {
9
+ if (value === undefined) return []
10
+ if (!Array.isArray(value) || value.length > 64) {
11
+ throw new Error('VitePress srcExclude must be an array of at most 64 glob patterns.')
12
+ }
13
+ return value.map((pattern) => {
14
+ if (typeof pattern !== 'string' || !pattern || pattern.length > 256 || pattern.includes('\0')) {
15
+ throw new Error('Each VitePress srcExclude pattern must be a non-empty string up to 256 characters.')
16
+ }
17
+ return pattern
18
+ })
19
+ }
20
+
21
+ const isExcluded = (relPath: string, patterns: readonly string[]): boolean =>
22
+ patterns.some((pattern) => minimatch(relPath, pattern, { dot: true }))
23
+
24
+ const vitepressSlug = (relPath: string, cleanUrls: boolean): string => {
25
+ const slug = routeSlug(relPath)
26
+ if (cleanUrls || /(?:^|\/)index\.mdx?$/.test(relPath)) return slug
27
+ return `${slug}.html`
28
+ }
29
+
30
+ export const vitepressAdapter: HumanAdapter = {
31
+ plugin: 'vitepress',
32
+ scan: ({ root, config }) => {
33
+ const docsDir = optionString(config.options, ['docsDir', 'root', 'srcDir'])
34
+ if (!docsDir) return []
35
+ const srcExclude = srcExcludePatterns(config.options?.srcExclude)
36
+
37
+ return scanMarkdownDocs(root, docsDir, {
38
+ includeRelPath: (relPath) => isVitePressPage(relPath) && !isExcluded(relPath, srcExclude),
39
+ slugForDoc: (relPath) => vitepressSlug(relPath, config.options?.cleanUrls === true),
40
+ urlPrefix: config.options?.urlPrefix,
41
+ })
42
+ },
43
+ }
@@ -0,0 +1,161 @@
1
+ import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs'
2
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'
3
+
4
+ import type { DocBridgeConfigV1 } from '../../config/schema.js'
5
+ import { detectPackageManager } from '../../lib/package-manager.js'
6
+ import { toPosix } from '../../lib/paths.js'
7
+ import { walkFiles } from '../../lib/walk.js'
8
+ import type { DiscoveredPackage } from './pnpm-monorepo.js'
9
+
10
+ const NX_SCAN_SKIP = new Set([
11
+ 'node_modules',
12
+ '.git',
13
+ '.nx',
14
+ 'dist',
15
+ 'coverage',
16
+ '.doc-bridge',
17
+ ])
18
+ const NX_PROJECT_NAME = /^(?:@[A-Za-z0-9._-]+\/)?[A-Za-z0-9][A-Za-z0-9._-]*$/
19
+
20
+ type JsonRecord = Record<string, unknown>
21
+
22
+ const isRecord = (value: unknown): value is JsonRecord =>
23
+ typeof value === 'object' && value !== null && !Array.isArray(value)
24
+
25
+ const readJsonRecord = (path: string): JsonRecord | undefined => {
26
+ try {
27
+ const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
28
+ return isRecord(parsed) ? parsed : undefined
29
+ } catch {
30
+ return undefined
31
+ }
32
+ }
33
+
34
+ const safeProjectPath = (
35
+ root: string,
36
+ manifestDir: string,
37
+ declaredRoot?: unknown,
38
+ ): string | undefined => {
39
+ const candidate = typeof declaredRoot === 'string' ? resolve(root, declaredRoot) : manifestDir
40
+ const rel = toPosix(relative(root, candidate)) || '.'
41
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith('../')) return undefined
42
+ try {
43
+ if (!lstatSync(candidate).isDirectory()) return undefined
44
+ const canonicalRoot = realpathSync.native(root)
45
+ const canonicalCandidate = realpathSync.native(candidate)
46
+ const canonicalRel = relative(canonicalRoot, canonicalCandidate)
47
+ if (
48
+ isAbsolute(canonicalRel) ||
49
+ canonicalRel === '..' ||
50
+ canonicalRel.startsWith(`..${sep}`)
51
+ ) {
52
+ return undefined
53
+ }
54
+ } catch {
55
+ return undefined
56
+ }
57
+ return rel
58
+ }
59
+
60
+ const packageId = (name: string): string =>
61
+ name.startsWith('@') ? (name.split('/').at(-1) ?? name) : name
62
+
63
+ const commandPrefix = (root: string): string => {
64
+ const manager = detectPackageManager(root)
65
+ if (manager === 'pnpm') return 'pnpm exec nx run'
66
+ if (manager === 'yarn') return 'yarn nx run'
67
+ if (manager === 'bun') return 'bunx nx run'
68
+ return 'npx nx run'
69
+ }
70
+
71
+ const inferredChecks = (
72
+ root: string,
73
+ config: DocBridgeConfigV1,
74
+ projectName: string,
75
+ targets: ReadonlySet<string>,
76
+ ): string[] | undefined => {
77
+ const prefix = commandPrefix(root)
78
+ const strict = (config.gates?.preset ?? 'minimal') !== 'minimal'
79
+ const checks = [
80
+ ...(targets.has('test') ? [`${prefix} ${projectName}:test`] : []),
81
+ ...(strict && targets.has('lint') ? [`${prefix} ${projectName}:lint`] : []),
82
+ ]
83
+ return checks.length ? checks : undefined
84
+ }
85
+
86
+ type RankedProject = DiscoveredPackage & {
87
+ readonly rank: number
88
+ readonly targets: readonly string[]
89
+ }
90
+
91
+ export const discoverNxProjects = (
92
+ root: string,
93
+ config: DocBridgeConfigV1,
94
+ ): DiscoveredPackage[] => {
95
+ if (!existsSync(resolve(root, 'nx.json'))) return []
96
+
97
+ const manifests = walkFiles(root, {
98
+ extensions: ['project.json', 'package.json'],
99
+ skipDirs: NX_SCAN_SKIP,
100
+ maxFiles: 10_000,
101
+ })
102
+ const projects = new Map<string, RankedProject>()
103
+
104
+ for (const manifest of manifests) {
105
+ const json = readJsonRecord(manifest)
106
+ if (!json) continue
107
+ const manifestDir = dirname(manifest)
108
+ const manifestName = basename(manifest)
109
+ const isProjectJson = manifestName === 'project.json'
110
+ if (!isProjectJson && manifestName !== 'package.json') continue
111
+ const nx = json.nx
112
+ if (!isProjectJson && !isRecord(nx)) continue
113
+
114
+ const path = safeProjectPath(root, manifestDir, isProjectJson ? json.root : undefined)
115
+ if (!path) continue
116
+ const projectPackage = isProjectJson
117
+ ? readJsonRecord(resolve(root, path, 'package.json'))
118
+ : undefined
119
+ const projectName =
120
+ typeof json.name === 'string'
121
+ ? json.name
122
+ : typeof projectPackage?.name === 'string'
123
+ ? projectPackage.name
124
+ : basename(path === '.' ? root : path)
125
+ if (!NX_PROJECT_NAME.test(projectName)) continue
126
+
127
+ const targets = new Set<string>()
128
+ const targetRecord = isProjectJson ? json.targets : isRecord(nx) ? nx.targets : undefined
129
+ if (isRecord(targetRecord)) {
130
+ for (const target of Object.keys(targetRecord)) targets.add(target)
131
+ }
132
+ if (!isProjectJson && isRecord(json.scripts)) {
133
+ for (const script of Object.keys(json.scripts)) targets.add(script)
134
+ }
135
+
136
+ const id = packageId(projectName)
137
+ const rank = isProjectJson ? 2 : 1
138
+ const existing = projects.get(id)
139
+ if (existing && existing.path !== path) {
140
+ throw new Error(
141
+ `Nx project identity collision for "${id}": "${existing.name ?? id}" at "${existing.path}" and "${projectName}" at "${path}". Use unique Nx project names.`,
142
+ )
143
+ }
144
+ const mergedTargets = [...new Set([...(existing?.targets ?? []), ...targets])]
145
+ const projectJsonWins = rank >= (existing?.rank ?? 0)
146
+ const resolvedName = projectJsonWins ? projectName : (existing?.name ?? projectName)
147
+ const checks = inferredChecks(root, config, resolvedName, new Set(mergedTargets))
148
+ projects.set(id, {
149
+ id,
150
+ path,
151
+ name: resolvedName,
152
+ ...(checks ? { checks } : {}),
153
+ rank: projectJsonWins ? rank : (existing?.rank ?? rank),
154
+ targets: mergedTargets,
155
+ })
156
+ }
157
+
158
+ return [...projects.values()]
159
+ .sort((a, b) => a.id.localeCompare(b.id))
160
+ .map(({ rank: _rank, targets: _targets, ...project }) => project)
161
+ }
@@ -9,6 +9,7 @@ export type DiscoveredPackage = {
9
9
  readonly id: string
10
10
  readonly path: string
11
11
  readonly name?: string
12
+ readonly checks?: readonly string[]
12
13
  }
13
14
 
14
15
  const parsePnpmWorkspace = (yaml: string): string[] => {
@@ -13,6 +13,7 @@ export type WatchIndexOptions = {
13
13
  }
14
14
 
15
15
  const WATCH_PATTERN = /\.(md|mdx|json|ya?ml|mdc)$/i
16
+ const NX_MANIFEST_PATTERN = /(^|[/\\])(project|package)\.json$/i
16
17
 
17
18
  const collectWatchRoots = (root: string, config: DocBridgeConfigV1, configPath?: string): string[] => {
18
19
  const roots = new Set<string>()
@@ -24,7 +25,7 @@ const collectWatchRoots = (root: string, config: DocBridgeConfigV1, configPath?:
24
25
  : []
25
26
  for (const source of humanSources) {
26
27
  const humanOpts = source.options ?? {}
27
- for (const key of ['contentDir', 'docsDir', 'root']) {
28
+ for (const key of ['contentDir', 'docsDir', 'root', 'srcDir']) {
28
29
  const value = humanOpts[key]
29
30
  if (typeof value === 'string' && value.length) roots.add(resolve(root, value))
30
31
  }
@@ -74,6 +75,14 @@ export const watchDocBridgeIndex = (opts: WatchIndexOptions): Promise<number> =>
74
75
  })
75
76
  }
76
77
 
78
+ const nxRoot = resolve(opts.root)
79
+ if (opts.config.routing?.plugin === 'nx' && existsSync(nxRoot)) {
80
+ watch(nxRoot, { recursive: true }, (_event, filename) => {
81
+ if (!filename || !NX_MANIFEST_PATTERN.test(filename)) return
82
+ rebuild()
83
+ })
84
+ }
85
+
77
86
  const configDir = resolve(opts.root)
78
87
  if (existsSync(configDir)) {
79
88
  watch(configDir, (_event, filename) => {
@@ -91,4 +100,4 @@ export const watchDocBridgeIndex = (opts: WatchIndexOptions): Promise<number> =>
91
100
  process.once('SIGINT', onSignal)
92
101
  process.once('SIGTERM', onSignal)
93
102
  })
94
- }
103
+ }
package/src/index.ts CHANGED
@@ -155,6 +155,7 @@ export {
155
155
  export { PACKAGE_VERSION } from './version.js'
156
156
 
157
157
  export { collectPackages, buildLookup } from './index-builder/build-handoffs.js'
158
+ export { discoverNxProjects } from './index-builder/plugins/nx.js'
158
159
  export { projectRootFromConfigPath } from './config/load-config.js'
159
160
  export { createDocBridgeRag } from './intelligence/rag.js'
160
161
  export { runChatOnce, startInkChat } from './intelligence/chat.js'
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const PACKAGE_VERSION = '1.2.6'
1
+ export const PACKAGE_VERSION = '1.4.0'