@agentskit/doc-bridge 1.7.44 → 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 +471 -0
- package/CONTRIBUTING.md +29 -4
- 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 +15888 -6061
- package/dist/cli/program.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +91 -9
- package/dist/config/index.js.map +1 -1
- package/dist/index-Beor6Yhi.d.ts +792 -0
- package/dist/index.d.ts +9979 -3257
- package/dist/index.js +15954 -5774
- 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/RELEASE.md +22 -8
- 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 +14 -3
- package/docs/agent-corpus/OVERVIEW.md +25 -0
- package/docs/agent-corpus/chat.md +7 -3
- package/docs/agent-corpus/cli.md +18 -2
- package/docs/agent-corpus/conformance.md +14 -2
- package/docs/agent-corpus/doc-bridge.md +48 -1
- package/docs/agent-corpus/doctor.md +10 -2
- package/docs/agent-corpus/gates.md +6 -2
- package/docs/agent-corpus/mcp.md +15 -2
- package/docs/agent-corpus/memory.md +6 -2
- package/docs/agent-corpus/query.md +35 -2
- 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 +51 -4
- 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 +39 -1
- package/docs/spec/cli.md +30 -10
- package/docs/spec/config-v1.md +192 -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 +33 -0
- package/docs/verification-harness.md +15 -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 +89 -72
- package/scripts/check-ecosystem-upstream.mjs +36 -7
- package/scripts/report-visual-check.mjs +20 -3
- 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/demo.ts +2 -2
- package/src/cli/program.ts +699 -79
- package/src/cli/usage.ts +71 -0
- package/src/config/defaults.ts +1 -0
- package/src/config/index.ts +4 -0
- package/src/config/load-config.ts +7 -1
- package/src/config/schema.ts +121 -4
- package/src/conformance/documentation-standard-v1.ts +22 -14
- package/src/discovery/areas.ts +182 -0
- package/src/discovery/documentation.ts +255 -23
- 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 +557 -125
- package/src/doctor/run-doctor.ts +246 -27
- 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 +74 -24
- package/src/findings/report.ts +103 -0
- package/src/fixes/proposals.ts +4 -3
- 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/human-adapters/fumadocs.ts +1 -1
- package/src/index-builder/llms-txt.ts +48 -8
- package/src/index-builder/project-corpus.ts +111 -0
- package/src/index-builder/watch-index.ts +1 -1
- package/src/index.ts +630 -2
- package/src/lib/bounded-text.ts +15 -10
- 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 +148 -15
- 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 +23 -17
- 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 +45 -12
- package/src/safety/repository.ts +1 -1
- 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 +19 -3
- 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 +8 -5
- package/src/version.ts +1 -1
- package/src/workflow/engine.ts +36 -11
- package/dist/index-C2PCQSrB.d.ts +0 -2251
- package/scripts/verification-harness.mjs +0 -483
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { foldAccents, hasSearchToken, searchTokens } from '../query/text.js'
|
|
2
|
+
import type { Confidence, RetrievalEdge, RetrievalEntry, RetrievalIndexV1 } from '../schemas/retrieval-index.js'
|
|
3
|
+
import { buildBm25Index, bm25Search, type Bm25Hit, type Bm25Index } from './bm25.js'
|
|
4
|
+
import { weakerConfidence } from './project.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Ranking over the retrieval projection.
|
|
8
|
+
*
|
|
9
|
+
* The score has named parts, and every part is reported when asked, because a ranking that
|
|
10
|
+
* cannot say why it put one record above another cannot be corrected: a wrong answer is then an
|
|
11
|
+
* opinion about weights rather than a bug with a line number. Lexical evidence is BM25 over the
|
|
12
|
+
* projected fields; identity boosts reward a query that names a thing rather than describes it;
|
|
13
|
+
* graph proximity rewards being next to what the query clearly found; canonicality rewards the
|
|
14
|
+
* page other pages point at; the audience prior is what `--agent` means. Priors multiply the
|
|
15
|
+
* lexical evidence rather than adding to it, so a favoured record still needs a real match.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Turns a BM25 score into the same magnitude as the identity boosts below. */
|
|
19
|
+
const BM25_SCALE = 50
|
|
20
|
+
|
|
21
|
+
const EXACT_ID = 200
|
|
22
|
+
const EXACT_PATH = 150
|
|
23
|
+
const EXACT_SYMBOL = 150
|
|
24
|
+
const DIRECTORY_MATCH = 120
|
|
25
|
+
const TOKEN_ID = 120
|
|
26
|
+
const TOKEN_PATH = 100
|
|
27
|
+
const TOKEN_SYMBOL = 90
|
|
28
|
+
const SHORT_ID_BONUS = 40
|
|
29
|
+
/** A query this short is a name; a longer one is a sentence that happens to contain names. */
|
|
30
|
+
const NAMING_QUERY_TOKENS = 2
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Proximity: a candidate one hop from something the query clearly found is probably what the
|
|
34
|
+
* query meant, or the place to read about it. Two hops is a weaker hint. Only the ten strongest
|
|
35
|
+
* lexical hits count as anchors — beyond that the "clearly found" premise no longer holds — and
|
|
36
|
+
* the boost is as strong as the anchor: being next to the best match is worth the full amount,
|
|
37
|
+
* being next to a marginal one worth a fraction, so a hub page that barely matched cannot lift
|
|
38
|
+
* everything it links to above the module that answers.
|
|
39
|
+
*/
|
|
40
|
+
const PROXIMITY_ANCHORS = 10
|
|
41
|
+
const PROXIMITY_ONE_HOP = 30
|
|
42
|
+
const PROXIMITY_TWO_HOPS = 12
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Canonicality: log-scaled PageRank, so a page every document links to earns a bounded bonus and
|
|
46
|
+
* the long tail of leaf pages earns close to nothing. Scaled by the corpus size, because a
|
|
47
|
+
* PageRank is a share of one and only means something relative to the uniform 1/N.
|
|
48
|
+
*/
|
|
49
|
+
const CANONICALITY_SCALE = 4
|
|
50
|
+
|
|
51
|
+
/** The `--agent` prior: documentation written for an agent answers an agent's question first. */
|
|
52
|
+
const AUDIENCE_FIT = 25
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Accepted agent signals: bounded influence.
|
|
56
|
+
*
|
|
57
|
+
* An entry's signal is a share in 0..1 of this weight, and the weight is 15% of the exact-id
|
|
58
|
+
* boost. That is the whole contract: an accepted rank hint or canonical marker can reorder
|
|
59
|
+
* near-ties among lexical hits, and can never lift an entry past one the query named exactly.
|
|
60
|
+
* Signals apply to lexical hits only, like every other tie-breaker.
|
|
61
|
+
*/
|
|
62
|
+
export const ACCEPTED_SIGNALS_SHARE = 0.15
|
|
63
|
+
export const ACCEPTED_SIGNALS_WEIGHT = Math.round(EXACT_ID * ACCEPTED_SIGNALS_SHARE)
|
|
64
|
+
|
|
65
|
+
const CURATED_FACTOR = 1.15
|
|
66
|
+
const OWNERSHIP_FACTOR = 1.1
|
|
67
|
+
const ROUTE_TITLE_FACTOR = 1.6
|
|
68
|
+
const CHANGE_INTENT_FACTOR = 1.4
|
|
69
|
+
const CHANGE_WITHOUT_INTENT_FACTOR = 0.15
|
|
70
|
+
const KIND_FACTOR = 1.15
|
|
71
|
+
const PARTIAL_ID_FACTOR = 1.2
|
|
72
|
+
|
|
73
|
+
export const RELEVANCE_FLOOR = 1 / 3
|
|
74
|
+
|
|
75
|
+
const PACKAGE_INTENT =
|
|
76
|
+
/\b(package|module|pkg|edit|change|where|owns?|ownership|handoff|start)\b|\b(?:pacote|pacotes|modulo|modulos|onde|quem|dono|donos|responsavel|responsaveis|comec\w*|inici\w*|edit\w*|mud\w*|alter\w*)/i
|
|
77
|
+
const CHANGE_INTENT =
|
|
78
|
+
/\b(change|edit|modify|update|fix|migrate|replace)\b|\b(?:alter\w*|mud\w*|modific\w*|edit\w*|atualiz\w*|corrig\w*|migr\w*|substitu\w*|troc\w*)/i
|
|
79
|
+
const SYMBOL_SHAPED = /[a-z0-9][A-Z]|^[A-Za-z_$][A-Za-z0-9_$]{2,}$/
|
|
80
|
+
const PATH_SHAPED = /\/|\.[A-Za-z]{1,4}$/
|
|
81
|
+
|
|
82
|
+
export type ScoreComponents = {
|
|
83
|
+
readonly lexical: number
|
|
84
|
+
readonly prior: number
|
|
85
|
+
readonly exactId: number
|
|
86
|
+
readonly exactPath: number
|
|
87
|
+
readonly exactSymbol: number
|
|
88
|
+
readonly graphProximity: number
|
|
89
|
+
readonly canonicality: number
|
|
90
|
+
readonly audienceFit: number
|
|
91
|
+
readonly acceptedAgentSignals: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type RankExplanation = {
|
|
95
|
+
/** Query terms after the lexicon: what the ranker actually looked for. */
|
|
96
|
+
readonly terms: readonly string[]
|
|
97
|
+
/** Which terms matched in which field. */
|
|
98
|
+
readonly matched: Readonly<Record<string, readonly string[]>>
|
|
99
|
+
readonly components: ScoreComponents
|
|
100
|
+
/**
|
|
101
|
+
* The edge proximity found this result through. When nothing matched lexically it is the only
|
|
102
|
+
* reason the result is here; otherwise it is what the proximity component stands on.
|
|
103
|
+
*/
|
|
104
|
+
readonly surfacedBy?: { readonly kind: string; readonly id: string; readonly confidence: Confidence; readonly hops: number }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type RankedEntry = {
|
|
108
|
+
readonly entry: RetrievalEntry
|
|
109
|
+
readonly score: number
|
|
110
|
+
/**
|
|
111
|
+
* The entry's own confidence when the query matched it directly; when a relation alone
|
|
112
|
+
* surfaced it, the weaker of the entry and that relation — a result is as trustworthy as the
|
|
113
|
+
* least trustworthy step that produced it.
|
|
114
|
+
*/
|
|
115
|
+
readonly confidence: Confidence
|
|
116
|
+
readonly explanation: RankExplanation
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type RankOptions = {
|
|
120
|
+
readonly limit?: number
|
|
121
|
+
/** The `--agent` prior: favour documentation written for an agent. */
|
|
122
|
+
readonly agent?: boolean
|
|
123
|
+
/** Per-entry accepted overlay signals (0..1), by entry id. Overrides the `agentSignal` the projection carries. */
|
|
124
|
+
readonly signals?: ReadonlyMap<string, number>
|
|
125
|
+
/**
|
|
126
|
+
* Results scoring below this share of the best score are dropped. The default spends context
|
|
127
|
+
* on nothing weak; a caller assembling a neighbourhood rather than an answer passes 0.
|
|
128
|
+
*/
|
|
129
|
+
readonly floor?: number
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const round = (value: number): number => Math.round(value * 1_000_000) / 1_000_000
|
|
133
|
+
|
|
134
|
+
const pathBase = (path: string): string => foldAccents((path.split('/').pop() ?? '').replace(/\.[A-Za-z0-9]+$/, '').toLowerCase())
|
|
135
|
+
|
|
136
|
+
const preferOwnership = (term: string): boolean => PACKAGE_INTENT.test(term) || /^(where|how).*(edit|change|package|module)/i.test(term)
|
|
137
|
+
|
|
138
|
+
const titleCoversQuery = (title: string, tokens: readonly string[]): boolean =>
|
|
139
|
+
tokens.length > 1 && tokens.every((token) => hasSearchToken(foldAccents(title.toLowerCase()), token))
|
|
140
|
+
|
|
141
|
+
const partialIdMatch = (id: string, tokens: readonly string[]): boolean => {
|
|
142
|
+
const idLower = foldAccents(id.toLowerCase())
|
|
143
|
+
return tokens.some(
|
|
144
|
+
(token) =>
|
|
145
|
+
idLower !== token &&
|
|
146
|
+
(idLower.startsWith(`${token}-`) || idLower.endsWith(`-${token}`) || (idLower.includes(token) && idLower.length <= token.length + 4)),
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The names an entry answers to when the query is an identifier: its aliases and, for a route, its id. */
|
|
151
|
+
const identifiers = (entry: RetrievalEntry): string[] =>
|
|
152
|
+
[...new Set([...entry.aliases, ...(entry.kind === 'intent' || entry.kind === 'change' ? [entry.id] : []), ...(entry.ownershipId ? [entry.ownershipId] : [])])].map((value) =>
|
|
153
|
+
foldAccents(value.toLowerCase()),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
type Identity = { readonly exactId: number; readonly exactPath: number; readonly exactSymbol: number }
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The query names the thing rather than describing it.
|
|
160
|
+
*
|
|
161
|
+
* A module is identified by its path and its exported symbols, a package or an area by its id and
|
|
162
|
+
* its aliases (an ownership id, a short name), a document by its filename. The whole query is
|
|
163
|
+
* checked before its tokens, so `src/mcp/server.ts` beats every record that merely mentions `mcp`.
|
|
164
|
+
*/
|
|
165
|
+
const identityBoost = (entry: RetrievalEntry, tokens: readonly string[], term: string): Identity => {
|
|
166
|
+
const ids = identifiers(entry)
|
|
167
|
+
const entityId = foldAccents(entry.id.toLowerCase())
|
|
168
|
+
const pathLower = foldAccents(entry.path.toLowerCase())
|
|
169
|
+
const termLower = foldAccents(term.toLowerCase().trim())
|
|
170
|
+
const base = pathBase(entry.path)
|
|
171
|
+
const symbols = new Set((entry.symbols ?? []).map((symbol) => foldAccents(symbol.toLowerCase())))
|
|
172
|
+
let exactId = 0
|
|
173
|
+
let exactPath = 0
|
|
174
|
+
let exactSymbol = 0
|
|
175
|
+
|
|
176
|
+
if (ids.includes(termLower) || entityId === termLower) exactId += EXACT_ID
|
|
177
|
+
if (base === termLower || pathLower === termLower) exactPath += EXACT_PATH
|
|
178
|
+
if (symbols.has(termLower)) exactSymbol += EXACT_SYMBOL
|
|
179
|
+
if (termLower && (pathLower.startsWith(`${termLower}/`) || pathLower.includes(`/${termLower}/`))) exactPath += DIRECTORY_MATCH
|
|
180
|
+
|
|
181
|
+
/*
|
|
182
|
+
* Per-token identity for filenames and symbols is for short queries: `search index` names
|
|
183
|
+
* `searchIndex`, and `bm25` names `bm25.ts`. A sentence does not name a file by containing one
|
|
184
|
+
* of its tokens — in "where are workflow transitions persisted", `transition` coinciding with an
|
|
185
|
+
* exported name is lexical evidence, which BM25 already weighs, not identity. An id or an alias
|
|
186
|
+
* is different: an ownership id is a name someone chose for a unit, and "integration setup auth"
|
|
187
|
+
* does name `auth`. Directory segments never count: an area or a package is named by the whole
|
|
188
|
+
* term, or by an alias it was given.
|
|
189
|
+
*/
|
|
190
|
+
const file = entry.kind === 'document' || entry.kind === 'module'
|
|
191
|
+
const naming = tokens.length <= NAMING_QUERY_TOKENS
|
|
192
|
+
for (const token of tokens) {
|
|
193
|
+
if (ids.includes(token)) exactId += TOKEN_ID
|
|
194
|
+
if (naming && file && base === token) exactPath += TOKEN_PATH
|
|
195
|
+
if (naming && symbols.has(token)) exactSymbol += TOKEN_SYMBOL
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Prefer a short id when the query is the id: "core" should mean the core package.
|
|
199
|
+
const shortest = ids.filter((id) => tokens.includes(id)).sort((a, b) => a.length - b.length)[0]
|
|
200
|
+
if (shortest) exactId += Math.max(0, SHORT_ID_BONUS - shortest.length)
|
|
201
|
+
|
|
202
|
+
return { exactId, exactPath, exactSymbol }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** A nudge toward the kind of record the query shape asks for. A prior, not a filter. */
|
|
206
|
+
const priorFactor = (entry: RetrievalEntry, tokens: readonly string[], term: string, wantOwnership: boolean): number => {
|
|
207
|
+
const trimmed = term.trim()
|
|
208
|
+
const covered = titleCoversQuery(entry.title, tokens)
|
|
209
|
+
let factor = 1
|
|
210
|
+
|
|
211
|
+
if (entry.audience === 'agent' || entry.audience === 'human-and-agent') factor *= CURATED_FACTOR
|
|
212
|
+
if (identifiers(entry).some((id) => partialIdMatch(id, tokens))) factor *= PARTIAL_ID_FACTOR
|
|
213
|
+
if (entry.ownershipId && wantOwnership) factor *= OWNERSHIP_FACTOR
|
|
214
|
+
if ((entry.kind === 'intent' || entry.kind === 'change') && covered) factor *= ROUTE_TITLE_FACTOR
|
|
215
|
+
if (entry.kind === 'change') factor *= CHANGE_INTENT.test(term) ? CHANGE_INTENT_FACTOR : covered ? 1 : CHANGE_WITHOUT_INTENT_FACTOR
|
|
216
|
+
|
|
217
|
+
const oneWord = !/\s/.test(trimmed)
|
|
218
|
+
const looksLikeSymbol = oneWord && SYMBOL_SHAPED.test(trimmed)
|
|
219
|
+
const looksLikePath = oneWord && PATH_SHAPED.test(trimmed)
|
|
220
|
+
if ((looksLikeSymbol || looksLikePath) && entry.kind === 'module') factor *= KIND_FACTOR
|
|
221
|
+
if (!looksLikeSymbol && !looksLikePath && tokens.length >= 3 && entry.kind === 'document') factor *= KIND_FACTOR
|
|
222
|
+
|
|
223
|
+
return factor
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
type Prepared = {
|
|
227
|
+
readonly byId: ReadonlyMap<string, RetrievalEntry>
|
|
228
|
+
readonly bm25: Bm25Index
|
|
229
|
+
/** Undirected one-hop neighbourhood over documentation and import edges, by entry id. */
|
|
230
|
+
readonly neighbours: ReadonlyMap<string, readonly RetrievalEdge[]>
|
|
231
|
+
readonly entryCount: number
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const prepared = new WeakMap<RetrievalIndexV1, Prepared>()
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Tokenising every entry is the expensive part of a search and gives the same result for every
|
|
238
|
+
* query against the same index, so it is done once per index object. The map is weak: nothing is
|
|
239
|
+
* retained once the caller drops the index.
|
|
240
|
+
*/
|
|
241
|
+
const prepare = (index: RetrievalIndexV1): Prepared => {
|
|
242
|
+
const cached = prepared.get(index)
|
|
243
|
+
if (cached) return cached
|
|
244
|
+
const neighbours = new Map<string, RetrievalEdge[]>()
|
|
245
|
+
const link = (from: string, edge: RetrievalEdge): void => {
|
|
246
|
+
const list = neighbours.get(from)
|
|
247
|
+
if (list) list.push(edge)
|
|
248
|
+
else neighbours.set(from, [edge])
|
|
249
|
+
}
|
|
250
|
+
for (const entry of index.entries) {
|
|
251
|
+
for (const edge of entry.graph.inbound) link(entry.id, edge)
|
|
252
|
+
for (const edge of entry.graph.outbound) link(entry.id, edge)
|
|
253
|
+
}
|
|
254
|
+
const value: Prepared = {
|
|
255
|
+
byId: new Map(index.entries.map((entry) => [entry.id, entry])),
|
|
256
|
+
bm25: buildBm25Index(index.entries.map((entry) => ({ ref: entry.id, fields: entry.fields })), index.weights, index.params),
|
|
257
|
+
neighbours,
|
|
258
|
+
entryCount: index.entries.length,
|
|
259
|
+
}
|
|
260
|
+
prepared.set(index, value)
|
|
261
|
+
return value
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
type Reach = { readonly hops: number; readonly via: RetrievalEdge; readonly anchorShare: number }
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* What the strongest lexical hits are next to, within two hops.
|
|
268
|
+
*
|
|
269
|
+
* Nearest wins: an entry reachable in one hop from one anchor and two from another is one hop
|
|
270
|
+
* away. The edge recorded is the one that made the shortest reach, so the explanation names a
|
|
271
|
+
* real relation and the result's confidence can be no better than that relation's.
|
|
272
|
+
*/
|
|
273
|
+
const reachable = (ready: Prepared, anchors: readonly Bm25Hit[]): ReadonlyMap<string, Reach> => {
|
|
274
|
+
const reach = new Map<string, Reach>()
|
|
275
|
+
const anchored = new Set(anchors.map((anchor) => anchor.ref))
|
|
276
|
+
const best = anchors[0]?.score ?? 0
|
|
277
|
+
// Anchors are visited strongest first, so the first reach recorded is the strongest one.
|
|
278
|
+
for (const anchor of anchors) {
|
|
279
|
+
const anchorShare = best > 0 ? anchor.score / best : 0
|
|
280
|
+
for (const first of ready.neighbours.get(anchor.ref) ?? []) {
|
|
281
|
+
if (anchored.has(first.id)) continue
|
|
282
|
+
const known = reach.get(first.id)
|
|
283
|
+
if (!known || known.hops > 1) reach.set(first.id, { hops: 1, via: { kind: first.kind, id: anchor.ref, confidence: first.confidence }, anchorShare })
|
|
284
|
+
for (const second of ready.neighbours.get(first.id) ?? []) {
|
|
285
|
+
if (anchored.has(second.id) || second.id === anchor.ref) continue
|
|
286
|
+
if (!reach.has(second.id)) {
|
|
287
|
+
reach.set(second.id, { hops: 2, via: { kind: second.kind, id: first.id, confidence: weakerConfidence(first.confidence, second.confidence) }, anchorShare })
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return reach
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const canonicalityBoost = (pagerank: number, entryCount: number): number =>
|
|
296
|
+
pagerank > 0 && entryCount > 0 ? CANONICALITY_SCALE * Math.log1p(pagerank * entryCount) : 0
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Rank the projection against a query.
|
|
300
|
+
*
|
|
301
|
+
* Ordering is total: score descending, then an exact id match, then an ownership-backed entry,
|
|
302
|
+
* then id ascending — so two runs agree exactly and a tie never depends on insertion order.
|
|
303
|
+
* Results below a third of the best score are dropped: retrieval exists to spend fewer tokens.
|
|
304
|
+
*/
|
|
305
|
+
export const rankRetrieval = (index: RetrievalIndexV1, term: string, options: RankOptions = {}): RankedEntry[] => {
|
|
306
|
+
const tokens = searchTokens(term)
|
|
307
|
+
if (!tokens.length || !index.entries.length) return []
|
|
308
|
+
const ready = prepare(index)
|
|
309
|
+
const hits = bm25Search(ready.bm25, tokens)
|
|
310
|
+
const wantOwnership = preferOwnership(term)
|
|
311
|
+
const limit = options.limit ?? 20
|
|
312
|
+
|
|
313
|
+
const hitByRef = new Map<string, Bm25Hit>(hits.map((hit) => [hit.ref, hit]))
|
|
314
|
+
const reach = reachable(ready, hits.slice(0, PROXIMITY_ANCHORS))
|
|
315
|
+
|
|
316
|
+
// Candidates are every lexical hit plus what the strongest hits are next to.
|
|
317
|
+
const candidateIds = new Set<string>([...hitByRef.keys(), ...reach.keys()])
|
|
318
|
+
const ranked: RankedEntry[] = []
|
|
319
|
+
|
|
320
|
+
for (const id of candidateIds) {
|
|
321
|
+
const entry = ready.byId.get(id)
|
|
322
|
+
if (!entry) continue
|
|
323
|
+
const hit = hitByRef.get(id)
|
|
324
|
+
const near = reach.get(id)
|
|
325
|
+
const lexical = round((hit?.score ?? 0) * BM25_SCALE)
|
|
326
|
+
const prior = round(priorFactor(entry, tokens, term, wantOwnership))
|
|
327
|
+
const identity = identityBoost(entry, tokens, term)
|
|
328
|
+
const graphProximity = near ? round((near.hops === 1 ? PROXIMITY_ONE_HOP : PROXIMITY_TWO_HOPS) * near.anchorShare) : 0
|
|
329
|
+
const canonicality = round(canonicalityBoost(entry.graph.pagerank, ready.entryCount))
|
|
330
|
+
const audienceFit = options.agent && (entry.audience === 'agent' || entry.audience === 'human-and-agent') ? AUDIENCE_FIT : 0
|
|
331
|
+
const signal = Math.min(1, Math.max(0, options.signals?.get(id) ?? entry.agentSignal ?? 0))
|
|
332
|
+
const acceptedAgentSignals = round(signal * ACCEPTED_SIGNALS_WEIGHT)
|
|
333
|
+
|
|
334
|
+
/*
|
|
335
|
+
* A record nothing matched lexically is only here because of proximity. It earns proximity
|
|
336
|
+
* and nothing else: the query did not name it, and canonicality is a tie-breaker among
|
|
337
|
+
* answers, not an answer. Everything else scales from the lexical evidence.
|
|
338
|
+
*/
|
|
339
|
+
const evidence = hit ? lexical * prior + identity.exactId + identity.exactPath + identity.exactSymbol : 0
|
|
340
|
+
const score = round(evidence + graphProximity + (hit ? canonicality + audienceFit + acceptedAgentSignals : 0))
|
|
341
|
+
if (score <= 0) continue
|
|
342
|
+
|
|
343
|
+
const components: ScoreComponents = {
|
|
344
|
+
lexical,
|
|
345
|
+
prior,
|
|
346
|
+
exactId: hit ? identity.exactId : 0,
|
|
347
|
+
exactPath: hit ? identity.exactPath : 0,
|
|
348
|
+
exactSymbol: hit ? identity.exactSymbol : 0,
|
|
349
|
+
graphProximity,
|
|
350
|
+
canonicality: hit ? canonicality : 0,
|
|
351
|
+
audienceFit: hit ? audienceFit : 0,
|
|
352
|
+
acceptedAgentSignals: hit ? acceptedAgentSignals : 0,
|
|
353
|
+
}
|
|
354
|
+
ranked.push({
|
|
355
|
+
entry,
|
|
356
|
+
score,
|
|
357
|
+
confidence: near && !hit ? weakerConfidence(entry.confidence, near.via.confidence) : entry.confidence,
|
|
358
|
+
explanation: {
|
|
359
|
+
terms: tokens,
|
|
360
|
+
matched: hit?.matched ?? {},
|
|
361
|
+
components,
|
|
362
|
+
...(near ? { surfacedBy: { ...near.via, hops: near.hops } } : {}),
|
|
363
|
+
},
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
ranked.sort((a, b) => {
|
|
368
|
+
if (b.score !== a.score) return b.score - a.score
|
|
369
|
+
const aExact = a.explanation.components.exactId > 0 ? 1 : 0
|
|
370
|
+
const bExact = b.explanation.components.exactId > 0 ? 1 : 0
|
|
371
|
+
if (bExact !== aExact) return bExact - aExact
|
|
372
|
+
const aOwned = a.entry.ownershipId ? 1 : 0
|
|
373
|
+
const bOwned = b.entry.ownershipId ? 1 : 0
|
|
374
|
+
if (bOwned !== aOwned) return bOwned - aOwned
|
|
375
|
+
return a.entry.id.localeCompare(b.entry.id)
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
const best = ranked[0]?.score ?? 0
|
|
379
|
+
const floor = options.floor ?? RELEVANCE_FLOOR
|
|
380
|
+
const kept = best > 0 ? ranked.filter((item) => item.score >= best * floor) : ranked
|
|
381
|
+
return kept.slice(0, limit)
|
|
382
|
+
}
|
|
383
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Bm25FieldWeights, Bm25Params } from './bm25.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which field an agent's query is most likely aiming at, in ranking order.
|
|
5
|
+
*
|
|
6
|
+
* These are a starting point, not a truth: they live in the index so a repository can retune
|
|
7
|
+
* retrieval from configuration, and a tuning change shows up as a changed artifact rather than a
|
|
8
|
+
* silent behaviour change. `title` sits at the top because a record is about what its title says;
|
|
9
|
+
* `aliases` carries the ids a record answers to; `body` sits at the bottom because a passing
|
|
10
|
+
* mention in prose is the weakest evidence a record is the answer.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_SEARCH_WEIGHTS: Bm25FieldWeights = {
|
|
13
|
+
title: 4,
|
|
14
|
+
headings: 3,
|
|
15
|
+
symbols: 3,
|
|
16
|
+
path: 2,
|
|
17
|
+
aliases: 2,
|
|
18
|
+
summary: 2,
|
|
19
|
+
body: 1,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_SEARCH_PARAMS: Required<Bm25Params> = { k1: 1.2, b: 0.75 }
|
|
23
|
+
|
|
24
|
+
/** Configured weights override the defaults field by field; an unknown field is ignored. */
|
|
25
|
+
export const resolveSearchWeights = (configured?: Readonly<Record<string, number>>): Bm25FieldWeights => {
|
|
26
|
+
if (!configured) return DEFAULT_SEARCH_WEIGHTS
|
|
27
|
+
const merged: Record<string, number> = { ...DEFAULT_SEARCH_WEIGHTS }
|
|
28
|
+
for (const [field, weight] of Object.entries(configured)) {
|
|
29
|
+
if (field in DEFAULT_SEARCH_WEIGHTS) merged[field] = weight
|
|
30
|
+
}
|
|
31
|
+
return merged
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const resolveSearchParams = (
|
|
35
|
+
configured?: { readonly k1?: number | undefined; readonly b?: number | undefined },
|
|
36
|
+
): Required<Bm25Params> => ({
|
|
37
|
+
k1: configured?.k1 ?? DEFAULT_SEARCH_PARAMS.k1,
|
|
38
|
+
b: configured?.b ?? DEFAULT_SEARCH_PARAMS.b,
|
|
39
|
+
})
|
|
@@ -1,9 +1,37 @@
|
|
|
1
1
|
import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
|
|
2
|
-
import { searchIndex } from '../query/search.js'
|
|
2
|
+
import { searchIndex, type SearchMatch } from '../query/search.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Doc Bridge as a `Retriever`.
|
|
6
|
+
*
|
|
7
|
+
* The types below mirror `RetrievedDocument`, `RetrieverRequest` and `Retriever` from
|
|
8
|
+
* `@agentskit/core`, which is an optional peer: a published type that only resolves when an
|
|
9
|
+
* optional package is installed breaks a clean install. A test asserts assignability against the
|
|
10
|
+
* real package and runs the real hybrid retriever over this one, so a drift fails there rather
|
|
11
|
+
* than at a consumer.
|
|
12
|
+
*/
|
|
13
|
+
export type RetrievedDocument = {
|
|
14
|
+
readonly id: string
|
|
15
|
+
readonly content: string
|
|
16
|
+
readonly source?: string
|
|
17
|
+
readonly score?: number
|
|
18
|
+
readonly metadata?: Record<string, unknown>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type RetrieverRequest = {
|
|
22
|
+
readonly query: string
|
|
23
|
+
readonly messages: readonly unknown[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type Retriever = {
|
|
27
|
+
readonly retrieve: (request: RetrieverRequest) => RetrievedDocument[] | Promise<RetrievedDocument[]>
|
|
28
|
+
}
|
|
3
29
|
|
|
4
30
|
export type DocBridgeRetrieverOptions = {
|
|
5
31
|
readonly property?: string
|
|
6
32
|
readonly limit?: number
|
|
33
|
+
/** The `--agent` prior. */
|
|
34
|
+
readonly agent?: boolean
|
|
7
35
|
}
|
|
8
36
|
|
|
9
37
|
export type DocBridgeRetrievedChunk = {
|
|
@@ -17,15 +45,13 @@ export type DocBridgeRetrievedChunk = {
|
|
|
17
45
|
readonly score: number
|
|
18
46
|
}
|
|
19
47
|
|
|
20
|
-
export type DocBridgeRetriever = {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
options?: Pick<DocBridgeRetrieverOptions, 'limit'
|
|
24
|
-
) => DocBridgeRetrievedChunk[]
|
|
48
|
+
export type DocBridgeRetriever = Retriever & {
|
|
49
|
+
/** The original calling convention, kept: a query string and an optional limit. */
|
|
50
|
+
readonly retrieve: ((request: RetrieverRequest) => RetrievedDocument[]) &
|
|
51
|
+
((query: string, options?: Pick<DocBridgeRetrieverOptions, 'limit'>) => RetrievedDocument[])
|
|
25
52
|
}
|
|
26
53
|
|
|
27
|
-
const chunkKey = (property: string, type: string, id: string): string =>
|
|
28
|
-
`${property}:${type}:${id}`
|
|
54
|
+
const chunkKey = (property: string, type: string, id: string): string => `${property}:${type}:${id}`
|
|
29
55
|
|
|
30
56
|
export const retrieveDocBridgeChunks = (
|
|
31
57
|
index: DocBridgeIndexV1,
|
|
@@ -35,28 +61,87 @@ export const retrieveDocBridgeChunks = (
|
|
|
35
61
|
const property = options.property ?? index.project?.name ?? 'local'
|
|
36
62
|
const limit = options.limit ?? 8
|
|
37
63
|
|
|
38
|
-
return searchIndex(index, query, limit).map((match) => {
|
|
64
|
+
return searchIndex(index, query, limit, options.agent ? { agent: true } : {}).map((match) => {
|
|
39
65
|
const knowledge = index.knowledge.find((entry) => entry.id === match.id && entry.path === match.path)
|
|
40
66
|
const owner = index.lookup?.ownership?.[match.id]
|
|
41
67
|
const type = match.type === 'ownership' ? 'ownership' : (knowledge?.type ?? match.type)
|
|
42
68
|
const summary = match.summary ?? owner?.purpose
|
|
69
|
+
const title = knowledge?.title ?? index.projection?.entries.find((entry) => entry.id === match.entityId)?.title
|
|
43
70
|
return {
|
|
44
71
|
chunkKey: chunkKey(property, type, match.id),
|
|
45
72
|
property,
|
|
46
73
|
type,
|
|
47
74
|
id: match.id,
|
|
48
75
|
path: match.path,
|
|
49
|
-
...(
|
|
76
|
+
...(title ? { title } : {}),
|
|
50
77
|
...(summary ? { summary } : {}),
|
|
51
78
|
score: match.score,
|
|
52
79
|
}
|
|
53
80
|
})
|
|
54
81
|
}
|
|
55
82
|
|
|
56
|
-
|
|
83
|
+
/**
|
|
84
|
+
* What a retrieved document's content is: the projected text, in the order a reader wants it.
|
|
85
|
+
*
|
|
86
|
+
* Title, then summary, then the search body — the same bounded text the ranker matched, so what
|
|
87
|
+
* a model reads is what the score was computed over. Without a projection the record has only
|
|
88
|
+
* its title and description, which is what the legacy index carried.
|
|
89
|
+
*/
|
|
90
|
+
const contentFor = (index: DocBridgeIndexV1, match: SearchMatch): string => {
|
|
91
|
+
const entry = match.entityId ? index.projection?.entries.find((item) => item.id === match.entityId) : undefined
|
|
92
|
+
if (entry) {
|
|
93
|
+
return [entry.title, entry.summary, entry.fields.body].filter((part): part is string => Boolean(part && part.trim())).join('\n\n')
|
|
94
|
+
}
|
|
95
|
+
const knowledge = index.knowledge.find((item) => item.id === match.id && item.path === match.path)
|
|
96
|
+
return [knowledge?.title ?? match.id, knowledge?.description ?? match.summary, knowledge?.body].filter((part): part is string => Boolean(part)).join('\n\n')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Rank the index and return the results as `RetrievedDocument`s.
|
|
101
|
+
*
|
|
102
|
+
* `metadata` carries what the ecosystem consumers read after the content: the record kind, its
|
|
103
|
+
* path, the evidence that backs it, the explanation of its rank and its confidence. A hybrid or
|
|
104
|
+
* reranked retriever wraps this one with no adapter, because the shape is the contract's.
|
|
105
|
+
*/
|
|
106
|
+
export const retrieveDocBridgeDocuments = (
|
|
57
107
|
index: DocBridgeIndexV1,
|
|
108
|
+
query: string,
|
|
58
109
|
options: DocBridgeRetrieverOptions = {},
|
|
59
|
-
):
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
110
|
+
): RetrievedDocument[] => {
|
|
111
|
+
const limit = options.limit ?? 8
|
|
112
|
+
return searchIndex(index, query, limit, { explain: true, ...(options.agent ? { agent: true } : {}) }).map((match) => ({
|
|
113
|
+
id: match.entityId ?? match.id,
|
|
114
|
+
content: contentFor(index, match),
|
|
115
|
+
source: match.path,
|
|
116
|
+
score: match.score,
|
|
117
|
+
metadata: {
|
|
118
|
+
kind: match.kind ?? match.type,
|
|
119
|
+
type: match.type,
|
|
120
|
+
id: match.id,
|
|
121
|
+
path: match.path,
|
|
122
|
+
...(match.evidence ? { evidence: match.evidence } : {}),
|
|
123
|
+
...(match.explain ? { explain: match.explain } : {}),
|
|
124
|
+
...(match.confidence ? { confidence: match.confidence } : {}),
|
|
125
|
+
...(match.provenance ? { provenance: match.provenance } : {}),
|
|
126
|
+
},
|
|
127
|
+
}))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const createDocBridgeRetriever = (index: DocBridgeIndexV1, options: DocBridgeRetrieverOptions = {}): DocBridgeRetriever => {
|
|
131
|
+
const retrieve = (request: RetrieverRequest | string, req: Pick<DocBridgeRetrieverOptions, 'limit'> = {}): RetrievedDocument[] =>
|
|
132
|
+
typeof request === 'string'
|
|
133
|
+
? retrieveDocBridgeDocuments(index, request, { ...options, ...req })
|
|
134
|
+
: retrieveDocBridgeDocuments(index, request.query, options)
|
|
135
|
+
return { retrieve: retrieve as DocBridgeRetriever['retrieve'] }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The prose rendering of retrieved documents, as `formatRetrievedDocuments` in `@agentskit/core`
|
|
140
|
+
* renders them: a numbered block per document, its source when it has one, then its content.
|
|
141
|
+
* Mirrored for the same reason the types above are — the peer is optional and the knowledge
|
|
142
|
+
* tools must render text without it — and asserted identical to the real function by test.
|
|
143
|
+
*/
|
|
144
|
+
export const formatRetrievedDocuments = (documents: readonly RetrievedDocument[]): string =>
|
|
145
|
+
documents.length === 0
|
|
146
|
+
? ''
|
|
147
|
+
: documents.map((document, position) => `[${position + 1}]\n${document.source ? `Source: ${document.source}\n` : ''}${document.content}`).join('\n\n')
|
package/src/rules/engine.ts
CHANGED
|
@@ -14,6 +14,16 @@ export type RuleMode = 'default' | 'recommended' | 'strict'
|
|
|
14
14
|
|
|
15
15
|
export type RuleEngineOptions = {
|
|
16
16
|
readonly config?: RulesConfig
|
|
17
|
+
/**
|
|
18
|
+
* Betweenness per entity, from `centrality` in the graph layer.
|
|
19
|
+
*
|
|
20
|
+
* Required for `centrality-risk`: without it the rule reports nothing. It used to count how
|
|
21
|
+
* many undocumented-relation findings an entity had, which measures documentation debt and
|
|
22
|
+
* calls it architecture — a module every import path runs through scored zero if it happened to
|
|
23
|
+
* be documented. Reporting nothing is better than reporting the wrong thing under a name people
|
|
24
|
+
* will act on.
|
|
25
|
+
*/
|
|
26
|
+
readonly centrality?: ReadonlyMap<string, number>
|
|
17
27
|
readonly preset?: RuleMode
|
|
18
28
|
readonly severity?: Partial<Record<RuleId, RuleSeverity>>
|
|
19
29
|
readonly ignore?: readonly RuleId[]
|
|
@@ -52,6 +62,8 @@ const diagnosticRules: Readonly<Record<string, RuleId>> = {
|
|
|
52
62
|
STALE_DOCUMENTATION: 'stale-documentation',
|
|
53
63
|
FRESHNESS_FAILURE: 'freshness',
|
|
54
64
|
OWNERSHIP_GAP: 'ownership',
|
|
65
|
+
OWNERSHIP_PATH_UNOBSERVED: 'ownership',
|
|
66
|
+
IMPORT_CYCLE: 'centrality-risk',
|
|
55
67
|
CENTRALITY_RISK: 'centrality-risk',
|
|
56
68
|
CRITICAL_PATH_RISK: 'critical-path-risk',
|
|
57
69
|
}
|
|
@@ -149,23 +161,31 @@ export const evaluateRules = (
|
|
|
149
161
|
}
|
|
150
162
|
}
|
|
151
163
|
|
|
152
|
-
|
|
164
|
+
/*
|
|
165
|
+
* Centrality risk: how much of the dependency structure runs through one entity.
|
|
166
|
+
*
|
|
167
|
+
* The threshold reads as a rank when it is 1 or more — "flag the three most central entities",
|
|
168
|
+
* which is what the previous count-based threshold meant to say — and as a minimum betweenness
|
|
169
|
+
* when it is below 1, for a repository that would rather set an absolute bar.
|
|
170
|
+
*/
|
|
153
171
|
const centralitySeverity = severityFor('centrality-risk', resolved.mode, resolved.severity)
|
|
154
|
-
if (!resolved.ignore.has('centrality-risk') && centralitySeverity !== 'off') {
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
172
|
+
if (options.centrality?.size && !resolved.ignore.has('centrality-risk') && centralitySeverity !== 'off') {
|
|
173
|
+
const threshold = resolved.warningThresholds['centrality-risk'] ?? 3
|
|
174
|
+
const ranked = [...options.centrality.entries()]
|
|
175
|
+
.filter(([, score]) => score > 0)
|
|
176
|
+
.sort(([leftId, left], [rightId, right]) => right - left || leftId.localeCompare(rightId))
|
|
177
|
+
const flagged = threshold >= 1 ? ranked.slice(0, Math.floor(threshold)) : ranked.filter(([, score]) => score >= threshold)
|
|
178
|
+
|
|
179
|
+
for (const [entityId, score] of [...flagged].sort(([a], [b]) => a.localeCompare(b))) {
|
|
180
|
+
const related = findings.filter((item) => item.entityIds?.includes(entityId))
|
|
161
181
|
findings.push({
|
|
162
182
|
id: `centrality-risk:${entityId}`,
|
|
163
183
|
ruleId: 'centrality-risk',
|
|
164
184
|
code: 'centrality-risk',
|
|
165
185
|
status: 'unresolved',
|
|
166
186
|
severity: centralitySeverity,
|
|
167
|
-
message:
|
|
168
|
-
evidence:
|
|
187
|
+
message: `${entityId} carries betweenness ${score} on the import graph${criticalEntitySet.has(entityId) ? ' and is declared critical' : ''}; static centrality is a review signal, not a runtime availability claim.`,
|
|
188
|
+
evidence: related.flatMap((item) => item.evidence).slice(0, 16),
|
|
169
189
|
entityIds: [entityId],
|
|
170
190
|
remediation: 'Review ownership, dependency boundaries, and runtime availability before declaring an SPOF.',
|
|
171
191
|
})
|
|
@@ -176,5 +196,18 @@ export const evaluateRules = (
|
|
|
176
196
|
return { mode: resolved.mode, findings: sortedFindings, exitCode: sortedFindings.some((finding) => finding.severity === 'error') ? 1 : 0 }
|
|
177
197
|
}
|
|
178
198
|
|
|
179
|
-
export const parseRuleId = (value: string): RuleId =>
|
|
180
|
-
|
|
199
|
+
export const parseRuleId = (value: string): RuleId => {
|
|
200
|
+
try {
|
|
201
|
+
return RuleIdSchema.parse(value)
|
|
202
|
+
} catch {
|
|
203
|
+
throw new Error(`Invalid enum value: ${value}`)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const parseRuleSeverity = (value: string): RuleSeverity => {
|
|
208
|
+
try {
|
|
209
|
+
return RuleSeveritySchema.parse(value)
|
|
210
|
+
} catch {
|
|
211
|
+
throw new Error(`Invalid enum value: ${value}`)
|
|
212
|
+
}
|
|
213
|
+
}
|
package/src/safety/repository.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { isAbsolute, relative, resolve, sep } from 'node:path'
|
|
|
3
3
|
|
|
4
4
|
import { minimatch } from 'minimatch'
|
|
5
5
|
|
|
6
|
-
export const DEFAULT_SAFETY_EXCLUDES = ['**/.git/**', '**/node_modules/**', '**/dist/**', '**/build/**', '**/coverage/**', '**/.doc-bridge/**', '**/.turbo/**', '**/.env', '**/.env.*', '**/*secret*', '**/*credential*', '**/*.pem', '**/*.key'] as const
|
|
6
|
+
export const DEFAULT_SAFETY_EXCLUDES = ['**/.git/**', '**/node_modules/**', '**/dist/**', '**/build/**', '**/coverage/**', '**/.doc-bridge/**', '**/.next/**', '**/out/**', '**/.turbo/**', '**/.svelte-kit/**', '**/.mcpb-build/**', '**/.mcpb-output/**', '**/.env', '**/.env.*', '**/*secret*', '**/*credential*', '**/*.pem', '**/*.key'] as const
|
|
7
7
|
|
|
8
8
|
export type SafeWalkOptions = {
|
|
9
9
|
readonly extensions?: readonly string[]
|