@preventive/upstream 1.0.0-alpha.1 → 1.0.0-alpha.11
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/LICENSE-APACHE +202 -0
- package/advisories.d.ts +68 -16
- package/cargo.d.ts +18 -1
- package/cargo.js +2 -1
- package/git.d.ts +2 -2
- package/github.d.ts +67 -13
- package/npm.d.ts +48 -20
- package/npm.js +1 -1
- package/package.d.ts +6 -3
- package/package.json +26 -4
- package/semver.d.ts +8 -2
- package/semver.js +1 -1
- package/src/advisories/common.js +27 -5
- package/src/advisories/github.js +98 -30
- package/src/advisories/index.js +7 -7
- package/src/advisories/npm.js +19 -5
- package/src/advisories/osv.js +34 -15
- package/src/advisories/repos.js +17 -35
- package/src/args.js +36 -2
- package/src/attributes.js +81 -0
- package/src/cache.js +93 -14
- package/src/{cargo.js → cargo/crate.js} +17 -11
- package/src/cargo/repos.js +52 -0
- package/src/download.js +18 -14
- package/src/git.js +1 -1
- package/src/github/read.js +113 -14
- package/src/http.js +16 -11
- package/src/npm/registry.js +19 -0
- package/src/npm/repos.js +30 -20
- package/src/npm/tarball.js +6 -10
- package/src/package.js +26 -7
- package/src/remote.js +8 -1
- package/src/semver-peer.cjs +8 -0
- package/src/semver.js +28 -12
- package/src/soldeer.js +2 -9
- package/src/tree.js +237 -0
- package/src/weak-edges.js +87 -0
- package/weak-edges.d.ts +36 -0
- package/weak-edges.js +1 -0
- /package/{LICENSE → LICENSE-MIT} +0 -0
package/src/tree.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { gunzipSync } from 'node:zlib'
|
|
4
|
+
|
|
5
|
+
import { isSha1, matches } from './args.js'
|
|
6
|
+
import { withAttributes, writtenWithCrlf } from './attributes.js'
|
|
7
|
+
|
|
8
|
+
const BLOCK = 512
|
|
9
|
+
const MAX_UNPACKED_BYTES = 2 ** 30
|
|
10
|
+
// Only what git writes is read: anything else, a pax `size` or a mode of
|
|
11
|
+
// 0000, would be extracted as it is not hashed. Modes are as its umask,
|
|
12
|
+
// 002 or 022, leaves them: a file 0664 or 0644, 0775 or 0755 executable;
|
|
13
|
+
// a directory 0775 or 0755; a symlink 0777.
|
|
14
|
+
const PAX_KEYS = new Set(['path', 'linkpath'])
|
|
15
|
+
const MODES = { 0: [0o664, 0o644, 0o775, 0o755], 2: [0o777], 5: [0o775, 0o755] }
|
|
16
|
+
// The top directory is not hashed, so it is held to GitHub's name for it,
|
|
17
|
+
// owner-repo-id: another, `..\..`, `C:` or a Windows device such as
|
|
18
|
+
// `con.txt`, could take an extractor elsewhere.
|
|
19
|
+
const isTop = matches(/^(?!(?:con|prn|aux|nul|com\d|lpt\d)(?:\.|$))[\da-z][\w.-]*$/iu)
|
|
20
|
+
|
|
21
|
+
// Names are kept as latin1 strings, a char per byte, so they sort and hash
|
|
22
|
+
// as the bytes git has.
|
|
23
|
+
const field = (header, start, end) => {
|
|
24
|
+
const bytes = header.subarray(start, end)
|
|
25
|
+
const nul = bytes.indexOf(0)
|
|
26
|
+
return Buffer.from(nul === -1 ? bytes : bytes.subarray(0, nul)).toString('latin1')
|
|
27
|
+
}
|
|
28
|
+
const octal = (header, start, end) => {
|
|
29
|
+
const text = field(header, start, end).trim()
|
|
30
|
+
return /^[0-7]{1,12}$/u.test(text) ? Number.parseInt(text, 8) : Number.NaN
|
|
31
|
+
}
|
|
32
|
+
export const objectId = (type, content) => createHash('sha1').update(`${type} ${content.length}\0`).update(content).digest()
|
|
33
|
+
const checksum = (header) => header.reduce((sum, byte, i) => sum + (i >= 148 && i < 156 ? 32 : byte), 0)
|
|
34
|
+
const isZero = (bytes) => bytes.every((byte) => byte === 0)
|
|
35
|
+
|
|
36
|
+
// `<length> <key>=<value>\n`, the length counting the whole record. A NUL
|
|
37
|
+
// would end a path for an extractor, and a name in a tree for git.
|
|
38
|
+
function paxRecords(body) {
|
|
39
|
+
const text = Buffer.from(body).toString('latin1')
|
|
40
|
+
const records = new Map()
|
|
41
|
+
for (let at = 0; at < text.length;) {
|
|
42
|
+
const space = text.indexOf(' ', at)
|
|
43
|
+
const length = text.slice(at, space)
|
|
44
|
+
const end = at + Number(length)
|
|
45
|
+
if (!/^[1-9]\d*$/u.test(length) || end > text.length || text[end - 1] !== '\n') return null
|
|
46
|
+
const record = text.slice(space + 1, end - 1)
|
|
47
|
+
const equals = record.indexOf('=')
|
|
48
|
+
if (equals < 1 || record.includes('\0')) return null
|
|
49
|
+
records.set(record.slice(0, equals), record.slice(equals + 1))
|
|
50
|
+
at = end
|
|
51
|
+
}
|
|
52
|
+
return records
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Git keeps no empty directory: in a tarball, one is a submodule. Each as
|
|
56
|
+
// [the directory holding it, its name, its path].
|
|
57
|
+
function emptyDirs(dir, prefix = '') {
|
|
58
|
+
return [...dir].flatMap(([name, entry]) => {
|
|
59
|
+
if (!(entry instanceof Map)) return []
|
|
60
|
+
const path = `${prefix}${name}`
|
|
61
|
+
return entry.size === 0 ? [[dir, name, path]] : emptyDirs(entry, `${path}/`)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Git sorts a subtree as if its name ended in `/`.
|
|
66
|
+
function treeId(dir) {
|
|
67
|
+
const entries = [...dir].map(([name, entry]) => (entry instanceof Map ? { mode: '40000', name, id: treeId(entry) } : { ...entry, name }))
|
|
68
|
+
const keyed = entries.map((entry) => [entry.mode === '40000' ? `${entry.name}/` : entry.name, entry])
|
|
69
|
+
keyed.sort(([a], [b]) => (a < b ? -1 : Number(a > b)))
|
|
70
|
+
return objectId('tree', Buffer.concat(keyed.flatMap(([, { mode, name, id }]) => [Buffer.from(`${mode} ${name}\0`, 'latin1'), id])))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const EMPTY_TREE = objectId('tree', Buffer.alloc(0)).toString('hex')
|
|
74
|
+
const isName = matches(/^[^\0/]+$/u)
|
|
75
|
+
const isEntry = (entry) => isName(entry?.path) && isSha1(entry.sha)
|
|
76
|
+
const nameOf = (path) => Buffer.from(path).toString('latin1')
|
|
77
|
+
const subtree = (sha) => ({ mode: '40000', id: Buffer.from(sha, 'hex') })
|
|
78
|
+
|
|
79
|
+
// A subtree with nothing in it but subtrees like it, down to the empty
|
|
80
|
+
// tree: its listing, read as subtrees alone, hashes to its id.
|
|
81
|
+
async function holdsNothing(sha, listed) {
|
|
82
|
+
if (sha === EMPTY_TREE) return true
|
|
83
|
+
const entries = await listed(sha)
|
|
84
|
+
if (treeId(new Map(entries.map((entry) => [entry.name, subtree(entry.sha)]))).toString('hex') !== sha) return false
|
|
85
|
+
for (const entry of entries) if (!await holdsNothing(entry.sha, listed)) return false
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const CHUNK = 64 * 1024
|
|
90
|
+
|
|
91
|
+
// The id of the blob that `eol=crlf`, a CR written before each LF with
|
|
92
|
+
// none, writes out as these bytes: theirs with each LF's CR taken out,
|
|
93
|
+
// hashed a chunk at a time, the bytes only read. Null where no blob writes
|
|
94
|
+
// them so: an LF with no CR before it, a CR before that CR, or no LF at
|
|
95
|
+
// all. Most files are passed over at their first LF, a binary one with
|
|
96
|
+
// none at once.
|
|
97
|
+
function crlfBlobId(written) {
|
|
98
|
+
const first = written.indexOf(0x0a)
|
|
99
|
+
if (first < 1 || written[first - 1] !== 0x0d) return null
|
|
100
|
+
let lfs = 0
|
|
101
|
+
for (let i = first; i < written.length; i++) {
|
|
102
|
+
if (written[i] !== 0x0a) continue
|
|
103
|
+
if (written[i - 1] !== 0x0d || written[i - 2] === 0x0d) return null
|
|
104
|
+
lfs++
|
|
105
|
+
}
|
|
106
|
+
const hash = createHash('sha1').update(`blob ${written.length - lfs}\0`)
|
|
107
|
+
const chunk = Buffer.allocUnsafe(CHUNK)
|
|
108
|
+
let at = 0
|
|
109
|
+
for (let i = 0; i < written.length; i++) {
|
|
110
|
+
if (written[i] === 0x0d && written[i + 1] === 0x0a) continue
|
|
111
|
+
chunk[at++] = written[i]
|
|
112
|
+
if (at === CHUNK) {
|
|
113
|
+
hash.update(chunk)
|
|
114
|
+
at = 0
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return hash.update(chunk.subarray(0, at)).digest()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Where a directory's id is not the one listed, its listing names what
|
|
121
|
+
// `git archive` wrote otherwise, as a checkout writes it. A subtree with no
|
|
122
|
+
// file in it, which only plumbing makes, it leaves out: one is put back
|
|
123
|
+
// only once its own listings show it holds nothing. A file marked
|
|
124
|
+
// `eol=crlf` it writes with CRLF where git has LF: one is hashed as the
|
|
125
|
+
// blob listed, its bytes left as written, where that blob writes them and
|
|
126
|
+
// the tree's .gitattributes have git write it so.
|
|
127
|
+
async function mend(dir, sha, listed, base = '', above = []) {
|
|
128
|
+
const own = dir.get('.gitattributes')
|
|
129
|
+
const attributes = withAttributes(above, base, own && (own.body ?? null))
|
|
130
|
+
for (const entry of await listed(sha)) {
|
|
131
|
+
const here = dir.get(entry.name)
|
|
132
|
+
if (entry.type === 'tree') {
|
|
133
|
+
if (here instanceof Map && treeId(here).toString('hex') !== entry.sha) await mend(here, entry.sha, listed, `${base}${entry.name}/`, attributes)
|
|
134
|
+
else if (here === undefined && await holdsNothing(entry.sha, listed)) dir.set(entry.name, subtree(entry.sha))
|
|
135
|
+
} else if (entry.type === 'blob' && here?.body && here.id.toString('hex') !== entry.sha
|
|
136
|
+
&& crlfBlobId(here.body)?.toString('hex') === entry.sha && writtenWithCrlf(attributes, `${base}${entry.name}`, here.body)) {
|
|
137
|
+
dir.set(entry.name, { ...here, id: Buffer.from(entry.sha, 'hex') })
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The id of the git tree a gzipped tarball holds, as `git archive` writes
|
|
143
|
+
// one, under a single top directory: a file's mode is its exec bit, a
|
|
144
|
+
// symlink's blob its target. What a tarball cannot show comes from `list`,
|
|
145
|
+
// which answers a listing of a tree by its id, as GitHub's trees API does
|
|
146
|
+
// ({ type, path, sha } entries), walked from `expected`: a submodule's
|
|
147
|
+
// commit for an empty directory, and, where the id comes out otherwise, the
|
|
148
|
+
// subtrees with nothing in them it leaves out and the blobs of the files it
|
|
149
|
+
// wrote with CRLF for `eol=crlf`. The id is `expected` only if those are
|
|
150
|
+
// right. Where there is no such tree, a reason, never an id.
|
|
151
|
+
export async function gitTreeOfTarball(gzipped, { expected, list } = {}) {
|
|
152
|
+
let bytes
|
|
153
|
+
try {
|
|
154
|
+
bytes = gunzipSync(gzipped, { maxOutputLength: MAX_UNPACKED_BYTES })
|
|
155
|
+
} catch {
|
|
156
|
+
return 'no tree: not gzip, or larger than 1 GiB unpacked'
|
|
157
|
+
}
|
|
158
|
+
const root = new Map()
|
|
159
|
+
let top
|
|
160
|
+
let pax = null
|
|
161
|
+
for (let at = 0; ;) {
|
|
162
|
+
const header = bytes.subarray(at, at + BLOCK)
|
|
163
|
+
if (header.length < BLOCK) return 'no tree: the tarball is cut short'
|
|
164
|
+
if (isZero(header)) {
|
|
165
|
+
if (!isZero(bytes.subarray(at))) return 'no tree: data after the end of the tarball'
|
|
166
|
+
break
|
|
167
|
+
}
|
|
168
|
+
if (Buffer.from(header.subarray(257, 265)).toString('latin1') !== 'ustar\u000000') return 'no tree: a header that is not POSIX ustar'
|
|
169
|
+
if (octal(header, 148, 156) !== checksum(header)) return 'no tree: a header that fails its checksum'
|
|
170
|
+
const size = octal(header, 124, 136)
|
|
171
|
+
const body = bytes.subarray(at + BLOCK, at + BLOCK + size)
|
|
172
|
+
if (body.length !== size) return 'no tree: the tarball is cut short'
|
|
173
|
+
at += BLOCK + Math.ceil(size / BLOCK) * BLOCK
|
|
174
|
+
const type = String.fromCodePoint(header[156])
|
|
175
|
+
if (type === 'x') {
|
|
176
|
+
const records = paxRecords(body)
|
|
177
|
+
if (records === null) return 'no tree: a malformed pax header'
|
|
178
|
+
const other = [...records.keys()].find((key) => !PAX_KEYS.has(key))
|
|
179
|
+
if (other !== undefined) return `no tree: a pax record git does not write, ${JSON.stringify(other)}`
|
|
180
|
+
if (pax !== null) return 'no tree: two pax headers for one entry'
|
|
181
|
+
pax = records
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
const prefix = field(header, 345, 500)
|
|
185
|
+
const path = pax?.get('path') ?? (prefix ? `${prefix}/${field(header, 0, 100)}` : field(header, 0, 100))
|
|
186
|
+
const target = pax?.get('linkpath') ?? field(header, 157, 257)
|
|
187
|
+
pax = null
|
|
188
|
+
if (!Object.hasOwn(MODES, type)) return `no tree: an entry of type ${JSON.stringify(type)}`
|
|
189
|
+
const mode = octal(header, 100, 108)
|
|
190
|
+
const asGitWrites = MODES[type].includes(mode) && (type === '0' || size === 0) && path.endsWith('/') === (type === '5')
|
|
191
|
+
&& octal(header, 108, 116) === 0 && octal(header, 116, 124) === 0 && field(header, 265, 297) === 'root' && field(header, 297, 329) === 'root'
|
|
192
|
+
if (!asGitWrites) return `no tree: a header git does not write, ${JSON.stringify(path)}`
|
|
193
|
+
const [first, ...parts] = path.replace(/\/$/u, '').split('/')
|
|
194
|
+
top ??= first
|
|
195
|
+
const name = type === '5' ? null : parts.pop()
|
|
196
|
+
if (!isTop(first) || first !== top || name === undefined || [...parts, name].some((part) => ['', '.', '..'].includes(part))) return `no tree: an entry outside one top directory, ${JSON.stringify(path)}`
|
|
197
|
+
let dir = root
|
|
198
|
+
for (const part of parts) {
|
|
199
|
+
if (!dir.has(part)) dir.set(part, new Map())
|
|
200
|
+
dir = dir.get(part)
|
|
201
|
+
if (!(dir instanceof Map)) return `no tree: a file where a directory is, ${JSON.stringify(path)}`
|
|
202
|
+
}
|
|
203
|
+
if (name === null) continue
|
|
204
|
+
if (dir.has(name)) return `no tree: ${JSON.stringify(path)} twice`
|
|
205
|
+
dir.set(name, type === '0' ? { mode: mode & 0o100 ? '100755' : '100644', id: objectId('blob', body), body } : { mode: '120000', id: objectId('blob', Buffer.from(target, 'latin1')) })
|
|
206
|
+
}
|
|
207
|
+
if (top === undefined) return 'no tree: an empty tarball'
|
|
208
|
+
const listed = async (sha) => (list && isSha1(sha) ? await list(sha) : [])
|
|
209
|
+
.filter(isEntry)
|
|
210
|
+
.map((entry) => ({ ...entry, name: nameOf(entry.path) }))
|
|
211
|
+
for (const [dir, name, path] of emptyDirs(root)) {
|
|
212
|
+
let sha = expected
|
|
213
|
+
const parts = path.split('/')
|
|
214
|
+
for (const [i, part] of parts.entries()) {
|
|
215
|
+
const type = i === parts.length - 1 ? 'commit' : 'tree'
|
|
216
|
+
sha = (await listed(sha)).find((entry) => entry.type === type && entry.name === part)?.sha
|
|
217
|
+
}
|
|
218
|
+
if (!isSha1(sha)) return `no tree: an empty directory, ${JSON.stringify(path)}, and no submodule there`
|
|
219
|
+
dir.set(name, { mode: '160000', id: Buffer.from(sha, 'hex') })
|
|
220
|
+
}
|
|
221
|
+
const id = treeId(root).toString('hex')
|
|
222
|
+
if (id === expected) return id
|
|
223
|
+
await mend(root, expected, listed)
|
|
224
|
+
return treeId(root).toString('hex')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// GitHub lists a subtree's mode as `040000`, which git writes `40000`.
|
|
228
|
+
const LISTED = new Set(['100644 blob', '100755 blob', '120000 blob', '040000 tree', '160000 commit'])
|
|
229
|
+
|
|
230
|
+
// The id of the tree a listing such as GitHub's names, its entries
|
|
231
|
+
// { path, mode, type, sha }, or null where one is not an entry a tree can
|
|
232
|
+
// hold, or a name is there twice.
|
|
233
|
+
export function gitTreeOfListing(entries) {
|
|
234
|
+
if (!entries.every((entry) => isEntry(entry) && LISTED.has(`${entry.mode} ${entry.type}`))) return null
|
|
235
|
+
const dir = new Map(entries.map(({ path, mode, sha }) => [nameOf(path), { mode: mode.replace(/^0/u, ''), id: Buffer.from(sha, 'hex') }]))
|
|
236
|
+
return dir.size === entries.length ? treeId(dir).toString('hex') : null
|
|
237
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Package-relative coordinates keep this inventory independent of installation
|
|
2
|
+
// roots, pnpm layouts, display-prefix stripping, and graph package grouping.
|
|
3
|
+
const babelConfig = { package: '@babel/core', path: /^lib\/config\//u }
|
|
4
|
+
const babelPlugins = { package: '@babel/core', path: /^lib\/config\/files\/(?:module-types|plugins)\.js$/u }
|
|
5
|
+
|
|
6
|
+
export const weakEdges = Object.freeze([
|
|
7
|
+
{
|
|
8
|
+
id: 'react-native-codegen-discovery',
|
|
9
|
+
reason: 'Codegen discovers package metadata and React Native configuration.',
|
|
10
|
+
from: { package: 'react-native', path: /^scripts\/codegen\/generate-artifacts-executor\.js$/u },
|
|
11
|
+
to: { path: /(?:^|\/)(?:package\.json|react-native\.config\.js)$/u },
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
id: 'react-native-release-version',
|
|
15
|
+
reason: 'The CLI release checker reads the installed React Native version.',
|
|
16
|
+
from: { package: '@react-native-community/cli-tools', path: /^build\/releaseChecker\/index\.js$/u },
|
|
17
|
+
to: { package: 'react-native', path: /^package\.json$/u },
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: 'react-native-platform-config',
|
|
21
|
+
reason: 'React Native configuration loads platform CLIs supplied by the host project.',
|
|
22
|
+
from: { package: 'react-native', path: /^react-native\.config\.js$/u },
|
|
23
|
+
to: { package: /^@react-native-community\/cli-platform-(?:android|ios)$/u, path: /^build\/index\.js$/u },
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'babel-project-config',
|
|
27
|
+
reason: 'Babel discovers project configuration.',
|
|
28
|
+
from: babelConfig,
|
|
29
|
+
to: { path: /(?:^|\/)babel\.config\.js$/u },
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'babel-typescript-preset',
|
|
33
|
+
reason: 'Babel configuration loads the TypeScript preset.',
|
|
34
|
+
from: babelConfig,
|
|
35
|
+
to: { package: '@babel/preset-typescript', path: /./u },
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'babel-react-native-preset',
|
|
39
|
+
reason: 'Babel config loaders load the configured React Native preset.',
|
|
40
|
+
from: babelPlugins,
|
|
41
|
+
to: { package: '@react-native/babel-preset', path: /^(?:src\/)?index\.js$/u },
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'babel-reanimated-plugin',
|
|
45
|
+
reason: 'Babel config loaders load the configured Reanimated plugin, including scoped copies.',
|
|
46
|
+
from: babelPlugins,
|
|
47
|
+
to: { package: /^(?:@[^/]+\/)?react-native-reanimated$/u, path: /^plugin\/index\.js$/u },
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'babel-transform-plugin',
|
|
51
|
+
reason: 'Babel config loaders load configured transform plugins.',
|
|
52
|
+
from: babelPlugins,
|
|
53
|
+
to: { package: /^@babel\/plugin-transform-[^/]+$/u, path: /^lib\/index\.js$/u },
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'cosmiconfig-config',
|
|
57
|
+
reason: 'Cosmiconfig loads discovered configuration, including dependency configuration.',
|
|
58
|
+
from: { package: 'cosmiconfig', path: /^dist\/loaders\.js$/u },
|
|
59
|
+
to: { path: /\.config\.js$/u },
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: 'import-fresh-own-config',
|
|
63
|
+
reason: 'import-fresh loads host-project configuration.',
|
|
64
|
+
from: { package: 'import-fresh', path: /./u },
|
|
65
|
+
to: { path: /\.config\.js$/u },
|
|
66
|
+
ownSourceOnly: true,
|
|
67
|
+
},
|
|
68
|
+
].map(rule => Object.freeze({ ...rule, from: Object.freeze(rule.from), to: Object.freeze(rule.to) })))
|
|
69
|
+
|
|
70
|
+
// Do not guess ownership from a partial or shortened path. A nested dependency
|
|
71
|
+
// must be passed as its own package, not as node_modules/... inside its parent.
|
|
72
|
+
function normalizedPath(path) {
|
|
73
|
+
return typeof path === 'string' && !path.includes('\\') && !path.includes(':')
|
|
74
|
+
&& path.split('/').every(part => part !== '' && part !== '.' && part !== '..' && part !== 'node_modules')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function matches(selector, file) {
|
|
78
|
+
const pkg = selector.package
|
|
79
|
+
if (pkg !== undefined && file.ownSource === true) return false
|
|
80
|
+
return (pkg === undefined || (typeof pkg === 'string' ? pkg === file.package : pkg.test(file.package ?? '')))
|
|
81
|
+
&& selector.path.test(file.path)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getWeakEdge(from, to) {
|
|
85
|
+
if (from?.ownSource === true || !normalizedPath(from?.path) || !normalizedPath(to?.path)) return undefined
|
|
86
|
+
return weakEdges.find(rule => (!rule.ownSourceOnly || to.ownSource === true) && matches(rule.from, from) && matches(rule.to, to))
|
|
87
|
+
}
|
package/weak-edges.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** A resolved file, identified by its owning package rather than a display path. */
|
|
2
|
+
export interface WeakEdgeFile {
|
|
3
|
+
/** Exact npm package name, including scope. May be absent for own source. */
|
|
4
|
+
package?: string
|
|
5
|
+
/** Normalized /-separated path relative to the owning package or own-source root. */
|
|
6
|
+
path: string
|
|
7
|
+
/** Authoritative project ownership, not inferred from a package name or path. */
|
|
8
|
+
ownSource?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface WeakEdgeSelector {
|
|
12
|
+
/** Absent means any package or own source. Regular expressions match package names. */
|
|
13
|
+
readonly package?: string | RegExp
|
|
14
|
+
readonly path: RegExp
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface WeakEdgeRule {
|
|
18
|
+
readonly id: string
|
|
19
|
+
readonly reason: string
|
|
20
|
+
readonly from: WeakEdgeSelector
|
|
21
|
+
readonly to: WeakEdgeSelector
|
|
22
|
+
readonly ownSourceOnly?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Curated weak loads: omit these file edges from cycles and default inclusion explanations. */
|
|
26
|
+
export const weakEdges: readonly WeakEdgeRule[]
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Returns the matching inventory rule, or undefined for an unrecognized edge.
|
|
30
|
+
* Own-source importers never match. import-fresh requires to.ownSource === true.
|
|
31
|
+
* Own-source targets only match selectors without a package constraint.
|
|
32
|
+
* Resolve nested installations to their actual owning package before calling;
|
|
33
|
+
* absolute, non-normalized, or nested node_modules paths do not match.
|
|
34
|
+
* Does not remove files, alter raw load records, or change runtime resolution.
|
|
35
|
+
*/
|
|
36
|
+
export function getWeakEdge(from: WeakEdgeFile, to: WeakEdgeFile): WeakEdgeRule | undefined
|
package/weak-edges.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { getWeakEdge, weakEdges } from './src/weak-edges.js'
|
/package/{LICENSE → LICENSE-MIT}
RENAMED
|
File without changes
|