@agentskit/doc-bridge 1.7.45 → 1.9.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.
- package/CHANGELOG.md +465 -0
- package/CONTRIBUTING.md +23 -0
- package/README.md +87 -40
- package/SECURITY.md +7 -0
- package/action.yml +1 -1
- package/bin/ak-docs.js +2 -2
- package/bin/ak-verify.js +13 -7
- package/dist/cli/program.d.ts +3 -1
- package/dist/cli/program.js +15861 -6223
- package/dist/cli/program.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +86 -6
- package/dist/config/index.js.map +1 -1
- package/dist/{index-BUL0q7s8.d.ts → index-Beor6Yhi.d.ts} +133 -1
- package/dist/index.d.ts +9871 -1062
- package/dist/index.js +16126 -6125
- package/dist/index.js.map +1 -1
- package/docs/MARKETPLACE.md +1 -1
- package/docs/PRD-documentation-efficiency-study.md +406 -0
- package/docs/PRD-knowledge-retrieval-and-enrichment.md +466 -0
- package/docs/adr/0002-documentation-audit-boundary.md +22 -0
- package/docs/adr/0003-study-protocol-and-historical-evidence.md +40 -0
- package/docs/adr/0004-controlled-study-runner.md +25 -0
- package/docs/adr/0005-documentation-quality-and-criticality.md +20 -0
- package/docs/adr/0006-registry-semantic-grounding.md +20 -0
- package/docs/adr/0007-longitudinal-study-metrics.md +21 -0
- package/docs/adr/0008-study-verification-boundary.md +21 -0
- package/docs/adr/0009-study-provider-cli-adapter.md +25 -0
- package/docs/agent-corpus/INDEX.md +12 -1
- package/docs/agent-corpus/OVERVIEW.md +25 -0
- package/docs/agent-corpus/chat.md +5 -1
- package/docs/agent-corpus/cli.md +16 -0
- package/docs/agent-corpus/conformance.md +12 -0
- package/docs/agent-corpus/doc-bridge.md +47 -0
- package/docs/agent-corpus/doctor.md +8 -0
- package/docs/agent-corpus/gates.md +4 -0
- package/docs/agent-corpus/mcp.md +13 -0
- package/docs/agent-corpus/memory.md +4 -0
- package/docs/agent-corpus/query.md +33 -0
- package/docs/bench/README.md +122 -0
- package/docs/bench/retrieval-baseline-v1.json +28 -0
- package/docs/bench/retrieval-suite-v1.json +1033 -0
- package/docs/chat-and-rag.md +3 -2
- package/docs/for-agents.md +9 -1
- package/docs/getting-started.md +4 -11
- package/docs/guides/gate-ci.md +11 -1
- package/docs/guides/install-and-run.md +9 -65
- package/docs/index.md +22 -1
- package/docs/knowledge-engine-runbook.md +38 -3
- package/docs/landing/assets/context-payload-reduction.svg +21 -0
- package/docs/landing/assets/controlled-ab-comparison.svg +30 -0
- package/docs/landing/index.html +119 -5
- package/docs/loop-workflow.md +117 -0
- package/docs/mcp.md +6 -1
- package/docs/parity/public-claims-v1.json +145 -0
- package/docs/playbook/doc-bridge-pattern.md +1 -1
- package/docs/query.md +90 -2
- package/docs/recipes/index-pipeline.md +1 -1
- package/docs/schemas/agent-handoff-v1.md +15 -0
- package/docs/schemas/doc-bridge-index-v1.md +65 -0
- package/docs/spec/benchmark-v1.md +33 -1
- package/docs/spec/cli.md +30 -10
- package/docs/spec/config-v1.md +147 -8
- package/docs/spec/documentation-audit-v1.md +61 -0
- package/docs/spec/enrichment-overlay-v1.md +241 -0
- package/docs/spec/graph-signals-v1.md +92 -0
- package/docs/spec/incremental-scan-v1.md +102 -0
- package/docs/spec/markdown-analyzer-v1.md +73 -0
- package/docs/spec/mcp-knowledge-tools-v1.md +147 -0
- package/docs/spec/measured-enrichment-v1.md +229 -0
- package/docs/spec/public-parity-v1.md +119 -0
- package/docs/spec/registry-agents.md +6 -0
- package/docs/spec/render-v1.md +122 -0
- package/docs/spec/retrieval-index-v1.md +164 -0
- package/docs/spec/study-metrics-v1.md +58 -0
- package/docs/spec/study-protocol-v1.md +46 -0
- package/docs/spec/study-provider-cli-v1.md +116 -0
- package/docs/spec/study-runner-v1.md +35 -0
- package/docs/spec/study-task-suite-v1.md +41 -0
- package/docs/spec/study-verification-v1.md +40 -0
- package/docs/study/README.md +84 -0
- package/docs/study/ab-adjudicated-cost-analysis-v1.md +29 -0
- package/docs/study/ab-adjudicated-cost-plan-v1.json +33 -0
- package/docs/study/ab-adjudicated-cost-plan-v2-v1.json +33 -0
- package/docs/study/ab-adjudicated-cost-result-v1.json +80 -0
- package/docs/study/ab-baseline-analysis-v1.md +21 -0
- package/docs/study/ab-baseline-plan-v1.json +33 -0
- package/docs/study/ab-baseline-recovery-plan-v1.json +33 -0
- package/docs/study/ab-baseline-result-v1.json +79 -0
- package/docs/study/documentation-audit-round-2026-08-31.json +183 -0
- package/docs/study/historical-evidence-v1.json +252 -0
- package/docs/study/observation-ledger-v1.json +30632 -0
- package/docs/study/phase3-task-coverage-v1.json +34 -0
- package/docs/study/phase4-public-pilot-ledger-v1.json +1344 -0
- package/docs/study/phase4-public-pilot-result-v1.json +52 -0
- package/docs/study/phase4-public-pilot-run-plan-v1.json +26 -0
- package/docs/study/phase4-public-pilot-task-suite-v1.json +71 -0
- package/docs/study/pilot-round-2026-08-31.json +46 -0
- package/docs/study/protocol-v1.json +90 -0
- package/docs/study/publication-gate-v1.md +45 -0
- package/docs/study/quality-scorecard-cycle-plan.md +545 -0
- package/docs/study/quality-scorecard-v1.json +38 -0
- package/docs/study/round-1-adjudicated-smoke-v1.json +30642 -0
- package/docs/study/round-1-instrumentation-plan-v1.md +39 -0
- package/docs/study/round-2-expanded-adjudication-v1.json +91 -0
- package/docs/study/round-2-expanded-validation-v1.md +58 -0
- package/docs/study/round-3-evidence-contract-v1.json +75 -0
- package/docs/study/round-3-evidence-contract-v1.md +57 -0
- package/docs/study/round-4-confirmation-v1.json +75 -0
- package/docs/study/round-4-confirmation-v1.md +55 -0
- package/docs/study/run-plan-v1.json +33 -0
- package/docs/study/semantic-adjudication-cycle-8.md +20 -0
- package/docs/study/task-suite-v1.json +96 -0
- package/docs/study/token-efficiency-plan-v1.md +337 -0
- package/docs/study/token-efficiency-protocol-v2.json +62 -0
- package/docs/study/verification-binding-v1.json +27 -0
- package/docs/validation-cycle-plan.md +14 -0
- package/docs/verification-harness.md +11 -6
- package/ecosystem-claims.json +2 -2
- package/ecosystem-upstream.json +2 -2
- package/ecosystem.json +4 -4
- package/mcpb/manifest.json +9 -1
- package/package.json +25 -6
- package/scripts/check-ecosystem-upstream.mjs +34 -6
- package/skills/doc-bridge-handoff/fixtures/synthetic-repo/docs/for-agents/packages/payments.md +7 -0
- package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
- package/src/agents/registry-adapter.ts +192 -24
- package/src/audit/documentation.ts +513 -0
- package/src/bench/baseline.ts +198 -0
- package/src/bench/overlay-delta.ts +139 -0
- package/src/bench/retrieval.ts +319 -0
- package/src/budget/compile.ts +91 -0
- package/src/budget/sections.ts +70 -0
- package/src/cli/program.ts +684 -74
- package/src/cli/usage.ts +71 -0
- package/src/config/defaults.ts +1 -0
- package/src/config/index.ts +4 -0
- package/src/config/schema.ts +117 -2
- package/src/conformance/documentation-standard-v1.ts +8 -6
- package/src/discovery/areas.ts +182 -0
- package/src/discovery/documentation.ts +211 -5
- package/src/discovery/identity.ts +24 -0
- package/src/discovery/incremental.ts +314 -0
- package/src/discovery/inputs.ts +110 -0
- package/src/discovery/markdown.ts +481 -0
- package/src/discovery/repository.ts +484 -101
- package/src/doctor/run-doctor.ts +244 -12
- package/src/enrich/approvals.ts +190 -0
- package/src/enrich/cache.ts +93 -0
- package/src/enrich/context-pack.ts +272 -0
- package/src/enrich/overlay.ts +255 -0
- package/src/enrich/review.ts +106 -0
- package/src/enrich/stage.ts +374 -0
- package/src/enrich/stats.ts +100 -0
- package/src/enrich/validate.ts +410 -0
- package/src/federation/llms.ts +2 -4
- package/src/findings/report.ts +103 -0
- package/src/graph/build.ts +356 -0
- package/src/graph/memory.ts +208 -0
- package/src/index-builder/build-handoffs.ts +22 -11
- package/src/index-builder/build-index.ts +132 -3
- package/src/index-builder/llms-txt.ts +48 -8
- package/src/index-builder/project-corpus.ts +111 -0
- package/src/index.ts +630 -2
- package/src/lib/fuzzy-match.ts +235 -0
- package/src/mcp/knowledge.ts +554 -0
- package/src/mcp/server.ts +113 -18
- package/src/metrics/benchmark.ts +21 -0
- package/src/parity/check.ts +309 -0
- package/src/parity/claims.ts +259 -0
- package/src/parity/resolve.ts +160 -0
- package/src/query/handoff.ts +326 -0
- package/src/query/load-index.ts +53 -1
- package/src/query/query.ts +92 -59
- package/src/query/search.ts +289 -92
- package/src/query/text.ts +155 -0
- package/src/reconciliation/reconcile.ts +101 -10
- package/src/render/data.ts +356 -0
- package/src/render/engine.ts +398 -0
- package/src/render/generated.ts +77 -0
- package/src/render/render.ts +209 -0
- package/src/render/template-source.ts +52 -0
- package/src/render/templates.ts +289 -0
- package/src/report/html.ts +2 -2
- package/src/retrieval/bm25.ts +161 -0
- package/src/retrieval/project.ts +495 -0
- package/src/retrieval/rank.ts +383 -0
- package/src/retrieval/weights.ts +39 -0
- package/src/retriever/doc-bridge-retriever.ts +100 -15
- package/src/rules/engine.ts +30 -10
- package/src/schemas/agent-handoff.ts +56 -0
- package/src/schemas/budget.ts +37 -0
- package/src/schemas/doc-bridge-index.ts +53 -2
- package/src/schemas/enrichment.ts +369 -0
- package/src/schemas/json-schemas.ts +39 -2
- package/src/schemas/knowledge.ts +14 -1
- package/src/schemas/retrieval-index.ts +152 -0
- package/src/shims/graphology.d.ts +91 -0
- package/src/study/adjudication.ts +196 -0
- package/src/study/execution.ts +350 -0
- package/src/study/expectations.ts +219 -0
- package/src/study/metrics.ts +467 -0
- package/src/study/protocol.ts +271 -0
- package/src/study/provider-cli.ts +115 -0
- package/src/study/provider-telemetry.ts +47 -0
- package/src/study/quality-scorecard.ts +164 -0
- package/src/study/runner.ts +461 -0
- package/src/study/task-suite.ts +321 -0
- package/src/study/verification.ts +134 -0
- package/src/validate.ts +1 -4
- package/src/version.ts +1 -1
- package/src/workflow/engine.ts +36 -11
- package/scripts/verification-harness.mjs +0 -482
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { extname } from 'node:path'
|
|
2
|
+
import * as ts from 'typescript'
|
|
3
|
+
|
|
4
|
+
import type { DocBridgeConfigV1 } from '../config/schema.js'
|
|
5
|
+
import { DEFAULT_SAFETY_EXCLUDES, type SafeWalkOptions } from '../safety/repository.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What counts as a repository input, in one place.
|
|
9
|
+
*
|
|
10
|
+
* The discovery snapshot and the retrieval projection must walk the same files under the same
|
|
11
|
+
* safety limits, or the index would claim to cover entities the snapshot does not have (or miss
|
|
12
|
+
* ones it does). Sharing these definitions makes that agreement structural instead of a comment.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const SOURCE_EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts'] as const
|
|
16
|
+
export const DOCUMENT_EXTENSIONS = ['.md', '.mdx'] as const
|
|
17
|
+
export const CONFIG_EXTENSIONS = ['.json', '.yaml', '.yml', '.js', '.ts'] as const
|
|
18
|
+
export const DEFAULT_MAX_FILES = 10_000
|
|
19
|
+
|
|
20
|
+
export const safeWalkOptions = (config: DocBridgeConfigV1 | undefined, overrides: { readonly maxFiles?: number; readonly maxBytes?: number } = {}): SafeWalkOptions => {
|
|
21
|
+
const safety = config?.safety
|
|
22
|
+
const maxBytes = overrides.maxBytes ?? safety?.maxBytes
|
|
23
|
+
return {
|
|
24
|
+
exclude: [...DEFAULT_SAFETY_EXCLUDES, ...(safety?.exclude ?? [])],
|
|
25
|
+
maxFiles: overrides.maxFiles ?? safety?.maxFiles ?? DEFAULT_MAX_FILES,
|
|
26
|
+
...(maxBytes !== undefined ? { maxBytes } : {}),
|
|
27
|
+
...(safety?.maxTimeMs !== undefined ? { maxTimeMs: safety.maxTimeMs } : {}),
|
|
28
|
+
...(safety?.maxMemoryMb !== undefined ? { maxMemoryMb: safety.maxMemoryMb } : {}),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Which audience a documentation file is written for, from its location. */
|
|
33
|
+
export const documentClassification = (path: string): string => {
|
|
34
|
+
if (/(^|\/)docs\/for-agents(?:\/|$)/.test(path)) return 'agent'
|
|
35
|
+
if (/(^|\/)docs-archive(?:\/|$)/.test(path)) return 'archive'
|
|
36
|
+
if (/(^|\/)docs(?:\/|$)/.test(path)) return 'human'
|
|
37
|
+
if (/(^|\/)(README|CONTRIBUTING|SECURITY|CHANGELOG)(?:\.|$)/i.test(path)) return 'project'
|
|
38
|
+
return 'unclassified'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const scriptKind = (path: string): ts.ScriptKind => {
|
|
42
|
+
switch (extname(path)) {
|
|
43
|
+
case '.js': return ts.ScriptKind.JS
|
|
44
|
+
case '.jsx': return ts.ScriptKind.JSX
|
|
45
|
+
case '.mjs': return ts.ScriptKind.JS
|
|
46
|
+
case '.cjs': return ts.ScriptKind.JS
|
|
47
|
+
case '.ts': return ts.ScriptKind.TS
|
|
48
|
+
case '.tsx': return ts.ScriptKind.TSX
|
|
49
|
+
case '.mts': return ts.ScriptKind.TS
|
|
50
|
+
case '.cts': return ts.ScriptKind.TS
|
|
51
|
+
default: return ts.ScriptKind.Unknown
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const isExported = (node: ts.Node): boolean => {
|
|
56
|
+
const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined
|
|
57
|
+
return modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type ExportedNamesOptions = {
|
|
61
|
+
/**
|
|
62
|
+
* Skip names the module only forwards (`export { x } from './y'`).
|
|
63
|
+
*
|
|
64
|
+
* A barrel file re-exports hundreds of names it does not define. Treating those as definitions
|
|
65
|
+
* makes almost every symbol look like it has two owners, which turns an unambiguous reference
|
|
66
|
+
* into an ambiguous one — so the callers that need "the module that defines this" ask for
|
|
67
|
+
* declarations only.
|
|
68
|
+
*/
|
|
69
|
+
readonly declaredOnly?: boolean
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Every name a module exports, sorted. `*` stands for a star re-export. */
|
|
73
|
+
export const exportedNames = (sourceFile: ts.SourceFile, options: ExportedNamesOptions = {}): string[] => {
|
|
74
|
+
const declaredOnly = options.declaredOnly ?? false
|
|
75
|
+
const names = new Set<string>()
|
|
76
|
+
const addDeclarationName = (node: ts.Declaration): void => {
|
|
77
|
+
if (!isExported(node)) return
|
|
78
|
+
const name = ts.getNameOfDeclaration(node)
|
|
79
|
+
if (name && ts.isIdentifier(name)) names.add(name.text)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const visit = (node: ts.Node): void => {
|
|
83
|
+
if (ts.isExportDeclaration(node)) {
|
|
84
|
+
if (declaredOnly && node.moduleSpecifier) {
|
|
85
|
+
// Forwarded from elsewhere: the other module is the one that defines it.
|
|
86
|
+
} else if (!node.exportClause) names.add('*')
|
|
87
|
+
else if (ts.isNamedExports(node.exportClause)) {
|
|
88
|
+
for (const element of node.exportClause.elements) names.add(element.name.text)
|
|
89
|
+
}
|
|
90
|
+
} else if (ts.isExportAssignment(node)) {
|
|
91
|
+
names.add('default')
|
|
92
|
+
} else if (
|
|
93
|
+
ts.isClassDeclaration(node) ||
|
|
94
|
+
ts.isFunctionDeclaration(node) ||
|
|
95
|
+
ts.isInterfaceDeclaration(node) ||
|
|
96
|
+
ts.isTypeAliasDeclaration(node) ||
|
|
97
|
+
ts.isEnumDeclaration(node) ||
|
|
98
|
+
ts.isModuleDeclaration(node)
|
|
99
|
+
) {
|
|
100
|
+
addDeclarationName(node)
|
|
101
|
+
} else if (ts.isVariableStatement(node) && isExported(node)) {
|
|
102
|
+
for (const declaration of node.declarationList.declarations) {
|
|
103
|
+
if (ts.isIdentifier(declaration.name)) names.add(declaration.name.text)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
ts.forEachChild(node, visit)
|
|
107
|
+
}
|
|
108
|
+
visit(sourceFile)
|
|
109
|
+
return [...names].sort()
|
|
110
|
+
}
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { toString as mdastToString } from 'mdast-util-to-string'
|
|
2
|
+
import remarkFrontmatter from 'remark-frontmatter'
|
|
3
|
+
import remarkGfm from 'remark-gfm'
|
|
4
|
+
import remarkParse from 'remark-parse'
|
|
5
|
+
import { unified } from 'unified'
|
|
6
|
+
import { visit } from 'unist-util-visit'
|
|
7
|
+
import { parse as parseYaml } from 'yaml'
|
|
8
|
+
import type { Root, RootContent } from 'mdast'
|
|
9
|
+
|
|
10
|
+
import { sha256NormalizedV1 } from '../index-builder/content-hash.js'
|
|
11
|
+
import { createFuzzyCandidateIndex, resolveFuzzyReference, type FuzzyCandidateIndex } from '../lib/fuzzy-match.js'
|
|
12
|
+
import { toPosix } from '../lib/paths.js'
|
|
13
|
+
import type { Evidence, KnowledgeRelation } from '../schemas/knowledge.js'
|
|
14
|
+
import { relationId } from './identity.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The Markdown analyzer.
|
|
18
|
+
*
|
|
19
|
+
* Documentation was previously read with regular expressions: frontmatter by one, the `docbridge`
|
|
20
|
+
* block by a hand-written YAML subset, and the prose not at all. Headings, links and inline code
|
|
21
|
+
* were discarded — so a repository where 23 documents linked to other documents and 14 cited
|
|
22
|
+
* source paths produced no edges from any of it, and one document in ninety-two counted as
|
|
23
|
+
* documented. A real parser turns that prose into evidence-backed relations with line numbers,
|
|
24
|
+
* which is the difference between a graph that knows what its documentation says and one that
|
|
25
|
+
* only knows the documentation exists.
|
|
26
|
+
*
|
|
27
|
+
* Every edge here is `observed` with file and line evidence from the node's own position. Nothing
|
|
28
|
+
* is inferred from a near-match unless the near-match is unambiguous.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export const MARKDOWN_ANALYZER_VERSION = '1.0.0'
|
|
32
|
+
|
|
33
|
+
/** Headings deeper than this are structure, not subject matter. */
|
|
34
|
+
const MAX_HEADING_DEPTH = 3
|
|
35
|
+
const MAX_HEADINGS = 64
|
|
36
|
+
const MAX_SUMMARY_LENGTH = 400
|
|
37
|
+
const MAX_TITLE_LENGTH = 256
|
|
38
|
+
|
|
39
|
+
/** A document with more relations than this is an index page; the tail adds noise, not knowledge. */
|
|
40
|
+
export const MARKDOWN_RELATION_CAP = 64
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A region a generator owns. The analyzer skips it when collecting mentions, so Doc Bridge does
|
|
44
|
+
* not read its own output back in as evidence about the repository.
|
|
45
|
+
*/
|
|
46
|
+
const GENERATED_OPEN = /<!--\s*doc-bridge:generated(?:\s+hash=([A-Za-z0-9]+))?[^>]*-->/
|
|
47
|
+
const GENERATED_CLOSE = /<!--\s*\/\s*doc-bridge:generated\s*-->/
|
|
48
|
+
|
|
49
|
+
const EXTERNAL_LINK = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#|mailto:)/i
|
|
50
|
+
|
|
51
|
+
export type MarkdownHeading = {
|
|
52
|
+
readonly depth: number
|
|
53
|
+
readonly text: string
|
|
54
|
+
readonly line: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type MarkdownReference = {
|
|
58
|
+
/** The link target, or the inline code token. */
|
|
59
|
+
readonly value: string
|
|
60
|
+
/** Link text, when the reference came from a link. */
|
|
61
|
+
readonly text?: string
|
|
62
|
+
readonly line: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type MarkdownGeneratedRegion = {
|
|
66
|
+
readonly lineStart: number
|
|
67
|
+
readonly lineEnd: number
|
|
68
|
+
readonly hash?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The frontmatter fields the knowledge model understands. Everything else is left alone. */
|
|
72
|
+
export type MarkdownFrontmatter = {
|
|
73
|
+
readonly type?: string
|
|
74
|
+
readonly audience?: string
|
|
75
|
+
readonly owner?: string
|
|
76
|
+
readonly lifecycle?: string
|
|
77
|
+
readonly tier?: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type MarkdownDocumentV1 = {
|
|
81
|
+
readonly path: string
|
|
82
|
+
readonly title?: string
|
|
83
|
+
readonly headings: readonly MarkdownHeading[]
|
|
84
|
+
readonly summary?: string
|
|
85
|
+
readonly wordCount: number
|
|
86
|
+
readonly frontmatter: MarkdownFrontmatter
|
|
87
|
+
readonly generatedRegions: readonly MarkdownGeneratedRegion[]
|
|
88
|
+
readonly contentHash: string
|
|
89
|
+
/** Links outside generated regions. */
|
|
90
|
+
readonly links: readonly MarkdownReference[]
|
|
91
|
+
/** Inline code tokens outside generated regions. */
|
|
92
|
+
readonly codeTokens: readonly MarkdownReference[]
|
|
93
|
+
/** Raw frontmatter text and the line it starts on, for the declaration parser. */
|
|
94
|
+
readonly frontmatterBlock?: { readonly value: string; readonly line: number }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const processor = unified()
|
|
98
|
+
.use(remarkParse)
|
|
99
|
+
.use(remarkFrontmatter, ['yaml'])
|
|
100
|
+
.use(remarkGfm)
|
|
101
|
+
|
|
102
|
+
type Positioned = { readonly position?: { readonly start: { readonly line: number } } | undefined }
|
|
103
|
+
|
|
104
|
+
const lineOf = (node: Positioned): number => node.position?.start.line ?? 1
|
|
105
|
+
|
|
106
|
+
const withinGenerated = (line: number, regions: readonly MarkdownGeneratedRegion[]): boolean =>
|
|
107
|
+
regions.some((region) => line >= region.lineStart && line <= region.lineEnd)
|
|
108
|
+
|
|
109
|
+
/** Cap a summary at a sentence boundary when there is one, and never mid-word. */
|
|
110
|
+
const boundedSummary = (value: string): string | undefined => {
|
|
111
|
+
const text = value.replace(/\s+/g, ' ').trim()
|
|
112
|
+
if (!text) return undefined
|
|
113
|
+
if (text.length <= MAX_SUMMARY_LENGTH) return text
|
|
114
|
+
const sliced = text.slice(0, MAX_SUMMARY_LENGTH)
|
|
115
|
+
const sentenceEnd = Math.max(sliced.lastIndexOf('. '), sliced.lastIndexOf('! '), sliced.lastIndexOf('? '))
|
|
116
|
+
if (sentenceEnd > MAX_SUMMARY_LENGTH * 0.4) return sliced.slice(0, sentenceEnd + 1).trim()
|
|
117
|
+
const wordEnd = sliced.lastIndexOf(' ')
|
|
118
|
+
return (wordEnd > 0 ? sliced.slice(0, wordEnd) : sliced).trim()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const frontmatterField = (data: unknown, key: keyof MarkdownFrontmatter): string | undefined => {
|
|
122
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) return undefined
|
|
123
|
+
const value = (data as Record<string, unknown>)[key]
|
|
124
|
+
if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 128)
|
|
125
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The frontmatter fields the knowledge model reads, parsed as real YAML.
|
|
131
|
+
*
|
|
132
|
+
* Unparseable frontmatter yields no fields rather than a guess: the declaration layer is what
|
|
133
|
+
* reports the syntax error, with a line number, and one report is better than two.
|
|
134
|
+
*/
|
|
135
|
+
const frontmatterSubset = (value: string | undefined): MarkdownFrontmatter => {
|
|
136
|
+
if (!value) return {}
|
|
137
|
+
let data: unknown
|
|
138
|
+
try {
|
|
139
|
+
data = parseYaml(value)
|
|
140
|
+
} catch {
|
|
141
|
+
return {}
|
|
142
|
+
}
|
|
143
|
+
const subset: Record<string, string> = {}
|
|
144
|
+
for (const key of ['type', 'audience', 'owner', 'lifecycle', 'tier'] as const) {
|
|
145
|
+
const field = frontmatterField(data, key)
|
|
146
|
+
if (field !== undefined) subset[key] = field
|
|
147
|
+
}
|
|
148
|
+
return subset
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The audience a document declares about itself.
|
|
153
|
+
*
|
|
154
|
+
* `audience` is authoritative by definition — it is the author naming their reader. `type` counts
|
|
155
|
+
* only when it happens to name an audience, because in practice it names a document kind
|
|
156
|
+
* (`package`, `module`, `index`), and putting that in the audience field would corrupt the
|
|
157
|
+
* coverage counts that read it.
|
|
158
|
+
*/
|
|
159
|
+
const AUDIENCES = new Set(['agent', 'human', 'archive', 'project', 'internal', 'external', 'unclassified'])
|
|
160
|
+
|
|
161
|
+
export const declaredAudience = (frontmatter: MarkdownFrontmatter): string | undefined => {
|
|
162
|
+
if (frontmatter.audience) return frontmatter.audience.toLowerCase()
|
|
163
|
+
const type = frontmatter.type?.toLowerCase()
|
|
164
|
+
return type && AUDIENCES.has(type) ? type : undefined
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const generatedRegions = (tree: Root, totalLines: number): MarkdownGeneratedRegion[] => {
|
|
168
|
+
const regions: MarkdownGeneratedRegion[] = []
|
|
169
|
+
let open: { line: number; hash?: string } | undefined
|
|
170
|
+
|
|
171
|
+
visit(tree, 'html', (node) => {
|
|
172
|
+
const line = lineOf(node)
|
|
173
|
+
const closing = GENERATED_CLOSE.exec(node.value)
|
|
174
|
+
if (closing) {
|
|
175
|
+
if (open) regions.push({ lineStart: open.line, lineEnd: node.position?.end.line ?? line, ...(open.hash ? { hash: open.hash } : {}) })
|
|
176
|
+
open = undefined
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
const opening = GENERATED_OPEN.exec(node.value)
|
|
180
|
+
if (!opening) return
|
|
181
|
+
// A second opening marker closes the previous region: generators emit regions, not nests.
|
|
182
|
+
if (open) regions.push({ lineStart: open.line, lineEnd: line - 1, ...(open.hash ? { hash: open.hash } : {}) })
|
|
183
|
+
open = { line, ...(opening[1] ? { hash: opening[1] } : {}) }
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// An unclosed marker owns the rest of the file, which is what a trailing generated block is.
|
|
187
|
+
if (open) regions.push({ lineStart: open.line, lineEnd: totalLines, ...(open.hash ? { hash: open.hash } : {}) })
|
|
188
|
+
return regions
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The hash of a document's content, as the entity records it.
|
|
193
|
+
*
|
|
194
|
+
* A byte-order mark is not content — a file that only gained one parses to the same tree — so it
|
|
195
|
+
* is stripped before hashing. Exported because deciding whether a document needs parsing at all
|
|
196
|
+
* means computing the same hash without parsing it.
|
|
197
|
+
*/
|
|
198
|
+
export const markdownContentHash = (content: string): string => sha256NormalizedV1(content.replace(/^\uFEFF/, ''))
|
|
199
|
+
|
|
200
|
+
export const parseMarkdownDocument = (path: string, content: string): MarkdownDocumentV1 => {
|
|
201
|
+
const normalized = content.replace(/^\uFEFF/, '')
|
|
202
|
+
const tree = processor.parse(normalized) as Root
|
|
203
|
+
const totalLines = normalized.split(/\r?\n/).length
|
|
204
|
+
|
|
205
|
+
const frontmatterNode = tree.children.find((child): child is RootContent & { type: 'yaml'; value: string } => child.type === 'yaml')
|
|
206
|
+
const regions = generatedRegions(tree, totalLines)
|
|
207
|
+
|
|
208
|
+
const headings: MarkdownHeading[] = []
|
|
209
|
+
const links: MarkdownReference[] = []
|
|
210
|
+
const codeTokens: MarkdownReference[] = []
|
|
211
|
+
let title: string | undefined
|
|
212
|
+
let summary: string | undefined
|
|
213
|
+
|
|
214
|
+
visit(tree, (node) => {
|
|
215
|
+
if (node.type === 'yaml') return
|
|
216
|
+
const line = lineOf(node as { position?: { start: { line: number } } })
|
|
217
|
+
|
|
218
|
+
if (node.type === 'heading') {
|
|
219
|
+
const text = mdastToString(node).trim()
|
|
220
|
+
if (!text) return
|
|
221
|
+
if (!title && node.depth === 1) title = text.slice(0, MAX_TITLE_LENGTH)
|
|
222
|
+
if (node.depth <= MAX_HEADING_DEPTH && headings.length < MAX_HEADINGS) {
|
|
223
|
+
headings.push({ depth: node.depth, text: text.slice(0, MAX_TITLE_LENGTH), line })
|
|
224
|
+
}
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (node.type === 'paragraph' && !summary && !withinGenerated(line, regions)) {
|
|
229
|
+
// A paragraph that is only a link is navigation, not a description of the document.
|
|
230
|
+
const onlyLink = node.children.length === 1 && node.children[0]?.type === 'link'
|
|
231
|
+
if (!onlyLink) summary = boundedSummary(mdastToString(node))
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (node.type === 'link' && !withinGenerated(line, regions)) {
|
|
236
|
+
const text = mdastToString(node).trim()
|
|
237
|
+
links.push({ value: node.url, ...(text ? { text } : {}), line })
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (node.type === 'inlineCode' && !withinGenerated(line, regions)) {
|
|
242
|
+
const value = node.value.trim()
|
|
243
|
+
if (value) codeTokens.push({ value, line })
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const frontmatter = frontmatterSubset(frontmatterNode?.value)
|
|
248
|
+
const body = tree.children.filter((child) => child.type !== 'yaml')
|
|
249
|
+
const prose = body.map((child) => mdastToString(child)).join(' ')
|
|
250
|
+
const wordCount = prose.split(/\s+/).filter(Boolean).length
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
path,
|
|
254
|
+
...(title ? { title } : {}),
|
|
255
|
+
headings,
|
|
256
|
+
...(summary ? { summary } : {}),
|
|
257
|
+
wordCount,
|
|
258
|
+
frontmatter,
|
|
259
|
+
generatedRegions: regions,
|
|
260
|
+
contentHash: markdownContentHash(normalized),
|
|
261
|
+
links,
|
|
262
|
+
codeTokens,
|
|
263
|
+
...(frontmatterNode
|
|
264
|
+
? { frontmatterBlock: { value: frontmatterNode.value, line: lineOf(frontmatterNode) } }
|
|
265
|
+
: {}),
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
export type MarkdownResolution = {
|
|
272
|
+
/** Repository-relative paths of every scanned document. */
|
|
273
|
+
readonly documents: ReadonlyMap<string, string>
|
|
274
|
+
/** Repository-relative module path to entity id. */
|
|
275
|
+
readonly modules: ReadonlyMap<string, string>
|
|
276
|
+
/** Repository-relative directory path to area entity id. */
|
|
277
|
+
readonly areas?: ReadonlyMap<string, string>
|
|
278
|
+
/** Package name, and short name, to entity id. */
|
|
279
|
+
readonly packages: ReadonlyMap<string, string>
|
|
280
|
+
/** Exported symbol to the entity ids of every module exporting it. */
|
|
281
|
+
readonly symbols: ReadonlyMap<string, readonly string[]>
|
|
282
|
+
readonly relationCap?: number
|
|
283
|
+
/**
|
|
284
|
+
* Path candidates for near-miss resolution, indexed by length.
|
|
285
|
+
*
|
|
286
|
+
* Built once per snapshot by the caller. Deriving it here meant rebuilding it for every document
|
|
287
|
+
* — with four thousand documents and nine thousand modules, fifty-five million string copies
|
|
288
|
+
* before any analysis, and a similarity scan over the whole universe per unresolved reference.
|
|
289
|
+
* `markdownPathCandidateIndex` builds it from the same three maps, so a caller that omits it
|
|
290
|
+
* still gets identical results, only slowly.
|
|
291
|
+
*/
|
|
292
|
+
readonly pathIndex?: FuzzyCandidateIndex
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The candidate index the analyzer wants, built once from a resolution universe. */
|
|
296
|
+
export const markdownPathCandidateIndex = (
|
|
297
|
+
resolution: Pick<MarkdownResolution, 'documents' | 'modules' | 'areas'>,
|
|
298
|
+
): FuzzyCandidateIndex =>
|
|
299
|
+
createFuzzyCandidateIndex([
|
|
300
|
+
...resolution.documents.keys(),
|
|
301
|
+
...resolution.modules.keys(),
|
|
302
|
+
...(resolution.areas ?? new Map<string, string>()).keys(),
|
|
303
|
+
])
|
|
304
|
+
|
|
305
|
+
export type MarkdownNote = {
|
|
306
|
+
readonly scope: string
|
|
307
|
+
readonly reason: string
|
|
308
|
+
readonly evidence: readonly Evidence[]
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export type MarkdownAnalysis = {
|
|
312
|
+
readonly relations: readonly KnowledgeRelation[]
|
|
313
|
+
readonly notes: readonly MarkdownNote[]
|
|
314
|
+
readonly truncated: boolean
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const documentEvidence = (path: string, line: number): Evidence => ({
|
|
318
|
+
source: 'documentation',
|
|
319
|
+
path,
|
|
320
|
+
lineStart: line,
|
|
321
|
+
lineEnd: line,
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
/** Resolve a relative link against the document's own directory, POSIX-style. */
|
|
325
|
+
const resolveRelative = (from: string, target: string): string | undefined => {
|
|
326
|
+
if (!target || EXTERNAL_LINK.test(target)) return undefined
|
|
327
|
+
const clean = target.split('#')[0]?.split('?')[0] ?? ''
|
|
328
|
+
if (!clean) return undefined
|
|
329
|
+
const base = clean.startsWith('/') ? [] : from.split('/').slice(0, -1)
|
|
330
|
+
const segments = [...base]
|
|
331
|
+
for (const segment of clean.replace(/^\//, '').split('/')) {
|
|
332
|
+
if (!segment || segment === '.') continue
|
|
333
|
+
if (segment === '..') {
|
|
334
|
+
if (!segments.length) return undefined
|
|
335
|
+
segments.pop()
|
|
336
|
+
continue
|
|
337
|
+
}
|
|
338
|
+
segments.push(segment)
|
|
339
|
+
}
|
|
340
|
+
return segments.length ? toPosix(segments.join('/')) : undefined
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** A token that could be a repository path, as opposed to prose or a symbol. */
|
|
344
|
+
const pathShaped = (value: string): boolean => /[/.]/.test(value) && !/\s/.test(value)
|
|
345
|
+
|
|
346
|
+
export const analyzeMarkdownDocument = (
|
|
347
|
+
document: MarkdownDocumentV1,
|
|
348
|
+
documentId: string,
|
|
349
|
+
resolution: MarkdownResolution,
|
|
350
|
+
): MarkdownAnalysis => {
|
|
351
|
+
const cap = resolution.relationCap ?? MARKDOWN_RELATION_CAP
|
|
352
|
+
const relations = new Map<string, KnowledgeRelation>()
|
|
353
|
+
const notes: MarkdownNote[] = []
|
|
354
|
+
const ambiguous = new Map<string, Evidence[]>()
|
|
355
|
+
let truncated = false
|
|
356
|
+
|
|
357
|
+
const add = (kind: string, to: string, line: number, confidence?: 'fuzzy'): void => {
|
|
358
|
+
if (to === documentId) return
|
|
359
|
+
const id = relationId(documentId, kind, to)
|
|
360
|
+
const existing = relations.get(id)
|
|
361
|
+
if (existing) {
|
|
362
|
+
// One relation, every place the document says it — evidence accumulates, the edge does not.
|
|
363
|
+
if (existing.evidence.length < 8) {
|
|
364
|
+
relations.set(id, { ...existing, evidence: [...existing.evidence, documentEvidence(document.path, line)] })
|
|
365
|
+
}
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
if (relations.size >= cap) {
|
|
369
|
+
truncated = true
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
relations.set(id, {
|
|
373
|
+
id,
|
|
374
|
+
kind,
|
|
375
|
+
from: documentId,
|
|
376
|
+
to,
|
|
377
|
+
provenance: 'observed',
|
|
378
|
+
evidence: [documentEvidence(document.path, line)],
|
|
379
|
+
...(confidence ? { metadata: { confidence } } : {}),
|
|
380
|
+
})
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const areas = resolution.areas ?? new Map<string, string>()
|
|
384
|
+
const pathCandidates = resolution.pathIndex ?? markdownPathCandidateIndex(resolution)
|
|
385
|
+
|
|
386
|
+
/** A path-shaped reference: a document link, a module mention, or an unambiguous near-miss. */
|
|
387
|
+
const resolvePath = (candidate: string, line: number, linkKind: 'links-to' | 'mentions'): boolean => {
|
|
388
|
+
const documentEntity = resolution.documents.get(candidate)
|
|
389
|
+
if (documentEntity) {
|
|
390
|
+
add(linkKind === 'links-to' ? 'links-to' : 'mentions', documentEntity, line)
|
|
391
|
+
return true
|
|
392
|
+
}
|
|
393
|
+
const moduleEntity = resolution.modules.get(candidate)
|
|
394
|
+
if (moduleEntity) {
|
|
395
|
+
add('mentions', moduleEntity, line)
|
|
396
|
+
return true
|
|
397
|
+
}
|
|
398
|
+
// A directory is a unit of architecture now: naming one is a mention of the area.
|
|
399
|
+
const areaEntity = areas.get(candidate)
|
|
400
|
+
if (areaEntity) {
|
|
401
|
+
add('mentions', areaEntity, line)
|
|
402
|
+
return true
|
|
403
|
+
}
|
|
404
|
+
const fuzzy = resolveFuzzyReference(candidate, pathCandidates)
|
|
405
|
+
if (!fuzzy) return false
|
|
406
|
+
const target =
|
|
407
|
+
resolution.documents.get(fuzzy.candidate) ?? resolution.modules.get(fuzzy.candidate) ?? areas.get(fuzzy.candidate)
|
|
408
|
+
if (!target) return false
|
|
409
|
+
add(resolution.documents.has(fuzzy.candidate) ? linkKind : 'mentions', target, line, 'fuzzy')
|
|
410
|
+
return true
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
for (const link of document.links) {
|
|
414
|
+
const resolved = resolveRelative(document.path, link.value)
|
|
415
|
+
if (resolved) resolvePath(resolved, link.line, 'links-to')
|
|
416
|
+
// Link text can name a path or package even when the href points elsewhere.
|
|
417
|
+
if (link.text) resolveToken(link.text, link.line)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
for (const token of document.codeTokens) resolveToken(token.value, token.line)
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* An inline code token or a link label. In order: a repository path, a package name, then an
|
|
424
|
+
* exported symbol — and a symbol only when exactly one module exports it, because sending an
|
|
425
|
+
* agent to one of two possible definitions is worse than sending it nowhere.
|
|
426
|
+
*/
|
|
427
|
+
function resolveToken(raw: string, line: number): void {
|
|
428
|
+
const value = raw.trim()
|
|
429
|
+
if (!value || value.length > 256) return
|
|
430
|
+
|
|
431
|
+
if (pathShaped(value) || areas.has(value)) {
|
|
432
|
+
const direct = value.replace(/^\.\//, '')
|
|
433
|
+
if (resolution.documents.has(direct) || resolution.modules.has(direct) || areas.has(direct)) {
|
|
434
|
+
resolvePath(direct, line, 'mentions')
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const packageEntity = resolution.packages.get(value)
|
|
440
|
+
if (packageEntity) {
|
|
441
|
+
add('mentions', packageEntity, line)
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const modules = resolution.symbols.get(value)
|
|
446
|
+
if (modules?.length === 1 && modules[0]) {
|
|
447
|
+
add('mentions-symbol', modules[0], line)
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
if (modules && modules.length > 1) {
|
|
451
|
+
const evidence = ambiguous.get(value) ?? []
|
|
452
|
+
if (evidence.length < 8) evidence.push(documentEvidence(document.path, line))
|
|
453
|
+
ambiguous.set(value, evidence)
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (pathShaped(value)) resolvePath(value.replace(/^\.\//, ''), line, 'mentions')
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
for (const [token, evidence] of [...ambiguous.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
461
|
+
notes.push({
|
|
462
|
+
scope: `mentions-symbol:${document.path}:${token}`,
|
|
463
|
+
reason: `"${token}" is exported by ${resolution.symbols.get(token)?.length ?? 0} modules; the reference is ambiguous and produced no relation.`,
|
|
464
|
+
evidence,
|
|
465
|
+
})
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (truncated) {
|
|
469
|
+
notes.push({
|
|
470
|
+
scope: `relations:${document.path}`,
|
|
471
|
+
reason: `Document references more than ${cap} entities; the remainder was not recorded.`,
|
|
472
|
+
evidence: [documentEvidence(document.path, 1)],
|
|
473
|
+
})
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
relations: [...relations.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
|
478
|
+
notes,
|
|
479
|
+
truncated,
|
|
480
|
+
}
|
|
481
|
+
}
|