@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.
- package/CHANGELOG.md +22 -0
- package/CONTRIBUTING.md +7 -0
- package/README.md +81 -15
- package/SECURITY.md +1 -1
- package/action.yml +2 -2
- package/dist/cli/program.js +1241 -309
- package/dist/cli/program.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +338 -13
- package/dist/config/index.js.map +1 -1
- package/dist/{index-CPUJbTbg.d.ts → index-DGI9TBLE.d.ts} +906 -11
- package/dist/index.d.ts +65 -11
- package/dist/index.js +1084 -171
- package/dist/index.js.map +1 -1
- package/docs/RELEASE.md +19 -21
- package/docs/getting-started.md +27 -2
- package/docs/landing/assets/doc-bridge-hero.webp +0 -0
- package/docs/landing/assets/doc-bridge-surfaces.webp +0 -0
- package/docs/landing/assets/doc-bridge-two-way.webp +0 -0
- package/docs/landing/index.html +70 -10
- package/docs/playbook/doc-bridge-pattern.md +2 -2
- package/docs/recipes/index-pipeline.md +2 -2
- package/docs/schemas/memory-candidate-v1.md +10 -1
- package/docs/spec/cli.md +1 -0
- package/docs/spec/config-v1.md +51 -6
- package/docs/spec/documentation-standard-v1.md +131 -0
- package/ecosystem-claims.json +187 -0
- package/ecosystem-upstream.json +9 -0
- package/ecosystem.json +231 -0
- package/package.json +11 -1
- package/scripts/check-ecosystem-upstream.mjs +50 -0
- package/src/cli/program.ts +36 -3
- package/src/config/index.ts +7 -1
- package/src/config/load-config.ts +4 -14
- package/src/config/schema.ts +91 -0
- package/src/conformance/documentation-standard-v1.ts +502 -0
- package/src/conformance/ecosystem-contract.ts +175 -0
- package/src/gates/run-gates.ts +33 -4
- package/src/index-builder/human-adapters/core.ts +12 -5
- package/src/index-builder/human-adapters/docusaurus.ts +29 -44
- package/src/index-builder/human-adapters/index.ts +15 -3
- package/src/index-builder/scan-corpus.ts +6 -6
- package/src/index.ts +17 -0
- package/src/lib/bounded-text.ts +25 -0
- package/src/lib/paths.ts +20 -2
- package/src/lib/static-js-literal.ts +261 -0
- package/src/lib/walk.ts +23 -4
- 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
|
+
}
|
package/src/gates/run-gates.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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 (
|
|
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 (
|
|
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 {
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
36
|
-
|
|
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 =
|
|
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 =
|
|
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 {
|
|
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
|
+
}
|