@preventive/upstream 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/advisories.d.ts CHANGED
@@ -1,6 +1,3 @@
1
- // Hand-written against advisories.js; a change to either belongs with the
2
- // other.
3
-
4
1
  import type { Client, RepoName } from './github.js'
5
2
  import type { CacheStore } from './npm.js'
6
3
 
package/cargo.d.ts CHANGED
@@ -1,5 +1,3 @@
1
- // Hand-written against cargo.js; a change to either belongs with the other.
2
- //
3
1
  // A name is asserted to be a crate name, a version an exact semver
4
2
  // version, and a checksum 64 lowercase hex digits, before any request or
5
3
  // cache path. The cache is setCacheDir's (npm.js).
package/git.d.ts CHANGED
@@ -1,5 +1,3 @@
1
- // Hand-written against git.js; a change to either belongs with the other.
2
-
3
1
  // The shape getRepo (package.js) answers in, and HEAD's commit. Each
4
2
  // field best-effort: left out where it cannot be read.
5
3
  export interface GitCheckout {
package/github/write.d.ts CHANGED
@@ -1,5 +1,4 @@
1
- // Hand-written against github/write.js; a change to either belongs with
2
- // the other. Arguments are checked as github.d.ts describes.
1
+ // Arguments are checked as github.d.ts describes.
3
2
 
4
3
  import type { Client, RepoName } from '../github.js'
5
4
 
package/github.d.ts CHANGED
@@ -1,5 +1,3 @@
1
- // Hand-written against github.js; a change to either belongs with the other.
2
- //
3
1
  // Every method checks its arguments hard before any request is built: an
4
2
  // options object holding only the keys listed here, a repo that is
5
3
  // `owner/name` by GitHub's rules, branches and tags that git would take,
@@ -7,11 +5,12 @@
7
5
  // component, titles and headlines on one line. A bad one is a rejection
8
6
  // naming the method; nothing is sent. Every URL is built from checked,
9
7
  // encoded segments and has to come back out of URL parsing unchanged.
10
- // Redirects are refused, except the tarballs', so an answer is about the
11
- // repo asked for. A response is read up to a size limit (64 MiB of JSON,
12
- // 128 MiB of a file, 512 MiB of a tarball) and within a timeout (30
13
- // seconds, 5 minutes for a tarball), and what it says goes into an error
14
- // message escaped.
8
+ // Redirects are refused, so an answer is about the repo asked for, except
9
+ // the tarballs' and, where asked, getRepo's for a repo renamed or
10
+ // transferred. A response is read up to a size limit (64 MiB of JSON, 128
11
+ // MiB of a file, 512 MiB of a tarball) and within a timeout (30 seconds, 5
12
+ // minutes for a tarball), and what it says goes into an error message
13
+ // escaped.
15
14
 
16
15
  // `owner/name`.
17
16
  export type RepoName = string
@@ -35,14 +34,62 @@ export interface RepoDirEntry {
35
34
  sha: string
36
35
  }
37
36
 
38
- // A failed request: `status` is the HTTP status GitHub answered with.
37
+ // A lockfile's format, by the tool that writes it, with the names it
38
+ // writes one under and those of the project files it locks as
39
+ // src/lockfiles.js lists them. Those @preventive/lockfile reads are as it
40
+ // names the modules that do, yarn as yarn1.js, which reads Yarn 1's alone:
41
+ // only the text tells a later Yarn's apart.
42
+ export type LockfileFormat =
43
+ | 'pnpm' | 'npm' | 'yarn' | 'bun' | 'deno'
44
+ | 'uv' | 'poetry' | 'pylock' | 'pipenv' | 'pdm' | 'pixi' | 'conda-lock'
45
+ | 'bundler' | 'composer' | 'cargo' | 'soldeer' | 'foundry'
46
+ | 'cocoapods' | 'swiftpm' | 'carthage'
47
+ | 'go' | 'dep' | 'nuget' | 'paket' | 'gradle' | 'pub' | 'mix' | 'rebar' | 'julia'
48
+ | 'stack' | 'cabal' | 'shards' | 'opam' | 'nix' | 'conan' | 'bazel' | 'helm' | 'renv' | 'terraform'
49
+
50
+ // A file by a lockfile's name: `path` from the repo's root, as getRepoFile
51
+ // takes it, and `sha` its blob's id.
52
+ export interface RepoLockfile {
53
+ path: string
54
+ format: LockfileFormat
55
+ sha: string
56
+ }
57
+
58
+ // A file by a project file's name, as RepoLockfile is, and the formats of
59
+ // the lockfiles that may lock its project, in src/lockfiles.js' order:
60
+ // whether one does, and where, is the tool's to say, a workspace's at its
61
+ // root.
62
+ export interface RepoProjectFile {
63
+ path: string
64
+ formats: LockfileFormat[]
65
+ sha: string
66
+ }
67
+
68
+ // A failed request: `status` is the HTTP status GitHub answered with, and
69
+ // `headers` its response's (a redirect's `location` among them), on every
70
+ // call but a GraphQL one (github/write.js).
39
71
  export class HttpError extends Error {
40
72
  name: 'HttpError'
41
73
  status: number
74
+ headers?: Headers
42
75
  }
43
76
 
44
77
  // Reads only.
45
78
  export interface Client {
79
+ // Every file by a lockfile's name, and every project file of a tool that
80
+ // writes one, in the full commit `sha`, or under its `directory`, found as
81
+ // getRepoTreeId finds it, each list sorted by path; and a project file no
82
+ // name tells, by a lockfile beside it named after it: a Python script
83
+ // `uv lock --script` locked, or a Stack or Cabal project file of another
84
+ // name than stack.yaml or cabal.project. From one listing of the tree
85
+ // with every tree under it, each hashed back to its id, so none is left
86
+ // out or changed; where GitHub cuts that short, as it does past 100,000
87
+ // entries, from the tree's own listing and then each subtree's in turn.
88
+ // Not looked in: a submodule, which is another repo's commit, or a
89
+ // `node_modules`, of packages installed. A symlink is no file. Refused
90
+ // where a path found is one getRepoFile does not take, such as one with a
91
+ // control character in it.
92
+ findRepoLockfiles(options: { repo: RepoName; sha: string; directory?: string }): Promise<{ lockfiles: RepoLockfile[]; projects: RepoProjectFile[] }>
46
93
  // An advisory by its GHSA id. With `repo`, that repository's published
47
94
  // copy: the maintainer's latest text, there before GitHub reviews it,
48
95
  // and shaped as a repository advisory (`state`, `patched_versions`, no
@@ -58,7 +105,14 @@ export interface Client {
58
105
  // Refused unless GitHub answers with that pull request in that repo.
59
106
  getPullRequest(options: { repo: RepoName; number: number }): Promise<{ title: string; status: PullRequestStatus }>
60
107
  // GitHub's repository object, refused unless it is the repo asked for.
61
- getRepo(options: { repo: RepoName }): Promise<any>
108
+ // With `followRedirect`, a repository renamed or transferred, which GitHub
109
+ // answers with a redirect to it by its id
110
+ // (`https://api.github.com/repositories/<id>`), is asked for there, with
111
+ // the same token, once: the repository as it is now, refused unless it
112
+ // has that id and a `full_name` that is `owner/name`, the name to ask its
113
+ // other calls with. A redirect anywhere else, or a second, is refused as
114
+ // without it: an HttpError with the redirect's status.
115
+ getRepo(options: { repo: RepoName; followRedirect?: boolean }): Promise<any>
62
116
  // A file's contents as text, at `ref` or the default branch. Refused for
63
117
  // a directory, a symlink or a submodule, unless the bytes hash to the
64
118
  // blob GitHub names for the path, and unless valid UTF-8.
package/npm.d.ts CHANGED
@@ -1,5 +1,3 @@
1
- // Hand-written against npm.js; a change to either belongs with the other.
2
- //
3
1
  // Every call that reaches the registry first asserts that the name is a
4
2
  // string of the shape npm takes, and a version a string that npm's
5
3
  // semver.valid answers with unchanged; neither goes near a request, or a
@@ -73,10 +71,12 @@ export interface CacheOptions {
73
71
  }
74
72
 
75
73
  // A failed request: `status` is the HTTP status the registry answered
76
- // with (404 for a package or version it does not have).
74
+ // with (404 for a package or version it does not have), and `headers` its
75
+ // response's.
77
76
  export class HttpError extends Error {
78
77
  name: 'HttpError'
79
78
  status: number
79
+ headers?: Headers
80
80
  }
81
81
 
82
82
  // A package's GitHub repo as its registry metadata names it -- getRepo
package/package.d.ts CHANGED
@@ -1,5 +1,3 @@
1
- // Hand-written against package.js; a change to either belongs with the other.
2
-
3
1
  export interface PackageRepoLink {
4
2
  // `owner/name`, by GitHub's own rules.
5
3
  github?: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/upstream",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Minimal npm, crates.io and Soldeer registry clients, a GitHub client, and a git checkout reader: the calls we use, nothing more",
5
5
  "type": "module",
6
6
  "exports": {
@@ -85,6 +85,7 @@
85
85
  "src/github/read.js",
86
86
  "src/github/write.js",
87
87
  "src/http.js",
88
+ "src/lockfiles.js",
88
89
  "src/npm/cacache.js",
89
90
  "src/npm/registry.js",
90
91
  "src/npm/repos.js",
package/semver.d.ts CHANGED
@@ -1,5 +1,3 @@
1
- // Hand-written against semver.js; a change to either belongs with the other.
2
-
3
1
  // npm's own semver, borrowed from the npm beside node and passed through
4
2
  // as it is; where there is no npm, the `semver` package, an optional peer
5
3
  // dependency. Without either, every call throws, except valid() on a plain
package/soldeer.d.ts CHANGED
@@ -1,6 +1,3 @@
1
- // Hand-written against soldeer.js; a change to either belongs with the
2
- // other.
3
- //
4
1
  // The name is asserted to be one Soldeer takes, the version a string of
5
2
  // letters, digits, `.`, `_`, `+` and `-`, and the checksum 64 lowercase hex
6
3
  // digits, before any request or cache path. The cache is setCacheDir's
@@ -1,9 +1,10 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { Buffer } from 'node:buffer'
3
3
 
4
- import { assertArgs, assertGhsa, assertLogin, assertNumber, assertPath, assertRef, assertRepo, assertSha, assertTagName, assertTreeId, isSha, isSha1, isTagName, optional, sameName, show } from '../args.js'
4
+ import { assertArgs, assertBoolean, assertGhsa, assertLogin, assertNumber, assertPath, assertRef, assertRepo, assertSha, assertTagName, assertTreeId, isRepo, isSha, isSha1, isTagName, optional, sameName, show } from '../args.js'
5
5
  import { verifiedDownload } from '../download.js'
6
- import { decode, encodeSegment, requestWithHeaders } from '../http.js'
6
+ import { HttpError, decode, encodeSegment, requestWithHeaders } from '../http.js'
7
+ import { FORMATS, lockfileOf, projectFormats } from '../lockfiles.js'
7
8
  import { gitTreeOfListing, objectId } from '../tree.js'
8
9
  import { api, bindMethods, call, clientHeaders, isGone, repoApi } from './client.js'
9
10
 
@@ -48,10 +49,29 @@ async function listUserRepos(headers, options) {
48
49
  return await paginate('listUserRepos', headers, (paging) => api(['user', 'repos'], { ...paging, sort: 'full_name' }), options?.maxPages)
49
50
  }
50
51
 
52
+ // Where GitHub's API sends a repository renamed or transferred: to that
53
+ // repository by its id.
54
+ const REPO_BY_ID = /^https:\/\/api\.github\.com\/repositories\/([1-9]\d{0,14})$/u
55
+ const REDIRECTS = new Set([301, 302, 307, 308])
56
+
57
+ // With `followRedirect`, GitHub's redirect for a repository renamed or
58
+ // transferred is followed, once, on its API alone, and the answer held to
59
+ // the id it names rather than to the name asked; any other redirect, or a
60
+ // second, is refused as without it.
51
61
  async function getRepo(headers, options) {
52
- assertArgs('getRepo', options, { repo: assertRepo })
53
- const info = await call(headers, repoApi(options.repo))
54
- assert.ok(sameName(info?.full_name, options.repo), `getRepo: answered for ${show(info?.full_name)}, not ${options.repo}`)
62
+ assertArgs('getRepo', options, { repo: assertRepo, followRedirect: optional(assertBoolean) })
63
+ const { repo, followRedirect } = options
64
+ let info
65
+ try {
66
+ info = await call(headers, repoApi(repo))
67
+ } catch (err) {
68
+ const id = followRedirect && err instanceof HttpError && REDIRECTS.has(err.status) ? REPO_BY_ID.exec(err.headers.get('location') ?? '')?.[1] : undefined
69
+ if (id === undefined) throw err
70
+ const moved = await call(headers, api(['repositories', id]))
71
+ assert.ok(moved?.id === Number(id) && isRepo(moved.full_name), `getRepo: ${repo} redirected to repository ${id}, answered for ${show(moved?.id)}, ${show(moved?.full_name)}`)
72
+ return moved
73
+ }
74
+ assert.ok(sameName(info?.full_name, repo), `getRepo: answered for ${show(info?.full_name)}, not ${repo}`)
55
75
  return info
56
76
  }
57
77
 
@@ -175,6 +195,71 @@ async function listRepoDir(headers, options) {
175
195
  return await listTree('listRepoDir', headers, options.repo, tree)
176
196
  }
177
197
 
198
+ // A tree and every tree under it in one listing, their entries by paths
199
+ // from it, or null where GitHub cut it short, as it does past 100,000
200
+ // entries or 7 MB. Each tree in it, the top one too, has to hash from the
201
+ // entries listed in it, so none is left out, changed or added: an entry is
202
+ // filed under the path before its last `/`, a tree's own with a `/` after
203
+ // it, so `/x` or `a//x` is in no tree.
204
+ async function listTreeDeep(method, headers, repo, tree) {
205
+ const listing = await call(headers, repoApi(repo, ['git', 'trees', tree], { recursive: 1 }))
206
+ if (listing?.truncated === true) return null
207
+ const entries = Array.isArray(listing?.tree) && listing.tree.every((entry) => typeof entry?.path === 'string') ? listing.tree : []
208
+ const dirs = new Map([['', { sha: tree, listed: [] }], ...entries.filter((entry) => entry.type === 'tree').map((entry) => [`${entry.path}/`, { sha: entry.sha, listed: [] }])])
209
+ const filed = entries.every(({ path, mode, type, sha }) => {
210
+ const at = path.lastIndexOf('/') + 1
211
+ const dir = dirs.get(path.slice(0, at))
212
+ dir?.listed.push({ path: path.slice(at), mode, type, sha })
213
+ return dir !== undefined
214
+ })
215
+ assert.ok(filed && [...dirs.values()].every(({ sha, listed }) => gitTreeOfListing(listed) === sha), `${method}: GitHub's listing of tree ${tree} in ${repo}, with the trees under it, is not that tree`)
216
+ return entries
217
+ }
218
+
219
+ // Each file in `tree`, which is at `base` in the repo, by its path, its
220
+ // name and its blob's id: from the tree's listing with every tree under
221
+ // it, or, where GitHub cuts that short, from its own and then each
222
+ // subtree's in turn, as GitHub advises for a large tree. A node_modules,
223
+ // of packages installed, is not the repo's own.
224
+ async function* filesIn(method, headers, repo, tree, base) {
225
+ const deep = await listTreeDeep(method, headers, repo, tree)
226
+ for (const { path, mode, type, sha } of deep ?? await listTree(method, headers, repo, tree)) {
227
+ const names = path.split('/')
228
+ if (names.includes('node_modules')) continue
229
+ if (type === 'blob' && mode !== '120000') yield { path: `${base}${path}`, name: names.at(-1), sha }
230
+ else if (deep === null && type === 'tree') yield* filesIn(method, headers, repo, sha, `${base}${path}/`)
231
+ }
232
+ }
233
+
234
+ const byPath = (a, b) => (a.path < b.path ? -1 : 1)
235
+
236
+ async function findRepoLockfiles(headers, options) {
237
+ assertArgs('findRepoLockfiles', options, { repo: assertRepo, sha: assertSha, directory: optional(assertPath) })
238
+ const { repo, sha, directory } = options
239
+ const tree = await treeAt('findRepoLockfiles', headers, options)
240
+ const lockfiles = []
241
+ const projects = new Map()
242
+ const files = new Map()
243
+ const locked = []
244
+ for await (const { path, name, sha: blob } of filesIn('findRepoLockfiles', headers, repo, tree, directory === undefined ? '' : `${directory}/`)) {
245
+ files.set(path, blob)
246
+ const lockfile = lockfileOf(name)
247
+ const formats = projectFormats(name)
248
+ if (lockfile === undefined && formats.length === 0) continue
249
+ assertPath('findRepoLockfiles', `${repo}@${sha}'s ${lockfile === undefined ? 'project file' : 'lockfile'}`, path)
250
+ if (lockfile !== undefined) lockfiles.push({ path, format: lockfile.format, sha: blob })
251
+ if (lockfile?.locks !== undefined) locked.push({ path: `${path.slice(0, -name.length)}${lockfile.locks}`, format: lockfile.format })
252
+ if (formats.length > 0) projects.set(path, { path, formats, sha: blob })
253
+ }
254
+ // A file a lockfile beside it locks is a project file of that format too.
255
+ for (const { path, format } of locked) {
256
+ if (!files.has(path)) continue
257
+ const formats = projects.get(path)?.formats ?? []
258
+ projects.set(path, { path, formats: Object.keys(FORMATS).filter((known) => known === format || formats.includes(known)), sha: files.get(path) })
259
+ }
260
+ return { lockfiles: lockfiles.toSorted(byPath), projects: [...projects.values()].toSorted(byPath) }
261
+ }
262
+
178
263
  async function getPullRequest(headers, options) {
179
264
  assertArgs('getPullRequest', options, { repo: assertRepo, number: assertNumber })
180
265
  const { repo, number } = options
@@ -219,5 +304,5 @@ async function listRepoAdvisories(headers, options) {
219
304
  return (await Array.fromAsync(cursorPages('listRepoAdvisories', headers, pageUrl))).flat()
220
305
  }
221
306
 
222
- export const readMethods = { getCurrentUser, listUserRepos, getRepo, getRepoHead, getRepoTag, listRepoTags, getRepoFile, getRepoTarball, getRepoTreeTarball, getRepoTreeId, listRepoDir, getPullRequest, getCollaboratorPermission, getAdvisory, listRepoAdvisories }
307
+ export const readMethods = { getCurrentUser, listUserRepos, getRepo, getRepoHead, getRepoTag, listRepoTags, getRepoFile, getRepoTarball, getRepoTreeTarball, getRepoTreeId, listRepoDir, findRepoLockfiles, getPullRequest, getCollaboratorPermission, getAdvisory, listRepoAdvisories }
223
308
  export const createClient = (options) => bindMethods(clientHeaders('createClient', options, true), readMethods)
package/src/http.js CHANGED
@@ -32,11 +32,13 @@ const LIMITS = {
32
32
  }
33
33
  const decoder = new TextDecoder('utf-8', { fatal: true })
34
34
 
35
+ // `headers` are the response's, where there was one to read them from.
35
36
  export class HttpError extends Error {
36
- constructor(status, message) {
37
+ constructor(status, message, headers) {
37
38
  super(message)
38
39
  this.name = 'HttpError'
39
40
  this.status = status
41
+ if (headers !== undefined) this.headers = headers
40
42
  }
41
43
  }
42
44
 
@@ -119,7 +121,7 @@ export async function requestWithHeaders(url, options) {
119
121
  if (!res.ok) {
120
122
  // A stream decode holds back a character cut at the limit rather than refusing it.
121
123
  const text = await readBody(res, 4096, { truncate: true }).then((bytes) => new TextDecoder('utf-8', { fatal: true }).decode(bytes, { stream: true })).catch(() => '')
122
- throw new HttpError(res.status, `${options.method ?? 'GET'} ${url} ${res.status}: ${printable(text)}`)
124
+ throw new HttpError(res.status, `${options.method ?? 'GET'} ${url} ${res.status}: ${printable(text)}`, res.headers)
123
125
  }
124
126
  return { body: parse(await readBody(res, LIMITS[options.as].bytes), url, options.as), headers: res.headers }
125
127
  }
@@ -0,0 +1,113 @@
1
+ // Each tool that writes a lockfile, by the format of it: the names it
2
+ // writes one under, and those of the project files it locks, each a name
3
+ // as git has it, `cargo.lock` not Cargo's, or a pattern. A lockfile's
4
+ // pattern names in a `locks` group the file beside it that it locks, where
5
+ // no name of that file's own tells it is one.
6
+ export const FORMATS = {
7
+ // pnpm-lock.<branch>.yaml with git-branch-lockfile; shrinkwrap.yaml before
8
+ // pnpm 3. package.yaml and package.json5 in place of package.json, and
9
+ // .pnpmfile's hooks, which change what is locked.
10
+ pnpm: {
11
+ lockfiles: ['pnpm-lock.yaml', /^pnpm-lock\..+\.yaml$/u, 'shrinkwrap.yaml'],
12
+ projects: ['package.json', 'package.yaml', 'package.json5', 'pnpm-workspace.yaml', '.pnpmfile.cjs', '.pnpmfile.mjs'],
13
+ },
14
+ // npm-shrinkwrap.json is npm's own format, which npm reads before a
15
+ // package-lock.json beside it.
16
+ npm: { lockfiles: ['package-lock.json', 'npm-shrinkwrap.json'], projects: ['package.json'] },
17
+ // Of Yarn 1 or a later Yarn, which only the text tells apart. .yarnrc.yml
18
+ // has Yarn 4's catalogs.
19
+ yarn: { lockfiles: ['yarn.lock'], projects: ['package.json', '.yarnrc.yml'] },
20
+ // bun.lockb is Bun's binary one, before Bun 1.2.
21
+ bun: { lockfiles: ['bun.lock', 'bun.lockb'], projects: ['package.json'] },
22
+ deno: { lockfiles: ['deno.lock'], projects: ['deno.json', 'deno.jsonc', 'package.json'] },
23
+ // <script>.py.lock, of `uv lock --script`, which locks the script.
24
+ uv: { lockfiles: ['uv.lock', /^(?<locks>.+\.py)\.lock$/u], projects: ['pyproject.toml'] },
25
+ poetry: { lockfiles: ['poetry.lock'], projects: ['pyproject.toml'] },
26
+ // PEP 751's.
27
+ pylock: { lockfiles: ['pylock.toml', /^pylock\.[^.]+\.toml$/u], projects: ['pyproject.toml'] },
28
+ pipenv: { lockfiles: ['Pipfile.lock'], projects: ['Pipfile'] },
29
+ pdm: { lockfiles: ['pdm.lock'], projects: ['pyproject.toml'] },
30
+ pixi: { lockfiles: ['pixi.lock'], projects: ['pixi.toml', 'pyproject.toml'] },
31
+ // A unified lockfile of another name has to end in `.conda-lock.yml`; the
32
+ // explicit and env ones are conda-<platform>.lock and .lock.yml. A
33
+ // conda-build recipe, meta.yaml, is a source too.
34
+ 'conda-lock': {
35
+ lockfiles: ['conda-lock.yml', 'conda-lock.yaml', /^.+\.conda-lock\.yml$/u, /^conda-[\da-z]+-[\da-z]+\.lock(?:\.yml)?$/u],
36
+ projects: ['environment.yml', 'environment.yaml', 'meta.yaml', 'pyproject.toml'],
37
+ },
38
+ // A gemspec, whose dependencies a Gemfile's `gemspec` takes.
39
+ bundler: { lockfiles: ['Gemfile.lock', 'gems.locked'], projects: ['Gemfile', 'gems.rb', /^.+\.gemspec$/u] },
40
+ composer: { lockfiles: ['composer.lock'], projects: ['composer.json'] },
41
+ cargo: { lockfiles: ['Cargo.lock'], projects: ['Cargo.toml'] },
42
+ soldeer: { lockfiles: ['soldeer.lock'], projects: ['soldeer.toml', 'foundry.toml'] },
43
+ foundry: { lockfiles: ['foundry.lock'], projects: ['foundry.toml'] },
44
+ // Each name CocoaPods reads a Podfile under, and a podspec, whose
45
+ // dependencies a Podfile's `podspec` takes.
46
+ cocoapods: { lockfiles: ['Podfile.lock'], projects: ['Podfile', 'Podfile.rb', 'CocoaPods.podfile', 'CocoaPods.podfile.yaml', /^.+\.podspec(?:\.json)?$/u] },
47
+ swiftpm: { lockfiles: ['Package.resolved'], projects: ['Package.swift', /^Package@swift-\d+(?:\.\d+){0,2}\.swift$/u] },
48
+ carthage: { lockfiles: ['Cartfile.resolved'], projects: ['Cartfile', 'Cartfile.private'] },
49
+ // go.sum has the checksums; go.mod, the versions, is locked as it is. A
50
+ // workspace's go.work.sum has those its modules' go.sum do not.
51
+ go: { lockfiles: ['go.sum', 'go.work.sum'], projects: ['go.mod', 'go.work'] },
52
+ dep: { lockfiles: ['Gopkg.lock'], projects: ['Gopkg.toml'] },
53
+ // packages.<project>.lock.json, for one of projects that share a
54
+ // directory; Directory.Packages.props has the versions of Central
55
+ // Package Management, which the project files then leave out.
56
+ nuget: { lockfiles: ['packages.lock.json', /^packages\..+\.lock\.json$/u], projects: [/^.+\.(?:cs|fs|vb)proj$/u, 'Directory.Packages.props'] },
57
+ paket: { lockfiles: ['paket.lock'], projects: ['paket.dependencies'] },
58
+ // Any *.lockfile: Gradle's own, the legacy one of each configuration, or
59
+ // one a build names. Version catalogs, libs.versions.toml and the like.
60
+ gradle: { lockfiles: [/^.+\.lockfile$/u], projects: ['build.gradle', 'build.gradle.kts', 'settings.gradle', 'settings.gradle.kts', /^.+\.versions\.toml$/u] },
61
+ // pubspec_overrides.yaml overrides pubspec.yaml's dependency_overrides,
62
+ // workspace and resolution.
63
+ pub: { lockfiles: ['pubspec.lock'], projects: ['pubspec.yaml', 'pubspec_overrides.yaml'] },
64
+ mix: { lockfiles: ['mix.lock'], projects: ['mix.exs'] },
65
+ // rebar.config.script, where there is one, makes the configuration.
66
+ rebar: { lockfiles: ['rebar.lock'], projects: ['rebar.config', 'rebar.config.script'] },
67
+ julia: { lockfiles: ['Manifest.toml', 'JuliaManifest.toml', /^(?:Julia)?Manifest-v\d+\.\d+\.toml$/u], projects: ['Project.toml', 'JuliaProject.toml'] },
68
+ // Each of a configuration's name, stack.yaml or another, and `.lock`.
69
+ stack: { lockfiles: [/^(?<locks>.+\.ya?ml)\.lock$/u], projects: ['stack.yaml'] },
70
+ // Each of a project file's name, cabal.project or another, and `.freeze`.
71
+ cabal: { lockfiles: [/^(?<locks>.+)\.freeze$/u], projects: ['cabal.project', /^.+\.cabal$/u] },
72
+ shards: { lockfiles: ['shard.lock'], projects: ['shard.yml', 'shard.override.yml'] },
73
+ opam: { lockfiles: ['opam.locked', /^.+\.opam\.locked$/u], projects: ['opam', /^.+\.opam$/u] },
74
+ nix: { lockfiles: ['flake.lock'], projects: ['flake.nix'] },
75
+ conan: { lockfiles: ['conan.lock'], projects: ['conanfile.txt', 'conanfile.py'] },
76
+ // <name>.MODULE.bazel, which MODULE.bazel's include() takes.
77
+ bazel: { lockfiles: ['MODULE.bazel.lock'], projects: ['MODULE.bazel', /^[^.].*\.MODULE\.bazel$/u] },
78
+ helm: { lockfiles: ['Chart.lock'], projects: ['Chart.yaml'] },
79
+ // Of no project file, but the library it is made from.
80
+ renv: { lockfiles: ['renv.lock'], projects: [] },
81
+ // Of the configuration in the directory, each of its .tf files, a child
82
+ // module's too, of which there is none; OpenTofu's .tofu files, and a
83
+ // Stack's components.
84
+ terraform: { lockfiles: ['.terraform.lock.hcl'], projects: [/^.+\.(?:tf|tofu)(?:\.json)?$/u, /^.+\.tfcomponent\.hcl$/u] },
85
+ }
86
+
87
+ const TOOLS = Object.entries(FORMATS)
88
+ const KNOWN = TOOLS.flatMap(([, { lockfiles, projects }]) => [...lockfiles, ...projects])
89
+
90
+ // A name of none of them, as most in a repository are, is passed over at
91
+ // once, by a set of the names and one pattern of all the patterns.
92
+ const NAMES = new Set(KNOWN.filter((known) => typeof known === 'string'))
93
+ const PATTERN = new RegExp(KNOWN.filter((known) => typeof known !== 'string').map(({ source }) => `(?:${source})`).join('|'), 'u')
94
+ const isKnown = (name) => NAMES.has(name) || PATTERN.test(name)
95
+
96
+ // Whether `name` is one of `names`, each a name or a pattern.
97
+ const isOf = (names, name) => names.some((known) => (typeof known === 'string' ? known === name : known.test(name)))
98
+
99
+ // The lockfile a file of this name is, `{ format, locks }`, `locks` the name
100
+ // of the file beside it that it locks where its pattern names one; or
101
+ // undefined.
102
+ export function lockfileOf(name) {
103
+ for (const [format, { lockfiles }] of isKnown(name) ? TOOLS : []) {
104
+ for (const known of lockfiles) {
105
+ const match = typeof known === 'string' ? known === name && {} : known.exec(name)
106
+ if (match) return { format, locks: match.groups?.locks }
107
+ }
108
+ }
109
+ }
110
+
111
+ // The formats of the lockfiles that lock a project of a file of this name,
112
+ // in FORMATS' order, none where it is no project file.
113
+ export const projectFormats = (name) => (isKnown(name) ? TOOLS.filter(([, { projects }]) => isOf(projects, name)).map(([format]) => format) : [])