dsh-plugin-discover 0.0.0-stage → 1.0.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 +31 -0
- package/LICENSE +26 -0
- package/README.md +209 -3
- package/README.zh.md +208 -0
- package/client.js +477 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +14 -0
- package/index.js +347 -0
- package/lib/bm25.js +190 -0
- package/lib/build-worker.js +32 -0
- package/lib/build.js +145 -0
- package/lib/cli.js +495 -0
- package/lib/compat.js +157 -0
- package/lib/filters.js +105 -0
- package/lib/model-client.js +163 -0
- package/lib/normalize.js +369 -0
- package/lib/paths.js +49 -0
- package/lib/quality.js +61 -0
- package/lib/readme-clean.js +128 -0
- package/lib/readme-source.js +380 -0
- package/lib/report.js +244 -0
- package/lib/runtime.js +247 -0
- package/lib/search.js +447 -0
- package/lib/store.js +184 -0
- package/lib/tokenize.js +141 -0
- package/lib/vector-store.js +142 -0
- package/lib/vector.js +592 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +67 -6
package/lib/normalize.js
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn the two directory sources into one card per installable target.
|
|
3
|
+
*
|
|
4
|
+
* dsh.so wins every field it supplies; the catalog fills title, repository,
|
|
5
|
+
* `engines.dsh`, publish time and npm versions. Ecosystem apps without an
|
|
6
|
+
* install command are not installable targets and are left out.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from 'node:crypto'
|
|
9
|
+
import { qualityPrior } from './quality.js'
|
|
10
|
+
|
|
11
|
+
export const DSH_SO_URL = 'https://www.dsh.so/plugins-index.json'
|
|
12
|
+
export const CATALOG_URL = 'https://cdn.jsdelivr.net/npm/dsh-plugin-catalog/index.json'
|
|
13
|
+
export const ATTRIBUTION = 'Powered by dsh.so — DSH Developer Hub'
|
|
14
|
+
|
|
15
|
+
const NPM_TARGET_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(@[a-z0-9][a-z0-9._+-]*)?$/i
|
|
16
|
+
const GITHUB_TARGET_RE = /^github:[a-z0-9][a-z0-9._-]*\/[a-z0-9._-]+(#([^\s;|&$`<>()*?\\]+))?$/iu
|
|
17
|
+
const INSTALL_RE = /^dsh\s+plugin\s+--profile\s+[a-z0-9._-]+\s+add\s+(.+)$/i
|
|
18
|
+
|
|
19
|
+
function asString(value) {
|
|
20
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function asNumber(value) {
|
|
24
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function asStringArray(value, limit = 32) {
|
|
28
|
+
if (!Array.isArray(value)) return []
|
|
29
|
+
const seen = new Set()
|
|
30
|
+
for (const item of value) {
|
|
31
|
+
if (typeof item !== 'string') continue
|
|
32
|
+
const text = item.trim().toLowerCase()
|
|
33
|
+
if (text === '' || seen.has(text)) continue
|
|
34
|
+
seen.add(text)
|
|
35
|
+
if (seen.size >= limit) break
|
|
36
|
+
}
|
|
37
|
+
return [...seen]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Normalize a repository URL so `git+https`, `.git`, case, and trailing slash compare equal. */
|
|
41
|
+
export function normalizeRepo(url) {
|
|
42
|
+
if (typeof url !== 'string') return ''
|
|
43
|
+
let value = url.trim().toLowerCase()
|
|
44
|
+
value = value.replace(/^git\+/, '').replace(/^git@([^:]+):/, '$1/')
|
|
45
|
+
value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//, '')
|
|
46
|
+
value = value.replace(/^www\./, '')
|
|
47
|
+
value = value.split(/[?#]/)[0]
|
|
48
|
+
value = value.replace(/\.git$/, '').replace(/\/+$/, '')
|
|
49
|
+
return value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether a target is a plain npm name or a GitHub spec, never a shell fragment. */
|
|
53
|
+
export function isSafeInstallTarget(target) {
|
|
54
|
+
if (typeof target !== 'string') return false
|
|
55
|
+
const text = target.trim()
|
|
56
|
+
if (text === '' || text.length > 200) return false
|
|
57
|
+
return NPM_TARGET_RE.test(text) || GITHUB_TARGET_RE.test(text)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Parse the dsh.so `install` command into its exact target. */
|
|
61
|
+
export function parseInstallTarget(install) {
|
|
62
|
+
if (typeof install !== 'string') return null
|
|
63
|
+
const match = INSTALL_RE.exec(install.trim())
|
|
64
|
+
if (!match) return null
|
|
65
|
+
const target = match[1].trim()
|
|
66
|
+
if (!isSafeInstallTarget(target)) return null
|
|
67
|
+
return { target }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Package name inside an npm or GitHub target (version and ref removed). */
|
|
71
|
+
export function packageNameOf(target) {
|
|
72
|
+
if (typeof target !== 'string') return ''
|
|
73
|
+
if (target.toLowerCase().startsWith('github:')) return target.split('#')[0]
|
|
74
|
+
if (target.startsWith('@')) {
|
|
75
|
+
const slash = target.indexOf('/')
|
|
76
|
+
if (slash === -1) return target
|
|
77
|
+
const at = target.indexOf('@', slash)
|
|
78
|
+
return at === -1 ? target : target.slice(0, at)
|
|
79
|
+
}
|
|
80
|
+
const at = target.indexOf('@')
|
|
81
|
+
return at === -1 ? target : target.slice(0, at)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function latestCatalogVersion(entry) {
|
|
85
|
+
const versions = Array.isArray(entry?.versions) ? entry.versions : []
|
|
86
|
+
let best = null
|
|
87
|
+
for (const version of versions) {
|
|
88
|
+
if (!version || typeof version !== 'object') continue
|
|
89
|
+
const time = asNumber(version.publishTimestamp) ?? -1
|
|
90
|
+
if (!best || time > best.time) best = { value: version, time }
|
|
91
|
+
}
|
|
92
|
+
return best?.value ?? null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function catalogText(value) {
|
|
96
|
+
if (typeof value === 'string') return value.trim()
|
|
97
|
+
if (value && typeof value === 'object') {
|
|
98
|
+
return asString(value.en) || asString(value.zh) || ''
|
|
99
|
+
}
|
|
100
|
+
return ''
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function catalogTopics(entry) {
|
|
104
|
+
const metadata = entry?.metadata && typeof entry.metadata === 'object' ? entry.metadata : {}
|
|
105
|
+
const values = []
|
|
106
|
+
if (typeof metadata.category === 'string') values.push(metadata.category)
|
|
107
|
+
if (Array.isArray(metadata.keywords)) values.push(...metadata.keywords)
|
|
108
|
+
if (Array.isArray(metadata.labels)) values.push(...metadata.labels)
|
|
109
|
+
return asStringArray(values, 16)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function fitsVerification(value) {
|
|
113
|
+
if (!value || typeof value !== 'object') return null
|
|
114
|
+
const level = asNumber(value.level)
|
|
115
|
+
if (level === null || level < 1 || level > 5) return null
|
|
116
|
+
return {
|
|
117
|
+
level,
|
|
118
|
+
label: asString(value.label) || `L${level}`,
|
|
119
|
+
lastVerifiedAt: asString(value.lastVerifiedAt) || null,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function fitsSecurity(value) {
|
|
124
|
+
if (!value || typeof value !== 'object') return null
|
|
125
|
+
const status = asString(value.status) || 'unknown'
|
|
126
|
+
const riskLevel = asString(value.riskLevel) || 'unknown'
|
|
127
|
+
const counts = value.counts && typeof value.counts === 'object'
|
|
128
|
+
? {
|
|
129
|
+
critical: asNumber(value.counts.critical) ?? 0,
|
|
130
|
+
warning: asNumber(value.counts.warning) ?? 0,
|
|
131
|
+
info: asNumber(value.counts.info) ?? 0,
|
|
132
|
+
}
|
|
133
|
+
: null
|
|
134
|
+
return {
|
|
135
|
+
status,
|
|
136
|
+
riskLevel,
|
|
137
|
+
scannedAt: asString(value.scannedAt) || null,
|
|
138
|
+
...(counts ? { counts } : {}),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function contentHash(record) {
|
|
143
|
+
const payload = JSON.stringify([
|
|
144
|
+
record.name,
|
|
145
|
+
record.title ?? '',
|
|
146
|
+
record.description ?? '',
|
|
147
|
+
record.topics,
|
|
148
|
+
record.capability,
|
|
149
|
+
record.install?.target ?? '',
|
|
150
|
+
record.repo ?? '',
|
|
151
|
+
])
|
|
152
|
+
return createHash('sha256').update(payload).digest('hex').slice(0, 16)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function baseRecord(record) {
|
|
156
|
+
return {
|
|
157
|
+
id: record.id,
|
|
158
|
+
name: record.name,
|
|
159
|
+
alias: record.alias ?? '',
|
|
160
|
+
title: record.title ?? '',
|
|
161
|
+
description: record.description ?? '',
|
|
162
|
+
topics: record.topics ?? [],
|
|
163
|
+
capability: record.capability ?? [],
|
|
164
|
+
stars: record.stars ?? null,
|
|
165
|
+
verification: record.verification ?? null,
|
|
166
|
+
security: record.security ?? null,
|
|
167
|
+
repo: record.repo ?? '',
|
|
168
|
+
repoKey: record.repoKey ?? '',
|
|
169
|
+
url: record.url ?? '',
|
|
170
|
+
install: record.install ?? null,
|
|
171
|
+
dshRange: record.dshRange ?? null,
|
|
172
|
+
updatedAt: record.updatedAt ?? null,
|
|
173
|
+
kind: record.kind ?? 'plugin',
|
|
174
|
+
sources: record.sources ?? [],
|
|
175
|
+
aliases: record.aliases ?? [],
|
|
176
|
+
alternatives: [],
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Build the card set.
|
|
182
|
+
* @param {unknown} dshSo parsed dsh.so index
|
|
183
|
+
* @param {unknown} catalog parsed format-1 catalog, or null when unavailable
|
|
184
|
+
*/
|
|
185
|
+
export function buildRecords(dshSo, catalog) {
|
|
186
|
+
if (!dshSo || typeof dshSo !== 'object' || !Array.isArray(dshSo.plugins)) {
|
|
187
|
+
throw new Error('dsh.so index is missing the plugins array')
|
|
188
|
+
}
|
|
189
|
+
const catalogPlugins = catalog && typeof catalog === 'object' && Array.isArray(catalog.plugins)
|
|
190
|
+
? catalog.plugins
|
|
191
|
+
: []
|
|
192
|
+
const catalogByRepo = new Map()
|
|
193
|
+
const catalogByName = new Map()
|
|
194
|
+
for (const entry of catalogPlugins) {
|
|
195
|
+
if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string') continue
|
|
196
|
+
const repoKey = normalizeRepo(entry.repository?.url)
|
|
197
|
+
if (repoKey && !catalogByRepo.has(repoKey)) catalogByRepo.set(repoKey, entry)
|
|
198
|
+
const name = entry.name.trim().toLowerCase()
|
|
199
|
+
if (name && !catalogByName.has(name)) catalogByName.set(name, entry)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const usedCatalog = new Set()
|
|
203
|
+
const usedNames = new Map()
|
|
204
|
+
const records = []
|
|
205
|
+
let skippedNoInstall = 0
|
|
206
|
+
let skippedBadInstall = 0
|
|
207
|
+
|
|
208
|
+
for (const plugin of dshSo.plugins) {
|
|
209
|
+
if (!plugin || typeof plugin !== 'object') continue
|
|
210
|
+
const install = parseInstallTarget(plugin.install)
|
|
211
|
+
if (plugin.install == null || String(plugin.install).trim() === '') {
|
|
212
|
+
skippedNoInstall += 1
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
if (!install) {
|
|
216
|
+
skippedBadInstall += 1
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
const repo = asString(plugin.source)
|
|
220
|
+
const repoKey = normalizeRepo(repo)
|
|
221
|
+
const alias = asString(plugin.alias)
|
|
222
|
+
const name = asString(plugin.name) || alias || asString(plugin.id)
|
|
223
|
+
if (name === '') continue
|
|
224
|
+
const catalogEntry = catalogByRepo.get(repoKey)
|
|
225
|
+
?? catalogByName.get(alias.toLowerCase())
|
|
226
|
+
?? catalogByName.get(name.toLowerCase())
|
|
227
|
+
if (catalogEntry) usedCatalog.add(catalogEntry)
|
|
228
|
+
|
|
229
|
+
const catalogVersion = catalogEntry ? latestCatalogVersion(catalogEntry) : null
|
|
230
|
+
const engines = catalogVersion?.engines && typeof catalogVersion.engines === 'object'
|
|
231
|
+
? asString(catalogVersion.engines.dsh) : ''
|
|
232
|
+
const publishedAt = asNumber(catalogVersion?.publishTimestamp)
|
|
233
|
+
const record = baseRecord({
|
|
234
|
+
id: asString(plugin.id) || name,
|
|
235
|
+
name,
|
|
236
|
+
alias,
|
|
237
|
+
title: catalogEntry ? asString(catalogEntry.title) : '',
|
|
238
|
+
description: asString(plugin.description),
|
|
239
|
+
topics: asStringArray(plugin.topics),
|
|
240
|
+
capability: asStringArray(plugin.capability?.samples),
|
|
241
|
+
stars: asNumber(plugin.stars) ?? 0,
|
|
242
|
+
verification: fitsVerification(plugin.verification),
|
|
243
|
+
security: fitsSecurity(plugin.security),
|
|
244
|
+
repo,
|
|
245
|
+
repoKey,
|
|
246
|
+
url: asString(plugin.url) || asString(catalogEntry?.homepage) || repo,
|
|
247
|
+
install,
|
|
248
|
+
dshRange: engines || null,
|
|
249
|
+
updatedAt: publishedAt && publishedAt > 0 ? new Date(publishedAt * 1000).toISOString() : null,
|
|
250
|
+
kind: asString(plugin.kind) || 'plugin',
|
|
251
|
+
sources: catalogEntry ? ['dsh.so', 'dsh-plugin-catalog'] : ['dsh.so'],
|
|
252
|
+
aliases: [...new Set([name, alias].filter(Boolean))],
|
|
253
|
+
})
|
|
254
|
+
records.push(record)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const dshSoInstallable = records.length
|
|
258
|
+
|
|
259
|
+
for (const entry of catalogPlugins) {
|
|
260
|
+
if (usedCatalog.has(entry)) continue
|
|
261
|
+
if (!entry || typeof entry !== 'object') continue
|
|
262
|
+
const name = asString(entry.name)
|
|
263
|
+
if (!name) continue
|
|
264
|
+
const repo = asString(entry.repository?.url)
|
|
265
|
+
const repoKey = normalizeRepo(repo)
|
|
266
|
+
if (repoKey && catalogByRepo.has(repoKey) && usedCatalog.has(catalogByRepo.get(repoKey))) continue
|
|
267
|
+
if (catalogByName.has(name.toLowerCase()) && usedCatalog.has(catalogByName.get(name.toLowerCase()))) continue
|
|
268
|
+
const version = latestCatalogVersion(entry)
|
|
269
|
+
const versionText = asString(version?.version)
|
|
270
|
+
const target = versionText ? `${name}@${versionText}` : name
|
|
271
|
+
if (!isSafeInstallTarget(target)) continue
|
|
272
|
+
const engines = version?.engines && typeof version.engines === 'object' ? asString(version.engines.dsh) : ''
|
|
273
|
+
const publishedAt = asNumber(version?.publishTimestamp)
|
|
274
|
+
const record = baseRecord({
|
|
275
|
+
id: `catalog:${name}`,
|
|
276
|
+
name,
|
|
277
|
+
alias: name,
|
|
278
|
+
title: asString(entry.title),
|
|
279
|
+
description: catalogText(entry.description),
|
|
280
|
+
topics: catalogTopics(entry),
|
|
281
|
+
capability: [],
|
|
282
|
+
stars: null,
|
|
283
|
+
verification: null,
|
|
284
|
+
security: null,
|
|
285
|
+
repo,
|
|
286
|
+
repoKey,
|
|
287
|
+
url: asString(entry.homepage) || repo,
|
|
288
|
+
install: { target },
|
|
289
|
+
dshRange: engines || null,
|
|
290
|
+
updatedAt: publishedAt && publishedAt > 0 ? new Date(publishedAt * 1000).toISOString() : null,
|
|
291
|
+
kind: 'catalog',
|
|
292
|
+
sources: ['dsh-plugin-catalog'],
|
|
293
|
+
aliases: [name],
|
|
294
|
+
})
|
|
295
|
+
records.push(record)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const groups = new Map()
|
|
299
|
+
for (const record of records) {
|
|
300
|
+
const key = record.repoKey || `name:${record.name.toLowerCase()}`
|
|
301
|
+
const group = groups.get(key)
|
|
302
|
+
if (group) group.push(record)
|
|
303
|
+
else groups.set(key, [record])
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const cards = []
|
|
307
|
+
let mergedCards = 0
|
|
308
|
+
for (const group of groups.values()) {
|
|
309
|
+
group.sort((a, b) => qualityPrior(b) - qualityPrior(a) || b.stars - a.stars)
|
|
310
|
+
const main = group[0]
|
|
311
|
+
if (group.length > 1) {
|
|
312
|
+
mergedCards += 1
|
|
313
|
+
main.alternatives = group.slice(1)
|
|
314
|
+
.map(item => ({ name: item.name, target: item.install?.target ?? '' }))
|
|
315
|
+
.filter(item => item.target !== '' && item.name !== main.name)
|
|
316
|
+
main.topics = [...new Set(group.flatMap(item => item.topics))]
|
|
317
|
+
main.capability = [...new Set(group.flatMap(item => item.capability))]
|
|
318
|
+
main.aliases = [...new Set(group.flatMap(item => item.aliases))]
|
|
319
|
+
const numericStars = group
|
|
320
|
+
.map(item => item.stars)
|
|
321
|
+
.filter(stars => typeof stars === 'number' && Number.isFinite(stars))
|
|
322
|
+
main.stars = numericStars.length > 0 ? Math.max(...numericStars) : null
|
|
323
|
+
const timestamps = group.map(item => item.updatedAt).filter(Boolean).sort()
|
|
324
|
+
main.updatedAt = timestamps.at(-1) ?? main.updatedAt
|
|
325
|
+
main.dshRange = main.dshRange ?? group.find(item => item.dshRange)?.dshRange ?? null
|
|
326
|
+
}
|
|
327
|
+
if (usedNames.has(main.id)) {
|
|
328
|
+
const next = usedNames.get(main.id) + 1
|
|
329
|
+
usedNames.set(main.id, next)
|
|
330
|
+
main.id = `${main.id}#${next}`
|
|
331
|
+
} else {
|
|
332
|
+
usedNames.set(main.id, 0)
|
|
333
|
+
}
|
|
334
|
+
main.contentHash = contentHash(main)
|
|
335
|
+
cards.push(main)
|
|
336
|
+
}
|
|
337
|
+
cards.sort((a, b) => a.id.localeCompare(b.id))
|
|
338
|
+
|
|
339
|
+
const generatedAt = asString(dshSo.generatedAt) || asString(dshSo.generated) || null
|
|
340
|
+
const catalogGeneratedAt = asString(catalog?.generatedAt) || asString(catalog?.metadata?.generatedAt) || null
|
|
341
|
+
return {
|
|
342
|
+
records: cards,
|
|
343
|
+
stats: {
|
|
344
|
+
dshSoTotal: dshSo.plugins.length,
|
|
345
|
+
dshSoInstallable,
|
|
346
|
+
skippedNoInstall,
|
|
347
|
+
skippedBadInstall,
|
|
348
|
+
catalogTotal: catalogPlugins.length,
|
|
349
|
+
catalogMerged: usedCatalog.size,
|
|
350
|
+
catalogOnly: cards.filter(record => record.sources.length === 1 && record.sources[0] === 'dsh-plugin-catalog').length,
|
|
351
|
+
cards: cards.length,
|
|
352
|
+
mergedCards,
|
|
353
|
+
},
|
|
354
|
+
sources: {
|
|
355
|
+
dshSo: {
|
|
356
|
+
url: DSH_SO_URL,
|
|
357
|
+
generatedAt,
|
|
358
|
+
count: dshSo.plugins.length,
|
|
359
|
+
ok: true,
|
|
360
|
+
},
|
|
361
|
+
catalog: {
|
|
362
|
+
url: CATALOG_URL,
|
|
363
|
+
generatedAt: catalogGeneratedAt,
|
|
364
|
+
count: catalogPlugins.length,
|
|
365
|
+
ok: catalogPlugins.length > 0,
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
}
|
|
369
|
+
}
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the shared, per-user index lives. The directory name is a stable
|
|
3
|
+
* identifier: it never follows a package rename and is shared by every DSH
|
|
4
|
+
* profile of the same OS user.
|
|
5
|
+
*/
|
|
6
|
+
import { homedir } from 'node:os'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
/** Absolute default index directory for the current platform. */
|
|
10
|
+
export function defaultIndexDir(env = process.env, platform = process.platform) {
|
|
11
|
+
if (platform === 'win32') {
|
|
12
|
+
return join(env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'dsh-plugin-discover')
|
|
13
|
+
}
|
|
14
|
+
if (platform === 'darwin') {
|
|
15
|
+
return join(homedir(), 'Library', 'Caches', 'dsh-plugin-discover')
|
|
16
|
+
}
|
|
17
|
+
return join(env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'dsh-plugin-discover')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The pointer file naming the published index directory. */
|
|
21
|
+
export function currentPath(indexDir) {
|
|
22
|
+
return join(indexDir, 'current')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The cross-process build lock. */
|
|
26
|
+
export function lockPath(indexDir) {
|
|
27
|
+
return join(indexDir, 'build.lock')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Human-readable build log written by the child process. */
|
|
31
|
+
export function buildLogPath(indexDir) {
|
|
32
|
+
return join(indexDir, 'build.log')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Reject any pointer value that would escape the index directory. */
|
|
36
|
+
export function isIndexDirName(name) {
|
|
37
|
+
return typeof name === 'string' && /^index-[0-9A-Za-zT._-]+$/.test(name)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Resolve an index directory name below its parent. */
|
|
41
|
+
export function resolveIndexDir(indexDir, name) {
|
|
42
|
+
if (!isIndexDirName(name)) return null
|
|
43
|
+
return join(indexDir, name)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** New sortable directory name, e.g. `index-20261022T120000000Z`. */
|
|
47
|
+
export function newIndexDirName(now = new Date()) {
|
|
48
|
+
return `index-${now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z')}`
|
|
49
|
+
}
|
package/lib/quality.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The quality prior. It may only lower a result by 20%: relevance stays the
|
|
3
|
+
* primary key, and stars can never silently become the sort key.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const VERIFICATION_SCORE = { 5: 1, 4: 0.8, 3: 0.6, 2: 0.45, 1: 0.3 }
|
|
7
|
+
|
|
8
|
+
/** Verification level as a 0..1 signal; missing is neutral. */
|
|
9
|
+
export function verificationScore(record) {
|
|
10
|
+
const level = record?.verification?.level
|
|
11
|
+
return VERIFICATION_SCORE[level] ?? 0.5
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Security status/risk as a 0..1 signal; missing and pending are neutral. */
|
|
15
|
+
export function securityScore(record) {
|
|
16
|
+
const security = record?.security
|
|
17
|
+
if (!security || typeof security !== 'object') return 0.5
|
|
18
|
+
const status = security.status
|
|
19
|
+
const risk = security.riskLevel
|
|
20
|
+
if (status === 'failed') return 0.2
|
|
21
|
+
if (status === 'audited') {
|
|
22
|
+
if (risk === 'low') return 1
|
|
23
|
+
if (risk === 'medium') return 0.7
|
|
24
|
+
if (risk === 'high') return 0.25
|
|
25
|
+
if (risk === 'critical') return 0
|
|
26
|
+
return 0.5
|
|
27
|
+
}
|
|
28
|
+
if (status === 'pending') return 0.45
|
|
29
|
+
return 0.4
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Popularity from stars, capped at 1000; unknown stars are neutral. */
|
|
33
|
+
export function popularityScore(record) {
|
|
34
|
+
const stars = record?.stars
|
|
35
|
+
if (typeof stars !== 'number' || !Number.isFinite(stars)) return 0.5
|
|
36
|
+
if (stars <= 0) return 0
|
|
37
|
+
return Math.min(1, Math.log1p(stars) / Math.log1p(1000))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Recency with a 90-day half-life; missing timestamps are neutral. */
|
|
41
|
+
export function recencyScore(record, now = Date.now()) {
|
|
42
|
+
const updatedAt = record?.updatedAt
|
|
43
|
+
if (!updatedAt) return 0.5
|
|
44
|
+
const time = Date.parse(updatedAt)
|
|
45
|
+
if (!Number.isFinite(time)) return 0.5
|
|
46
|
+
const ageDays = Math.max(0, (now - time) / 86_400_000)
|
|
47
|
+
return 0.5 ** (ageDays / 90)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Combined quality prior in 0..1. */
|
|
51
|
+
export function qualityPrior(record, now = Date.now()) {
|
|
52
|
+
return 0.55 * verificationScore(record)
|
|
53
|
+
+ 0.20 * securityScore(record)
|
|
54
|
+
+ 0.15 * popularityScore(record)
|
|
55
|
+
+ 0.10 * recencyScore(record, now)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The bounded multiplier applied to a relevance score. */
|
|
59
|
+
export function qualityFactor(record, now = Date.now()) {
|
|
60
|
+
return 0.8 + 0.2 * qualityPrior(record, now)
|
|
61
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip README presentation and boilerplate that do not describe what the
|
|
3
|
+
* plugin does: badges, images, HTML, code, front matter, link-only lines, and
|
|
4
|
+
* license/install/contributing/changelog sections. The remaining text is then
|
|
5
|
+
* token-truncated by the model client.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const README_CLEAN_VERSION = 1
|
|
9
|
+
|
|
10
|
+
const DROP_SECTIONS = new Set([
|
|
11
|
+
'license',
|
|
12
|
+
'licence',
|
|
13
|
+
'licensing',
|
|
14
|
+
'contributing',
|
|
15
|
+
'contribution',
|
|
16
|
+
'code of conduct',
|
|
17
|
+
'security',
|
|
18
|
+
'changelog',
|
|
19
|
+
'release notes',
|
|
20
|
+
'releases',
|
|
21
|
+
'table of contents',
|
|
22
|
+
'contents',
|
|
23
|
+
'installation',
|
|
24
|
+
'install',
|
|
25
|
+
'getting started',
|
|
26
|
+
'setup',
|
|
27
|
+
'development',
|
|
28
|
+
'developing',
|
|
29
|
+
'testing',
|
|
30
|
+
'tests',
|
|
31
|
+
'sponsors',
|
|
32
|
+
'sponsor',
|
|
33
|
+
'support',
|
|
34
|
+
'acknowledgements',
|
|
35
|
+
'acknowledgments',
|
|
36
|
+
'credits',
|
|
37
|
+
'authors',
|
|
38
|
+
'star history',
|
|
39
|
+
'changelog and versioning',
|
|
40
|
+
])
|
|
41
|
+
|
|
42
|
+
function normalizeHeading(value) {
|
|
43
|
+
return value
|
|
44
|
+
.toLowerCase()
|
|
45
|
+
.replace(/[`*_~]/g, '')
|
|
46
|
+
.replace(/[^\p{L}\p{N}\s/-]/gu, ' ')
|
|
47
|
+
.replace(/\s+/g, ' ')
|
|
48
|
+
.trim()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isDropHeading(value) {
|
|
52
|
+
return DROP_SECTIONS.has(normalizeHeading(value))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function stripBoilerplateSections(text) {
|
|
56
|
+
const lines = text.split('\n')
|
|
57
|
+
const kept = []
|
|
58
|
+
let skipLevel = 0
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
const heading = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/)
|
|
61
|
+
if (heading) {
|
|
62
|
+
const level = heading[1].length
|
|
63
|
+
if (skipLevel && level <= skipLevel) skipLevel = 0
|
|
64
|
+
if (!skipLevel && isDropHeading(heading[2])) {
|
|
65
|
+
skipLevel = level
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (skipLevel) continue
|
|
70
|
+
kept.push(line)
|
|
71
|
+
}
|
|
72
|
+
return kept.join('\n')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function stripBadges(text) {
|
|
76
|
+
return text
|
|
77
|
+
// Markdown image, including the common linked-badge form.
|
|
78
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
|
79
|
+
.replace(/!\[[^\]]*\]\[[^\]]*\]/g, '')
|
|
80
|
+
.replace(/\[\s*\]\([^)]*\)/g, '')
|
|
81
|
+
// Reference-style link definitions.
|
|
82
|
+
.replace(/^\s*\[[^\]]+\]:\s*\S+.*$/gm, '')
|
|
83
|
+
// Remaining markdown links keep only their label.
|
|
84
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
85
|
+
.replace(/\[([^\]]+)\]\[[^\]]*\]/g, '$1')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function cleanReadmeText(input) {
|
|
89
|
+
let text = String(input ?? '').replace(/\r\n?/g, '\n')
|
|
90
|
+
// YAML front matter.
|
|
91
|
+
text = text.replace(/^---\n[\s\S]*?\n---\n?/, '')
|
|
92
|
+
// HTML comments.
|
|
93
|
+
text = text.replace(/<!--[\s\S]*?-->/g, '')
|
|
94
|
+
// Fenced code and indented code blocks.
|
|
95
|
+
text = text.replace(/```[\s\S]*?```/g, '\n')
|
|
96
|
+
text = text.replace(/~~~[\s\S]*?~~~/g, '\n')
|
|
97
|
+
text = text.replace(/^(?: {4}|\t).*$/gm, '')
|
|
98
|
+
// HTML tags; keep line breaks.
|
|
99
|
+
text = text.replace(/<br\s*\/?>/gi, '\n')
|
|
100
|
+
text = text.replace(/<[^>]*>/g, '')
|
|
101
|
+
// Badges, images, and link labels.
|
|
102
|
+
text = stripBadges(text)
|
|
103
|
+
// Drop badges/shields and link-only lines that survived as plain URLs.
|
|
104
|
+
text = text.split('\n').filter(line => {
|
|
105
|
+
if (/shields\.io|badgen|img\.shields|badge/i.test(line) && !/[.!?]/.test(line)) return false
|
|
106
|
+
const withoutUrls = line.replace(/https?:\/\/\S+/gi, '').replace(/[\s\-|:]/g, '')
|
|
107
|
+
return withoutUrls !== ''
|
|
108
|
+
}).join('\n')
|
|
109
|
+
// Drop boilerplate sections before removing heading markers.
|
|
110
|
+
text = stripBoilerplateSections(text)
|
|
111
|
+
// Heading/list/blockquote markers, keeping the text.
|
|
112
|
+
text = text.replace(/^\s{0,3}#{1,6}\s+/gm, '')
|
|
113
|
+
text = text.replace(/^\s{0,3}>\s?/gm, '')
|
|
114
|
+
text = text.replace(/^\s{0,3}(?:[-*+]|\d+[.)])\s+/gm, '')
|
|
115
|
+
// Table separators, pipes, emphasis, inline code markup.
|
|
116
|
+
text = text.replace(/^\s*\|?[\s:|-]+\|[\s:|-]*\|?\s*$/gm, '')
|
|
117
|
+
text = text.replace(/\|/g, ' ')
|
|
118
|
+
text = text.replace(/`([^`]+)`/g, '$1')
|
|
119
|
+
text = text.replace(/(\*\*|__)(.*?)\1/g, '$2')
|
|
120
|
+
text = text.replace(/[*_]{1,2}/g, '')
|
|
121
|
+
text = text.replace(/&(?:nbsp|amp|lt|gt|quot|#39);/gi, ' ')
|
|
122
|
+
// Horizontal rules and empty/punctuation-only lines.
|
|
123
|
+
text = text.split('\n')
|
|
124
|
+
.map(line => line.replace(/[ \t]+/g, ' ').trim())
|
|
125
|
+
.filter(line => line !== '' && !/^[-=*_]{3,}$/.test(line) && /[\p{L}\p{N}]/u.test(line))
|
|
126
|
+
.join('\n')
|
|
127
|
+
return text.replace(/\n{3,}/g, '\n\n').trim()
|
|
128
|
+
}
|