@preventive/upstream 1.0.0-alpha.8 → 1.0.0

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.
@@ -0,0 +1,97 @@
1
+ import assert from 'node:assert/strict'
2
+
3
+ import { assertArgs, assertion, matches, show } from '../args.js'
4
+ import { readJSON, writeJSON } from '../cache.js'
5
+ import { NPM_REGISTRY, buildUrl, isNotFound, recover } from '../http.js'
6
+ import { getDocument } from './registry.js'
7
+
8
+ const DIR = 'npm/versions' // No expiry: the registry never takes a version twice, nor changes its dist.
9
+ // One sha512 and nothing else, as the registry writes it: a sha1, a second
10
+ // hash or an option is refused rather than trusted. 64 bytes leave the
11
+ // last character before `==` two bits, so only A, Q, g or w is canonical.
12
+ const assertIntegrity = assertion('"sha512-" and a base64 sha512', matches(/^sha512-[\dA-Za-z+/]{85}[AQgw]==$/u))
13
+
14
+ // `{ tarball, integrity }` and nothing else, the tarball exactly the
15
+ // registry's own URL for that version. Each field is read once, and only
16
+ // as the object's own, into a copy that is checked and used from then on:
17
+ // the caller's object can change while a request is out, and a field on
18
+ // its prototype is not one it has.
19
+ export function checkedDist(method, name, version, dist) {
20
+ assertArgs(method, dist, { tarball: null, integrity: null }, 'dist')
21
+ const [tarball, integrity] = ['tarball', 'integrity'].map((key) => (Object.hasOwn(dist, key) ? dist[key] : undefined))
22
+ assertIntegrity(method, 'dist.integrity', integrity)
23
+ const segments = name.split('/')
24
+ const expected = buildUrl(NPM_REGISTRY, [...segments, '-', `${segments.at(-1)}-${version}.tgz`])
25
+ assert.ok(tarball === expected, `${method}: dist.tarball must be ${expected}, got ${show(tarball)}`)
26
+ return { tarball, integrity }
27
+ }
28
+
29
+ const distOf = (method, name, version, json) => checkedDist(method, name, version, { tarball: json.dist?.tarball, integrity: json.dist?.integrity })
30
+
31
+ // Whether a version document is one to keep, or to take as kept: for that
32
+ // name and version, with a dist that passes.
33
+ function isKeepable(method, name, version, json) {
34
+ if (json?.name !== name || json.version !== version) return false
35
+ try {
36
+ distOf(method, name, version, json)
37
+ return true
38
+ } catch {
39
+ return false
40
+ }
41
+ }
42
+
43
+ // A version's document as `cache` keeps it (readJSON), or none.
44
+ async function keptDocument(method, name, version, cache) {
45
+ const json = await readJSON(DIR, `${name}@${version}`, cache)
46
+ return isKeepable(method, name, version, json) ? json : undefined
47
+ }
48
+
49
+ // The registry's document, kept whole in `cache` once it is keepable; one
50
+ // whose dist is refused is answered all the same.
51
+ async function keepDocument(method, name, version, json, cache) {
52
+ assert.ok(json.version === version, `${method}: the registry answered for ${name}@${show(json.version)}, not ${name}@${version}`)
53
+ if (isKeepable(method, name, version, json)) await writeJSON(DIR, `${name}@${version}`, json, cache)
54
+ return json
55
+ }
56
+
57
+ const fetchDocument = async (method, name, version, cache) => await keepDocument(method, name, version, await getDocument(method, name, version), cache)
58
+
59
+ // A version's document, kept or else fetched, for what is not an
60
+ // integrity: a package's repo (advisories.js), as trusted as setCacheDir's
61
+ // repo records are. Null where the registry does not have that version:
62
+ // its 404 alone, never a failure of the cache's.
63
+ export async function getVersionDocument(method, name, version, cache) {
64
+ const kept = await keptDocument(method, name, version, cache)
65
+ if (kept) return kept
66
+ const json = await getDocument(method, name, version).catch(recover(isNotFound, null))
67
+ return json && await keepDocument(method, name, version, json, cache)
68
+ }
69
+
70
+ // The dist of a version document a cache answers, where it is that
71
+ // version's, its dist passes, and its tarball and integrity are `given`'s
72
+ // (checkedDist): a cache never supplies a dist, only confirms one.
73
+ export function matchingDist(method, name, version, json, given) {
74
+ if (!isKeepable(method, name, version, json)) return undefined
75
+ const dist = distOf(method, name, version, json)
76
+ return dist.integrity === given.integrity && dist.tarball === given.tarball ? dist : undefined
77
+ }
78
+
79
+ // A version's document, and its dist checked. A kept document is read only
80
+ // for a caller that already has a dist, `given`, and answers with no
81
+ // request where it matches (matchingDist). Else, or without one, the
82
+ // registry is asked, and its document, kept in place of any other, throws
83
+ // where it does not.
84
+ export async function getVersion(method, name, version, given, cache) {
85
+ if (given !== undefined) {
86
+ const kept = await readJSON(DIR, `${name}@${version}`, cache)
87
+ const dist = matchingDist(method, name, version, kept, given)
88
+ if (dist) return { dist, json: kept }
89
+ }
90
+ const json = await fetchDocument(method, name, version, cache)
91
+ const dist = distOf(method, name, version, json)
92
+ if (given !== undefined) {
93
+ assert.ok(dist.integrity === given.integrity, `${method}: ${name}@${version} is ${dist.integrity} on the registry, not ${given.integrity}`)
94
+ assert.ok(dist.tarball === given.tarball, `${method}: ${name}@${version} is at ${dist.tarball} on the registry, not ${given.tarball}`)
95
+ }
96
+ return { dist, json }
97
+ }
package/src/package.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert/strict'
2
2
 
3
3
  import { assertion, isRepo, isRepoPath, sameName } from './args.js'
4
- import { githubRepoOfUrl } from './remote.js'
4
+ import { githubRepoOfUrl, withoutDotGit } from './remote.js'
5
5
 
6
6
  const bugsRegex = /^(?i:https?:\/\/github\.com)(?::\d{1,5})?\/(?<repo>[\w-]+\/[\w.-]+)\/issues\/?$/u
7
7
  // npm's `owner/name` shorthand means GitHub. No dots in the owner, so a
@@ -11,9 +11,12 @@ const shorthandRegex = /^(?:github:)?(?<repo>[\w-]+\/[\w.-]+)$/u
11
11
  // The ref is one segment: a branch with a `/` reads as part of the directory.
12
12
  const homepageRegex = /^(?:https?:\/\/)?(?:www\.)?github\.com(?::\d{1,5})?\/(?<repo>[\w-]+\/[\w.-]+?)(?:\.git)?(?:\/(?:tree\/[^/]+(?:\/(?<directory>.*))?)?)?$/iu
13
13
  const str = (value) => (typeof value === 'string' ? value : '')
14
- const urlOf = (field) => str(field?.url ?? field)
14
+ // Read past the whitespace around it, as `homepage` is.
15
+ const urlOf = (field) => str(field?.url ?? field).trim()
15
16
  const homepageUrl = (homepage) => str(homepage).trim().split(/[?#]/u)[0] // npm appends `#readme`.
16
- const repoIn = (regex, text) => regex.exec(text)?.groups.repo
17
+ // Every trailing `.git` dropped, as the URLs' are: no answer ends in one
18
+ // (the npm repo cache refuses one).
19
+ const repoIn = (regex, text) => withoutDotGit(regex.exec(text)?.groups.repo)
17
20
  // A URL's path spells a tree path percent-encoded; one that does not decode names none.
18
21
  function decodePath(path = '') {
19
22
  try {
@@ -33,6 +36,19 @@ function repoSubdirectory(value) {
33
36
  return isRepoPath(path) ? path : undefined // No empty, `.`, `..` or `.git` part: a path inside the repo.
34
37
  }
35
38
 
39
+ // `repository.directory` is a path its author may write with Windows' `\`.
40
+ // A tree path is `/`-separated: a `\` is a name's own only in a homepage's
41
+ // (`%5C`), which is the path itself. Its empty and `.` parts are dropped
42
+ // (`./a//b/.` is `a/b`), and one of nothing else (`./`, `/`, `/.`, `.`, `''`)
43
+ // declares the repo's root, `''`; one with a `..` part names none, whatever
44
+ // it would come to. A package.json that declares none says nothing of where
45
+ // in the repo it sits.
46
+ function declaredDirectory(value) {
47
+ if (typeof value !== 'string') return undefined
48
+ const parts = value.replaceAll('\\', '/').split('/').filter((part) => part !== '' && part !== '.')
49
+ return parts.length === 0 ? '' : repoSubdirectory(parts.join('/'))
50
+ }
51
+
36
52
  export const isRepoDirectory = (value) => typeof value === 'string' && (value === '' || repoSubdirectory(value) === value)
37
53
  export const assertRepoDirectory = assertion('a path inside the repository', isRepoDirectory)
38
54
 
@@ -41,11 +57,12 @@ export function getRepo(pkg) {
41
57
  const { bugs, repository } = pkg
42
58
  const declared = repositoryRepo(urlOf(repository))
43
59
  const homepage = homepageRegex.exec(homepageUrl(pkg.homepage))?.groups
60
+ const homepageRepo = withoutDotGit(homepage?.repo)
44
61
  // `repository` first: it is the field a publisher maintains, where a
45
62
  // `bugs` tracker can be left pointing at a former owner or misspelt.
46
- const github = [declared, repoIn(bugsRegex, urlOf(bugs)), homepage?.repo].find(isRepo)
63
+ const github = [declared, repoIn(bugsRegex, urlOf(bugs)), homepageRepo].find(isRepo)
47
64
  if (github === undefined) return {}
48
- const directory = (sameName(declared, github) && repoSubdirectory(repository?.directory))
49
- || (sameName(homepage?.repo, github) && repoSubdirectory(decodePath(homepage.directory)))
50
- return { github, ...(directory && { directory }), url: `https://github.com/${github}` }
65
+ const directory = (sameName(declared, github) ? declaredDirectory(repository?.directory) : undefined)
66
+ ?? (sameName(homepageRepo, github) ? repoSubdirectory(decodePath(homepage.directory)) : undefined)
67
+ return { github, ...(directory !== undefined && { directory }), url: `https://github.com/${github}` }
51
68
  }
package/src/remote.js CHANGED
@@ -1,15 +1,22 @@
1
1
  import { isRepo } from './args.js'
2
2
 
3
3
  // `user:token@` is skipped, never returned. It is held to the characters
4
- // RFC 3986 allows there — a `#`, `?` or `\` before the `@` ends the
4
+ // RFC 3986 allows there -- a `#`, `?` or `\` before the `@` ends the
5
5
  // authority early, so `https://evil.example#@github.com/a/b` goes to
6
- // evil.example — and URL parsing has to agree the host is github.com.
6
+ // evil.example -- and URL parsing has to agree the host is github.com.
7
7
  const gitUrlRegex = /^(?i:(?:git\+)?(?:https?|git|ssh)):\/\/(?:[\w.~%!$&'()*+,;=:-]*@)?(?i:github\.com)(?::\d{1,5})?\/(?<repo>[\w-]+\/[\w.-]+?)(?:\.git)?\/?$/u
8
8
  const scpRegex = /^(?i:git\+ssh:\/\/)?git@(?i:github\.com):(?<repo>[\w-]+\/[\w.-]+?)(?:\.git)?$/u
9
9
 
10
+ // A repo with every trailing `.git` dropped, so none ends in one: in a loop,
11
+ // as a regex would backtrack over a long run of them in a crafted URL.
12
+ export function withoutDotGit(repo) {
13
+ while (repo?.endsWith('.git')) repo = repo.slice(0, -4)
14
+ return repo
15
+ }
16
+
10
17
  export function githubRepoOfUrl(url) {
11
18
  if (typeof url !== 'string') return undefined
12
19
  const onGitHub = URL.parse(url.replace(/^git\+/iu, ''))?.hostname.toLowerCase() === 'github.com'
13
- const repo = scpRegex.exec(url)?.groups.repo ?? (onGitHub ? gitUrlRegex.exec(url)?.groups.repo : undefined)
20
+ const repo = withoutDotGit(scpRegex.exec(url)?.groups.repo ?? (onGitHub ? gitUrlRegex.exec(url)?.groups.repo : undefined))
14
21
  return isRepo(repo) ? repo : undefined
15
22
  }
@@ -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'