@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
package/src/query/search.ts
CHANGED
|
@@ -1,126 +1,323 @@
|
|
|
1
1
|
import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
|
|
2
|
+
import type { Evidence } from '../schemas/knowledge.js'
|
|
3
|
+
import type { Confidence, RetrievalKind } from '../schemas/retrieval-index.js'
|
|
4
|
+
import { rankRetrieval, type RankExplanation, type RankedEntry } from '../retrieval/rank.js'
|
|
5
|
+
import { buildBm25Index, bm25Search, type Bm25Index, type Bm25Input } from '../retrieval/bm25.js'
|
|
6
|
+
import { isProjectedEntry } from '../index-builder/project-corpus.js'
|
|
7
|
+
import { foldAccents, hasSearchToken, searchTokens } from './text.js'
|
|
2
8
|
|
|
3
9
|
export type SearchMatch = {
|
|
10
|
+
/** The record kind as consumers have always read it: `ownership`, `intent`, `change`, or the entry kind. */
|
|
4
11
|
readonly type: string
|
|
5
12
|
readonly id: string
|
|
6
13
|
readonly path: string
|
|
7
14
|
readonly summary?: string
|
|
8
15
|
readonly score: number
|
|
16
|
+
/** The snapshot entity this result stands for, when it stands for one. */
|
|
17
|
+
readonly entityId?: string
|
|
18
|
+
readonly kind?: RetrievalKind
|
|
19
|
+
readonly provenance?: 'observed' | 'declared' | 'proposed'
|
|
20
|
+
readonly confidence?: Confidence
|
|
21
|
+
readonly evidence?: readonly Evidence[]
|
|
22
|
+
readonly explain?: RankExplanation
|
|
9
23
|
}
|
|
10
24
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
25
|
+
export type SearchOptions = {
|
|
26
|
+
/** Attach the matched terms and each scoring component to every result. Never changes the ranking. */
|
|
27
|
+
readonly explain?: boolean
|
|
28
|
+
/** The `--agent` prior. */
|
|
29
|
+
readonly agent?: boolean
|
|
30
|
+
}
|
|
16
31
|
|
|
17
|
-
|
|
18
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Search the index.
|
|
34
|
+
*
|
|
35
|
+
* An index that carries the retrieval projection is ranked over it — every snapshot entity, with
|
|
36
|
+
* evidence, provenance and confidence on each result. An index built before the projection
|
|
37
|
+
* existed is ranked the way it always was, over its `knowledge[]` and lookup records, so an older
|
|
38
|
+
* artifact keeps answering until it is rebuilt.
|
|
39
|
+
*/
|
|
40
|
+
export const searchIndex = (index: DocBridgeIndexV1, term: string, limit = 20, options: SearchOptions = {}): SearchMatch[] => {
|
|
41
|
+
if (index.projection) return projectedSearch(index, term, limit, options)
|
|
42
|
+
return legacySearch(index, term, limit)
|
|
43
|
+
}
|
|
19
44
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
45
|
+
/**
|
|
46
|
+
* How a projected result presents itself to a reader of the old shape.
|
|
47
|
+
*
|
|
48
|
+
* An entry that stands for an ownership record is reported as that record — `type: ownership`,
|
|
49
|
+
* the record's id, its agent document as path — because that is what a routing command expects
|
|
50
|
+
* to receive and what `ak-docs query ownership <id>` accepts. Everything else is reported by its
|
|
51
|
+
* entity id and its kind.
|
|
52
|
+
*/
|
|
53
|
+
type Presented = { readonly agentDocs: ReadonlyMap<string, string>; readonly curatedIds: ReadonlyMap<string, string> }
|
|
54
|
+
|
|
55
|
+
const presentation = (index: DocBridgeIndexV1, known: Presented, item: RankedEntry): Pick<SearchMatch, 'type' | 'id' | 'path'> => {
|
|
56
|
+
const { entry } = item
|
|
57
|
+
// The unit an ownership record names, or the agent document that record points at: one answer.
|
|
58
|
+
const ownershipId = entry.ownershipId ?? (entry.kind === 'document' ? known.agentDocs.get(entry.path) : undefined)
|
|
59
|
+
if (ownershipId) {
|
|
60
|
+
const record = index.lookup?.ownership?.[ownershipId]
|
|
61
|
+
return { type: 'ownership', id: ownershipId, path: record?.agentDoc ?? entry.path }
|
|
62
|
+
}
|
|
63
|
+
// A curated sidecar keeps the id its frontmatter gave it: that is the id `doc.get` and llms.txt know.
|
|
64
|
+
const curatedId = entry.kind === 'document' ? known.curatedIds.get(entry.path) : undefined
|
|
65
|
+
return { type: entry.kind, id: curatedId ?? entry.id, path: entry.path }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const projectedSearch = (index: DocBridgeIndexV1, term: string, limit: number, options: SearchOptions): SearchMatch[] => {
|
|
69
|
+
const projection = index.projection
|
|
70
|
+
if (!projection) return []
|
|
71
|
+
const ranked = rankRetrieval(projection, term, { limit: Math.max(limit * 2, 40), ...(options.agent ? { agent: true } : {}) })
|
|
72
|
+
/*
|
|
73
|
+
* Which ownership record an agent document stands for. A record attached to a projected unit
|
|
74
|
+
* is authoritative; a corpus-derived seed that points at the same document is the same unit
|
|
75
|
+
* under another name and must not win the presentation over it.
|
|
76
|
+
*/
|
|
77
|
+
const agentDocs = new Map<string, string>()
|
|
78
|
+
for (const entry of projection.entries) {
|
|
79
|
+
const record = entry.ownershipId ? index.lookup?.ownership?.[entry.ownershipId] : undefined
|
|
80
|
+
if (record?.agentDoc && !agentDocs.has(record.agentDoc)) agentDocs.set(record.agentDoc, record.id)
|
|
81
|
+
}
|
|
82
|
+
for (const record of Object.values(index.lookup?.ownership ?? {})) {
|
|
83
|
+
if (record.agentDoc && !agentDocs.has(record.agentDoc)) agentDocs.set(record.agentDoc, record.id)
|
|
84
|
+
}
|
|
85
|
+
const known: Presented = {
|
|
86
|
+
agentDocs,
|
|
87
|
+
curatedIds: new Map(index.knowledge.filter((entry) => !isProjectedEntry(entry)).map((entry) => [entry.path, entry.id])),
|
|
88
|
+
}
|
|
89
|
+
const byPath = new Map<string, SearchMatch>()
|
|
90
|
+
|
|
91
|
+
for (const item of ranked) {
|
|
92
|
+
const presented = presentation(index, known, item)
|
|
93
|
+
const match: SearchMatch = {
|
|
94
|
+
...presented,
|
|
95
|
+
...(item.entry.summary ? { summary: item.entry.summary } : {}),
|
|
96
|
+
score: item.score,
|
|
97
|
+
entityId: item.entry.id,
|
|
98
|
+
kind: item.entry.kind,
|
|
99
|
+
provenance: item.entry.provenance,
|
|
100
|
+
confidence: item.confidence,
|
|
101
|
+
evidence: [{ source: evidenceSource(item.entry.kind), path: item.entry.path, contentHash: item.entry.contentHash }],
|
|
102
|
+
...(options.explain ? { explain: item.explanation } : {}),
|
|
103
|
+
}
|
|
104
|
+
const existing = byPath.get(match.path)
|
|
105
|
+
if (!existing || match.score > existing.score || (match.score === existing.score && match.type === 'ownership' && existing.type !== 'ownership')) {
|
|
106
|
+
byPath.set(match.path, match)
|
|
28
107
|
}
|
|
29
108
|
}
|
|
30
|
-
|
|
109
|
+
|
|
110
|
+
return [...byPath.values()].sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, limit)
|
|
31
111
|
}
|
|
32
112
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const idLower = id.toLowerCase()
|
|
36
|
-
const termLower = term.toLowerCase().trim()
|
|
37
|
-
const base = path.split('/').pop()?.replace(/\.mdx?$/i, '').toLowerCase() ?? ''
|
|
38
|
-
let boost = 0
|
|
113
|
+
const evidenceSource = (kind: RetrievalKind): Evidence['source'] =>
|
|
114
|
+
kind === 'module' ? 'code' : kind === 'document' ? 'documentation' : kind === 'intent' || kind === 'change' ? 'configuration' : 'derived'
|
|
39
115
|
|
|
40
|
-
|
|
41
|
-
|
|
116
|
+
/*
|
|
117
|
+
* ---------------------------------------------------------------------------------------------
|
|
118
|
+
* The pre-projection ranking, kept verbatim for indexes that carry no projection.
|
|
119
|
+
* ---------------------------------------------------------------------------------------------
|
|
120
|
+
*/
|
|
42
121
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
122
|
+
const BM25_SCALE = 50
|
|
123
|
+
const EXACT_IDENTITY = 240
|
|
124
|
+
const EXACT_SYMBOL = 200
|
|
125
|
+
const DIRECTORY_MATCH = 120
|
|
126
|
+
const TOKEN_ID = 120
|
|
127
|
+
const TOKEN_BASE = 100
|
|
128
|
+
const TOKEN_SYMBOL = 90
|
|
129
|
+
const SHORT_ID_BONUS = 40
|
|
130
|
+
const CURATED_FACTOR = 1.15
|
|
131
|
+
const OWNERSHIP_FACTOR = 1.1
|
|
132
|
+
const ROUTE_TITLE_FACTOR = 1.6
|
|
133
|
+
const CHANGE_INTENT_FACTOR = 1.4
|
|
134
|
+
const CHANGE_WITHOUT_INTENT_FACTOR = 0.15
|
|
135
|
+
const KIND_FACTOR = 1.15
|
|
136
|
+
const PARTIAL_ID_FACTOR = 1.2
|
|
137
|
+
const RELEVANCE_FLOOR = 1 / 3
|
|
49
138
|
|
|
50
|
-
|
|
51
|
-
if (tokens.includes(idLower)) boost += Math.max(0, 40 - idLower.length)
|
|
139
|
+
const LEGACY_WEIGHTS = { id: 8, symbols: 7, title: 6, path: 4, tags: 3, description: 2, body: 1 } as const
|
|
52
140
|
|
|
53
|
-
|
|
141
|
+
const PACKAGE_INTENT =
|
|
142
|
+
/\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
|
|
143
|
+
const CHANGE_INTENT =
|
|
144
|
+
/\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
|
|
145
|
+
const SYMBOL_SHAPED = /[a-z0-9][A-Z]|^[A-Za-z_$][A-Za-z0-9_$]{2,}$/
|
|
146
|
+
const PATH_SHAPED = /\/|\.[A-Za-z]{1,4}$/
|
|
147
|
+
|
|
148
|
+
type CandidateFields = {
|
|
149
|
+
readonly id: string
|
|
150
|
+
readonly title: string
|
|
151
|
+
readonly path: string
|
|
152
|
+
readonly description?: string
|
|
153
|
+
readonly body?: string
|
|
154
|
+
readonly tags?: readonly string[]
|
|
155
|
+
readonly symbols?: readonly string[]
|
|
54
156
|
}
|
|
55
157
|
|
|
56
|
-
|
|
57
|
-
|
|
158
|
+
type Candidate = {
|
|
159
|
+
readonly ref: string
|
|
160
|
+
readonly match: Omit<SearchMatch, 'score'>
|
|
161
|
+
readonly fields: CandidateFields
|
|
162
|
+
readonly entryType?: string
|
|
163
|
+
}
|
|
58
164
|
|
|
59
|
-
|
|
60
|
-
const tokens = tokenize(term)
|
|
61
|
-
if (!tokens.length) return []
|
|
165
|
+
const pathBase = (path: string): string => foldAccents((path.split('/').pop() ?? '').replace(/\.[A-Za-z0-9]+$/, '').toLowerCase())
|
|
62
166
|
|
|
63
|
-
|
|
64
|
-
const byPath = new Map<string, SearchMatch>()
|
|
167
|
+
const preferOwnership = (term: string): boolean => PACKAGE_INTENT.test(term) || /^(where|how).*(edit|change|package|module)/i.test(term)
|
|
65
168
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
169
|
+
const titleCoversQuery = (title: string, tokens: readonly string[]): boolean =>
|
|
170
|
+
tokens.length > 1 && tokens.every((token) => hasSearchToken(foldAccents(title.toLowerCase()), token))
|
|
171
|
+
|
|
172
|
+
const identityBoost = (fields: CandidateFields, tokens: readonly string[], term: string): number => {
|
|
173
|
+
const idLower = foldAccents(fields.id.toLowerCase())
|
|
174
|
+
const pathLower = foldAccents(fields.path.toLowerCase())
|
|
175
|
+
const termLower = foldAccents(term.toLowerCase().trim())
|
|
176
|
+
const base = pathBase(fields.path)
|
|
177
|
+
const symbols = new Set((fields.symbols ?? []).map((symbol) => foldAccents(symbol.toLowerCase())))
|
|
178
|
+
let boost = 0
|
|
179
|
+
if (idLower === termLower || base === termLower || pathLower === termLower) boost += EXACT_IDENTITY
|
|
180
|
+
if (symbols.has(termLower)) boost += EXACT_SYMBOL
|
|
181
|
+
if (termLower && (pathLower.startsWith(`${termLower}/`) || pathLower.includes(`/${termLower}/`))) boost += DIRECTORY_MATCH
|
|
182
|
+
for (const token of tokens) {
|
|
183
|
+
if (idLower === token) boost += TOKEN_ID
|
|
184
|
+
if (base === token) boost += TOKEN_BASE
|
|
185
|
+
if (symbols.has(token)) boost += TOKEN_SYMBOL
|
|
79
186
|
}
|
|
187
|
+
if (tokens.includes(idLower)) boost += Math.max(0, SHORT_ID_BONUS - idLower.length)
|
|
188
|
+
return boost
|
|
189
|
+
}
|
|
80
190
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
})
|
|
191
|
+
const candidateRef = (kind: string, position: number, id: string): string => `${kind}#${position}#${id}`
|
|
192
|
+
|
|
193
|
+
const knowledgeCandidates = (index: DocBridgeIndexV1): Candidate[] =>
|
|
194
|
+
index.knowledge.map((entry, position) => ({
|
|
195
|
+
ref: candidateRef('knowledge', position, entry.id),
|
|
196
|
+
match: { type: 'knowledge', id: entry.id, path: entry.path, ...(entry.description ? { summary: entry.description } : {}) },
|
|
197
|
+
fields: {
|
|
198
|
+
id: entry.id,
|
|
199
|
+
title: entry.title,
|
|
200
|
+
path: entry.path,
|
|
201
|
+
...(entry.description ? { description: entry.description } : {}),
|
|
202
|
+
...(entry.body ? { body: entry.body } : {}),
|
|
203
|
+
...(entry.tags ? { tags: entry.tags } : {}),
|
|
204
|
+
...(entry.symbols ? { symbols: entry.symbols } : {}),
|
|
205
|
+
},
|
|
206
|
+
entryType: entry.type,
|
|
207
|
+
}))
|
|
208
|
+
|
|
209
|
+
const ownershipCandidates = (index: DocBridgeIndexV1): Candidate[] =>
|
|
210
|
+
Object.entries(index.lookup?.ownership ?? {}).map(([id, owner], position) => {
|
|
211
|
+
const agentDoc = owner.agentDoc ? index.knowledge.find((entry) => entry.path === owner.agentDoc) : undefined
|
|
212
|
+
const description = owner.purpose ?? agentDoc?.description
|
|
213
|
+
return {
|
|
214
|
+
ref: candidateRef('ownership', position, id),
|
|
215
|
+
match: { type: 'ownership', id, path: owner.agentDoc ?? owner.path, ...(owner.purpose ? { summary: owner.purpose } : {}) },
|
|
216
|
+
fields: {
|
|
217
|
+
id,
|
|
218
|
+
title: agentDoc?.title ?? id,
|
|
219
|
+
path: [owner.path, owner.agentDoc, owner.humanDoc].filter(Boolean).join(' '),
|
|
220
|
+
...(description ? { description } : {}),
|
|
221
|
+
...(agentDoc?.body ? { body: agentDoc.body } : {}),
|
|
222
|
+
tags: [owner.group, owner.layer, 'ownership'].filter((value): value is string => Boolean(value)),
|
|
223
|
+
},
|
|
224
|
+
entryType: 'ownership',
|
|
94
225
|
}
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
const intentCandidates = (index: DocBridgeIndexV1): Candidate[] =>
|
|
229
|
+
Object.values(index.lookup?.intents ?? {}).map((intent, position) => ({
|
|
230
|
+
ref: candidateRef('intent', position, intent.id),
|
|
231
|
+
match: { type: 'intent', id: intent.id, path: intent.paths[0] ?? '', summary: intent.title },
|
|
232
|
+
fields: { id: intent.id, title: intent.title, path: intent.paths.join(' '), tags: ['intent'] },
|
|
233
|
+
entryType: 'intent',
|
|
234
|
+
}))
|
|
235
|
+
|
|
236
|
+
const changeCandidates = (index: DocBridgeIndexV1): Candidate[] =>
|
|
237
|
+
Object.values(index.lookup?.changes ?? {}).map((change, position) => ({
|
|
238
|
+
ref: candidateRef('change', position, change.id),
|
|
239
|
+
match: { type: 'change', id: change.id, path: change.startHere, summary: change.title },
|
|
240
|
+
fields: { id: change.id, title: change.title, path: change.startHere, tags: ['change', ...(change.relatedPackages ?? [])] },
|
|
241
|
+
entryType: 'change',
|
|
242
|
+
}))
|
|
243
|
+
|
|
244
|
+
const partialIdMatch = (id: string, tokens: readonly string[]): boolean => {
|
|
245
|
+
const idLower = foldAccents(id.toLowerCase())
|
|
246
|
+
return tokens.some(
|
|
247
|
+
(token) => idLower !== token && (idLower.startsWith(`${token}-`) || idLower.endsWith(`-${token}`) || (idLower.includes(token) && idLower.length <= token.length + 4)),
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const priorFactor = (candidate: Candidate, tokens: readonly string[], term: string, wantOwnership: boolean): number => {
|
|
252
|
+
const type = candidate.match.type
|
|
253
|
+
const trimmed = term.trim()
|
|
254
|
+
const covered = titleCoversQuery(candidate.fields.title, tokens)
|
|
255
|
+
let factor = 1
|
|
256
|
+
if (!isProjectedEntry({ type: candidate.entryType ?? '' })) factor *= CURATED_FACTOR
|
|
257
|
+
if (partialIdMatch(candidate.fields.id, tokens)) factor *= PARTIAL_ID_FACTOR
|
|
258
|
+
if (type === 'ownership' && wantOwnership) factor *= OWNERSHIP_FACTOR
|
|
259
|
+
if ((type === 'intent' || type === 'change') && covered) factor *= ROUTE_TITLE_FACTOR
|
|
260
|
+
if (type === 'change') factor *= CHANGE_INTENT.test(term) ? CHANGE_INTENT_FACTOR : covered ? 1 : CHANGE_WITHOUT_INTENT_FACTOR
|
|
261
|
+
const oneWord = !/\s/.test(trimmed)
|
|
262
|
+
const looksLikeSymbol = oneWord && SYMBOL_SHAPED.test(trimmed)
|
|
263
|
+
const looksLikePath = oneWord && PATH_SHAPED.test(trimmed)
|
|
264
|
+
if ((looksLikeSymbol || looksLikePath) && candidate.entryType === 'module') factor *= KIND_FACTOR
|
|
265
|
+
if (!looksLikeSymbol && !looksLikePath && tokens.length >= 3 && candidate.entryType === 'document') factor *= KIND_FACTOR
|
|
266
|
+
return factor
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
type PreparedIndex = { readonly byRef: ReadonlyMap<string, Candidate>; readonly bm25: Bm25Index }
|
|
270
|
+
|
|
271
|
+
const prepared = new WeakMap<DocBridgeIndexV1, PreparedIndex>()
|
|
272
|
+
|
|
273
|
+
const prepare = (index: DocBridgeIndexV1): PreparedIndex | undefined => {
|
|
274
|
+
const cached = prepared.get(index)
|
|
275
|
+
if (cached) return cached
|
|
276
|
+
const candidates = [...knowledgeCandidates(index), ...ownershipCandidates(index), ...intentCandidates(index), ...changeCandidates(index)]
|
|
277
|
+
if (!candidates.length) return undefined
|
|
278
|
+
const inputs: Bm25Input[] = candidates.map((candidate) => ({ ref: candidate.ref, fields: candidate.fields }))
|
|
279
|
+
const weights = { ...LEGACY_WEIGHTS, ...Object.fromEntries(Object.entries(index.retrieval?.weights ?? {}).filter(([field]) => field in LEGACY_WEIGHTS)) }
|
|
280
|
+
const value: PreparedIndex = {
|
|
281
|
+
byRef: new Map(candidates.map((candidate) => [candidate.ref, candidate])),
|
|
282
|
+
bm25: buildBm25Index(inputs, weights, index.retrieval?.params ?? {}),
|
|
95
283
|
}
|
|
284
|
+
prepared.set(index, value)
|
|
285
|
+
return value
|
|
286
|
+
}
|
|
96
287
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
288
|
+
const legacySearch = (index: DocBridgeIndexV1, term: string, limit: number): SearchMatch[] => {
|
|
289
|
+
const tokens = searchTokens(term)
|
|
290
|
+
if (!tokens.length) return []
|
|
291
|
+
const ready = prepare(index)
|
|
292
|
+
if (!ready) return []
|
|
293
|
+
const { byRef, bm25 } = ready
|
|
294
|
+
const hits = bm25Search(bm25, tokens)
|
|
295
|
+
const wantOwnership = preferOwnership(term)
|
|
296
|
+
const byPath = new Map<string, SearchMatch>()
|
|
297
|
+
|
|
298
|
+
for (const hit of hits) {
|
|
299
|
+
const candidate = byRef.get(hit.ref)
|
|
300
|
+
if (!candidate) continue
|
|
301
|
+
const score = hit.score * BM25_SCALE * priorFactor(candidate, tokens, term, wantOwnership) + identityBoost(candidate.fields, tokens, term)
|
|
302
|
+
const match: SearchMatch = { ...candidate.match, score }
|
|
303
|
+
const existing = byPath.get(match.path)
|
|
304
|
+
if (!existing || match.score > existing.score || (match.score === existing.score && match.type === 'ownership' && existing.type !== 'ownership')) {
|
|
305
|
+
byPath.set(match.path, match)
|
|
113
306
|
}
|
|
114
307
|
}
|
|
115
308
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
309
|
+
const ranked = [...byPath.values()]
|
|
310
|
+
.sort((a, b) => {
|
|
311
|
+
if (b.score !== a.score) return b.score - a.score
|
|
312
|
+
const aExact = tokens.includes(foldAccents(a.id.toLowerCase())) ? 1 : 0
|
|
313
|
+
const bExact = tokens.includes(foldAccents(b.id.toLowerCase())) ? 1 : 0
|
|
314
|
+
if (bExact !== aExact) return bExact - aExact
|
|
315
|
+
if (a.type === 'ownership' && b.type !== 'ownership') return -1
|
|
316
|
+
if (b.type === 'ownership' && a.type !== 'ownership') return 1
|
|
317
|
+
return a.id.localeCompare(b.id)
|
|
318
|
+
})
|
|
319
|
+
.slice(0, limit)
|
|
320
|
+
|
|
321
|
+
const best = ranked[0]?.score ?? 0
|
|
322
|
+
return best > 0 ? ranked.filter((match) => match.score >= best * RELEVANCE_FLOOR) : ranked
|
|
126
323
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
const SEARCH_TOKEN_SEPARATOR = /[^\p{L}\p{N}@/_-]+/gu
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bumped whenever a stopword list or the token expansion changes. The index records it, so a
|
|
5
|
+
* ranking that depended on a different list is visible rather than silently incomparable.
|
|
6
|
+
*/
|
|
7
|
+
export const SEARCH_LEXICON_VERSION = 1 as const
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Words that carry no retrieval signal because they appear in nearly every sentence of the
|
|
11
|
+
* corpus. Without them `search and` scores every record that has a prose summary.
|
|
12
|
+
*
|
|
13
|
+
* Portuguese is here for the same reason English is: the repository targets non-English
|
|
14
|
+
* documentation, and a query in one language must be ranked by the same rules as the other.
|
|
15
|
+
* Single characters never reach these sets, since tokens shorter than two characters are dropped.
|
|
16
|
+
*/
|
|
17
|
+
const ENGLISH_STOPWORDS = [
|
|
18
|
+
'about', 'after', 'again', 'all', 'also', 'an', 'and', 'any', 'are', 'as', 'at', 'be', 'been',
|
|
19
|
+
'before', 'being', 'between', 'both', 'but', 'by', 'can', 'could', 'did', 'do', 'does', 'doing',
|
|
20
|
+
'done', 'during', 'each', 'either', 'else', 'for', 'from', 'had', 'has', 'have', 'he', 'her',
|
|
21
|
+
'here', 'hers', 'him', 'his', 'how', 'if', 'in', 'into', 'is', 'it', 'its', 'just', 'me', 'more',
|
|
22
|
+
'most', 'much', 'must', 'my', 'no', 'nor', 'not', 'of', 'off', 'on', 'once', 'only', 'or',
|
|
23
|
+
'other', 'our', 'out', 'over', 'own', 'same', 'she', 'should', 'so', 'some', 'such', 'than',
|
|
24
|
+
'that', 'the', 'their', 'them', 'then', 'there', 'these', 'they', 'this', 'those', 'through',
|
|
25
|
+
'to', 'too', 'under', 'until', 'up', 'us', 'very', 'was', 'we', 'were', 'what', 'when', 'where',
|
|
26
|
+
'which', 'while', 'who', 'whom', 'why', 'will', 'with', 'would', 'you', 'your',
|
|
27
|
+
] as const
|
|
28
|
+
|
|
29
|
+
const PORTUGUESE_STOPWORDS = [
|
|
30
|
+
'ainda', 'ao', 'aos', 'aquela', 'aquelas', 'aquele', 'aqueles', 'aquilo', 'as', 'até', 'com',
|
|
31
|
+
'como', 'da', 'das', 'de', 'dela', 'delas', 'dele', 'deles', 'depois', 'do', 'dos', 'ela',
|
|
32
|
+
'elas', 'ele', 'eles', 'em', 'entre', 'era', 'eram', 'essa', 'essas', 'esse', 'esses', 'esta',
|
|
33
|
+
'estas', 'este', 'estes', 'está', 'estão', 'eu', 'fica', 'ficam', 'foi', 'foram', 'isso',
|
|
34
|
+
'isto', 'já', 'lhe', 'lhes', 'mais', 'mas', 'me', 'menos', 'mesmo', 'meu', 'minha', 'muito',
|
|
35
|
+
'na', 'nas', 'nem', 'no', 'nos', 'nossa', 'nosso', 'num', 'numa', 'não', 'onde', 'os', 'ou',
|
|
36
|
+
'para', 'pela', 'pelas', 'pelo', 'pelos', 'per', 'por', 'porque', 'pra', 'qual', 'quais',
|
|
37
|
+
'quando', 'que', 'quem', 'se', 'sem', 'ser', 'seu', 'seus', 'sobre', 'sua', 'suas', 'são',
|
|
38
|
+
'também', 'tem', 'ter', 'teu', 'toda', 'todas', 'todo', 'todos', 'tua', 'têm', 'um', 'uma',
|
|
39
|
+
'umas', 'uns', 'vira', 'você', 'vocês',
|
|
40
|
+
] as const
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Strip diacritics so `reconciliacao` and `reconciliação` are the same term.
|
|
44
|
+
*
|
|
45
|
+
* Non-English documentation is written with accents and queried without them at least as often as
|
|
46
|
+
* the reverse, and a retrieval path that treats the two as different words fails a question it has
|
|
47
|
+
* the answer to. Folding happens on both sides — the indexed text and the query — so the two can
|
|
48
|
+
* never disagree.
|
|
49
|
+
*/
|
|
50
|
+
export const foldAccents = (value: string): string =>
|
|
51
|
+
value.normalize('NFD').replace(/\p{Diacritic}/gu, '').normalize('NFC')
|
|
52
|
+
|
|
53
|
+
export const SEARCH_STOPWORDS: ReadonlySet<string> = new Set<string>(
|
|
54
|
+
[...ENGLISH_STOPWORDS, ...PORTUGUESE_STOPWORDS].map(foldAccents),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
export const isSearchStopword = (token: string): boolean => SEARCH_STOPWORDS.has(foldAccents(token))
|
|
58
|
+
|
|
59
|
+
/** Keep the deterministic search path usable for non-English documentation too. */
|
|
60
|
+
export const tokenizeSearchText = (value: string): string[] =>
|
|
61
|
+
value
|
|
62
|
+
.toLowerCase()
|
|
63
|
+
.split(SEARCH_TOKEN_SEPARATOR)
|
|
64
|
+
.filter((token) => token.length >= 2)
|
|
65
|
+
|
|
66
|
+
const CAMEL_BOUNDARY = /(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/g
|
|
67
|
+
|
|
68
|
+
const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u
|
|
69
|
+
const CJK_RUN = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/gu
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Characters and character bigrams of a CJK run.
|
|
73
|
+
*
|
|
74
|
+
* CJK text has no spaces, so a whole sentence arrives as one token and an exact-term index would
|
|
75
|
+
* never match a query for two of its characters. Indexing unigrams and bigrams on both sides is
|
|
76
|
+
* the standard answer: the bigram carries the signal, the unigram keeps a one-character query
|
|
77
|
+
* answerable.
|
|
78
|
+
*/
|
|
79
|
+
const cjkGrams = (run: string): string[] => {
|
|
80
|
+
const characters = [...run]
|
|
81
|
+
const grams = [...characters]
|
|
82
|
+
for (let position = 0; position + 1 < characters.length; position += 1) {
|
|
83
|
+
grams.push(`${characters[position]}${characters[position + 1]}`)
|
|
84
|
+
}
|
|
85
|
+
return grams
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A code identifier or path, plus the words inside it.
|
|
90
|
+
*
|
|
91
|
+
* `reconcileKnowledge` yields the whole identifier and `reconcile` and `knowledge`, so an agent
|
|
92
|
+
* that types the exact symbol gets an exact match while a human who half-remembers it still
|
|
93
|
+
* lands nearby. `src/mcp/server.ts` yields the whole path and each segment without its extension.
|
|
94
|
+
*/
|
|
95
|
+
export const expandSearchToken = (token: string): string[] => {
|
|
96
|
+
const whole = foldAccents(token.toLowerCase())
|
|
97
|
+
const parts = foldAccents(token)
|
|
98
|
+
.replace(CAMEL_BOUNDARY, ' ')
|
|
99
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
100
|
+
.map((part) => part.toLowerCase())
|
|
101
|
+
.filter((part) => part.length >= 2 && part !== whole)
|
|
102
|
+
const grams = CJK.test(token) ? [...token.matchAll(CJK_RUN)].flatMap(([run]) => cjkGrams(run)) : []
|
|
103
|
+
return [...new Set([whole, ...parts, ...grams])]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Collapse a plural to its singular so `schema` finds `schemas`.
|
|
108
|
+
*
|
|
109
|
+
* The substring matcher this path replaced accepted an optional plural suffix, and losing that
|
|
110
|
+
* would have made an exact-term index quietly worse at the most ordinary query there is. These
|
|
111
|
+
* are the safe rules only: applied to both the indexed text and the query, an imperfect stem
|
|
112
|
+
* still matches itself, so the worst case is a missed relation rather than a wrong one.
|
|
113
|
+
*/
|
|
114
|
+
export const singularizeSearchToken = (token: string): string => {
|
|
115
|
+
if (token.length <= 3) return token
|
|
116
|
+
if (token.endsWith('ies')) return `${token.slice(0, -3)}y`
|
|
117
|
+
if (/(?:sses|xes|ches|shes|zes)$/.test(token)) return token.slice(0, -2)
|
|
118
|
+
if (/(?:ss|us|is|os)$/.test(token)) return token
|
|
119
|
+
if (token.endsWith('es') && token.length > 4) return token.slice(0, -1)
|
|
120
|
+
if (token.endsWith('s')) return token.slice(0, -1)
|
|
121
|
+
return token
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type SearchTokenizeOptions = {
|
|
125
|
+
/** Drop stopwords. On for queries and for indexed text; off only when a caller needs raw tokens. */
|
|
126
|
+
readonly dropStopwords?: boolean
|
|
127
|
+
/** Also emit the words inside an identifier or path. */
|
|
128
|
+
readonly expand?: boolean
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The tokenizer both indexing and querying use, so a term can never be present on one side and
|
|
133
|
+
* absent on the other.
|
|
134
|
+
*/
|
|
135
|
+
export const searchTokens = (value: string, options: SearchTokenizeOptions = {}): string[] => {
|
|
136
|
+
const dropStopwords = options.dropStopwords ?? true
|
|
137
|
+
const expand = options.expand ?? true
|
|
138
|
+
const tokens: string[] = []
|
|
139
|
+
for (const raw of value.split(SEARCH_TOKEN_SEPARATOR)) {
|
|
140
|
+
if (!raw) continue
|
|
141
|
+
for (const token of expand ? expandSearchToken(raw) : [raw.toLowerCase()]) {
|
|
142
|
+
if (token.length < 2 && !CJK.test(token)) continue
|
|
143
|
+
if (dropStopwords && isSearchStopword(token)) continue
|
|
144
|
+
tokens.push(CJK.test(token) ? token : singularizeSearchToken(token))
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return tokens
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const hasSearchToken = (hay: string, token: string): boolean => {
|
|
151
|
+
// CJK text commonly has no whitespace; substring matching is the native word boundary there.
|
|
152
|
+
if (/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(token)) return hay.includes(token)
|
|
153
|
+
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
154
|
+
return new RegExp(`(?:^|[^\\p{L}\\p{N}])${escaped}(?:s|es)?(?:[^\\p{L}\\p{N}]|$)`, 'u').test(hay)
|
|
155
|
+
}
|