Checks each package the model installs against its registry and OSV.dev, and stops a missing, brand-new, look-alike, outdated or vulnerable one, naming the…

When the model installs a package, it types a name from memory. That name can be misspelled, point to a package that does not exist, belong to a look-alike published last week, or pin a version with known vulnerabilities. This mod checks every package before the install runs: it asks the package's registry and OSV.dev, and it stops the install when something is wrong. The model reads why, and which version is the latest.
npm i|install|add, pnpm add, yarn add, bun add|install;pip install, pip3 install, python -m pip install, uv pip install, uv add, poetry add;go get, go install;cargo add;composer require.A local path, a URL, a git source, a requirements file (-r) and an editable install (-e) are not checked. It checks at most 10 packages per command.
npm view <name> time.created dist-tags.latest versions --json instead; without npm on the PATH the package stays unchecked. A larger answer from another registry is reported as unchecked, by name.lodash@4.17.15, requests==2.25.0, tokio@=1.38.0, go get x@v1.9.0, vendor/pkg:2.0.0) is not the latest version; the reason names the latest, and also the latest in the same major version when that differs;DEP_SENTINEL_SKIP=1 prefix. The mod logs such a skip.dep-sentinel: the install ran unchecked for: lodash (api.osv.dev answered HTTP 503)
The note and the line are separate channels: the model never reads the line, and you never read the note.
lodash does not close a PyPI lodash), and a guarded git command runs the check itself, in both modes. The entry is cleared and a new one takes its place:dep-sentinel: a later install checked the packages that stayed unchecked: lodash dep-sentinel: the registry and OSV.dev answered for the packages that stayed unchecked: lodash
Whatever the late answer has to say gets its own entry, because the install it belongs to already ran. There the pinned old version and has N known vulnerability(ies) are red, the latest version and fixed in X green, and no fixed version is listed and the age of a new package yellow:
dep-sentinel: the check that was owed says: lodash@4.17.21 has 1 known vulnerability(ies) on OSV.dev: GHSA-29mw-wpgm-hmr9; fixed in 4.17.22
With the sidebar closed the same texts are transcript lines. The model reads none of this.
dep-sentinel: 1 package(s) are still installed unchecked: lodash. Run the install again so the registry and OSV.dev answer, or take the package out.
That is one note per turn, not one per prompt. Without it the finding would be said once, at the install, and then sit in the pane while the model forgot it. You read nothing new, because the pane already carries the same finding.
deny mode the mod also stops git commit, git push and git merge while a package stays unchecked. The gate runs the owed check first, so a package that failed only because the network was down opens the gate by itself. A package the registry still does not answer for stops the command. There is no bypass; only you turn the gate off, with /dep-sentinel mode note. note mode is the default and stops no git command, but it runs the same check at a git command, so a settled finding does not stay in the pane. An install is stopped in both modes, as above.In the live check npm install --dry-run lodash@4.17.15 was stopped with the latest version 4.18.1 and 6 OSV ids, npm install --dry-run lodahs was stopped as a look-alike of lodash with OSV id MAL-2025-25502, and npm install --dry-run left-pad ran.
/dep-sentinel on or off, the mode, and the packages still unchecked /dep-sentinel on | off on by default /dep-sentinel mode note an unchecked package is only reported; the default /dep-sentinel mode deny a commit, a push and a merge also stop while a package stayed unchecked
claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install dep-sentinel@kilimcininkoroglu-mods
Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.
Validated with claude plugin validate on Claude Code 2.1.283:
❯ ./register.ts hooks: session.start, command.run{command=dep-sentinel}, turn.complete, prompt.submit, tool.call{tool=Bash} ❯ ./register.ts calls: $.clock.now, $.command.register, $.http.fetch (via fetchText, osvCheck), $.process.run (via npmView), $.sidebar.clear (via dropEntry), $.sidebar.set (via toPerson), $.store.get (via isEnabled, readSettings), $.store.set (via runCommand, setMode), $.ui.log (via toPerson)
Reach L3: it reaches the network.
hooks/popular.ts (about 150 npm and PyPI names, fewer for the other registries). A look-alike of a package not on the list goes unseen.^18, >=4, cargo add serde@1.0) is not stopped as old, because the installer resolves it. Its latest version is checked on OSV.dev.npm ci, pip install -r) is not checked.deny mode has no bypass. When a registry stays unreachable, you turn the gate off with /dep-sentinel mode note.git commit is not stopped.make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test
hooks/register.ts 313 lines1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2import { planOf, type Install } from './parse.ts'
3import { cratesInfo, FETCH_BODY_LIMIT, goInfo, goOldest, npmInfo, npmViewInfo, osvVulns, packagistInfo, pypiInfo, reachedFetchLimit, registryUrl, type Info } from './registry.ts'
4import {
5 checkedLog,
6 denyText,
7 doneLines,
8 failureLines,
9 failureText,
10 gateCheckedLog,
11 gateText,
12 isGuarded,
13 lateReasonLog,
14 missingReason,
15 modeOf,
16 openNote,
17 reasonLines,
18 registryReasonParts,
19 skippedLines,
20 targetVersion,
21 textOf,
22 uncheckedLog,
23 uncheckedNote,
24 vulnParts,
25 type Failure,
26 type Line,
27 type Mode,
28 type Reason,
29} from './rules.ts'
30
31const ENABLED_KEY = 'enabled'
32const MODE_KEY = 'mode'
33
34const USAGE = 'expects nothing (the status), on, off or mode note | deny'
35
36/** crates.io refuses a request without a User-Agent. */
37const HEADERS = { 'User-Agent': 'dep-sentinel (Claude Code mod; github.com/KilimcininKorOglu/claude-code-mods)', Accept: 'application/json' }
38
39/** A Go install names a package; its module is the path or one of its first few parents. */
40const GO_PARENT_TRIES = 4
41
42/** One package's check: reasons to stop it, or why it could not be checked. */
43type Outcome = { reasons: Reason[]; failure?: Failure }
44
45/**
46 * The mode as the store held it at the last read, and the packages an earlier install could not check,
47 * each with the install it came from, so the check can be run again: by a later install of the same
48 * package, by the gate itself and at each turn's end. `owed` says the model is owed a note for the
49 * packages that were still open at the turn's end. A package is keyed by its ecosystem and name
50 * (`openKey`), because npm and PyPI can each hold a package of one name.
51 */
52type State = { mode: Mode; open: Map<string, Install>; owed: boolean }
53
54/**
55 * Reads the mode from the store, which every window shares, so a change made in another window applies
56 * here at the next hook that acts on it. The on/off setting is read fresh by `isEnabled`.
57 */
58async function readSettings($: EngineInterface, state: State): Promise<void> {
59 state.mode = (await $.store.get(MODE_KEY)) === 'deny' ? 'deny' : 'note'
60}
61
62/** The key of an open package: two ecosystems' packages of one name are two findings. */
63function openKey(p: Install): string {
64 return `${p.ecosystem}:${p.name}`
65}
66
67/** The names of the packages still open, as the texts name them. */
68function openNames(state: State): string[] {
69 return [...state.open.values()].map(p => p.name)
70}
71
72function errorText(err: unknown): string {
73 return err instanceof Error ? err.message : String(err)
74}
75
76async function isEnabled($: EngineInterface): Promise<boolean> {
77 return (await $.store.get(ENABLED_KEY)) !== false
78}
79
80/** GETs a URL and answers its status and body; a network failure throws. */
81async function fetchText($: EngineInterface, url: string): Promise<{ status: number; text: string }> {
82 const r = await $.http.fetch(url, { headers: HEADERS })
83 if (r.status !== 404 && r.status !== 410 && !r.ok) throw new Error(`${/^https:\/\/([^/]+)/.exec(url)?.[1] ?? url} answered HTTP ${r.status}`)
84 return { status: r.status, text: r.text }
85}
86
87/** A Go module: the path, else the nearest parent the proxy knows. */
88async function goModule($: EngineInterface, p: Install): Promise<Info | undefined> {
89 let path = p.name
90 for (let i = 0; i < GO_PARENT_TRIES && path.includes('/'); i++) {
91 const base = registryUrl({ ecosystem: 'Go', name: path }).replace(/@latest$/, '')
92 const latest = await fetchText($, `${base}@latest`)
93 if (latest.status === 200) {
94 const list = (await fetchText($, `${base}@v/list`)).text
95 const oldest = goOldest(list)
96 const info = oldest === undefined ? undefined : JSON.parse((await fetchText($, `${base}@v/${oldest}.info`)).text) as unknown
97 return goInfo(JSON.parse(latest.text), list, info)
98 }
99 path = path.slice(0, path.lastIndexOf('/'))
100 }
101 return undefined
102}
103
104/** How long `npm view` may take; it answers in about a second for next's 24 MiB document (measured). */
105const NPM_VIEW_MS = 60_000
106
107/** npm's own reading of a package whose registry document passed the fetch limit. */
108async function npmView($: EngineInterface, p: Install): Promise<Info> {
109 const r = await $.process.run(['npm', 'view', p.name, 'time.created', 'dist-tags.latest', 'versions', '--json'], { timeoutMs: NPM_VIEW_MS })
110 if (r.exitCode !== 0) throw new Error(`npm view exited ${r.exitCode}: ${r.stderr.split('\n')[0] ?? ''}`)
111 const info = npmViewInfo(JSON.parse(r.stdout))
112 if (info === undefined) throw new Error(`npm view answered for ${p.name} in a shape dep-sentinel does not read`)
113 return info
114}
115
116/**
117 * The registry's answer for a package, or undefined when the registry does not know it. An npm document
118 * cut at the fetch limit is read through `npm view`; another registry's cut document is named as such.
119 */
120async function lookUp($: EngineInterface, p: Install): Promise<Info | undefined> {
121 if (p.ecosystem === 'Go') return goModule($, p)
122 const r = await fetchText($, registryUrl(p))
123 if (r.status !== 200) return undefined
124 if (reachedFetchLimit(r.text)) {
125 if (p.ecosystem === 'npm') return npmView($, p)
126 throw new Error(`the ${p.ecosystem} document of ${p.name} passed the ${FETCH_BODY_LIMIT} bytes a fetch reads`)
127 }
128 const json = JSON.parse(r.text) as unknown
129 const read = { npm: npmInfo, PyPI: pypiInfo, 'crates.io': cratesInfo, Packagist: (j: unknown) => packagistInfo(j, p.name) }[p.ecosystem]
130 const info = read(json)
131 if (info === undefined) throw new Error(`${p.ecosystem} answered for ${p.name} in a shape dep-sentinel does not read`)
132 return info
133}
134
135async function osvCheck($: EngineInterface, p: Install, version: string): Promise<Reason | undefined> {
136 const body = JSON.stringify({ version: version.replace(/^v(?=\d)/, p.ecosystem === 'Go' ? 'v' : ''), package: { name: p.name, ecosystem: p.ecosystem } })
137 const r = await $.http.fetch('https://api.osv.dev/v1/query', { method: 'POST', headers: { ...HEADERS, 'Content-Type': 'application/json' }, body })
138 if (!r.ok) throw new Error(`api.osv.dev answered HTTP ${r.status}`)
139 return vulnParts(p, version, osvVulns(JSON.parse(r.text)))
140}
141
142async function checkOne($: EngineInterface, p: Install, now: number): Promise<Outcome> {
143 try {
144 const info = await lookUp($, p)
145 if (info === undefined) return { reasons: [missingReason(p)] }
146 const vuln = await osvCheck($, p, targetVersion(p, info))
147 return { reasons: [...registryReasonParts(p, info, now), ...(vuln === undefined ? [] : [vuln])] }
148 } catch (err) {
149 return { reasons: [], failure: { name: p.name, why: errorText(err) } }
150 }
151}
152
153/**
154 * The finding the person reads: an entry in the shared sidebar's stream while it is open, else the
155 * transcript line, as before. The model's note is another channel and does not change here.
156 */
157async function toPerson($: EngineInterface, key: string, title: string, lines: readonly Line[], line: string): Promise<void> {
158 try {
159 const taken = await $.sidebar.set({ consumer: 'dep-sentinel', key, title, lines, until: 'stream' })
160 if (taken) return
161 } catch {
162 // The sidebar mod is not installed.
163 }
164 $.ui.log(line)
165}
166
167/** Drops the sidebar entries of one finding, so a package that was checked leaves no warning behind. */
168async function dropEntry($: EngineInterface, key: string): Promise<void> {
169 try {
170 await $.sidebar.clear({ consumer: 'dep-sentinel', key })
171 } catch {
172 // The sidebar mod is not installed.
173 }
174}
175
176/** The installs whose check finished. */
177function checkedOf(installs: readonly Install[], outcomes: readonly Outcome[]): Install[] {
178 return installs.filter((_, i) => outcomes[i]?.failure === undefined)
179}
180
181/** Closes the unchecked finding once every package it named was checked, and reports it. */
182async function closeChecked($: EngineInterface, state: State, checked: readonly Install[]): Promise<void> {
183 if (state.open.size === 0) return
184 const named = openNames(state)
185 for (const p of checked) state.open.delete(openKey(p))
186 if (state.open.size > 0) return
187 await dropEntry($, 'unchecked')
188 await toPerson($, 'unchecked', 'packages checked after all', doneLines(named), checkedLog(named))
189}
190
191function withNote(r: ToolCallResult, note: string): ToolCallResult {
192 if (r.deny !== undefined || r.isError === true) return r
193 return { ...r, context: [...(r.context ?? []), note] }
194}
195
196/**
197 * Runs the owed check again for every package still open, and closes the finding when the registry and
198 * OSV.dev answer for all of them. A package they still do not answer for stays open. What a late answer
199 * has to say is written as its own finding, because the install it belongs to already ran.
200 */
201async function recheckOpen($: EngineInterface, state: State, now: number): Promise<void> {
202 if (state.open.size === 0) return
203 const named = openNames(state)
204 const reasons: Reason[] = []
205 for (const [key, install] of [...state.open]) {
206 const outcome = await checkOne($, install, now)
207 if (outcome.failure !== undefined) continue
208 state.open.delete(key)
209 reasons.push(...outcome.reasons)
210 }
211 if (state.open.size > 0) return
212 await dropEntry($, 'unchecked')
213 await toPerson($, 'unchecked', 'packages checked after all', doneLines(named), gateCheckedLog(named))
214 if (reasons.length > 0) await toPerson($, 'late', 'the late check has something to say', reasonLines(reasons), lateReasonLog(reasons.map(textOf)))
215}
216
217/**
218 * The gate of the `deny` mode. The owed check is run again first, in both modes, so a package the
219 * registry did not answer for earlier closes its own finding instead of waiting for another install.
220 * A package they still do not answer for stops the command.
221 */
222async function gate($: EngineInterface, state: State, command: string): Promise<string | undefined> {
223 if (state.open.size === 0 || !isGuarded(command) || !(await isEnabled($))) return undefined
224 await readSettings($, state)
225 await recheckOpen($, state, await $.clock.now())
226 if (state.mode !== 'deny' || state.open.size === 0) return undefined
227 return gateText(openNames(state))
228}
229
230async function setMode($: EngineInterface, state: State, word: string): Promise<string> {
231 const mode = modeOf(word)
232 if (mode === undefined) return 'mode expects note or deny'
233 await $.store.set(MODE_KEY, mode)
234 state.mode = mode
235 return mode === 'deny' ? 'mode deny: git commit, push and merge stop while a package stayed unchecked' : 'mode note: an unchecked package is only reported'
236}
237
238async function statusText($: EngineInterface, state: State): Promise<string> {
239 const open = state.open.size === 0 ? 'no package is open' : `${state.open.size} package(s) still unchecked`
240 return `${(await isEnabled($)) ? 'on' : 'off'} · mode ${state.mode} · ${open}; npm, PyPI, Go, crates.io and Packagist installs are checked`
241}
242
243async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
244 const word = args.trim()
245 if (word === 'on' || word === 'off') {
246 await $.store.set(ENABLED_KEY, word === 'on')
247 return word === 'on' ? 'on: each install is checked against its registry and OSV.dev' : 'off: installs run unchecked'
248 }
249 if (word.startsWith('mode')) return setMode($, state, word.slice(4).trim())
250 if (word !== '') return USAGE
251 await readSettings($, state)
252 return statusText($, state)
253}
254
255export const register: Register = on => {
256 const state: State = { mode: 'note', open: new Map(), owed: false }
257
258 on('session.start', async ($, e, next) => {
259 const r = await next(e)
260 await $.command.register({ name: 'dep-sentinel', description: 'Package installs checked before they run: status, on, off, mode (dep-sentinel)', argumentHint: '[on | off | mode note | deny]' })
261 await readSettings($, state)
262 return r
263 })
264
265 // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
266 on('command.run', { command: 'dep-sentinel' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
267
268 /*
269 * The turn's end runs the owed check again and owes the model a note for the packages that are still
270 * open, because a finding it did not close would otherwise stand in the pane and reach it never again.
271 * The check asks the registry and OSV.dev, once per open package, and a package they answer for closes.
272 */
273 on('turn.complete', async ($, e, next) => {
274 const r = await next(e)
275 if (e.agentId !== undefined || state.open.size === 0 || !(await isEnabled($))) return r
276 await recheckOpen($, state, await $.clock.now())
277 state.owed = state.open.size > 0
278 return r
279 })
280
281 // The note goes to the model alone; the person reads the pane, which carries the same finding.
282 on('prompt.submit', async (_, e, next) => {
283 if (!state.owed || state.open.size === 0) return next(e)
284 state.owed = false
285 return next({ ...e, context: [...(e.context ?? []), openNote(openNames(state))] })
286 })
287
288 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
289 const stop = await gate($, state, e.command)
290 if (stop !== undefined) return { deny: stop }
291 const plan = planOf(e.command)
292 if (plan.installs.length === 0 || !(await isEnabled($))) return next(e)
293 if (plan.skipped) {
294 const names = plan.installs.map(p => p.name)
295 await toPerson($, 'skipped', 'installs skipped on request', skippedLines(names), `skipped on request: ${names.join(', ')}`)
296 return next(e)
297 }
298 const now = await $.clock.now()
299 const outcomes = await Promise.all(plan.installs.map(p => checkOne($, p, now)))
300 await closeChecked($, state, checkedOf(plan.installs, outcomes))
301 const reasons = outcomes.flatMap(o => o.reasons)
302 if (reasons.length > 0) return { deny: denyText(reasons.map(textOf)) }
303 const failures = outcomes.map(o => o.failure).filter((f): f is Failure => f !== undefined)
304 const r = await next(e)
305 if (failures.length === 0) return r
306 plan.installs.forEach((p, i) => { if (outcomes[i]?.failure !== undefined) state.open.set(openKey(p), p) })
307 // The note goes to the model, the finding to the person: neither reads the other's channel.
308 const texts = failures.map(failureText)
309 await toPerson($, 'unchecked', 'packages the install did not check', failureLines(failures), uncheckedLog(texts))
310 return withNote(r, uncheckedNote(texts))
311 })
312}
313hooks/parse.ts 141 lines1/** The packages a shell command installs, read without a shell. */
2
3export type Ecosystem = 'npm' | 'PyPI' | 'Go' | 'crates.io' | 'Packagist'
4
5/** One package to install: its name as the registry knows it, and the version asked for when one is. */
6export type Install = { ecosystem: Ecosystem; name: string; version?: string; exact: boolean }
7
8/** What a command installs; `skipped` when it carries the DEP_SENTINEL_SKIP=1 prefix. */
9export type Plan = { skipped: boolean; installs: Install[] }
10
11/** A redirection (`2>&1`, `>log`, `&>all`, `<in`); a bare operator (`>`, `2>`) takes the next word as its target. */
12const REDIRECT = /^(?:\d*|&)[<>]/
13const BARE_REDIRECT = /^(?:\d*|&)[<>]+$/
14
15/**
16 * The shell words of a command, quotes removed; a separator (`&&`, `;`, `|`, a newline) is its own word.
17 * A redirection stays one word (`2>&1`), so `commands` can drop it with its target.
18 */
19export function words(command: string): string[] {
20 const out: string[] = []
21 const re = /"([^"]*)"|'([^']*)'|(&&|\|\||[;|\n])|((?:\d*|&)[<>]+&?[^\s"';|&<>]*)|([^\s"';|&]+)/g
22 for (const m of command.matchAll(re)) out.push(m[1] ?? m[2] ?? m[3] ?? m[4] ?? m[5] ?? '')
23 return out
24}
25
26const SEPARATOR = new Set(['&&', '||', ';', '|', '\n'])
27
28/** The simple commands of a command line, each without its leading `VAR=value` words and its redirections. */
29export function commands(command: string): { env: string[]; argv: string[] }[] {
30 const out: { env: string[]; argv: string[] }[] = [{ env: [], argv: [] }]
31 const all = words(command)
32 for (let i = 0; i < all.length; i++) {
33 const w = all[i] ?? ''
34 const cur = out[out.length - 1]
35 if (cur === undefined) break
36 if (REDIRECT.test(w)) i += BARE_REDIRECT.test(w) ? 1 : 0
37 else if (SEPARATOR.has(w)) out.push({ env: [], argv: [] })
38 else if (cur.argv.length === 0 && /^[A-Za-z_]\w*=/.test(w)) cur.env.push(w)
39 else cur.argv.push(w)
40 }
41 return out.filter(c => c.argv.length > 0)
42}
43
44const EXACT = /^v?\d+\.\d+\.\d+([-+][\w.]+)?$/
45
46/** Options whose next word is their value, so that word is not read as a package. */
47const VALUE_FLAGS = new Set(['-r', '--requirement', '-c', '--constraint', '-e', '--editable', '-i', '--index-url', '--extra-index-url', '--registry', '--prefix', '-C', '--dir', '--filter', '-w', '--workspace', '--features', '-F', '--package', '-p', '--target', '--python', '--group', '-G', '--optional'])
48
49/** The words after `from` that are packages: options and the values they take are left out. */
50function operands(argv: readonly string[], from: number): string[] {
51 const out: string[] = []
52 for (let i = from; i < argv.length; i++) {
53 const w = argv[i] ?? ''
54 if (VALUE_FLAGS.has(w)) i++
55 else if (!w.startsWith('-')) out.push(w)
56 }
57 return out
58}
59
60/** A requirement file or an editable install is not checked, so a command with one is read as having none. */
61const hasFileInstall = (argv: readonly string[]): boolean => argv.some(w => ['-r', '--requirement', '-e', '--editable'].includes(w))
62
63const isLocal = (spec: string): boolean => /^(\.|\/|~|file:|git[+:]|https?:|link:|workspace:|npm:)/.test(spec) || /\.(tgz|tar\.gz|whl|zip)$/.test(spec)
64
65export function npmSpec(spec: string): Install | undefined {
66 if (isLocal(spec)) return undefined
67 const m = /^((?:@[\w.-]+\/)?[\w.-]+)(?:@(.+))?$/.exec(spec)
68 if (m === null) return undefined
69 const version = m[2]?.replace(/^=/, '')
70 return { ecosystem: 'npm', name: (m[1] ?? '').toLowerCase(), ...(version === undefined ? {} : { version }), exact: version !== undefined && EXACT.test(version) }
71}
72
73/** A PyPI name in its normalized form (PEP 503). */
74export const pypiName = (name: string): string => name.toLowerCase().replace(/[-_.]+/g, '-')
75
76export function pySpec(spec: string): Install | undefined {
77 if (isLocal(spec) || spec.includes('/')) return undefined
78 const m = /^([A-Za-z0-9][\w.-]*)(?:\[[^\]]*\])?\s*(==|===|>=|<=|~=|!=|>|<)?\s*([^;,\s]*)/.exec(spec)
79 if (m === null) return undefined
80 const exact = (m[2] === '==' || m[2] === '===') && /^\d+(\.\d+)*$/.test(m[3] ?? '')
81 return { ecosystem: 'PyPI', name: pypiName(m[1] ?? ''), ...(exact ? { version: m[3] } : {}), exact }
82}
83
84export function goSpec(spec: string): Install | undefined {
85 const [path = '', version] = spec.split('@')
86 if (!/^[a-z0-9.-]+\.[a-z]{2,}\//i.test(path) || path.includes('...')) return undefined
87 const exact = version !== undefined && EXACT.test(version)
88 return { ecosystem: 'Go', name: path, ...(exact ? { version } : {}), exact }
89}
90
91export function crateSpec(spec: string): Install | undefined {
92 const m = /^([A-Za-z0-9_-]+)(?:@(=?)(.+))?$/.exec(spec)
93 if (m === null) return undefined
94 const exact = m[2] === '=' && EXACT.test(m[3] ?? '')
95 return { ecosystem: 'crates.io', name: (m[1] ?? '').toLowerCase(), ...(m[3] === undefined ? {} : { version: m[3] }), exact }
96}
97
98export function composerSpec(spec: string, next?: string): Install | undefined {
99 const [name = '', inline] = spec.split(':')
100 if (!/^[\w.-]+\/[\w.-]+$/.test(name)) return undefined
101 const version = inline ?? (next !== undefined && /^[v\d^~*<>=]/.test(next) ? next : undefined)
102 return { ecosystem: 'Packagist', name: name.toLowerCase(), ...(version === undefined ? {} : { version }), exact: version !== undefined && EXACT.test(version) }
103}
104
105type Reader = { at: (argv: readonly string[]) => number | undefined; spec: (spec: string, next?: string) => Install | undefined }
106
107const after = (argv: readonly string[], lead: readonly string[][], verbs: readonly string[]): number | undefined => {
108 const hit = lead.find(l => l.every((w, i) => argv[i] === w))
109 if (hit === undefined) return undefined
110 return verbs.includes(argv[hit.length] ?? '') ? hit.length + 1 : undefined
111}
112
113/** Where each installer's packages start, and how one of its package words reads. */
114const READERS: Reader[] = [
115 { at: a => after(a, [['npm'], ['pnpm'], ['bun']], ['i', 'install', 'add', 'in']) ?? after(a, [['yarn']], ['add']), spec: npmSpec },
116 { at: a => after(a, [['pip'], ['pip3'], ['python', '-m', 'pip'], ['python3', '-m', 'pip'], ['uv', 'pip']], ['install']) ?? after(a, [['uv'], ['poetry']], ['add']), spec: pySpec },
117 { at: a => after(a, [['go']], ['get', 'install']), spec: goSpec },
118 { at: a => after(a, [['cargo']], ['add']), spec: crateSpec },
119 { at: a => after(a, [['composer']], ['require']), spec: composerSpec },
120]
121
122function installsOf(argv: readonly string[]): Install[] {
123 if (hasFileInstall(argv)) return []
124 for (const reader of READERS) {
125 const from = reader.at(argv)
126 if (from === undefined) continue
127 const specs = operands(argv, from)
128 return specs.map((s, i) => reader.spec(s, specs[i + 1])).filter((p): p is Install => p !== undefined)
129 }
130 return []
131}
132
133/** The packages a command installs, at most `limit`, and whether it carries the skip prefix. */
134export function planOf(command: string, limit = 10): Plan {
135 const cmds = commands(command)
136 const skipped = cmds.some(c => c.env.includes('DEP_SENTINEL_SKIP=1'))
137 const installs = cmds.flatMap(c => installsOf(c.argv))
138 const unique = installs.filter((p, i) => installs.findIndex(q => q.ecosystem === p.ecosystem && q.name === p.name) === i)
139 return { skipped, installs: unique.slice(0, limit) }
140}
141hooks/registry.ts 152 lines1/** What each registry and OSV.dev answer about a package, read into one shape. */
2
3import type { Ecosystem, Install } from './parse.ts'
4
5/** A package as its registry lists it: the latest stable version, every published version, and when it was first published. */
6export type Info = { latest: string; versions: string[]; created?: number }
7
8type Json = Record<string, unknown>
9
10const obj = (v: unknown): Json => (v !== null && typeof v === 'object' ? (v as Json) : {})
11const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
12const time = (v: unknown): number | undefined => {
13 const t = typeof v === 'string' ? Date.parse(v) : NaN
14 return Number.isNaN(t) ? undefined : t
15}
16const earliest = (times: readonly (number | undefined)[]): number | undefined => {
17 const known = times.filter((t): t is number => t !== undefined)
18 return known.length === 0 ? undefined : Math.min(...known)
19}
20
21/** A release without a pre-release part: `1.2.3`, `v1.2.3`, `2.0`, `1.0.post1`. */
22export const isStable = (v: string): boolean => /^v?\d+(\.\d+)*(\.post\d+)?$/.test(v)
23
24/** The numeric parts of a version, `v` and any pre-release part dropped. */
25const parts = (v: string): number[] => (/^v?(\d+(?:\.\d+)*)/.exec(v)?.[1] ?? '0').split('.').map(Number)
26
27/** Compares two versions by their numeric parts; a stable release sorts after its pre-releases. */
28export function compareVersions(a: string, b: string): number {
29 const pa = parts(a)
30 const pb = parts(b)
31 for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
32 const d = (pa[i] ?? 0) - (pb[i] ?? 0)
33 if (d !== 0) return d
34 }
35 return Number(isStable(a)) - Number(isStable(b))
36}
37
38const newest = (versions: readonly string[]): string | undefined => [...versions].sort(compareVersions).at(-1)
39
40/** The newest stable release with the same major part as `version`, when there is one. */
41export function latestInMajor(versions: readonly string[], version: string): string | undefined {
42 const major = parts(version)[0]
43 return newest(versions.filter(v => isStable(v) && parts(v)[0] === major))
44}
45
46/** The registry URL of a package; a Go module path is case-encoded as the proxy expects. */
47export function registryUrl(p: Pick<Install, 'ecosystem' | 'name'>): string {
48 const urls: Record<Ecosystem, string> = {
49 npm: `https://registry.npmjs.org/${p.name.replace('/', '%2F')}`,
50 PyPI: `https://pypi.org/pypi/${p.name}/json`,
51 Go: `https://proxy.golang.org/${p.name.replace(/[A-Z]/g, c => `!${c.toLowerCase()}`)}/@latest`,
52 'crates.io': `https://crates.io/api/v1/crates/${p.name}`,
53 Packagist: `https://repo.packagist.org/p2/${p.name}.json`,
54 }
55 return urls[p.ecosystem]
56}
57
58/**
59 * The most of a body `$.http.fetch` hands back: it cuts the rest and still answers 200 with no sign of
60 * the cut (measured on 2.1.282). npm documents pass it (webpack 5 MB, vite 39 MB).
61 */
62export const FETCH_BODY_LIMIT = 4 * 1024 * 1024
63
64/** The UTF-8 length of a text: the unit the fetch limit counts in. */
65function utf8Length(text: string): number {
66 let n = 0
67 for (const ch of text) {
68 const c = ch.codePointAt(0) ?? 0
69 n += c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4
70 }
71 return n
72}
73
74/** Whether a body reached the fetch limit, and so may have been cut. */
75export const reachedFetchLimit = (text: string): boolean => text.length >= FETCH_BODY_LIMIT / 4 && utf8Length(text) >= FETCH_BODY_LIMIT
76
77/**
78 * `npm view <name> time.created dist-tags.latest versions --json`: npm prints an array of one object; an
79 * object is read the same way.
80 */
81export function npmViewInfo(json: unknown): Info | undefined {
82 const row = obj(Array.isArray(json) ? json[0] : json)
83 const latest = str(row['dist-tags.latest'])
84 const versions = row['versions']
85 if (latest === undefined || !Array.isArray(versions)) return undefined
86 const created = time(row['time.created'])
87 return { latest, versions: versions.filter((v): v is string => typeof v === 'string'), ...(created === undefined ? {} : { created }) }
88}
89
90export function npmInfo(json: unknown): Info | undefined {
91 const doc = obj(json)
92 const latest = str(obj(doc['dist-tags'])['latest'])
93 if (latest === undefined) return undefined
94 const created = time(obj(doc['time'])['created'])
95 return { latest, versions: Object.keys(obj(doc['versions'])), ...(created === undefined ? {} : { created }) }
96}
97
98export function pypiInfo(json: unknown): Info | undefined {
99 const doc = obj(json)
100 const latest = str(obj(doc['info'])['version'])
101 if (latest === undefined) return undefined
102 const releases = Object.entries(obj(doc['releases'])).filter(([, files]) => Array.isArray(files) && files.length > 0)
103 const created = earliest(releases.flatMap(([, files]) => (files as unknown[]).map(f => time(obj(f)['upload_time_iso_8601']))))
104 return { latest, versions: releases.map(([v]) => v), ...(created === undefined ? {} : { created }) }
105}
106
107/** A Go module from the proxy's `@latest`, `@v/list` and the `.info` of its oldest version. */
108export function goInfo(latestJson: unknown, list: string, oldestJson?: unknown): Info | undefined {
109 const latest = str(obj(latestJson)['Version'])
110 if (latest === undefined) return undefined
111 const versions = list.split('\n').map(v => v.trim()).filter(v => v !== '')
112 const created = time(obj(oldestJson ?? latestJson)['Time'])
113 return { latest, versions: versions.length === 0 ? [latest] : versions, ...(created === undefined ? {} : { created }) }
114}
115
116/** The oldest version in a Go proxy `@v/list`, whose `.info` gives the first publish time. */
117export const goOldest = (list: string): string | undefined => [...list.split('\n').map(v => v.trim()).filter(v => v !== '')].sort(compareVersions)[0]
118
119export function cratesInfo(json: unknown): Info | undefined {
120 const doc = obj(json)
121 const crate = obj(doc['crate'])
122 const latest = str(crate['max_stable_version']) ?? str(crate['max_version'])
123 if (latest === undefined) return undefined
124 const versions = (Array.isArray(doc['versions']) ? doc['versions'] : []).map(obj).filter(v => v['yanked'] !== true).map(v => str(v['num']) ?? '')
125 const created = time(crate['created_at'])
126 return { latest, versions: versions.filter(v => v !== ''), ...(created === undefined ? {} : { created }) }
127}
128
129export function packagistInfo(json: unknown, name: string): Info | undefined {
130 const list = obj(obj(json)['packages'])[name]
131 const releases = (Array.isArray(list) ? list : []).map(obj).map(r => ({ version: (str(r['version']) ?? '').replace(/^v/, ''), time: time(r['time']) }))
132 const versions = releases.map(r => r.version).filter(v => v !== '')
133 const latest = newest(versions.filter(isStable)) ?? newest(versions)
134 if (latest === undefined) return undefined
135 const created = earliest(releases.map(r => r.time))
136 return { latest, versions, ...(created === undefined ? {} : { created }) }
137}
138
139/** The known vulnerabilities OSV.dev lists for one version, and the versions that fix them. */
140export type Vulns = { ids: string[]; fixed: string[] }
141
142export function osvVulns(json: unknown): Vulns {
143 const vulns = (Array.isArray(obj(json)['vulns']) ? (obj(json)['vulns'] as unknown[]) : []).map(obj)
144 const ids = vulns.map(v => str(v['id']) ?? '?')
145 const events = vulns.flatMap(v => (Array.isArray(v['affected']) ? v['affected'] : []).map(obj))
146 .flatMap(a => (Array.isArray(a['ranges']) ? a['ranges'] : []).map(obj))
147 .flatMap(r => (Array.isArray(r['events']) ? r['events'] : []).map(obj))
148 // A GIT range fixes at a commit hash, which is no version to install.
149 const fixed = [...new Set(events.map(e => str(e['fixed'])).filter((f): f is string => f !== undefined && /^v?\d+\.\d+/.test(f)))].sort(compareVersions)
150 return { ids, fixed }
151}
152hooks/rules.ts 181 lines1/** Why an install is stopped, from what the registry and OSV.dev said, and the texts the model reads. */
2
3import type { Install } from './parse.ts'
4import { lookAlike } from './popular.ts'
5import { compareVersions, latestInMajor, type Info, type Vulns } from './registry.ts'
6
7const DAY_MS = 24 * 60 * 60 * 1000
8
9/** A package first published less than this long ago is refused. */
10export const NEW_PACKAGE_MS = 7 * DAY_MS
11
12/** A look-alike name is let through when the package is this old and has this many versions: squats are young or thin. */
13const ESTABLISHED_MS = 365 * DAY_MS
14const ESTABLISHED_VERSIONS = 10
15
16/** How the sidebar colours a line or a part of one. */
17type Tone = 'ok' | 'warn' | 'error' | 'dim'
18export type Part = { text: string; kind?: Tone }
19/** A line; `parts` colour pieces of it, and `text` holds the whole line for a sidebar that draws no parts. */
20export type Line = { text: string; kind?: Tone; parts?: Part[] }
21
22const part = (text: string, kind: Tone | undefined): Part => (kind === undefined ? { text } : { text, kind })
23
24/** The text a reason's parts make, as the deny text and the transcript line read it. */
25export const textOf = (parts: readonly Part[]): string => parts.map(p => p.text).join('')
26
27/** A line made of parts, its `text` their texts joined. */
28const partsLine = (parts: Part[]): Line => ({ text: textOf(parts), parts })
29
30/** One reason to stop an install, in parts, so the sidebar colours its versions and its finding. */
31export type Reason = Part[]
32
33const label = (p: Install): string => `${p.name}${p.version === undefined ? '' : `@${p.version}`} (${p.ecosystem})`
34
35/** The package is on no registry. */
36export const missingReason = (p: Install): Reason => [part(`${label(p)} `, undefined), part('does not exist on the registry', 'error'), part('; check the name', undefined)]
37
38const isEstablished = (info: Info, now: number): boolean =>
39 info.created !== undefined && now - info.created >= ESTABLISHED_MS && info.versions.length >= ESTABLISHED_VERSIONS
40
41function lookAlikeReason(p: Install, info: Info, now: number): Reason | undefined {
42 const target = lookAlike(p.ecosystem, p.name)
43 if (target === undefined || isEstablished(info, now)) return undefined
44 return [part(p.name, 'error'), part(' looks like the popular package ', undefined), part(target, 'ok'), part(', and it is not that package; check the name', undefined)]
45}
46
47function newReason(p: Install, info: Info, now: number): Reason | undefined {
48 if (info.created === undefined || now - info.created >= NEW_PACKAGE_MS) return undefined
49 const days = Math.max(0, Math.floor((now - info.created) / DAY_MS))
50 return [part(`${p.name} was first published `, undefined), part(days === 0 ? 'today' : `${days} day(s) ago`, 'warn'), part(', less than 7 days ago', undefined)]
51}
52
53/** The pinned version red, the latest and the latest in its major version green. */
54function oldReason(p: Install, info: Info): Reason | undefined {
55 if (!p.exact || p.version === undefined || compareVersions(p.version, info.latest) >= 0) return undefined
56 const inMajor = latestInMajor(info.versions, p.version)
57 const same = inMajor !== undefined && inMajor !== info.latest && compareVersions(inMajor, p.version) > 0 ? [part(', the latest in its major version is ', undefined), part(inMajor, 'ok')] : []
58 return [
59 part(`${p.name}@`, undefined),
60 part(p.version, 'error'),
61 part(` (${p.ecosystem}) is not the latest version: the latest is `, undefined),
62 part(info.latest, 'ok'),
63 ...same,
64 ]
65}
66
67/** The version that would be installed: the pinned one, else the latest. */
68export const targetVersion = (p: Install, info: Info): string => (p.exact && p.version !== undefined ? p.version : info.latest)
69
70/** The known vulnerabilities red, a fixed version green, and no fixed version yellow. */
71export function vulnParts(p: Install, version: string, v: Vulns): Reason | undefined {
72 if (v.ids.length === 0) return undefined
73 const ids = v.ids.slice(0, 5).join(', ') + (v.ids.length > 5 ? ` and ${v.ids.length - 5} more` : '')
74 const fixed = v.fixed.filter(f => compareVersions(f, version) > 0)
75 const fix = fixed.length === 0 ? part('no fixed version is listed', 'warn') : part(`fixed in ${fixed.slice(-3).join(', ')}`, 'ok')
76 return [part(`${p.name}@${version} `, undefined), part(`has ${v.ids.length} known vulnerability(ies)`, 'error'), part(` on OSV.dev: ${ids}; `, undefined), fix]
77}
78
79export function vulnReason(p: Install, version: string, v: Vulns): string | undefined {
80 const parts = vulnParts(p, version, v)
81 return parts === undefined ? undefined : textOf(parts)
82}
83
84/** The reasons the registry answer alone gives to stop the install, in parts. */
85export function registryReasonParts(p: Install, info: Info, now: number): Reason[] {
86 return [lookAlikeReason(p, info, now), newReason(p, info, now), oldReason(p, info)].filter((r): r is Reason => r !== undefined)
87}
88
89/** The reasons the registry answer alone gives to stop the install. */
90export function registryReasons(p: Install, info: Info, now: number): string[] {
91 return registryReasonParts(p, info, now).map(textOf)
92}
93
94/** The `deny` text the model reads. */
95export function denyText(reasons: readonly string[]): string {
96 return `dep-sentinel stopped this install: ${reasons.join(' · ')}. Install the latest version or the right name instead. If the user needs exactly this, tell them why, then run the same command again with the DEP_SENTINEL_SKIP=1 prefix.`
97}
98
99/** The global flags git takes before the subcommand, so `git -c user.name=x commit` is still a commit. */
100const GIT_FLAG = String.raw`(?:\s+-[cC]\s+\S+|\s+--(?:git-dir|work-tree|namespace)=\S+|\s+--(?:no-pager|no-replace-objects|bare|literal-pathspecs|paginate))`
101
102/** A `git commit`, `git push` or `git merge` the gate stops while a package stayed unchecked. */
103const GUARDED = new RegExp(String.raw`(^|[\s;&|(])git(?:${GIT_FLAG})*\s+(commit|push|merge)\b`)
104const ASKING = /\s(--dry-run|--help|-h)(\s|$)/
105
106export function isGuarded(command: string): boolean {
107 return GUARDED.test(command) && !ASKING.test(command)
108}
109
110/** The mode of the mod: a note only after an unchecked install, or a note and a gate on git commit, push and merge. */
111export type Mode = 'note' | 'deny'
112
113/** The mode a `/dep-sentinel mode <word>` argument names, or undefined when it is not one. */
114export function modeOf(arg: string): Mode | undefined {
115 return arg === 'note' || arg === 'deny' ? arg : undefined
116}
117
118/** The gate text both the model and the person read: which packages stayed unchecked, and the one way out. */
119export function gateText(names: readonly string[]): string {
120 return `stopped: ${names.length} package(s) were installed unchecked: ${names.join(' · ')}. Run the install again so the registry and OSV.dev answer, then run the command again; there is no way around this gate.`
121}
122
123/** The note the model reads after an install whose check could not finish. */
124export function uncheckedNote(failures: readonly string[]): string {
125 return `dep-sentinel could not check every package, so the install ran unchecked for: ${failures.join(' · ')}. Tell the user.`
126}
127
128/**
129 * The note the model reads at the next prompt while a finding stands, so a finding it did not close
130 * reaches it again instead of standing in the pane alone. The person reads the pane and needs no line.
131 */
132export function openNote(names: readonly string[]): string {
133 return `dep-sentinel: ${names.length} package(s) are still installed unchecked: ${names.join(' · ')}. Run the install again so the registry and OSV.dev answer, or take the package out.`
134}
135
136/** The transcript line: the unchecked packages alone, without the instruction the model reads. The engine adds the mod name. */
137export function uncheckedLog(failures: readonly string[]): string {
138 return `the install ran unchecked for: ${failures.join(' · ')}`
139}
140
141/** A package the check could not finish for, and why. */
142export type Failure = { name: string; why: string }
143
144/** A failure as the texts name it: `lodash (api.osv.dev answered HTTP 503)`. */
145export const failureText = (f: Failure): string => `${f.name} (${f.why})`
146
147/** One sidebar line per unchecked package, the name red and the reason faint, so the section reads as a list. */
148export function failureLines(failures: readonly Failure[]): Line[] {
149 return failures.map(f => partsLine([part(f.name, 'error'), part(` (${f.why})`, 'dim')]))
150}
151
152/** One sidebar line per package installed unchecked on request: yellow, because the person asked for it. */
153export function skippedLines(names: readonly string[]): Line[] {
154 return names.map(text => ({ text, kind: 'warn' }))
155}
156
157/** One sidebar line per reason, each coloured by its parts. */
158export function reasonLines(reasons: readonly Reason[]): Line[] {
159 return reasons.map(partsLine)
160}
161
162/** The transcript line of an unchecked finding a later install closed. */
163export function checkedLog(names: readonly string[]): string {
164 return `a later install checked the packages that stayed unchecked: ${names.join(' · ')}`
165}
166
167/** The transcript line of an unchecked finding the gate's own check closed. */
168export function gateCheckedLog(names: readonly string[]): string {
169 return `the registry and OSV.dev answered for the packages that stayed unchecked: ${names.join(' · ')}`
170}
171
172/** The transcript line of a package the gate's check answered for, with something to say about it. */
173export function lateReasonLog(reasons: readonly string[]): string {
174 return `the check that was owed says: ${reasons.join(' · ')}`
175}
176
177/** One sidebar line per package a later install checked. */
178export function doneLines(names: readonly string[]): Line[] {
179 return names.map(text => ({ text, kind: 'ok' }))
180}
181hooks/popular.ts 137 lines1/** Widely used package names per ecosystem, and the one a new name looks like. */
2
3import type { Ecosystem } from './parse.ts'
4
5const NPM = `
6react react-dom next vue nuxt svelte angular @angular/core @angular/cli preact solid-js lodash lodash-es underscore ramda
7express koa fastify hapi @hapi/hapi nestjs @nestjs/core axios node-fetch got request superagent ky undici cross-fetch
8typescript ts-node tsx esbuild vite webpack rollup parcel babel-core @babel/core @babel/preset-env swc @swc/core terser
9eslint prettier jest mocha chai vitest jasmine karma cypress playwright puppeteer sinon supertest nock @testing-library/react
10moment dayjs date-fns luxon chalk commander yargs inquirer ora debug dotenv uuid nanoid classnames clsx
11mongoose mongodb pg mysql mysql2 sqlite3 redis ioredis sequelize typeorm prisma @prisma/client knex drizzle-orm
12socket.io socket.io-client ws graphql apollo-server @apollo/client jsonwebtoken bcrypt bcryptjs passport cors helmet
13body-parser cookie-parser multer morgan winston pino bunyan nodemon pm2 concurrently cross-env rimraf mkdirp glob
14fs-extra chokidar minimist semver qs ms async bluebird rxjs immer zustand redux react-redux @reduxjs/toolkit mobx
15react-router react-router-dom @tanstack/react-query swr formik react-hook-form yup zod joi ajv
16tailwindcss postcss autoprefixer sass less styled-components @emotion/react @mui/material antd bootstrap jquery
17three d3 chart.js echarts leaflet marked markdown-it highlight.js prismjs cheerio jsdom sharp jimp canvas
18electron electron-builder react-native expo @expo/vector-icons aws-sdk @aws-sdk/client-s3 firebase firebase-admin
19stripe openai @anthropic-ai/sdk langchain discord.js telegraf twilio nodemailer handlebars ejs pug mustache
20core-js regenerator-runtime tslib @types/node @types/react @types/express husky lint-staged
21`
22
23const PYPI = `
24requests urllib3 certifi idna charset-normalizer numpy pandas scipy matplotlib seaborn scikit-learn
25tensorflow keras torch torchvision torchaudio transformers datasets tokenizers accelerate diffusers
26flask django fastapi starlette uvicorn gunicorn werkzeug jinja2 markupsafe itsdangerous click
27sqlalchemy alembic psycopg2 psycopg2-binary psycopg pymysql mysqlclient redis celery kombu pymongo
28boto3 botocore s3transfer awscli google-cloud-storage google-api-python-client azure-storage-blob
29pydantic pydantic-core attrs dataclasses-json marshmallow pyyaml toml tomli python-dotenv
30pytest pytest-cov pytest-mock pytest-asyncio tox nox coverage mock hypothesis
31black flake8 pylint mypy isort ruff autopep8 pre-commit bandit
32setuptools wheel pip virtualenv pipenv poetry twine build packaging six
33beautifulsoup4 lxml html5lib scrapy selenium playwright httpx aiohttp httplib2 websockets
34pillow opencv-python imageio scikit-image
35cryptography pyopenssl paramiko bcrypt passlib pyjwt oauthlib requests-oauthlib
36python-dateutil pytz tzdata arrow pendulum
37tqdm rich colorama termcolor tabulate prettytable loguru
38openai anthropic langchain langchain-core tiktoken sentence-transformers huggingface-hub
39jupyter notebook ipython ipykernel jupyterlab nbformat
40networkx sympy statsmodels xgboost lightgbm catboost plotly bokeh dash streamlit gradio
41grpcio protobuf msgpack orjson ujson simplejson
42typing-extensions filelock fsspec pyarrow polars dask
43docker kubernetes ansible fabric invoke
44sentry-sdk prometheus-client psutil gevent eventlet greenlet
45`
46
47const GO = `
48github.com/gin-gonic/gin github.com/labstack/echo/v4 github.com/gofiber/fiber/v2 github.com/gorilla/mux
49github.com/go-chi/chi/v5 github.com/julienschmidt/httprouter github.com/spf13/cobra github.com/spf13/viper
50github.com/spf13/pflag github.com/urfave/cli/v2 github.com/sirupsen/logrus go.uber.org/zap github.com/rs/zerolog
51github.com/stretchr/testify github.com/onsi/ginkgo/v2 github.com/onsi/gomega github.com/golang/mock
52go.uber.org/mock github.com/google/uuid github.com/gofrs/uuid github.com/pkg/errors
53github.com/go-sql-driver/mysql github.com/lib/pq github.com/jackc/pgx/v5 github.com/mattn/go-sqlite3
54gorm.io/gorm gorm.io/driver/postgres github.com/jmoiron/sqlx github.com/redis/go-redis/v9
55go.mongodb.org/mongo-driver github.com/golang-jwt/jwt/v5 golang.org/x/crypto golang.org/x/net
56golang.org/x/sync golang.org/x/sys golang.org/x/text golang.org/x/tools golang.org/x/oauth2
57google.golang.org/grpc google.golang.org/protobuf github.com/golang/protobuf github.com/grpc-ecosystem/grpc-gateway/v2
58github.com/prometheus/client_golang github.com/aws/aws-sdk-go-v2 github.com/aws/aws-sdk-go
59cloud.google.com/go/storage github.com/gorilla/websocket github.com/joho/godotenv
60gopkg.in/yaml.v3 gopkg.in/yaml.v2 github.com/BurntSushi/toml github.com/mitchellh/mapstructure
61github.com/go-playground/validator/v10 github.com/google/go-cmp github.com/davecgh/go-spew
62github.com/fsnotify/fsnotify github.com/hashicorp/go-multierror github.com/cenkalti/backoff/v4
63github.com/charmbracelet/bubbletea github.com/charmbracelet/lipgloss github.com/fatih/color
64github.com/nats-io/nats.go github.com/segmentio/kafka-go github.com/IBM/sarama github.com/robfig/cron/v3
65k8s.io/client-go k8s.io/apimachinery sigs.k8s.io/controller-runtime github.com/docker/docker
66github.com/tidwall/gjson github.com/json-iterator/go github.com/valyala/fasthttp
67`
68
69const CRATES = `
70serde serde_json serde_yaml serde_derive toml tokio tokio-util futures async-trait anyhow thiserror
71clap structopt log env_logger tracing tracing-subscriber rand regex lazy_static once_cell
72reqwest hyper axum actix-web warp rocket tower tower-http http bytes url
73chrono time uuid itertools rayon crossbeam parking_lot dashmap indexmap hashbrown smallvec
74sqlx diesel rusqlite redis mongodb sea-orm postgres tokio-postgres
75base64 hex sha2 md5 ring rustls openssl hmac aes bcrypt argon2 jsonwebtoken
76num num-traits bitflags byteorder memchr libc nix winapi windows-sys
77syn quote proc-macro2 derive_more strum strum_macros paste cfg-if
78image flate2 zip tar walkdir glob tempfile dirs directories notify
79criterion proptest mockall pretty_assertions insta assert_cmd predicates
80tonic prost prost-types protobuf tungstenite tokio-tungstenite
81colored console indicatif dialoguer crossterm ratatui termcolor atty
82wasm-bindgen js-sys web-sys serde-wasm-bindgen getrandom
83nom pest csv semver humantime bincode rmp-serde ciborium
84`
85
86const PACKAGIST = `
87laravel/framework laravel/laravel laravel/sanctum laravel/tinker laravel/sail laravel/horizon laravel/passport
88symfony/console symfony/http-foundation symfony/http-kernel symfony/routing symfony/yaml symfony/process
89symfony/finder symfony/event-dispatcher symfony/dependency-injection symfony/var-dumper symfony/mailer
90symfony/framework-bundle symfony/twig-bundle symfony/validator symfony/serializer symfony/cache symfony/dotenv
91guzzlehttp/guzzle guzzlehttp/psr7 guzzlehttp/promises psr/log psr/container psr/http-message psr/cache
92monolog/monolog doctrine/orm doctrine/dbal doctrine/annotations doctrine/inflector doctrine/collections
93phpunit/phpunit mockery/mockery fakerphp/faker phpstan/phpstan vimeo/psalm squizlabs/php_codesniffer
94friendsofphp/php-cs-fixer nesbot/carbon ramsey/uuid vlucas/phpdotenv twig/twig league/flysystem
95league/oauth2-client league/csv intervention/image phpmailer/phpmailer swiftmailer/swiftmailer
96firebase/php-jwt aws/aws-sdk-php google/apiclient stripe/stripe-php predis/predis
97nikic/php-parser composer/composer spatie/laravel-permission barryvdh/laravel-debugbar
98filp/whoops nunomaduro/collision livewire/livewire inertiajs/inertia-laravel
99slim/slim cakephp/cakephp yiisoft/yii2 dompdf/dompdf phpoffice/phpspreadsheet
100`
101
102const list = (text: string): string[] => text.split(/\s+/).filter(w => w !== '')
103
104export const POPULAR: Record<Ecosystem, ReadonlySet<string>> = {
105 npm: new Set(list(NPM)),
106 PyPI: new Set(list(PYPI)),
107 Go: new Set(list(GO)),
108 'crates.io': new Set(list(CRATES)),
109 Packagist: new Set(list(PACKAGIST)),
110}
111
112/** Edits between two names, a swap of two neighbours counted as one (optimal string alignment). */
113export function distance(a: string, b: string): number {
114 const d: number[][] = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)))
115 const at = (i: number, j: number): number => d[i]?.[j] ?? 0
116 for (let i = 1; i <= a.length; i++) {
117 for (let j = 1; j <= b.length; j++) {
118 const best = Math.min(at(i - 1, j) + 1, at(i, j - 1) + 1, at(i - 1, j - 1) + (a[i - 1] === b[j - 1] ? 0 : 1))
119 const swapped = i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]
120 ;(d[i] as number[])[j] = swapped ? Math.min(best, at(i - 2, j - 2) + 1) : best
121 }
122 }
123 return at(a.length, b.length)
124}
125
126/** How many edits make a look-alike: none under 4 characters, one under 7, two from 7. */
127const allowed = (length: number): number => (length < 4 ? 0 : length < 7 ? 1 : 2)
128
129/** The popular name `name` looks like, when it is not popular itself. */
130export function lookAlike(ecosystem: Ecosystem, name: string): string | undefined {
131 const popular = POPULAR[ecosystem]
132 if (popular.has(name)) return undefined
133 const limit = allowed(name.length)
134 if (limit === 0) return undefined
135 return [...popular].find(p => Math.abs(p.length - name.length) <= limit && distance(p, name) <= limit)
136}
137