@preventive/compare 0.0.0-stage → 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.
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/compare.d.ts +187 -0
- package/compare.js +29 -0
- package/names.d.ts +44 -0
- package/names.js +9 -0
- package/package.json +131 -4
- package/pretty.d.ts +16 -0
- package/pretty.js +4 -0
- package/src/blocks.js +17 -0
- package/src/changes.js +90 -0
- package/src/css.js +70 -0
- package/src/error.js +12 -0
- package/src/layout.js +94 -0
- package/src/marked.js +20 -0
- package/src/names/analyze.js +235 -0
- package/src/names/binds.js +20 -0
- package/src/names/check.js +68 -0
- package/src/names/diff.js +33 -0
- package/src/names/misread.js +38 -0
- package/src/names/pairing.js +111 -0
- package/src/names/rename.js +69 -0
- package/src/names/syntax.js +63 -0
- package/src/oxc.js +6 -0
- package/src/oxc.node.js +16 -0
- package/src/pairs.js +100 -0
- package/src/patch.js +78 -0
- package/src/paths.js +22 -0
- package/src/peers.js +18 -0
- package/src/pretty.js +106 -0
- package/src/quote.js +16 -0
- package/src/renames.js +205 -0
- package/src/same-bytes.js +18 -0
- package/src/same-bytes.node.js +7 -0
- package/src/script.js +57 -0
- package/src/similarity.js +53 -0
- package/src/text.js +41 -0
- package/src/tree.js +98 -0
- package/src/whitespace.js +44 -0
- package/src/words.js +57 -0
- package/README.md +0 -3
package/src/blocks.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Whether a change set takes `before` to `after` as @preventive/diff holds its
|
|
2
|
+
// own to, every kept line alike, its terminator with it: renamedDiff's check on
|
|
3
|
+
// the blocks it gives, and markedBlocks' test of lines alike.
|
|
4
|
+
|
|
5
|
+
import { DiffError, formatChangeSet } from '@preventive/diff'
|
|
6
|
+
import { checkLineOptions } from './text.js'
|
|
7
|
+
|
|
8
|
+
export function blocksHold(before, after, blocks, options) {
|
|
9
|
+
const { whitespace, ignoreCase } = checkLineOptions(options)
|
|
10
|
+
try {
|
|
11
|
+
formatChangeSet(before, after, blocks, { format: 'normal', whitespace, ignoreCase })
|
|
12
|
+
return true
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (error instanceof DiffError) return false
|
|
15
|
+
throw error
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/changes.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// compareTrees: a path in both trees is modified or type-changed, a path in one
|
|
2
|
+
// added or deleted unless renames.js pairs it with another, as git diff -M does
|
|
3
|
+
// and, unless `renamesByPath` is false, by the paths a build moves files to.
|
|
4
|
+
|
|
5
|
+
import { MAX_SCORE } from './similarity.js'
|
|
6
|
+
import { findRenames } from './renames.js'
|
|
7
|
+
import { checkLineOptions, contentOf } from './text.js'
|
|
8
|
+
import { readTree, sameContent } from './tree.js'
|
|
9
|
+
|
|
10
|
+
// git's defaults: -M at 50%, diff.renameLimit at 1000.
|
|
11
|
+
const DEFAULT_RENAMES = 50
|
|
12
|
+
const DEFAULT_RENAME_LIMIT = 1000
|
|
13
|
+
|
|
14
|
+
// Code point order, which is git's byte order for UTF-8 paths; `<` on strings
|
|
15
|
+
// would put U+E000..U+FFFF after astral characters.
|
|
16
|
+
function byPath(a, b) {
|
|
17
|
+
const shared = Math.min(a.length, b.length)
|
|
18
|
+
for (let i = 0; i < shared; i++) {
|
|
19
|
+
if (a[i] !== b[i]) return a.codePointAt(i) - b.codePointAt(i)
|
|
20
|
+
}
|
|
21
|
+
return a.length - b.length
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// -M's percentage as the least score out of MAX_SCORE, or null for no renames.
|
|
25
|
+
function minimumScore(renames) {
|
|
26
|
+
if (renames === false) return null
|
|
27
|
+
const percent = renames === true ? DEFAULT_RENAMES : renames
|
|
28
|
+
if (typeof percent !== 'number') throw new TypeError(`renames must be a boolean or a percentage, not ${typeof renames}`)
|
|
29
|
+
if (!(percent > 0 && percent <= 100)) throw new RangeError(`renames must be a percentage above 0 and at most 100, not ${renames}`)
|
|
30
|
+
return Math.max(1, Math.trunc(MAX_SCORE * percent / 100))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function checkOptions({ renamesByPath, renameLimit, renameGroup, paths, lines }) {
|
|
34
|
+
if (typeof renamesByPath !== 'boolean') throw new TypeError(`renamesByPath must be a boolean, not ${typeof renamesByPath}`)
|
|
35
|
+
if (renameLimit !== Infinity && !(Number.isSafeInteger(renameLimit) && renameLimit >= 0)) throw new RangeError(`renameLimit must be a whole number or Infinity, not ${renameLimit}`)
|
|
36
|
+
if (renameGroup !== null && typeof renameGroup !== 'function') throw new TypeError(`renameGroup must be a function, not ${typeof renameGroup}`)
|
|
37
|
+
if (paths !== 'tree' && paths !== 'keys') throw new RangeError(`paths must be 'tree' or 'keys', not ${paths}`)
|
|
38
|
+
if (typeof lines !== 'boolean') throw new TypeError(`lines must be a boolean, not ${typeof lines}`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const sameFile = (a, b) => sameContent(a, b) && (a.type !== 'file' || a.mode === b.mode)
|
|
42
|
+
|
|
43
|
+
const status = (kind, old, fresh, similarity = null) => ({ status: kind, old, new: fresh, similarity })
|
|
44
|
+
|
|
45
|
+
function pathChanges(old, fresh) {
|
|
46
|
+
const added = [], changes = [], deleted = []
|
|
47
|
+
for (const [path, file] of old) {
|
|
48
|
+
const now = fresh.get(path)
|
|
49
|
+
if (now === undefined) deleted.push(file)
|
|
50
|
+
else if (now.type !== file.type) changes.push(status('type-changed', file, now))
|
|
51
|
+
else if (!sameFile(file, now)) changes.push(status('modified', file, now))
|
|
52
|
+
}
|
|
53
|
+
for (const [path, file] of fresh) if (!old.has(path)) added.push(file)
|
|
54
|
+
deleted.sort((a, b) => byPath(a.path, b.path))
|
|
55
|
+
added.sort((a, b) => byPath(a.path, b.path))
|
|
56
|
+
return { changes, deleted, added }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// With `group`, renames are looked for within each group alone, as in a tree
|
|
60
|
+
// of its own: a file is never taken for one of another group.
|
|
61
|
+
function renamePairs(deleted, added, { group, ...options }) {
|
|
62
|
+
if (group === null) return findRenames(deleted, added, options)
|
|
63
|
+
const pairs = new Map()
|
|
64
|
+
const groupsOf = (files) => Map.groupBy(files.keys(), (i) => group(files[i].path))
|
|
65
|
+
const sourcesBy = groupsOf(deleted)
|
|
66
|
+
for (const [key, ds] of groupsOf(added)) {
|
|
67
|
+
const ss = sourcesBy.get(key)
|
|
68
|
+
if (ss === undefined) continue
|
|
69
|
+
for (const [d, { source, score }] of findRenames(ss.map((s) => deleted[s]), ds.map((i) => added[i]), options)) pairs.set(ds[d], { source: ss[source], score })
|
|
70
|
+
}
|
|
71
|
+
return pairs
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function compareTrees(a, b, { renames = true, renamesByPath = true, renameLimit = DEFAULT_RENAME_LIMIT, renameGroup = null, paths = 'tree', lines = true, horizon, whitespace, ignoreCase } = {}) {
|
|
75
|
+
const minimum = minimumScore(renames)
|
|
76
|
+
checkOptions({ renamesByPath, renameLimit, renameGroup, paths, lines })
|
|
77
|
+
const lineOptions = checkLineOptions({ horizon, whitespace, ignoreCase })
|
|
78
|
+
const keys = paths === 'keys'
|
|
79
|
+
const { changes, deleted, added } = pathChanges(readTree(a, 'old', keys), readTree(b, 'new', keys))
|
|
80
|
+
const pairs = minimum === null ? new Map() : renamePairs(deleted, added, { minimum, limit: renameLimit, byPath: renamesByPath, group: renameGroup })
|
|
81
|
+
for (const [d, file] of added.entries()) {
|
|
82
|
+
const pair = pairs.get(d)
|
|
83
|
+
changes.push(pair === undefined ? status('added', null, file) : status('renamed', deleted[pair.source], file, Math.trunc(pair.score * 100 / MAX_SCORE)))
|
|
84
|
+
}
|
|
85
|
+
const moved = new Set(pairs.values().map(({ source }) => source))
|
|
86
|
+
for (const [s, file] of deleted.entries()) if (!moved.has(s)) changes.push(status('deleted', file, null))
|
|
87
|
+
// A rename stands at its new path, as git lists it.
|
|
88
|
+
changes.sort((x, y) => byPath((x.new ?? x.old).path, (y.new ?? y.old).path))
|
|
89
|
+
return lines ? changes.map((change) => ({ ...change, ...contentOf(change.old, change.new, lineOptions) })) : changes
|
|
90
|
+
}
|
package/src/css.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Two style sheets alike but for layout, as css-tree reads them: one tree but for the ASCII case of what
|
|
2
|
+
// CSS reads in any case (properties, at-rules, pseudo-classes, units, `!important`, hex colors where the
|
|
3
|
+
// property's grammar takes them), zeros in a number that change neither its value nor its type
|
|
4
|
+
// (`.50` as `0.5`), an attribute's value quoted, and comments' indentation.
|
|
5
|
+
|
|
6
|
+
// A comment but for its lines' indentation, which formatting changes.
|
|
7
|
+
export const indentless = (text) => text.replaceAll(/(?<![ \t])[ \t]*\r?\n[ \t]*/gu, '\n')
|
|
8
|
+
|
|
9
|
+
const FOLDED = new Set(['Declaration.property', 'Atrule.name', 'Dimension.unit', 'PseudoClassSelector.name', 'PseudoElementSelector.name'])
|
|
10
|
+
const NUMBERS = new Set(['Number', 'Dimension', 'Percentage'])
|
|
11
|
+
|
|
12
|
+
// A number as written but for the zeros formatting adds before its point and drops after its last
|
|
13
|
+
// digit (`.50` as `0.5`), one digit kept after the point (`1.00` as `1.0`), as `1` is an integer.
|
|
14
|
+
const numberForm = (number) => number.replace(/^(?<sign>[+-]?)\./u, '$<sign>0.').replace(/^(?<kept>[+-]?\d*\.\d+?)0+(?!\d)/u, '$<kept>')
|
|
15
|
+
const lower = (text) => text.replaceAll(/[A-Z]+/gu, (upper) => upper.toLowerCase())
|
|
16
|
+
|
|
17
|
+
// A custom property's value, read as written but for CSS's own blanks at either end and its numbers in that form.
|
|
18
|
+
function raw(csstree, value) {
|
|
19
|
+
const { Dimension: dimension, Number: number, Percentage: percentage } = csstree.tokenTypes
|
|
20
|
+
let at = 0, form = ''
|
|
21
|
+
csstree.tokenize(value, (type, start, end) => {
|
|
22
|
+
if (type !== number && type !== percentage && type !== dimension) return
|
|
23
|
+
form += value.slice(at, start) + numberForm(value.slice(start, end))
|
|
24
|
+
at = end
|
|
25
|
+
})
|
|
26
|
+
return (form + value.slice(at)).replaceAll(/^[\t\n\f\r ]+|[\t\n\f\r ]+$/gu, '')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The hex colors among a sheet's declarations, as each known property's grammar matches its value:
|
|
30
|
+
// matched only where one holds a capital, the one case folding changes, as matching is slow.
|
|
31
|
+
function colorsOf(csstree, tree) {
|
|
32
|
+
const colors = new Set()
|
|
33
|
+
csstree.walk(tree, { visit: 'Declaration', enter(declaration) {
|
|
34
|
+
if (!csstree.lexer.getProperty(declaration.property)) return
|
|
35
|
+
const hashes = []
|
|
36
|
+
csstree.walk(declaration.value, { visit: 'Hash', enter: (hash) => /[A-Z]/u.test(hash.value) && hashes.push(hash) })
|
|
37
|
+
if (hashes.length === 0) return
|
|
38
|
+
const match = csstree.lexer.matchProperty(declaration.property, declaration.value)
|
|
39
|
+
if (match.matched) for (const hash of hashes) if (match.isType(hash, 'hex-color')) colors.add(hash)
|
|
40
|
+
} })
|
|
41
|
+
return colors
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A sheet's tree and comments in one form; null where css-tree finds an error.
|
|
45
|
+
function read(csstree, text) {
|
|
46
|
+
const comments = []
|
|
47
|
+
let failed = false
|
|
48
|
+
const tree = csstree.parse(text, { positions: false, parseCustomProperty: false, onComment: (value) => comments.push(indentless(value)), onParseError: () => { failed = true } })
|
|
49
|
+
if (failed) return null
|
|
50
|
+
const colors = colorsOf(csstree, tree)
|
|
51
|
+
return JSON.stringify([tree, comments], function form(key, value) {
|
|
52
|
+
if (key === 'loc') return undefined
|
|
53
|
+
// An attribute's unquoted value is the string it spells, unless it holds an escape: css-tree
|
|
54
|
+
// decodes those in strings alone.
|
|
55
|
+
if (key === 'value' && this.type === 'AttributeSelector' && value?.type === 'Identifier' && !value.name.includes('\\')) return { type: 'String', value: value.name }
|
|
56
|
+
if (typeof value !== 'string') return value
|
|
57
|
+
if (this.type === 'Declaration' && key === 'important') return lower(value) === 'important' || value
|
|
58
|
+
if (FOLDED.has(`${this.type}.${key}`) && !value.startsWith('--')) return lower(value)
|
|
59
|
+
if (key !== 'value') return value
|
|
60
|
+
if (this.type === 'Hash' && colors.has(this)) return lower(value)
|
|
61
|
+
if (NUMBERS.has(this.type)) return numberForm(value)
|
|
62
|
+
return this.type === 'Raw' ? raw(csstree, value) : value
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// What css-tree throws on is format's to refuse.
|
|
67
|
+
export function sameCss(csstree, text, formatted) {
|
|
68
|
+
const before = read(csstree, text)
|
|
69
|
+
return before !== null && before === read(csstree, formatted)
|
|
70
|
+
}
|
package/src/error.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// A tree that cannot be a tree: `path` is the name at fault as its entry
|
|
2
|
+
// spelled it, `tree` 'old' or 'new'. Wrong argument types stay TypeErrors.
|
|
3
|
+
|
|
4
|
+
export class CompareError extends Error {
|
|
5
|
+
constructor(detail, path, tree) {
|
|
6
|
+
const where = tree === undefined ? '' : ` in the ${tree} tree`
|
|
7
|
+
super(path === undefined ? detail : `${detail}: ${JSON.stringify(path)}${where}`)
|
|
8
|
+
this.name = 'CompareError'
|
|
9
|
+
this.path = path
|
|
10
|
+
this.tree = tree
|
|
11
|
+
}
|
|
12
|
+
}
|
package/src/layout.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Two trees taken from different roots lined up: the directory one's files sit
|
|
2
|
+
// under in the other (`root`), and each old path as the new tree lays it out.
|
|
3
|
+
|
|
4
|
+
function throughRoot(root, path) {
|
|
5
|
+
if (!root) return path
|
|
6
|
+
if (root.base === '') return `${root.other}${path}`
|
|
7
|
+
return path.startsWith(root.base) ? path.slice(root.base.length) : null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// A path outside a root as one inside it reaches it.
|
|
11
|
+
const outside = (root, path) => `${'../'.repeat(root.base.split('/').length - 1)}${path}`
|
|
12
|
+
|
|
13
|
+
// Each old path as the new tree lays it out: through the root where the new
|
|
14
|
+
// tree has it there, else as it is where it has that, else through the root,
|
|
15
|
+
// one outside it a `../` further out, clear of every path taken.
|
|
16
|
+
function rootPaths(base, other, root) {
|
|
17
|
+
const paths = new Map(), taken = new Set()
|
|
18
|
+
let moved = 0
|
|
19
|
+
for (const path of base) {
|
|
20
|
+
const to = throughRoot(root, path)
|
|
21
|
+
if (to === null || !other.has(to)) continue
|
|
22
|
+
paths.set(path, to)
|
|
23
|
+
taken.add(to)
|
|
24
|
+
if (to !== path) moved++
|
|
25
|
+
}
|
|
26
|
+
for (const path of base) {
|
|
27
|
+
if (paths.has(path) || !other.has(path) || taken.has(path)) continue
|
|
28
|
+
paths.set(path, path)
|
|
29
|
+
taken.add(path)
|
|
30
|
+
}
|
|
31
|
+
const paired = paths.size
|
|
32
|
+
for (const path of base) {
|
|
33
|
+
if (paths.has(path)) continue
|
|
34
|
+
let to = throughRoot(root, path) ?? outside(root, path)
|
|
35
|
+
while (other.has(to) || taken.has(to)) to = `../${to}`
|
|
36
|
+
paths.set(path, to)
|
|
37
|
+
taken.add(to)
|
|
38
|
+
}
|
|
39
|
+
return { paths, paired, moved }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function detectRoot(basePaths, otherPaths) {
|
|
43
|
+
const base = new Set(basePaths), other = new Set(otherPaths)
|
|
44
|
+
const likeliest = (within, from) => {
|
|
45
|
+
const counts = new Map()
|
|
46
|
+
for (const path of within) {
|
|
47
|
+
for (let at = path.indexOf('/'); at !== -1; at = path.indexOf('/', at + 1)) {
|
|
48
|
+
if (from.has(path.slice(at + 1))) counts.set(path.slice(0, at + 1), (counts.get(path.slice(0, at + 1)) ?? 0) + 1)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
let best = null, tied = false
|
|
52
|
+
for (const [dir, count] of counts) {
|
|
53
|
+
if (count > (counts.get(best) ?? 0)) [best, tied] = [dir, false]
|
|
54
|
+
else if (count === counts.get(best)) tied = true
|
|
55
|
+
}
|
|
56
|
+
return tied ? null : best
|
|
57
|
+
}
|
|
58
|
+
const same = rootPaths(base, other, null).paired
|
|
59
|
+
const found = [[{ base: '', other: likeliest(other, base) }, base], [{ base: likeliest(base, other), other: '' }, other]]
|
|
60
|
+
.filter(([root]) => root.base !== null && root.other !== null)
|
|
61
|
+
.map(([root, side]) => ({ root, side, ...rootPaths(base, other, root) }))
|
|
62
|
+
.filter(({ side, paired, moved }) => paired > same && moved * 2 >= side.size)
|
|
63
|
+
if (found.length === 2 && found[0].paired === found[1].paired) return null
|
|
64
|
+
return found.toSorted((a, b) => b.paired - a.paired)[0]?.root ?? null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function treeLayout(basePaths, otherPaths) {
|
|
68
|
+
const base = [...basePaths], other = new Set(otherPaths)
|
|
69
|
+
const root = detectRoot(base, other)
|
|
70
|
+
return { root, paths: rootPaths(base, other, root).paths }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function layoutDir({ root, paths }, dir, files) {
|
|
74
|
+
if (!root || dir === '.') return dir
|
|
75
|
+
for (const file of files) {
|
|
76
|
+
const to = paths.get(`${dir}/${file}`)
|
|
77
|
+
if (to?.endsWith(`/${file}`)) return to.slice(0, -file.length - 1)
|
|
78
|
+
}
|
|
79
|
+
const to = throughRoot(root, `${dir}/`)
|
|
80
|
+
return to === null ? outside(root, dir) : to.slice(0, -1) || '.'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function layoutPackageDirs(baseDirs, otherDirs, layout) {
|
|
84
|
+
if (!baseDirs && !otherDirs) return null
|
|
85
|
+
const files = Map.groupBy([...baseDirs ?? []], ([, dir]) => dir)
|
|
86
|
+
const dirs = new Map([...files].map(([dir, entries]) => [dir, layoutDir(layout, dir, entries.map(([path]) => path.slice(dir.length + 1)))]))
|
|
87
|
+
return new Map([...[...baseDirs ?? []].map(([path, dir]) => [layout.paths.get(path) ?? path, dirs.get(dir)]), ...otherDirs ?? []])
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function rootLabel(root, prefix) {
|
|
91
|
+
if (!root) return { prefix, from: null, to: null }
|
|
92
|
+
const kept = prefix.startsWith(root.other) ? prefix : ''
|
|
93
|
+
return { prefix: kept, from: `./${root.base}${kept.slice(root.other.length)}`, to: `./${kept || root.other}` }
|
|
94
|
+
}
|
package/src/marked.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The blocks of the diff of `before` and `after` whose old lines, as `given`
|
|
2
|
+
// (`before` renamed, line for line) has them, are their new lines: changed in
|
|
3
|
+
// renamed names alone. null where `given` has another number of lines.
|
|
4
|
+
|
|
5
|
+
import { changeSet } from '@preventive/diff'
|
|
6
|
+
import { blocksHold } from './blocks.js'
|
|
7
|
+
import { checkLineOptions } from './text.js'
|
|
8
|
+
|
|
9
|
+
const records = (text) => (text === '' ? [] : text.split(/(?<=\n)/u))
|
|
10
|
+
|
|
11
|
+
export function markedBlocks(before, given, after, options) {
|
|
12
|
+
const lineOptions = checkLineOptions(options)
|
|
13
|
+
const renamed = records(given)
|
|
14
|
+
if (renamed.length !== records(before).length) return null
|
|
15
|
+
// Lines alike are those an empty change set takes from one to the other.
|
|
16
|
+
const alike = (olds, news) => blocksHold(olds.join(''), news.join(''), [], lineOptions)
|
|
17
|
+
return changeSet(before, after, lineOptions)
|
|
18
|
+
.filter(({ a0, a1, b0, b1, insert }) => a1 - a0 === b1 - b0 && alike(renamed.slice(a0, a1), insert))
|
|
19
|
+
.map(({ a0, a1, b0, b1 }) => ({ a0, a1, b0, b1 }))
|
|
20
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// A text's scopes and variables, each name read a variable's or a global's, or
|
|
2
|
+
// `{ reason }` where names can't all be followed. Renamable (`aside`): short and
|
|
3
|
+
// declared, and no script's global, export's or import's own name, or shorthand.
|
|
4
|
+
|
|
5
|
+
import { getOxc } from '#oxc'
|
|
6
|
+
import { MINIFIED, NAME_READS, RENAMED_MAX_LENGTH, declared, hoistedPast, holds, resolve, saysStrict } from './syntax.js'
|
|
7
|
+
|
|
8
|
+
const FUNCTIONS = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'])
|
|
9
|
+
// Statements whose `let`, `const`, `class` and functions are their own.
|
|
10
|
+
const BLOCKS = new Set(['BlockStatement', 'ForStatement', 'ForInStatement', 'ForOfStatement', 'SwitchStatement'])
|
|
11
|
+
// Globals that make a script a CommonJS module, its top level its own.
|
|
12
|
+
const COMMONJS = new Set(['exports', 'module', 'require'])
|
|
13
|
+
|
|
14
|
+
// oxc is loaded outside the try: one missing is no text that won't parse. A text
|
|
15
|
+
// oxc's semantic check finds an error in is one it can't vouch for.
|
|
16
|
+
function parsed(text, extension) {
|
|
17
|
+
const sourceType = extension === 'mjs' ? 'module' : extension === 'cjs' ? 'commonjs' : 'unambiguous'
|
|
18
|
+
const { parseSync } = getOxc()
|
|
19
|
+
try {
|
|
20
|
+
const result = parseSync('file.js', text, { lang: 'jsx', sourceType, showSemanticErrors: true })
|
|
21
|
+
return result.errors.length > 0 ? null : result.program
|
|
22
|
+
} catch {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function scope(s, parent, fn) {
|
|
28
|
+
const made = { parent, fn, names: new Map(), strict: parent?.strict ?? false }
|
|
29
|
+
s.scopes.push(made)
|
|
30
|
+
return made
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A body's `var` or function named as one of its parameters is that parameter:
|
|
34
|
+
// `function f(a) { var a; return a }` returns its argument.
|
|
35
|
+
function declare(s, where, node) {
|
|
36
|
+
const at = where.parameters?.names.has(node.name) ? where.parameters : where
|
|
37
|
+
let variable = at.names.get(node.name)
|
|
38
|
+
if (!variable) {
|
|
39
|
+
variable = { name: node.name, scope: at }
|
|
40
|
+
at.names.set(node.name, variable)
|
|
41
|
+
s.variables.push(variable)
|
|
42
|
+
}
|
|
43
|
+
s.declarations.set(node, [variable, at])
|
|
44
|
+
return variable
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const fnOf = (where) => (where.fn ? where : fnOf(where.parent))
|
|
48
|
+
|
|
49
|
+
// Two variables that must keep one name, read from then on as the first.
|
|
50
|
+
function join(s, kept, gone) {
|
|
51
|
+
if (kept === gone || !s.variables.includes(gone)) return
|
|
52
|
+
for (const path of gone.through ?? []) (kept.through ??= []).push(path)
|
|
53
|
+
for (const at of s.scopes) for (const [name, variable] of at.names) if (variable === gone) at.names.set(name, kept)
|
|
54
|
+
for (const [node, [variable, where]] of s.declarations) if (variable === gone) s.declarations.set(node, [kept, where])
|
|
55
|
+
s.variables.splice(s.variables.indexOf(gone), 1)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A `var` is its function's, passing the blocks it is in on the way (`through`);
|
|
59
|
+
// one named as a `catch` parameter it is in keeps one name with it.
|
|
60
|
+
function declareVariables(node, where, s) {
|
|
61
|
+
for (const id of node.declarations.flatMap((declarator) => declared(declarator.id))) {
|
|
62
|
+
const variable = declare(s, node.kind === 'var' ? fnOf(where) : where, id)
|
|
63
|
+
if (node.kind !== 'var') continue
|
|
64
|
+
const path = []
|
|
65
|
+
for (let at = where; !at.fn; at = at.parent) path.push(at)
|
|
66
|
+
if (path.length > 0) (variable.through ??= []).push(path)
|
|
67
|
+
const caught = path.find((at) => at.caught && at.names.has(id.name))
|
|
68
|
+
if (caught) join(s, caught.names.get(id.name), variable)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// An imported or exported name is no variable's; a variable imported or
|
|
73
|
+
// exported by its own name keeps it.
|
|
74
|
+
function moduleNames(node, s) {
|
|
75
|
+
if (node.type === 'ImportDeclaration') {
|
|
76
|
+
for (const specifier of node.specifiers) {
|
|
77
|
+
declare(s, s.root, specifier.local)
|
|
78
|
+
if (specifier.type !== 'ImportSpecifier') continue
|
|
79
|
+
s.others.add(specifier.imported)
|
|
80
|
+
if (specifier.imported.start === specifier.local.start) s.keeping.add(specifier.local)
|
|
81
|
+
}
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
for (const specifier of node.specifiers) {
|
|
85
|
+
s.others.add(specifier.exported)
|
|
86
|
+
if (node.source) s.others.add(specifier.local)
|
|
87
|
+
else if (specifier.exported.start === specifier.local.start) s.keeping.add(specifier.local)
|
|
88
|
+
}
|
|
89
|
+
if (node.declaration?.type === 'VariableDeclaration') for (const declarator of node.declaration.declarations) for (const id of declared(declarator.id)) s.keeping.add(id)
|
|
90
|
+
else if (node.declaration?.id) s.keeping.add(node.declaration.id)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Names that are no variable's (keys, labels), names kept, and the properties
|
|
94
|
+
// names are read through.
|
|
95
|
+
function otherNames(node, s) {
|
|
96
|
+
switch (node.type) {
|
|
97
|
+
case 'ExportDefaultDeclaration': if (node.declaration.id) s.keeping.add(node.declaration.id); break
|
|
98
|
+
case 'ExportAllDeclaration': if (node.exported) s.others.add(node.exported); break
|
|
99
|
+
case 'ImportAttribute': s.others.add(node.key); break
|
|
100
|
+
case 'MemberExpression':
|
|
101
|
+
if (!node.computed) s.others.add(node.property)
|
|
102
|
+
if (!node.computed && NAME_READS.has(node.property.name)) s.reads.add(node.property.name)
|
|
103
|
+
break
|
|
104
|
+
case 'ObjectPattern':
|
|
105
|
+
for (const { type, computed, key } of node.properties) if (type === 'Property' && !computed && NAME_READS.has(key.name)) s.reads.add(key.name)
|
|
106
|
+
break
|
|
107
|
+
case 'Literal': if (NAME_READS.has(node.value)) s.reads.add(node.value); break
|
|
108
|
+
case 'TemplateLiteral': for (const { value } of node.quasis) if (NAME_READS.has(value.cooked)) s.reads.add(value.cooked); break
|
|
109
|
+
case 'Property': case 'MethodDefinition': case 'PropertyDefinition': case 'AccessorProperty':
|
|
110
|
+
if (node.computed) break
|
|
111
|
+
if (node.shorthand) s.keeping.add(node.key)
|
|
112
|
+
else s.others.add(node.key)
|
|
113
|
+
break
|
|
114
|
+
case 'LabeledStatement': case 'BreakStatement': case 'ContinueStatement': if (node.label) s.others.add(node.label); break
|
|
115
|
+
case 'MetaProperty': s.others.add(node.meta).add(node.property); break
|
|
116
|
+
default: break
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// What a node declares, or why its names can't all be followed.
|
|
121
|
+
function visit(node, where, s) {
|
|
122
|
+
switch (node.type) {
|
|
123
|
+
case 'JSXElement': case 'JSXFragment': return 'jsx'
|
|
124
|
+
case 'WithStatement': return 'dynamic'
|
|
125
|
+
case 'VariableDeclaration': declareVariables(node, where, s); return null
|
|
126
|
+
case 'FunctionDeclaration':
|
|
127
|
+
if (node.id && !where.fn && !where.strict) return 'block-function'
|
|
128
|
+
if (node.id) declare(s, where, node.id)
|
|
129
|
+
return null
|
|
130
|
+
// A function an `if` holds without braces is one in a block.
|
|
131
|
+
case 'IfStatement':
|
|
132
|
+
return !where.strict && [node.consequent, node.alternate].some((clause) => clause?.type === 'FunctionDeclaration') ? 'block-function' : null
|
|
133
|
+
case 'ClassDeclaration': if (node.id) declare(s, where, node.id); return null
|
|
134
|
+
case 'ImportDeclaration': case 'ExportNamedDeclaration': moduleNames(node, s); return null
|
|
135
|
+
default: otherNames(node, s); return null
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Parameters are a scope, the body's declarations one apart (a default reads
|
|
140
|
+
// none: `(a = b) => { var b }`), and an expression's own name one around both.
|
|
141
|
+
function enterFunction(node, where, s) {
|
|
142
|
+
const named = node.type !== 'FunctionDeclaration' && node.id ? scope(s, where, false) : where
|
|
143
|
+
if (named !== where) declare(s, named, node.id)
|
|
144
|
+
if (node.type === 'ClassExpression') {
|
|
145
|
+
for (const child of [node.superClass, node.body, ...node.decorators ?? []]) if (child) s.work.push([child, named])
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
const parameters = scope(s, named, true)
|
|
149
|
+
if (node.body?.type === 'BlockStatement' && saysStrict(node.body.body)) parameters.strict = true
|
|
150
|
+
for (const parameter of node.params) {
|
|
151
|
+
for (const id of declared(parameter)) declare(s, parameters, id)
|
|
152
|
+
s.work.push([parameter, parameters])
|
|
153
|
+
}
|
|
154
|
+
if (node.body?.type === 'BlockStatement') {
|
|
155
|
+
const body = Object.assign(scope(s, parameters, true), { parameters })
|
|
156
|
+
for (const statement of node.body.body) s.work.push([statement, body])
|
|
157
|
+
} else if (node.body) s.work.push([node.body, parameters])
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// A node's children, each with the scope it is read in.
|
|
161
|
+
function enter(node, where, s) {
|
|
162
|
+
if (FUNCTIONS.has(node.type) || node.type === 'ClassExpression') {
|
|
163
|
+
enterFunction(node, where, s)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
let inner = where
|
|
167
|
+
if (BLOCKS.has(node.type)) inner = scope(s, where, false)
|
|
168
|
+
else if (node.type === 'ClassBody') inner = Object.assign(scope(s, where, false), { strict: true })
|
|
169
|
+
else if (node.type === 'StaticBlock') inner = scope(s, where, true)
|
|
170
|
+
else if (node.type === 'CatchClause') {
|
|
171
|
+
inner = Object.assign(scope(s, where, false), { caught: true, simple: node.param?.type === 'Identifier' })
|
|
172
|
+
for (const id of declared(node.param)) declare(s, inner, id)
|
|
173
|
+
}
|
|
174
|
+
// A switch's discriminant is read before its cases' scope is entered.
|
|
175
|
+
for (const [field, value] of Object.entries(node)) {
|
|
176
|
+
if (Array.isArray(value)) {
|
|
177
|
+
for (const child of value) if (child && typeof child.type === 'string') s.work.push([child, inner])
|
|
178
|
+
} else if (value && typeof value.type === 'string' && field !== 'type') s.work.push([value, field === 'discriminant' ? where : inner])
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// A work list rather than a recursion: a minified file's expressions nest deeply.
|
|
183
|
+
function walk(program) {
|
|
184
|
+
const s = { declarations: new Map(), scopes: [], variables: [], keeping: new Set(), others: new Set(), reads: new Set(), references: [], work: [] }
|
|
185
|
+
s.root = Object.assign(scope(s, null, true), { strict: program.sourceType === 'module' || saysStrict(program.body) })
|
|
186
|
+
s.work.push([program, s.root])
|
|
187
|
+
while (s.work.length > 0) {
|
|
188
|
+
const [node, where] = s.work.pop()
|
|
189
|
+
if (node.type === 'Identifier') {
|
|
190
|
+
if (!s.declarations.has(node) && !s.others.has(node)) s.references.push([node, where])
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
const reason = visit(node, where, s)
|
|
194
|
+
if (reason) return { reason }
|
|
195
|
+
enter(node, where, s)
|
|
196
|
+
}
|
|
197
|
+
return s
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Each name read resolved outward from where it is read, else a global's.
|
|
201
|
+
// `eval(...)` reads names by their spelling, wherever `eval` is declared.
|
|
202
|
+
function resolved(program, s) {
|
|
203
|
+
const globals = [], occurrences = [...s.declarations].map(([node, [variable, where]]) => [node, variable, where])
|
|
204
|
+
for (const [node, where] of s.references) {
|
|
205
|
+
if (node.name === 'eval') return { reason: 'dynamic' }
|
|
206
|
+
const variable = resolve(node.name, where)
|
|
207
|
+
if (variable) occurrences.push([node, variable, where])
|
|
208
|
+
else globals.push([node, where])
|
|
209
|
+
}
|
|
210
|
+
for (const [node, variable] of occurrences) if (s.keeping.has(node)) variable.kept = true
|
|
211
|
+
const own = program.sourceType === 'module' || program.sourceType === 'commonjs' || globals.some(([node]) => COMMONJS.has(node.name))
|
|
212
|
+
for (const variable of s.variables) variable.aside = variable.name.length <= RENAMED_MAX_LENGTH && MINIFIED.test(variable.name) && !variable.kept && (own || variable.scope !== s.root)
|
|
213
|
+
// A shorthand property's two names are one, at one place.
|
|
214
|
+
const byStart = (x, y) => x[0].start - y[0].start
|
|
215
|
+
occurrences.sort(byStart)
|
|
216
|
+
globals.sort(byStart)
|
|
217
|
+
for (let i = 1; i < occurrences.length; i++) {
|
|
218
|
+
const [[node, variable], [previous, other]] = [occurrences[i], occurrences[i - 1]]
|
|
219
|
+
holds(node.start !== previous.start || variable === other, `${node.name} at ${node.start} two variables'`)
|
|
220
|
+
}
|
|
221
|
+
const deduplicated = occurrences.filter((entry, i) => i === 0 || entry[0].start !== occurrences[i - 1][0].start)
|
|
222
|
+
const names = new Set([...s.variables.map(({ name }) => name), ...globals.map(([node]) => node.name)])
|
|
223
|
+
return { scopes: s.scopes, variables: s.variables, occurrences: deduplicated, globals, names, reads: s.reads, module: program.sourceType === 'module', commonjs: program.sourceType === 'commonjs', own }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function analyze(text, extension) {
|
|
227
|
+
const program = parsed(text, extension)
|
|
228
|
+
if (!program) return { reason: 'unparsed' }
|
|
229
|
+
const s = walk(program)
|
|
230
|
+
if (s.reason) return s
|
|
231
|
+
// A `var` named as a block's it hoists past does not compile, which oxc's
|
|
232
|
+
// semantic check misses beside a `catch`.
|
|
233
|
+
for (const variable of s.variables) for (const path of variable.through ?? []) if (hoistedPast(variable, path, variable.name, (at) => at.names).length > 0) return { reason: 'unparsed' }
|
|
234
|
+
return resolved(program, s)
|
|
235
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Whether `renamed` binds as `before` does by oxc's minifier: both mangled,
|
|
2
|
+
// every variable named by its place alone, they read the same. A check apart
|
|
3
|
+
// from analyze's, each catching what the other misses; the top level as `kind`.
|
|
4
|
+
|
|
5
|
+
import { getOxc } from '#oxc'
|
|
6
|
+
|
|
7
|
+
export function bindsAlike(before, renamed, { module = false, commonjs = false, own = module || commonjs } = {}) {
|
|
8
|
+
const { minifySync } = getOxc()
|
|
9
|
+
const file = module ? 'file.mjs' : commonjs ? 'file.cjs' : 'file.js'
|
|
10
|
+
const mangled = (text) => {
|
|
11
|
+
try {
|
|
12
|
+
const result = minifySync(file, text, { module, compress: false, mangle: { toplevel: own }, codegen: { removeWhitespace: true } })
|
|
13
|
+
return result.errors.length === 0 ? result.code : null
|
|
14
|
+
} catch {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const code = mangled(before)
|
|
19
|
+
return code !== null && code === mangled(renamed)
|
|
20
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Checks that should never fail, between the steps of renaming: each throws a
|
|
2
|
+
// Broken naming what failed, and renameMap then gives no names.
|
|
3
|
+
|
|
4
|
+
import { analyze } from './analyze.js'
|
|
5
|
+
import { APART, MINIFIED, RESERVED, holds } from './syntax.js'
|
|
6
|
+
|
|
7
|
+
// Each name to rename written as that name where it is, read once and in
|
|
8
|
+
// order, none a variable's and a global's both, each variable declared, read
|
|
9
|
+
// and standing in its scopes by its own name.
|
|
10
|
+
export function checkAnalysis(text, { scopes, variables, occurrences, globals }) {
|
|
11
|
+
const known = new Set(variables), read = new Set()
|
|
12
|
+
let end = 0
|
|
13
|
+
for (const [node, variable] of occurrences) {
|
|
14
|
+
holds(known.has(variable), `${node.name} at ${node.start} read as a variable none declares`)
|
|
15
|
+
holds(node.name === variable.name, `${node.name} at ${node.start} read as ${variable.name}`)
|
|
16
|
+
holds(node.start >= end && node.end > node.start, `${node.name} at ${node.start} out of order`)
|
|
17
|
+
holds(text.slice(node.start, node.end) === node.name, `${node.name} at ${node.start} written otherwise`)
|
|
18
|
+
end = node.end
|
|
19
|
+
read.add(node.start)
|
|
20
|
+
}
|
|
21
|
+
for (const [node] of globals) holds(!read.has(node.start), `${node.name} at ${node.start} both a variable's and a global's`)
|
|
22
|
+
const standing = new Set(occurrences.map(([, variable]) => variable))
|
|
23
|
+
for (const variable of variables) holds(standing.has(variable), `${variable.name} read nowhere`)
|
|
24
|
+
for (const at of scopes) for (const [name, variable] of at.names) holds(known.has(variable) && variable.name === name, `a scope's ${name} no variable of its name`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Renamable variables alone paired, each new one once, the weak among them.
|
|
28
|
+
export function checkPairing(a, b, { map, weak }) {
|
|
29
|
+
const news = new Set(b.variables), taken = new Set()
|
|
30
|
+
for (const [variable, to] of map) {
|
|
31
|
+
holds(variable.aside && to.aside && news.has(to), `${variable.name} paired with ${to.name}, the two not both renamable`)
|
|
32
|
+
holds(!taken.has(to), `two variables paired with ${to.name}`)
|
|
33
|
+
taken.add(to)
|
|
34
|
+
}
|
|
35
|
+
for (const variable of weak) holds(map.has(variable), `${variable.name} weak, and paired with none`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Each name changed a renamable variable's, one a minifier gives and no
|
|
39
|
+
// keyword, and its counterpart's or one apart that neither side holds.
|
|
40
|
+
export function checkGiven(a, b, map, given) {
|
|
41
|
+
for (const variable of a.variables) {
|
|
42
|
+
const name = given.get(variable)
|
|
43
|
+
if (name === variable.name) continue
|
|
44
|
+
holds(variable.aside, `${variable.name}, which keeps its name, named ${name}`)
|
|
45
|
+
holds(MINIFIED.test(name) && !RESERVED.has(name), `${variable.name} named ${name}, which no variable may be`)
|
|
46
|
+
holds(map.has(variable) ? map.get(variable).name === name : name.startsWith(variable.name + APART) && !a.names.has(name) && !b.names.has(name),
|
|
47
|
+
`${variable.name} named ${name}, neither its counterpart's name nor one apart`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The renamed text read again: each name where it was, written as given, one
|
|
52
|
+
// variable's names still one variable's and two's two, the same globals read.
|
|
53
|
+
export function checkRenamed(a, given, text, extension) {
|
|
54
|
+
const r = analyze(text, extension)
|
|
55
|
+
holds(!r.reason, `the renamed text read again: ${r.reason}`)
|
|
56
|
+
checkAnalysis(text, r)
|
|
57
|
+
holds(r.occurrences.length === a.occurrences.length && r.globals.length === a.globals.length && r.variables.length === a.variables.length,
|
|
58
|
+
'the renamed text reads another number of names')
|
|
59
|
+
const backward = new Map(), forward = new Map()
|
|
60
|
+
a.occurrences.forEach(([, variable], i) => {
|
|
61
|
+
const [node, other] = r.occurrences[i]
|
|
62
|
+
holds(node.name === given.get(variable), `${variable.name} written ${node.name}, not ${given.get(variable)}`)
|
|
63
|
+
holds((forward.get(variable) ?? other) === other && (backward.get(other) ?? variable) === variable, `${variable.name}'s names read as another variable's`)
|
|
64
|
+
forward.set(variable, other)
|
|
65
|
+
backward.set(other, variable)
|
|
66
|
+
})
|
|
67
|
+
a.globals.forEach(([node], i) => holds(r.globals[i][0].name === node.name, `the global ${node.name} read as ${r.globals[i][0].name}`))
|
|
68
|
+
}
|