@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/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
+ }