@agentskit/doc-bridge 1.0.2 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/CONTRIBUTING.md +8 -0
- package/README.md +114 -17
- package/SECURITY.md +1 -1
- package/action.yml +23 -26
- 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/DOGFOOD-ROUND2.md +5 -0
- package/docs/DOGFOOD-ROUND3.md +5 -0
- package/docs/DOGFOOD-V1.md +5 -0
- package/docs/DOGFOOD.md +5 -0
- package/docs/MARKETPLACE-ECOSYSTEM-PLAN.md +16 -0
- package/docs/MARKETPLACE.md +39 -0
- package/docs/POSITIONING.md +5 -0
- package/docs/RELEASE.md +26 -19
- package/docs/agent-corpus/INDEX.md +10 -0
- package/docs/agent-corpus/OVERVIEW.md +9 -0
- package/docs/agent-corpus/chat.md +10 -0
- package/docs/agent-corpus/cli.md +10 -0
- package/docs/agent-corpus/conformance.md +10 -0
- package/docs/agent-corpus/doc-bridge.md +10 -0
- package/docs/agent-corpus/doctor.md +10 -0
- package/docs/agent-corpus/gates.md +10 -0
- package/docs/agent-corpus/mcp.md +10 -0
- package/docs/agent-corpus/memory.md +10 -0
- package/docs/agent-corpus/query.md +10 -0
- package/docs/chat-and-rag.md +34 -0
- package/docs/examples.md +5 -0
- package/docs/for-agents.md +31 -0
- package/docs/getting-started.md +32 -2
- package/docs/index.md +23 -0
- 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/mcp.md +5 -0
- package/docs/meta.json +20 -0
- package/docs/ollama-demo.md +6 -1
- package/docs/playbook/doc-bridge-pattern.md +4 -2
- package/docs/query.md +34 -0
- package/docs/recipes/index-pipeline.md +7 -2
- package/docs/schemas/agent-handoff-v1.md +5 -0
- package/docs/schemas/doc-bridge-index-v1.md +5 -0
- package/docs/schemas/memory-candidate-v1.md +15 -1
- package/docs/skills/doc-bridge.md +6 -1
- package/docs/spec/cli.md +6 -0
- package/docs/spec/config-v1.md +56 -6
- package/docs/spec/documentation-standard-v1.md +136 -0
- package/docs/spec/playbook-feedback.md +5 -0
- package/docs/spec/registry-agents.md +5 -0
- package/ecosystem-claims.json +187 -0
- package/ecosystem-upstream.json +9 -0
- package/ecosystem.json +235 -0
- package/examples/verify-handoff.mjs +5 -0
- package/package.json +46 -4
- 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
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
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
const stripComments = (source: string): string => {
|
|
2
|
+
let out = ''
|
|
3
|
+
let index = 0
|
|
4
|
+
let quote: string | undefined
|
|
5
|
+
while (index < source.length) {
|
|
6
|
+
const char = source[index] ?? ''
|
|
7
|
+
const next = source[index + 1] ?? ''
|
|
8
|
+
if (quote) {
|
|
9
|
+
out += char
|
|
10
|
+
if (char === '\\') {
|
|
11
|
+
out += next
|
|
12
|
+
index += 2
|
|
13
|
+
continue
|
|
14
|
+
}
|
|
15
|
+
if (char === quote) quote = undefined
|
|
16
|
+
index += 1
|
|
17
|
+
continue
|
|
18
|
+
}
|
|
19
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
20
|
+
quote = char
|
|
21
|
+
out += char
|
|
22
|
+
index += 1
|
|
23
|
+
continue
|
|
24
|
+
}
|
|
25
|
+
if (char === '/' && next === '/') {
|
|
26
|
+
while (index < source.length && source[index] !== '\n') index += 1
|
|
27
|
+
out += '\n'
|
|
28
|
+
index += 1
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
if (char === '/' && next === '*') {
|
|
32
|
+
index += 2
|
|
33
|
+
while (index < source.length && !(source[index] === '*' && source[index + 1] === '/')) index += 1
|
|
34
|
+
index += 2
|
|
35
|
+
out += ' '
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
out += char
|
|
39
|
+
index += 1
|
|
40
|
+
}
|
|
41
|
+
if (quote) throw new Error('Unterminated string in static JavaScript literal.')
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class LiteralParser {
|
|
46
|
+
private index = 0
|
|
47
|
+
|
|
48
|
+
constructor(private readonly source: string) {}
|
|
49
|
+
|
|
50
|
+
parseAt(index: number): unknown {
|
|
51
|
+
this.index = index
|
|
52
|
+
const value = this.value()
|
|
53
|
+
this.space()
|
|
54
|
+
return value
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private space(): void {
|
|
58
|
+
while (/\s/.test(this.source[this.index] ?? '')) this.index += 1
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private value(): unknown {
|
|
62
|
+
this.space()
|
|
63
|
+
const char = this.source[this.index]
|
|
64
|
+
if (char === '{') return this.object()
|
|
65
|
+
if (char === '[') return this.array()
|
|
66
|
+
if (char === '"' || char === "'") return this.string()
|
|
67
|
+
const word = this.word()
|
|
68
|
+
if (word === 'true') return true
|
|
69
|
+
if (word === 'false') return false
|
|
70
|
+
if (word === 'null') return null
|
|
71
|
+
if (/^-?\d+(?:\.\d+)?$/.test(word)) return Number(word)
|
|
72
|
+
throw new Error(`Unsupported dynamic sidebar expression near "${word || char || 'EOF'}".`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private object(): Record<string, unknown> {
|
|
76
|
+
const result: Record<string, unknown> = {}
|
|
77
|
+
this.index += 1
|
|
78
|
+
this.space()
|
|
79
|
+
while (this.source[this.index] !== '}') {
|
|
80
|
+
const key = this.source[this.index] === '"' || this.source[this.index] === "'" ? this.string() : this.word()
|
|
81
|
+
if (!key) throw new Error('Static sidebar object key is required.')
|
|
82
|
+
this.space()
|
|
83
|
+
if (this.source[this.index] !== ':') throw new Error(`Static sidebar key "${key}" requires a literal value.`)
|
|
84
|
+
this.index += 1
|
|
85
|
+
result[key] = this.value()
|
|
86
|
+
this.space()
|
|
87
|
+
if (this.source[this.index] === ',') {
|
|
88
|
+
this.index += 1
|
|
89
|
+
this.space()
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
if (this.source[this.index] !== '}') throw new Error('Expected comma or closing brace in static sidebar.')
|
|
93
|
+
}
|
|
94
|
+
this.index += 1
|
|
95
|
+
return result
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private array(): unknown[] {
|
|
99
|
+
const result: unknown[] = []
|
|
100
|
+
this.index += 1
|
|
101
|
+
this.space()
|
|
102
|
+
while (this.source[this.index] !== ']') {
|
|
103
|
+
result.push(this.value())
|
|
104
|
+
this.space()
|
|
105
|
+
if (this.source[this.index] === ',') {
|
|
106
|
+
this.index += 1
|
|
107
|
+
this.space()
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
if (this.source[this.index] !== ']') throw new Error('Expected comma or closing bracket in static sidebar.')
|
|
111
|
+
}
|
|
112
|
+
this.index += 1
|
|
113
|
+
return result
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private string(): string {
|
|
117
|
+
const quote = this.source[this.index] ?? ''
|
|
118
|
+
this.index += 1
|
|
119
|
+
let result = ''
|
|
120
|
+
while (this.index < this.source.length) {
|
|
121
|
+
const char = this.source[this.index] ?? ''
|
|
122
|
+
if (char === quote) {
|
|
123
|
+
this.index += 1
|
|
124
|
+
return result
|
|
125
|
+
}
|
|
126
|
+
if (char === '\\') {
|
|
127
|
+
const escaped = this.source[this.index + 1]
|
|
128
|
+
if (escaped === undefined) break
|
|
129
|
+
const escapes: Record<string, string> = { n: '\n', r: '\r', t: '\t' }
|
|
130
|
+
result += escapes[escaped] ?? escaped
|
|
131
|
+
this.index += 2
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
result += char
|
|
135
|
+
this.index += 1
|
|
136
|
+
}
|
|
137
|
+
throw new Error('Unterminated string in static sidebar.')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private word(): string {
|
|
141
|
+
this.space()
|
|
142
|
+
const start = this.index
|
|
143
|
+
while (/[A-Za-z0-9_$.-]/.test(this.source[this.index] ?? '')) this.index += 1
|
|
144
|
+
return this.source.slice(start, this.index)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const maskStrings = (source: string): string => {
|
|
149
|
+
const chars = [...source]
|
|
150
|
+
let quote: string | undefined
|
|
151
|
+
let previousToken: string | undefined
|
|
152
|
+
const regexPrefixKeywords = new Set([
|
|
153
|
+
'await', 'case', 'delete', 'do', 'else', 'in', 'instanceof', 'new',
|
|
154
|
+
'of', 'return', 'throw', 'typeof', 'void', 'yield',
|
|
155
|
+
])
|
|
156
|
+
const canStartRegex = (): boolean => previousToken === undefined
|
|
157
|
+
|| '=(:,[!&|?{};'.includes(previousToken)
|
|
158
|
+
|| previousToken === '=>'
|
|
159
|
+
|| regexPrefixKeywords.has(previousToken)
|
|
160
|
+
for (let index = 0; index < chars.length; index += 1) {
|
|
161
|
+
const char = chars[index] ?? ''
|
|
162
|
+
if (quote) {
|
|
163
|
+
chars[index] = ' '
|
|
164
|
+
if (char === '\\') {
|
|
165
|
+
if (index + 1 < chars.length) chars[index + 1] = ' '
|
|
166
|
+
index += 1
|
|
167
|
+
} else if (char === quote) {
|
|
168
|
+
quote = undefined
|
|
169
|
+
previousToken = 'literal'
|
|
170
|
+
}
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
174
|
+
quote = char
|
|
175
|
+
chars[index] = ' '
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
if (char === '/' && canStartRegex()) {
|
|
179
|
+
chars[index] = ' '
|
|
180
|
+
let inClass = false
|
|
181
|
+
for (index += 1; index < chars.length; index += 1) {
|
|
182
|
+
const regexChar = chars[index] ?? ''
|
|
183
|
+
chars[index] = ' '
|
|
184
|
+
if (regexChar === '\\') {
|
|
185
|
+
if (index + 1 < chars.length) chars[index + 1] = ' '
|
|
186
|
+
index += 1
|
|
187
|
+
} else if (regexChar === '[') {
|
|
188
|
+
inClass = true
|
|
189
|
+
} else if (regexChar === ']') {
|
|
190
|
+
inClass = false
|
|
191
|
+
} else if (regexChar === '/' && !inClass) {
|
|
192
|
+
while (/[A-Za-z]/.test(chars[index + 1] ?? '')) {
|
|
193
|
+
index += 1
|
|
194
|
+
chars[index] = ' '
|
|
195
|
+
}
|
|
196
|
+
break
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
previousToken = 'literal'
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
if (/[A-Za-z_$]/.test(char)) {
|
|
203
|
+
const start = index
|
|
204
|
+
while (/[A-Za-z0-9_$]/.test(chars[index + 1] ?? '')) index += 1
|
|
205
|
+
previousToken = chars.slice(start, index + 1).join('')
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
if (char === '=' && chars[index + 1] === '>') {
|
|
209
|
+
previousToken = '=>'
|
|
210
|
+
index += 1
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
if (!/\s/.test(char)) previousToken = char
|
|
214
|
+
}
|
|
215
|
+
return chars.join('')
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const nonSpace = (source: string, start: number): number => {
|
|
219
|
+
let index = start
|
|
220
|
+
while (/\s/.test(source[index] ?? '')) index += 1
|
|
221
|
+
return index
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const escapedRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
225
|
+
|
|
226
|
+
export const parseStaticJsObject = (source: string): unknown => {
|
|
227
|
+
const clean = stripComments(source)
|
|
228
|
+
const mask = maskStrings(clean)
|
|
229
|
+
const parseBinding = (identifier: string): unknown => {
|
|
230
|
+
const declaration = new RegExp(`\\b(?:const|let|var)\\s+${escapedRegex(identifier)}(?:\\s*:[^=;]+)?\\s*=`).exec(mask)
|
|
231
|
+
if (!declaration) throw new Error(`Exported binding "${identifier}" must reference a static object declaration.`)
|
|
232
|
+
const start = nonSpace(mask, declaration.index + declaration[0].length)
|
|
233
|
+
if (mask[start] !== '{') throw new Error(`Exported binding "${identifier}" must be a static object literal.`)
|
|
234
|
+
return new LiteralParser(clean).parseAt(start)
|
|
235
|
+
}
|
|
236
|
+
const commonJs = /\bmodule\.exports\s*=/.exec(mask)
|
|
237
|
+
if (commonJs) {
|
|
238
|
+
const start = nonSpace(mask, commonJs.index + commonJs[0].length)
|
|
239
|
+
if (mask[start] === '{') return new LiteralParser(clean).parseAt(start)
|
|
240
|
+
const identifier = /^[A-Za-z_$][\w$]*/.exec(mask.slice(start))?.[0]
|
|
241
|
+
if (identifier) return parseBinding(identifier)
|
|
242
|
+
throw new Error('module.exports must be assigned a static object literal or binding.')
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const exported = /\bexport\s+default\b/.exec(mask)
|
|
246
|
+
if (exported) {
|
|
247
|
+
let start = nonSpace(mask, exported.index + exported[0].length)
|
|
248
|
+
if (mask.slice(start).startsWith('defineConfig')) {
|
|
249
|
+
start = nonSpace(mask, start + 'defineConfig'.length)
|
|
250
|
+
if (mask[start] !== '(') throw new Error('defineConfig must be called with a static object literal.')
|
|
251
|
+
start = nonSpace(mask, start + 1)
|
|
252
|
+
if (mask[start] !== '{') throw new Error('defineConfig must be called with a static object literal.')
|
|
253
|
+
return new LiteralParser(clean).parseAt(start)
|
|
254
|
+
}
|
|
255
|
+
if (mask[start] === '{') return new LiteralParser(clean).parseAt(start)
|
|
256
|
+
|
|
257
|
+
const identifier = /^[A-Za-z_$][\w$]*/.exec(mask.slice(start))?.[0]
|
|
258
|
+
if (identifier) return parseBinding(identifier)
|
|
259
|
+
}
|
|
260
|
+
throw new Error('Docusaurus sidebar must export a static object or assign one to a const.')
|
|
261
|
+
}
|