@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/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
+ }
@@ -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'
File without changes