@agentskit/doc-bridge 1.2.6 → 1.3.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 (36) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +9 -3
  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/POSITIONING.md +1 -1
  14. package/docs/examples.md +4 -0
  15. package/docs/getting-started.md +1 -1
  16. package/docs/landing/index.html +1 -1
  17. package/docs/qa/vitepress-starlight-adapters.md +19 -0
  18. package/docs/spec/config-v1.md +33 -2
  19. package/examples/nextra-only.config.ts +17 -0
  20. package/examples/nx-monorepo.config.ts +11 -0
  21. package/examples/starlight-only.config.ts +17 -0
  22. package/examples/vitepress-only.config.ts +19 -0
  23. package/mcpb/manifest.json +1 -1
  24. package/package.json +4 -2
  25. package/src/config/schema.ts +3 -1
  26. package/src/index-builder/build-handoffs.ts +2 -0
  27. package/src/index-builder/build-index.ts +7 -1
  28. package/src/index-builder/human-adapters/index.ts +6 -0
  29. package/src/index-builder/human-adapters/nextra.ts +43 -0
  30. package/src/index-builder/human-adapters/starlight.ts +40 -0
  31. package/src/index-builder/human-adapters/vitepress.ts +43 -0
  32. package/src/index-builder/plugins/nx.ts +161 -0
  33. package/src/index-builder/plugins/pnpm-monorepo.ts +1 -0
  34. package/src/index-builder/watch-index.ts +11 -2
  35. package/src/index.ts +1 -0
  36. package/src/version.ts +1 -1
@@ -70,7 +70,7 @@ Engineering teams with real ownership (monorepos first). Secondary: solo libs, i
70
70
 
71
71
  ```
72
72
  required: index, CLI, MCP handoff tools, gate presets
73
- optional: fumadocs | docusaurus | plain-markdown plugins
73
+ optional: fumadocs | docusaurus | vitepress | starlight | nextra | plain-markdown plugins
74
74
  optional: memory ingest → promote
75
75
  optional: @agentskit/rag + ink chat (intelligence.*)
76
76
  optional: Playbook / Registry federation
package/docs/examples.md CHANGED
@@ -11,8 +11,12 @@ Config sketches under [`examples/`](../examples/):
11
11
  |------|---------|
12
12
  | `minimal-plain-markdown.config.ts` | Solo markdown, Layer 0 only |
13
13
  | `pnpm-monorepo.config.ts` | Workspace discovery + ownership |
14
+ | `nx-monorepo.config.ts` | Static Nx project discovery + inferred checks |
14
15
  | `fumadocs-only.config.ts` | Human bridge via Fumadocs |
15
16
  | `docusaurus-only.config.ts` | Human bridge via Docusaurus |
17
+ | `vitepress-only.config.ts` | Human bridge via VitePress |
18
+ | `starlight-only.config.ts` | Human bridge via Astro Starlight |
19
+ | `nextra-only.config.ts` | Human bridge via Nextra |
16
20
  | `fumadocs-with-chat.config.ts` | Standard + intelligence (AgentsKit peers) |
17
21
  | `docusaurus-with-memory.config.ts` | Assisted memory promotion path |
18
22
 
@@ -105,7 +105,7 @@ Tools: `handoff.resolve`, `doc.search`, `doc.get`, `gate.status`, …
105
105
  ## Human ↔ agent bridge
106
106
 
107
107
  ```bash
108
- # After configuring corpus.human (fumadocs | docusaurus | plain-markdown)
108
+ # After configuring corpus.human (fumadocs | docusaurus | vitepress | starlight | nextra | plain-markdown)
109
109
  ak-docs index
110
110
  ak-docs query package <id> --agent # includes humanDoc when linked
111
111
  ak-docs gate run human-guide-links
@@ -272,7 +272,7 @@
272
272
  <tr><td>CLI</td><td>Query ownership, inspect handoffs, run doctor, search docs.</td><td><code>ak-docs query package auth --agent</code></td></tr>
273
273
  <tr><td>MCP</td><td>Lets Cursor, Claude Code, and Codex-style agents resolve before editing.</td><td><code>ak-docs mcp install --cursor</code></td></tr>
274
274
  <tr><td>CI</td><td>Blocks stale indexes and broken human-doc bridges.</td><td><code>ak-docs index && ak-docs gate run</code></td></tr>
275
- <tr><td>Adapters</td><td>Connect Fumadocs, Docusaurus, plain markdown, and pnpm workspaces.</td><td><code>fumadocs</code> · <code>docusaurus</code> · <code>plain-markdown</code></td></tr>
275
+ <tr><td>Adapters</td><td>Connect Fumadocs, Docusaurus, VitePress, Starlight, Nextra, plain markdown, and pnpm workspaces.</td><td><code>fumadocs</code> · <code>docusaurus</code> · <code>vitepress</code> · <code>starlight</code> · <code>nextra</code> · <code>plain-markdown</code></td></tr>
276
276
  <tr><td>Memory pipeline</td><td>Classifies local agent memory and drafts documentation updates.</td><td><code>ak-docs memory promote --pr</code></td></tr>
277
277
  <tr><td>Optional RAG/chat</td><td>Adds handoff-first chat when you install AgentsKit peers.</td><td><code>ak-docs rag ingest && ak-docs chat</code></td></tr>
278
278
  </tbody>
@@ -0,0 +1,19 @@
1
+ # VitePress and Starlight adapter QA plan
2
+
3
+ This plan records the local acceptance criteria for the first-party VitePress and Astro Starlight human-documentation adapters.
4
+
5
+ ## Contract checks
6
+
7
+ - A VitePress corpus scans Markdown and MDX below its configured documentation root, maps `index` files to their directory route, follows the framework's default `.html` routes or optional `cleanUrls`, applies declarative `srcExclude` globs, respects an optional `urlPrefix`, and keeps Doc Bridge join metadata (`package`, `module`, or `id`) stable.
8
+ - A Starlight corpus scans Markdown and MDX below its configured content root, uses Astro's optional `slug` frontmatter for the public route, maps index pages correctly, respects an optional `urlPrefix`, and keeps Doc Bridge join metadata stable.
9
+ - Both adapters reject roots outside the project through the shared bounded, containment-aware scanner.
10
+ - Neither adapter executes framework configuration or user JavaScript.
11
+ - Existing Fumadocs, Docusaurus, and plain-Markdown behavior remains unchanged.
12
+
13
+ ## Local verification
14
+
15
+ 1. Add focused fixtures for default routes, index routes, metadata overrides, URL prefixes, and nested agent-corpus exclusion.
16
+ 2. Run `pnpm vitest run tests/human-adapters.test.ts tests/schemas.test.ts`.
17
+ 3. Run `pnpm typecheck` and `pnpm build` to validate the public configuration type and emitted declarations.
18
+ 4. Run the complete `pnpm test` suite to detect regressions.
19
+ 5. Run `git diff --check` and inspect the final diff for scope and generated-file noise.
@@ -129,10 +129,34 @@ type HumanCorpusPluginId =
129
129
  | 'fumadocs' // markdown scan with index routes, (group) slugs, pages allowlists
130
130
  | 'docusaurus' // markdown scan with id/slug frontmatter + static sidebars.js
131
131
  | 'mkdocs' // planned
132
- | 'vitepress' // planned
132
+ | 'vitepress' // file routes, srcDir/docsDir, optional cleanUrls
133
+ | 'starlight' // Astro content routes, slug and draft frontmatter
134
+ | 'nextra' // content-directory routes and contentDirBasePath
133
135
  | 'custom' // path to user plugin module
134
136
  ```
135
137
 
138
+ VitePress options accept `docsDir` (also `root` or `srcDir`), `urlPrefix`,
139
+ `cleanUrls`, and up to 64 declarative `srcExclude` glob patterns. Doc Bridge
140
+ uses `.html` routes unless `cleanUrls: true`, matching
141
+ VitePress routing without loading or executing `.vitepress/config.*`. Route
142
+ rewrites must therefore be reflected in the configured corpus or URL prefix.
143
+
144
+ Starlight options accept `contentDir` (also `docsDir` or `root`) and
145
+ `urlPrefix`. The adapter reads the standard `slug` and `draft` frontmatter,
146
+ follows the default filename sluggifier, excludes underscore-prefixed
147
+ partials, and never executes `astro.config.*`. Sites using a custom
148
+ `docsLoader({ generateId })` should add explicit `slug` frontmatter so Doc
149
+ Bridge can resolve the same public route without executing project code.
150
+
151
+ Nextra options accept `contentDir` (also `docsDir` or `root`), `urlPrefix`,
152
+ and `contentDirBasePath`. The adapter maps `index.md` and `index.mdx` to the
153
+ containing route and uses Doc Bridge `package`, `module`, or `id` frontmatter
154
+ as the join key. `urlPrefix` takes precedence over `contentDirBasePath`. Point
155
+ `contentDir` at either `content` or `src/content`; Doc Bridge never imports or
156
+ executes `next.config.*`, `_meta.*`, themes, or other project code. This
157
+ adapter targets Nextra's content-directory convention; app-router `page.mdx`
158
+ trees are not inferred by this plugin.
159
+
136
160
  ### Bridge to agent docs
137
161
 
138
162
  When `corpus.human` is set, plugins MUST:
@@ -199,7 +223,7 @@ Without `routing`, handoffs are inferred from agent corpus links only. Monorepo
199
223
 
200
224
  ```ts
201
225
  type RoutingConfig = {
202
- plugin?: 'pnpm-monorepo' | 'npm-workspaces' | 'yarn-workspaces' | 'custom'
226
+ plugin?: 'pnpm-monorepo' | 'npm-workspaces' | 'yarn-workspaces' | 'nx' | 'pattern-files' | 'custom'
203
227
 
204
228
  options?: {
205
229
  /** Workspace globs; default from package manager */
@@ -240,6 +264,13 @@ type ChangeRouteEntry = {
240
264
  }
241
265
  ```
242
266
 
267
+ `routing.plugin: 'nx'` reads `project.json` and package manifests with an `nx`
268
+ object. It does not load Nx plugins or execute workspace code. Project roots must
269
+ resolve inside the configured project root. Declared `test` targets become Nx test
270
+ checks; non-minimal gate presets also include declared `lint` targets. Explicit
271
+ `routing.options.ownership` remains authoritative. Targets created only at runtime
272
+ by Nx plugins are intentionally not inferred by this read-only adapter.
273
+
243
274
  ### Join keys (agent ↔ human ↔ ownership)
244
275
 
245
276
  | Entity | Primary key | Human plugin maps via |
@@ -0,0 +1,17 @@
1
+ import { defineConfig } from '@agentskit/doc-bridge'
2
+
3
+ /** Nextra bridge only: no chat, no memory, no provider key. */
4
+ export default defineConfig({
5
+ schemaVersion: 1,
6
+ corpus: {
7
+ agent: { root: 'docs/for-agents' },
8
+ human: {
9
+ plugin: 'nextra',
10
+ options: {
11
+ contentDir: 'content',
12
+ contentDirBasePath: '/docs',
13
+ },
14
+ },
15
+ },
16
+ gates: { preset: 'standard' },
17
+ })
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from '@agentskit/doc-bridge'
2
+
3
+ /** Nx — infer project ownership and available test/lint checks without executing Nx */
4
+ export default defineConfig({
5
+ schemaVersion: 1,
6
+ corpus: {
7
+ agent: { root: 'docs/for-agents' },
8
+ },
9
+ routing: { plugin: 'nx' },
10
+ gates: { preset: 'standard' },
11
+ })
@@ -0,0 +1,17 @@
1
+ import { defineConfig } from '@agentskit/doc-bridge'
2
+
3
+ /** Starlight bridge only: no chat, no memory, no provider key. */
4
+ export default defineConfig({
5
+ schemaVersion: 1,
6
+ corpus: {
7
+ agent: { root: 'docs/for-agents' },
8
+ human: {
9
+ plugin: 'starlight',
10
+ options: {
11
+ contentDir: 'src/content/docs',
12
+ urlPrefix: '/docs',
13
+ },
14
+ },
15
+ },
16
+ gates: { preset: 'standard' },
17
+ })
@@ -0,0 +1,19 @@
1
+ import { defineConfig } from '@agentskit/doc-bridge'
2
+
3
+ /** VitePress bridge only: no chat, no memory, no provider key. */
4
+ export default defineConfig({
5
+ schemaVersion: 1,
6
+ corpus: {
7
+ agent: { root: 'docs/for-agents' },
8
+ human: {
9
+ plugin: 'vitepress',
10
+ options: {
11
+ docsDir: 'docs',
12
+ urlPrefix: '/docs',
13
+ cleanUrls: true,
14
+ srcExclude: ['archive/**'],
15
+ },
16
+ },
17
+ },
18
+ gates: { preset: 'standard' },
19
+ })
@@ -2,7 +2,7 @@
2
2
  "manifest_version": "0.3",
3
3
  "name": "doc-bridge",
4
4
  "display_name": "Doc Bridge",
5
- "version": "1.2.6",
5
+ "version": "1.3.0",
6
6
  "description": "Deterministic repository handoffs for coding agents, running locally without an LLM or API key.",
7
7
  "long_description": "Doc Bridge turns a repository's own documentation and ownership metadata into deterministic handoffs: where an agent should start, which paths it may edit, which checks it must run, and when a human must take over. The local connector exposes the same read-only contract available through Doc Bridge CLI and CI.",
8
8
  "author": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentskit/doc-bridge",
3
- "version": "1.2.6",
3
+ "version": "1.3.0",
4
4
  "mcpName": "io.github.AgentsKit-io/doc-bridge",
5
5
  "description": "Human↔agent documentation bridge — deterministic handoffs, doc-site links, memory→docs, optional AgentsKit RAG/chat.",
6
6
  "type": "module",
@@ -103,7 +103,9 @@
103
103
  "access": "public"
104
104
  },
105
105
  "dependencies": {
106
- "mermaid": "^11.16.0",
106
+ "github-slugger": "^2.0.0",
107
+ "mermaid": "^11.16.1",
108
+ "minimatch": "^10.2.6",
107
109
  "zod": "^3.24.2"
108
110
  },
109
111
  "peerDependencies": {
@@ -8,6 +8,8 @@ export const HumanCorpusPluginIdSchema = z.enum([
8
8
  'docusaurus',
9
9
  'mkdocs',
10
10
  'vitepress',
11
+ 'starlight',
12
+ 'nextra',
11
13
  'custom',
12
14
  ])
13
15
 
@@ -71,7 +73,7 @@ export const OwnershipEntrySchema = z
71
73
  export const RoutingConfigSchema = z
72
74
  .object({
73
75
  plugin: z
74
- .enum(['pnpm-monorepo', 'npm-workspaces', 'yarn-workspaces', 'pattern-files', 'custom'])
76
+ .enum(['pnpm-monorepo', 'npm-workspaces', 'yarn-workspaces', 'nx', 'pattern-files', 'custom'])
75
77
  .optional(),
76
78
  options: z
77
79
  .object({
@@ -70,6 +70,7 @@ export const collectPackages = (
70
70
  id: pkg.id,
71
71
  path: existing.path || pkg.path,
72
72
  ...(pkg.name ? { name: pkg.name } : existing.name ? { name: existing.name } : {}),
73
+ ...(pkg.checks ? { checks: pkg.checks } : existing.checks ? { checks: existing.checks } : {}),
73
74
  })
74
75
  }
75
76
 
@@ -163,6 +164,7 @@ export const buildLookup = (
163
164
  const checks = [
164
165
  ...(override?.checks ??
165
166
  fm?.checks ??
167
+ pkg.checks ??
166
168
  defaultChecksForTarget(root, {
167
169
  packageId: pkg.id,
168
170
  packagePath: path,
@@ -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.3.0'