SLOPSHOPPER

dep-sentinel

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…

newguardcommandpromptprocessnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dep-sentinel
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /dep-sentinel ⎿ dep-sentinel: on · mode note · no package is open; npm, PyPI, Go, crates.io and Packagist installs are checked ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

dep-sentinel

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.

What it does

  1. It reads the Bash command without a shell and finds the packages it installs:
  2. npm: npm i|install|add, pnpm add, yarn add, bun add|install;
  3. PyPI: pip install, pip3 install, python -m pip install, uv pip install, uv add, poetry add;
  4. Go: go get, go install;
  5. crates.io: cargo add;
  6. Packagist: 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.

  1. For each package it asks the registry: registry.npmjs.org, pypi.org, proxy.golang.org, crates.io or repo.packagist.org. A Go package path is looked up at its module, the nearest parent path the proxy knows. Claude Code hands a mod at most 4 MiB of an answer and cuts the rest without a sign, and an npm document can be larger (webpack 5 MB, vite 39 MB). For such a package the mod runs 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.
  2. It asks OSV.dev for known vulnerabilities of the version that would be installed: the pinned version, else the latest.
  3. It stops the install when:
  4. no registry knows the package;
  5. the name is one or two edits away from a popular package name (one edit under 7 characters, none under 4), unless the package is over a year old with 10 or more versions;
  6. the package was first published less than 7 days ago; a new version of an older package is not stopped;
  7. an exact pin (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;
  8. OSV.dev lists a known vulnerability for that version; the reason names the ids and the versions that fix them.
  9. The model reads the reasons as the command's error, with the instruction to install the latest version or the right name. When you need exactly that package, the model tells you why and runs the command again with the DEP_SENTINEL_SKIP=1 prefix. The mod logs such a skip.
  10. When a registry or OSV.dev cannot be reached, the install runs, and the model reads which package ran unchecked and why. At the same moment you get one line in the transcript:

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.

  1. With the sidebar open, the unchecked and the skipped packages go into its stream instead, one line per package, and the transcript stays clean. An unchecked package shows its name red and the reason faint. A package skipped on request is yellow, because you asked for it. An entry stays until newer ones push it off the pane. With the sidebar closed, or not installed, the lines land in the transcript as above.
  1. An unchecked finding is never a remembered answer. The check it is owed runs again, so it closes in two ways. A later install of the same package in the same ecosystem checks it (an npm 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.

  1. The same check runs at the end of each main-loop turn, and the packages still open reach the model as one note with its next prompt:

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.

  1. In 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.

Command

/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

Install

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.

After installing

  1. Restart Claude Code.

What it can reach

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.

  1. Reads: the Bash command text
  2. Runs: npm view, only for an npm package whose registry document passes the 4 MiB a fetch reads
  3. Sends: each package name, and its version, to its public registry and to api.osv.dev, at an install and again at a guarded git command and at each turn's end while a finding is open; a note to the model and one line to the transcript when a check failed, and one more note with the next prompt while a finding stands; nothing else leaves the machine
  4. Persists: in $.store, the on/off setting and the mode
  5. Hostile input: a package name comes from the model's command; it reaches a registry only inside a URL path or a JSON body, and a registry answer is read as data

Limits

  • The popular-name list is fixed in 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.
  • A version range (^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 package documents are large (16 MB for typescript, measured), so a check can take a few seconds.
  • An install that a script, an alias or a lockfile runs (npm ci, pip install -r) is not checked.
  • The deny mode has no bypass. When a registry stays unreachable, you turn the gate off with /dep-sentinel mode note.
  • A git command while a finding is open waits for that check, so the first commit after a failed install takes as long as the registry does.
  • The gate reads the command text. A commit through a script or an alias that hides git commit is not stopped.

Development

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

Source 5 files
hooks/register.ts 313 lines
1import 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}
313
hooks/parse.ts 141 lines
1/** 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}
141
hooks/registry.ts 152 lines
1/** 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}
152
hooks/rules.ts 181 lines
1/** 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}
181
hooks/popular.ts 137 lines
1/** 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