@agentskit/doc-bridge 1.0.2 → 1.1.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 (48) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/CONTRIBUTING.md +7 -0
  3. package/README.md +81 -15
  4. package/SECURITY.md +1 -1
  5. package/action.yml +2 -2
  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/RELEASE.md +19 -21
  16. package/docs/getting-started.md +27 -2
  17. package/docs/landing/assets/doc-bridge-hero.webp +0 -0
  18. package/docs/landing/assets/doc-bridge-surfaces.webp +0 -0
  19. package/docs/landing/assets/doc-bridge-two-way.webp +0 -0
  20. package/docs/landing/index.html +70 -10
  21. package/docs/playbook/doc-bridge-pattern.md +2 -2
  22. package/docs/recipes/index-pipeline.md +2 -2
  23. package/docs/schemas/memory-candidate-v1.md +10 -1
  24. package/docs/spec/cli.md +1 -0
  25. package/docs/spec/config-v1.md +51 -6
  26. package/docs/spec/documentation-standard-v1.md +131 -0
  27. package/ecosystem-claims.json +187 -0
  28. package/ecosystem-upstream.json +9 -0
  29. package/ecosystem.json +231 -0
  30. package/package.json +11 -1
  31. package/scripts/check-ecosystem-upstream.mjs +50 -0
  32. package/src/cli/program.ts +36 -3
  33. package/src/config/index.ts +7 -1
  34. package/src/config/load-config.ts +4 -14
  35. package/src/config/schema.ts +91 -0
  36. package/src/conformance/documentation-standard-v1.ts +502 -0
  37. package/src/conformance/ecosystem-contract.ts +175 -0
  38. package/src/gates/run-gates.ts +33 -4
  39. package/src/index-builder/human-adapters/core.ts +12 -5
  40. package/src/index-builder/human-adapters/docusaurus.ts +29 -44
  41. package/src/index-builder/human-adapters/index.ts +15 -3
  42. package/src/index-builder/scan-corpus.ts +6 -6
  43. package/src/index.ts +17 -0
  44. package/src/lib/bounded-text.ts +25 -0
  45. package/src/lib/paths.ts +20 -2
  46. package/src/lib/static-js-literal.ts +261 -0
  47. package/src/lib/walk.ts +23 -4
  48. package/src/version.ts +1 -1
@@ -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
+ }
@@ -1,14 +1,23 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
 
3
3
  import type { DocBridgeConfigV1 } from '../config/schema.js'
4
+ import {
5
+ runDocumentationStandardV1,
6
+ type DocumentationConformanceReportV1,
7
+ } from '../conformance/documentation-standard-v1.js'
4
8
  import { buildDocBridgeIndex } from '../index-builder/build-index.js'
5
9
  import { scanAgentCorpus } from '../index-builder/scan-corpus.js'
6
10
  import { scanHumanDocRecords } from '../index-builder/human-adapters/index.js'
7
11
  import { IndexNotFoundError, loadDocBridgeIndex } from '../query/load-index.js'
8
12
 
9
- export type GateId = 'index-freshness' | 'human-guide-links' | 'okf-type' | 'docs-style'
13
+ export type GateId =
14
+ | 'index-freshness'
15
+ | 'human-guide-links'
16
+ | 'okf-type'
17
+ | 'docs-style'
18
+ | 'documentation-standard-v1'
10
19
 
11
- const SUPPORTED_GATES = ['index-freshness', 'human-guide-links', 'okf-type', 'docs-style'] as const
20
+ const RESERVED_GATE_IDS = new Set(['link-rot', 'routing-currency', 'bootstrap-size'])
12
21
 
13
22
  export type GateResult = {
14
23
  readonly id: GateId
@@ -16,6 +25,7 @@ export type GateResult = {
16
25
  readonly message: string
17
26
  readonly expected?: string
18
27
  readonly actual?: string
28
+ readonly details?: DocumentationConformanceReportV1
19
29
  }
20
30
 
21
31
  export type GateRunResult = {
@@ -28,6 +38,19 @@ export const runGate = (
28
38
  config: DocBridgeConfigV1,
29
39
  id: GateId,
30
40
  ): GateResult => {
41
+ if (id === 'documentation-standard-v1') {
42
+ const details = runDocumentationStandardV1(root, config)
43
+ return {
44
+ id,
45
+ ok: details.ok,
46
+ message: details.ok
47
+ ? 'Documentation Standard v1 required rules pass'
48
+ : `${details.summary.required.failed} Documentation Standard v1 required rule(s) failed`,
49
+ expected: 'all required rules pass or have approved exceptions',
50
+ actual: `${details.summary.required.passed} passed, ${details.summary.required.failed} failed, ${details.summary.required.excepted} excepted`,
51
+ details,
52
+ }
53
+ }
31
54
  if (id === 'human-guide-links') return runHumanGuideLinksGate(root, config)
32
55
  if (id === 'okf-type') return runOkfTypeGate(root, config)
33
56
  if (id === 'docs-style') return runDocsStyleGate(root, config)
@@ -264,10 +287,16 @@ export const resolveGateIds = (config: DocBridgeConfigV1): GateId[] => {
264
287
  )
265
288
 
266
289
  for (const id of config.gates?.include ?? []) {
267
- if (SUPPORTED_GATES.includes(id as GateId)) ids.add(id as GateId)
290
+ if (RESERVED_GATE_IDS.has(id)) {
291
+ process.emitWarning(`Gate "${id}" is reserved and has no runtime implementation; it was not executed.`, {
292
+ code: 'AK_DOCS_RESERVED_GATE',
293
+ })
294
+ continue
295
+ }
296
+ ids.add(id as GateId)
268
297
  }
269
298
  for (const id of config.gates?.exclude ?? []) {
270
- if (SUPPORTED_GATES.includes(id as GateId)) ids.delete(id as GateId)
299
+ if (!RESERVED_GATE_IDS.has(id)) ids.delete(id as GateId)
271
300
  }
272
301
 
273
302
  return [...ids]
@@ -1,9 +1,10 @@
1
- import { readFileSync } from 'node:fs'
2
- import { join } from 'node:path'
1
+ import { realpathSync } from 'node:fs'
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path'
3
3
 
4
4
  import type { HumanCorpusConfig } from '../../config/schema.js'
5
5
  import { slugFromPath } from '../../lib/markdown.js'
6
- import { toPosix } from '../../lib/paths.js'
6
+ import { readBoundedText } from '../../lib/bounded-text.js'
7
+ import { containedProjectPath, toPosix } from '../../lib/paths.js'
7
8
  import { walkFiles } from '../../lib/walk.js'
8
9
 
9
10
  export type HumanDocRecord = {
@@ -79,11 +80,17 @@ export const scanMarkdownDocs = (
79
80
  },
80
81
  ): HumanDocRecord[] => {
81
82
  const out: HumanDocRecord[] = []
82
- const absRoot = join(root, humanRoot)
83
+ const projectRoot = realpathSync.native(resolve(root))
84
+ const absRoot = containedProjectPath(root, humanRoot)
85
+ if (!absRoot) return out
86
+ const budget = { used: 0 }
83
87
 
84
88
  for (const abs of walkFiles(absRoot, { extensions: ['.md', '.mdx'] })) {
89
+ const canonical = realpathSync.native(abs)
90
+ const fileRelative = relative(projectRoot, canonical)
91
+ if (isAbsolute(fileRelative) || fileRelative === '..' || fileRelative.startsWith(`..${sep}`)) continue
85
92
  const relToHumanRoot = toPosix(abs.replace(`${toPosix(absRoot)}/`, ''))
86
- const raw = readFileSync(abs, 'utf8')
93
+ const raw = readBoundedText(abs, budget)
87
94
  if (options?.includeRelPath && !options.includeRelPath(relToHumanRoot, raw)) continue
88
95
  out.push({
89
96
  id: options?.idForDoc?.(relToHumanRoot, raw) ?? docId(relToHumanRoot, raw),
@@ -1,7 +1,8 @@
1
- import { existsSync, readFileSync } from 'node:fs'
2
- import { join } from 'node:path'
3
- import vm from 'node:vm'
1
+ import { existsSync } from 'node:fs'
4
2
 
3
+ import { readBoundedText } from '../../lib/bounded-text.js'
4
+ import { containedProjectPath } from '../../lib/paths.js'
5
+ import { parseStaticJsObject } from '../../lib/static-js-literal.js'
5
6
  import {
6
7
  optionString,
7
8
  parseFrontmatter,
@@ -43,48 +44,31 @@ const docusaurusRecordId = (relPath: string, raw: string): string => {
43
44
  return frontmatter.package ?? frontmatter.module ?? docusaurusSidebarId(relPath, raw)
44
45
  }
45
46
 
46
- const sidebarsValue = (file: string): unknown => {
47
- if (!existsSync(file)) return undefined
48
- const raw = readFileSync(file, 'utf8')
49
- .replace(/import\s+type\s+[\s\S]*?;?\n/g, '')
50
- .replace(/:\s*[A-Za-z0-9_.$<>{}\[\],\s]+(?=\s*=)/g, '')
51
- .replace(/\s+satisfies\s+[A-Za-z0-9_.$<>{}\[\],\s]+(?=\s*(?:;|\n|$))/g, '')
52
- .replace(/export\s+default/, 'module.exports =')
53
- const sandbox = { module: { exports: {} as unknown }, exports: {} }
54
- vm.runInNewContext(raw, sandbox, { timeout: 250 })
55
- return sandbox.module.exports
56
- }
57
-
58
- const visitSidebar = (value: unknown, filter: { ids: Set<string>; autogenDirs: string[] }): void => {
59
- if (typeof value === 'string') {
60
- filter.ids.add(value)
61
- return
62
- }
63
- if (Array.isArray(value)) {
64
- for (const item of value) visitSidebar(item, filter)
65
- return
66
- }
67
- if (!value || typeof value !== 'object') return
68
-
69
- const item = value as Record<string, unknown>
70
- if ((item.type === 'doc' || item.type === 'ref') && typeof item.id === 'string') {
71
- filter.ids.add(item.id)
72
- }
73
- if (item.type === 'autogenerated' && typeof item.dirName === 'string') {
74
- filter.autogenDirs.push(item.dirName)
75
- }
76
- if (Array.isArray(item.items)) visitSidebar(item.items, filter)
77
- if (item.link && typeof item.link === 'object') visitSidebar(item.link, filter)
78
- for (const child of Object.values(item)) {
79
- if (Array.isArray(child)) visitSidebar(child, filter)
80
- }
81
- }
82
-
83
- const readSidebars = (root: string, sidebarsFile: string | undefined): SidebarDocFilter => {
47
+ const readSidebars = (sidebarsFile: string | undefined): SidebarDocFilter => {
84
48
  if (!sidebarsFile) return { enabled: false, ids: new Set(), autogenDirs: [] }
85
- const value = sidebarsValue(join(root, sidebarsFile))
49
+ if (!existsSync(sidebarsFile)) return { enabled: false, ids: new Set(), autogenDirs: [] }
50
+ const raw = readBoundedText(sidebarsFile, { used: 0 }, { maxFileBytes: 1_048_576, maxCorpusBytes: 1_048_576 })
86
51
  const filter = { ids: new Set<string>(), autogenDirs: [] as string[] }
87
- visitSidebar(value, filter)
52
+ const visit = (value: unknown): void => {
53
+ if (typeof value === 'string') {
54
+ filter.ids.add(value)
55
+ return
56
+ }
57
+ if (Array.isArray(value)) {
58
+ for (const item of value) visit(item)
59
+ return
60
+ }
61
+ if (!value || typeof value !== 'object') return
62
+ const item = value as Record<string, unknown>
63
+ if ((item.type === 'doc' || item.type === 'ref') && typeof item.id === 'string') filter.ids.add(item.id)
64
+ if (item.type === 'autogenerated' && typeof item.dirName === 'string') filter.autogenDirs.push(item.dirName)
65
+ if (Array.isArray(item.items)) visit(item.items)
66
+ if (item.link && typeof item.link === 'object') visit(item.link)
67
+ for (const child of Object.values(item)) {
68
+ if (Array.isArray(child)) visit(child)
69
+ }
70
+ }
71
+ visit(parseStaticJsObject(raw))
88
72
  return { enabled: true, ...filter }
89
73
  }
90
74
 
@@ -102,7 +86,8 @@ export const docusaurusAdapter: HumanAdapter = {
102
86
  scan: ({ root, config }) => {
103
87
  const docsDir = optionString(config.options, ['docsDir', 'root'])
104
88
  if (!docsDir) return []
105
- const sidebarFilter = readSidebars(root, optionString(config.options, ['sidebarsFile']))
89
+ const sidebarsFile = optionString(config.options, ['sidebarsFile'])
90
+ const sidebarFilter = readSidebars(sidebarsFile ? containedProjectPath(root, sidebarsFile) : undefined)
106
91
  return scanMarkdownDocs(root, docsDir, {
107
92
  includeRelPath: (relPath, raw) => isIncludedBySidebar(sidebarFilter, docusaurusSidebarId(relPath, raw)),
108
93
  idForDoc: docusaurusRecordId,
@@ -1,3 +1,6 @@
1
+ import { realpathSync } from 'node:fs'
2
+ import { resolve, sep } from 'node:path'
3
+
1
4
  import type { DocBridgeConfigV1, HumanCorpusConfig } from '../../config/schema.js'
2
5
  import { docusaurusAdapter } from './docusaurus.js'
3
6
  import { fumadocsAdapter } from './fumadocs.js'
@@ -18,22 +21,31 @@ const humanConfigs = (config: DocBridgeConfigV1): HumanCorpusConfig[] => {
18
21
  return Array.isArray(human) ? human : [human]
19
22
  }
20
23
 
24
+ const canonicalPath = (path: string): string => {
25
+ try {
26
+ return realpathSync.native(path)
27
+ } catch {
28
+ return resolve(path)
29
+ }
30
+ }
31
+
21
32
  export const scanHumanDocRecords = (
22
33
  root: string,
23
34
  config: DocBridgeConfigV1,
24
35
  ): HumanDocRecord[] => {
25
36
  const out: HumanDocRecord[] = []
26
37
  const seen = new Set<string>()
27
- const agentRoot = config.corpus.agent.root.replace(/\/$/, '')
38
+ const agentRoot = canonicalPath(resolve(root, config.corpus.agent.root))
28
39
 
29
40
  for (const human of humanConfigs(config)) {
30
41
  const adapter = ADAPTERS.find((candidate) => candidate.plugin === human.plugin)
31
42
  if (!adapter) continue
32
43
  for (const record of adapter.scan({ root, config: human })) {
33
44
  // Never treat agent-corpus files as human docs (nested for-agents, etc.)
45
+ const recordPath = canonicalPath(record.path)
34
46
  if (
35
- record.path === agentRoot ||
36
- record.path.startsWith(`${agentRoot}/`) ||
47
+ recordPath === agentRoot ||
48
+ recordPath.startsWith(`${agentRoot}${sep}`) ||
37
49
  record.path.includes('/for-agents/') ||
38
50
  record.path.endsWith('/for-agents')
39
51
  ) {
@@ -1,7 +1,5 @@
1
- import { readFileSync } from 'node:fs'
2
- import { join } from 'node:path'
3
-
4
1
  import type { DocBridgeConfigV1 } from '../config/schema.js'
2
+ import { readBoundedText } from '../lib/bounded-text.js'
5
3
  import {
6
4
  extractSearchBody,
7
5
  firstHeading,
@@ -12,7 +10,7 @@ import {
12
10
  slugFromPath,
13
11
  type FrontmatterData,
14
12
  } from '../lib/markdown.js'
15
- import { toPosix } from '../lib/paths.js'
13
+ import { containedProjectPath, toPosix } from '../lib/paths.js'
16
14
  import { walkFiles } from '../lib/walk.js'
17
15
  import type { KnowledgeEntry } from '../schemas/doc-bridge-index.js'
18
16
 
@@ -34,14 +32,16 @@ export type OwnershipSeed = {
34
32
  const relFromRoot = (root: string, abs: string): string => toPosix(abs.replace(`${toPosix(root)}/`, ''))
35
33
 
36
34
  export const scanAgentCorpus = (root: string, config: DocBridgeConfigV1): CorpusDoc[] => {
37
- const agentRoot = join(root, config.corpus.agent.root)
35
+ const agentRoot = containedProjectPath(root, config.corpus.agent.root)
36
+ if (!agentRoot) throw new Error('Agent corpus root escapes the project root.')
38
37
  const files = walkFiles(agentRoot, { extensions: ['.md', '.mdx'] })
39
38
  const corpusRelRoot = toPosix(config.corpus.agent.root)
40
39
 
40
+ const budget = { used: 0 }
41
41
  return files.map((abs) => {
42
42
  const relToCorpus = toPosix(abs.replace(`${toPosix(agentRoot)}/`, ''))
43
43
  const relPath = `${corpusRelRoot}/${relToCorpus}`
44
- const raw = readFileSync(abs, 'utf8')
44
+ const raw = readBoundedText(abs, budget)
45
45
  const { data: frontmatter } = parseFrontmatter(raw)
46
46
  const id =
47
47
  frontmatterString(frontmatter, 'id') ??
package/src/index.ts CHANGED
@@ -9,8 +9,12 @@ export {
9
9
  } from './config/load-config.js'
10
10
  export {
11
11
  DocBridgeConfigV1Schema,
12
+ DocumentationStandardRuleIdSchema,
13
+ DocumentationStandardV1ConfigSchema,
14
+ EcosystemContractEvidenceSchema,
12
15
  type DocBridgeConfigV1,
13
16
  type AgentCorpusConfig,
17
+ type DocumentationStandardV1Config,
14
18
  } from './config/schema.js'
15
19
 
16
20
  export {
@@ -75,6 +79,19 @@ export {
75
79
  type GateResult,
76
80
  type GateRunResult,
77
81
  } from './gates/run-gates.js'
82
+ export {
83
+ DOCUMENTATION_STANDARD_V1_ID,
84
+ DOCUMENTATION_STANDARD_V1_STATUS,
85
+ formatDocumentationStandardText,
86
+ runDocumentationStandardV1,
87
+ type DocumentationConformanceReportV1,
88
+ type DocumentationStandardEvidence,
89
+ type DocumentationStandardRemediation,
90
+ type DocumentationStandardRuleId,
91
+ type DocumentationStandardRuleLevel,
92
+ type DocumentationStandardRuleResult,
93
+ type DocumentationStandardRuleStatus,
94
+ } from './conformance/documentation-standard-v1.js'
78
95
  export { MCP_TOOLS, handleMcpRequest, startMcpStdioServer } from './mcp/server.js'
79
96
  export { installMcpConfig, mcpSnippet, type McpInstallResult, type McpInstallTarget } from './mcp/install.js'
80
97
  export { runDoctor, formatDoctorText, type DoctorReport, type DoctorIssue, type DoctorCoverage } from './doctor/run-doctor.js'
@@ -0,0 +1,25 @@
1
+ import { readFileSync, statSync } from 'node:fs'
2
+
3
+ export const MAX_DOCUMENT_BYTES = 4 * 1_024 * 1_024
4
+ export const MAX_CORPUS_BYTES = 64 * 1_024 * 1_024
5
+
6
+ export type TextReadBudget = { used: number }
7
+
8
+ export const readBoundedText = (
9
+ path: string,
10
+ budget: TextReadBudget,
11
+ limits?: { readonly maxFileBytes?: number; readonly maxCorpusBytes?: number },
12
+ ): string => {
13
+ const maxFileBytes = limits?.maxFileBytes ?? MAX_DOCUMENT_BYTES
14
+ const maxCorpusBytes = limits?.maxCorpusBytes ?? MAX_CORPUS_BYTES
15
+ const stat = statSync(path)
16
+ if (!stat.isFile()) throw new Error(`Documentation path is not a regular file: ${path}`)
17
+ if (stat.size > maxFileBytes) {
18
+ throw new Error(`Documentation file exceeds the ${maxFileBytes} byte limit: ${path}`)
19
+ }
20
+ if (budget.used + stat.size > maxCorpusBytes) {
21
+ throw new Error(`Documentation corpus exceeds the ${maxCorpusBytes} byte read budget.`)
22
+ }
23
+ budget.used += stat.size
24
+ return readFileSync(path, 'utf8')
25
+ }
package/src/lib/paths.ts CHANGED
@@ -1,6 +1,24 @@
1
- import { resolve } from 'node:path'
1
+ import { existsSync, realpathSync } from 'node:fs'
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path'
2
3
 
3
4
  export const toPosix = (value: string): string => value.split('\\').join('/')
4
5
 
5
6
  export const resolveFromRoot = (root: string, rel: string): string =>
6
- resolve(root, rel)
7
+ resolve(root, rel)
8
+
9
+ export const containedProjectPath = (root: string, path: string): string | undefined => {
10
+ const projectRoot = realpathSync.native(resolve(root))
11
+ const unresolved = resolve(projectRoot, path)
12
+ const unresolvedRelative = relative(projectRoot, unresolved)
13
+ if (
14
+ isAbsolute(unresolvedRelative) ||
15
+ unresolvedRelative === '..' ||
16
+ unresolvedRelative.startsWith(`..${sep}`)
17
+ ) return undefined
18
+
19
+ const canonical = existsSync(unresolved) ? realpathSync.native(unresolved) : unresolved
20
+ const canonicalRelative = relative(projectRoot, canonical)
21
+ return isAbsolute(canonicalRelative) || canonicalRelative === '..' || canonicalRelative.startsWith(`..${sep}`)
22
+ ? undefined
23
+ : canonical
24
+ }