@preventive/compare 1.2.5 → 1.2.7

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/names.d.ts CHANGED
@@ -21,12 +21,15 @@ export type RenameReason = 'unparsed' | 'dynamic' | 'jsx' | 'block-function' | '
21
21
  // The diff of two JavaScript files minified on both sides with renamed names
22
22
  // set aside. The old side is given the new side's names wherever a minifier
23
23
  // renamed a variable alike throughout (a declared variable's short ASCII name,
24
- // not a script's top level -- but where the file's other version is a module,
25
- // as a bundle's chunk is once it exports, the same file loaded alike -- a name
26
- // exported or imported as itself, or a shorthand property's), checked to bind
24
+ // not a script's top level -- but where either version of the file is a module,
25
+ // as a bundle's chunk is once it exports, or declares 16 names or more there,
26
+ // every one short, as a minifier renames a script's top level only told that no
27
+ // other script reads it: the same file loaded alike -- a name exported or
28
+ // imported as itself, or a shorthand property's), checked to bind
27
29
  // every name as before by this package's scope analysis and again by oxc's
28
30
  // minifier; formatted again where that changes its layout alone and still binds
29
- // alike; diffed as diff -U0 has it; and the blocks checked to take `before` to
31
+ // alike; diffed as diff -U0 has it, and as --minimal does where that is cheap
32
+ // (lines by lines changed up to 2e8); and the blocks checked to take `before` to
30
33
  // the new side (thrown should they not). `reason` says why no names were given;
31
34
  // `reads` the properties either text reads names through (`name`, `toString`,
32
35
  // `stack`, `callee`, `caller`), sorted, as code reading its names may act
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/compare",
3
- "version": "1.2.5",
3
+ "version": "1.2.7",
4
4
  "description": "Git-compatible file tree diffs and minified JavaScript comparison",
5
5
  "type": "module",
6
6
  "main": "./compare.js",
@@ -248,8 +248,18 @@ function resolved(program, s) {
248
248
  return { scopes: s.scopes, variables: s.variables, occurrences: deduplicated, globals, names, reads: s.reads, module: program.sourceType === 'module', commonjs: program.sourceType === 'commonjs', own }
249
249
  }
250
250
 
251
+ const minified = (name) => name.length <= RENAMED_MAX_LENGTH && MINIFIED.test(name)
251
252
  // A short minified name, declared, and kept by no export, import or shorthand.
252
- const renamable = (variable) => variable.name.length <= RENAMED_MAX_LENGTH && MINIFIED.test(variable.name) && !variable.kept
253
+ const renamable = (variable) => minified(variable.name) && !variable.kept
254
+
255
+ // A script whose top level a minifier renamed: many names there, every one short.
256
+ // A minifier renames a script's top level only told that no other script reads
257
+ // it, as a bundle's chunk loaded as a module is; globals others read are few.
258
+ const MANY_TOP_LEVEL = 16
259
+ export function minifiedTopLevel(analysis) {
260
+ const top = analysis.variables.filter((variable) => variable.scope.parent === null)
261
+ return top.length >= MANY_TOP_LEVEL && top.every((variable) => minified(variable.name))
262
+ }
253
263
 
254
264
  // A script's analysis with its top level its own, as a module's: renamable there
255
265
  // too. Still read as a script, `this` and all, but no global of it set aside.
@@ -24,12 +24,14 @@ export function checkAnalysis(text, { scopes, variables, occurrences, globals })
24
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
25
  }
26
26
 
27
- // Renamable variables alone paired, each new one once, the weak among them.
28
- export function checkPairing(a, b, { map, weak }) {
27
+ // Renamable variables alone paired, each new one once and by some line, the
28
+ // weak among them.
29
+ export function checkPairing(a, b, { map, support, weak }) {
29
30
  const news = new Set(b.variables), taken = new Set()
30
31
  for (const [variable, to] of map) {
31
32
  holds(variable.aside && to.aside && news.has(to), `${variable.name} paired with ${to.name}, the two not both renamable`)
32
33
  holds(!taken.has(to), `two variables paired with ${to.name}`)
34
+ holds(support.get(variable) > 0, `${variable.name} paired with ${to.name} by no line`)
33
35
  taken.add(to)
34
36
  }
35
37
  for (const variable of weak) holds(map.has(variable), `${variable.name} weak, and paired with none`)
package/src/names/diff.js CHANGED
@@ -1,12 +1,12 @@
1
1
  // A diff of a file minified on both sides with renamed names set aside: the
2
2
  // old side renamed, formatted again where that changes its layout alone and
3
- // still binds alike, diffed, and the diff checked to hide no change.
3
+ // still binds alike, diffed at its shortest where affordable, and the diff
4
+ // checked to hide no change.
4
5
 
5
- import { changeSet } from '@preventive/diff'
6
6
  import { blocksHold } from '../blocks.js'
7
7
  import { markedBlocks } from '../marked.js'
8
8
  import { format } from '../pretty.js'
9
- import { checkLineOptions } from '../text.js'
9
+ import { checkLineOptions, shortestChangeSet } from '../text.js'
10
10
  import { bindsAlike } from './binds.js'
11
11
  import { renameMap } from './rename.js'
12
12
 
@@ -30,7 +30,7 @@ export async function renamedDiffs(before, after, { extension = 'js', otherExten
30
30
  }
31
31
  let unmarked = false
32
32
  const diffs = settings.map((options) => {
33
- const blocks = changeSet(renamed, after, options).map(ranges)
33
+ const blocks = shortestChangeSet(renamed, after, options).map(ranges)
34
34
  if (!blocksHold(renamed, after, blocks, options)) throw new Error('change blocks that do not take the old side to the new')
35
35
  // Read off the renamed text before formatting, line for line the old side's.
36
36
  const marks = mapped.reason ? [] : markedBlocks(before, mapped.text, after, options)
@@ -2,24 +2,14 @@
2
2
  // their keys (names set aside) pairs them, counts its variables as standing for
3
3
  // each other, and two each the other's most counted pair, a tie pairing neither.
4
4
 
5
- import { changeSet } from '@preventive/diff'
5
+ import { shortestChangeSet } from '../text.js'
6
6
  import { RESERVED } from './syntax.js'
7
7
 
8
8
  // Unchanged lines between changes that pair as unchanged ones, not as changed.
9
9
  const ANCHORED_RUN = 3
10
10
 
11
11
  // Whitespace alone never tells a name apart.
12
- const changes = (a, b, minimal = false) => changeSet(a, b, { horizon: 0, whitespace: 'all', minimal })
13
-
14
- // The search's cutoff can face a run with lines alike far from its own: old code
15
- // that a block of new code now comes before, its lines faced with that block's
16
- // braces. The shortest diff instead, where its cost, lines by lines changed, allows.
17
- const MINIMAL_COST = 2e8
18
- function linedUp(a, b, lines) {
19
- const quick = changes(a, b)
20
- const changed = quick.reduce((n, { a0, a1, b0, b1 }) => n + a1 - a0 + b1 - b0, 0)
21
- return lines * changed > MINIMAL_COST ? quick : changes(a, b, true)
22
- }
12
+ const KEYS = { horizon: 0, whitespace: 'all' }
23
13
 
24
14
  // A text's lines with its renamable variables' names set aside (`key`), but
25
15
  // those `written` names (a pair found), each line's variables set aside in
@@ -79,7 +69,7 @@ const reading = (k, from = 0) => (x, y) => {
79
69
  // The pairs of lines a diff of `a` and `b` keeps, each told whether slack.
80
70
  function* kept(a, b, sameA, sameB) {
81
71
  const text = (lines) => lines.map((line) => `${line}\n`).join('')
82
- const blocks = linedUp(text(a), text(b), a.length + b.length)
72
+ const blocks = shortestChangeSet(text(a), text(b), KEYS, a.length + b.length)
83
73
  const [looseA, looseB] = slack(a, b, blocks, sameA, sameB)
84
74
  let i = 0, j = 0
85
75
  for (const { a0, a1, b1 } of [...blocks, { a0: a.length, a1: a.length, b1: b.length }]) {
@@ -161,20 +151,20 @@ function moves(ka, kb, blocks, alike) {
161
151
  }
162
152
  }
163
153
 
164
- // Pairs of lines counted, a long run of unchanged ones apart from the rest, and
165
- // apart from both the variables a change's tokens pair (`tokened`) and those of
166
- // code moved (`moved`). Lines reading variables one side has and the other not
167
- // say nothing. In a change of unlike lengths, lines alike but for a final `;` or
168
- // `,` pair (a declaration others now follow: `let t = ...;` -> `let t = ...,`).
154
+ // Pairs of lines counted, a long run of unchanged ones apart from the rest, as
155
+ // is code moved, and apart from both the variables a change's tokens pair
156
+ // (`tokened`). Lines reading variables one side has and the other not say
157
+ // nothing. In a change of unlike lengths, lines alike but for a final `;` or `,`
158
+ // pair (a declaration others now follow: `let t = ...;` -> `let t = ...,`).
169
159
  function tallied(ka, kb) {
170
- const changed = tallies(), moved = tallies(), tokened = tallies(), unchanged = tallies()
160
+ const changed = tallies(), tokened = tallies(), unchanged = tallies()
171
161
  const counted = ({ backward, forward }, variable, other) => {
172
162
  count(forward, variable, other)
173
163
  count(backward, other, variable)
174
164
  }
175
165
  const alike = (counts, olds, news) => olds.length === news.length && olds.forEach((variable, k) => counted(counts, variable, news[k]))
176
166
  const trimmed = (lines, from, to) => plain(lines.slice(from, to).map((line) => line.replace(/[;,]\s*$/u, '')))
177
- const blocks = linedUp(ka.key, kb.key, ka.keys.length + kb.keys.length)
167
+ const blocks = shortestChangeSet(ka.key, kb.key, KEYS, ka.keys.length + kb.keys.length)
178
168
  const [looseA, looseB] = slack(plain(ka.keys), plain(kb.keys), blocks, reading(ka), reading(kb))
179
169
  let i = 0, j = 0
180
170
  for (const block of [...blocks, { a0: ka.lines.length, a1: ka.lines.length, b0: kb.lines.length, b1: kb.lines.length }]) {
@@ -192,8 +182,19 @@ function tallied(ka, kb) {
192
182
  i = block.a1
193
183
  j = block.b1
194
184
  }
195
- moves(ka, kb, blocks, (olds, news) => alike(moved, olds, news))
196
- return { changed, moved, tokened, unchanged }
185
+ // Code moved counts a pair once a line, so that a moved line reading a variable
186
+ // often outweighs no lines in place reading it once each.
187
+ const once = (olds, news) => {
188
+ if (olds.length !== news.length) return
189
+ const seen = new Map()
190
+ olds.forEach((variable, k) => {
191
+ const others = seen.get(variable) ?? seen.set(variable, new Set()).get(variable)
192
+ if (!others.has(news[k])) counted(unchanged, variable, news[k])
193
+ others.add(news[k])
194
+ })
195
+ }
196
+ moves(ka, kb, blocks, once)
197
+ return { changed, tokened, unchanged }
197
198
  }
198
199
 
199
200
  // The most counted of those not yet paired, none where two tie.
@@ -209,8 +210,9 @@ function best(counts, taken) {
209
210
  return found
210
211
  }
211
212
 
212
- // Pairs each the other's best, into `map`, until none more pair.
213
- function matched({ backward, forward }, { map, paired }, eligible = () => true) {
213
+ // Pairs each the other's best, into `map`, until none more pair: each pair's
214
+ // count its `support`.
215
+ function matched({ backward, forward }, { map, paired, support }, eligible = () => true) {
214
216
  const chosen = []
215
217
  for (let more = true; more;) {
216
218
  more = false
@@ -219,6 +221,7 @@ function matched({ backward, forward }, { map, paired }, eligible = () => true)
219
221
  const to = best(counts, (other) => paired.has(other))
220
222
  if (!to || best(backward.get(to), (other) => map.has(other)) !== variable || !eligible(variable, to)) continue
221
223
  map.set(variable, to)
224
+ support.set(variable, counts.get(to))
222
225
  paired.add(to)
223
226
  chosen.push(variable)
224
227
  more = true
@@ -227,23 +230,25 @@ function matched({ backward, forward }, { map, paired }, eligible = () => true)
227
230
  return chosen
228
231
  }
229
232
 
230
- // Unchanged lines pair first; changed ones then pair only what those say
231
- // nothing of, and a change's tokens only what no line says anything of: lines
232
- // reordered, a change's tokens may line up across them. Both are `weak` pairs,
233
- // the first to give way where names read wrongly. A second pass reads the
234
- // pairs found as names, so lines alike with every name set aside and diffed
235
- // against the wrong one line up by those.
233
+ // Unchanged lines pair first, and code moved counted with them: a function's
234
+ // code moved into a helper reads its variables as the helper's, the function's
235
+ // own lines as its own, and the most lines decide. Changed ones then pair only
236
+ // what those say nothing of, and a change's tokens only what no line says
237
+ // anything of: lines reordered, a change's tokens may line up across them. Both
238
+ // are `weak` pairs, the first to give way where names read wrongly. A second
239
+ // pass reads the pairs found as names, so lines alike with every name set aside
240
+ // and diffed against the wrong one line up by those.
236
241
  export function pairing(before, after, a, b) {
237
- const found = { map: new Map(), paired: new Set() }, said = new Set(), weak = new Set()
242
+ const found = { map: new Map(), paired: new Set(), support: new Map() }, said = new Set(), weak = new Set()
238
243
  const passes = [[() => null, () => null], [(variable) => found.map.get(variable)?.name ?? null, (variable) => (found.paired.has(variable) ? variable.name : null)]]
239
244
  for (const [old, now] of passes) {
240
245
  // A pass pairs nothing where every variable of a side is paired already.
241
246
  if (!a.variables.some((variable) => variable.aside && !found.map.has(variable)) || !b.variables.some((variable) => variable.aside && !found.paired.has(variable))) break
242
- const { changed, moved, tokened, unchanged } = tallied(keyed(before, a, old), keyed(after, b, now))
243
- for (const [counts, tier] of [[unchanged, false], [changed, true], [tokened, true], [moved, true]]) {
247
+ const { changed, tokened, unchanged } = tallied(keyed(before, a, old), keyed(after, b, now))
248
+ for (const [counts, tier] of [[unchanged, false], [changed, true], [tokened, true]]) {
244
249
  for (const variable of matched(counts, found, (from, to) => !said.has(from) && !said.has(to))) if (tier) weak.add(variable)
245
250
  for (const variable of [...counts.forward.keys(), ...counts.backward.keys()]) said.add(variable)
246
251
  }
247
252
  }
248
- return { map: found.map, weak }
253
+ return { map: found.map, support: found.support, weak }
249
254
  }
@@ -3,7 +3,7 @@
3
3
  // to read wrongly, and the result checked again by oxc (bindsAlike).
4
4
 
5
5
  import { getOxc } from '#oxc'
6
- import { analyze, ownTopLevel } from './analyze.js'
6
+ import { analyze, minifiedTopLevel, ownTopLevel } from './analyze.js'
7
7
  import { bindsAlike } from './binds.js'
8
8
  import { checkAnalysis, checkGiven, checkPairing, checkRenamed } from './check.js'
9
9
  import { misread } from './misread.js'
@@ -11,9 +11,10 @@ import { pairing } from './pairing.js'
11
11
  import { APART, Broken, holds } from './syntax.js'
12
12
 
13
13
  // Each old variable's name: its counterpart's, else its own, or one apart where
14
- // the new side has its own. Those misread yield, weak pairs first; null where
15
- // none can.
16
- function givenNames(a, b, { map, weak }) {
14
+ // the new side has its own. Those misread yield, weak pairs first, else those
15
+ // fewest lines pair: a pair that a few lines alone say gives way to one most
16
+ // do. null where none can.
17
+ function givenNames(a, b, { map, support, weak }) {
17
18
  const apart = (name) => {
18
19
  let named = name + APART
19
20
  for (let n = 2; a.names.has(named) || b.names.has(named); n++) named = `${name}${APART}${n}`
@@ -27,7 +28,9 @@ function givenNames(a, b, { map, weak }) {
27
28
  }
28
29
  const wrong = [...misread(a, given)]
29
30
  if (wrong.length === 0) return given
30
- const yielding = wrong.some((variable) => weak.has(variable) && map.has(variable)) ? wrong.filter((variable) => weak.has(variable)) : wrong
31
+ const paired = wrong.filter((variable) => map.has(variable))
32
+ const least = paired.reduce((n, variable) => Math.min(n, support.get(variable)), Infinity)
33
+ const yielding = paired.some((variable) => weak.has(variable)) ? paired.filter((variable) => weak.has(variable)) : paired.filter((variable) => support.get(variable) === least)
31
34
  if (yielding.filter((variable) => map.delete(variable)).length === 0) return null
32
35
  }
33
36
  }
@@ -36,9 +39,10 @@ function renamedNames(before, after, { extension = 'js', otherExtension = extens
36
39
  let a = analyze(before, extension), b = analyze(after, otherExtension)
37
40
  if (a.reason || b.reason) return { reason: a.reason ?? b.reason }
38
41
  // A file is a module by how it is loaded, which its versions share, more than
39
- // by its syntax: a bundle's chunk that one version exports from, the other not.
40
- // Where its other version is a module, a script's top level is its own too.
41
- if (a.module || b.module) [a, b] = [ownTopLevel(a), ownTopLevel(b)]
42
+ // by its syntax: a bundle's chunk that one version exports from, the other not,
43
+ // or that neither does, its top level renamed by a minifier as a module's is.
44
+ // Where either version is one, a script's top level is its own too.
45
+ if ([a, b].some((side) => side.module || minifiedTopLevel(side))) [a, b] = [ownTopLevel(a), ownTopLevel(b)]
42
46
  checkAnalysis(before, a)
43
47
  checkAnalysis(after, b)
44
48
  const paired = pairing(before, after, a, b)
package/src/text.js CHANGED
@@ -31,6 +31,17 @@ export function checkLineOptions({ horizon = Infinity, whitespace = 'none', igno
31
31
  return { horizon, whitespace, ignoreCase }
32
32
  }
33
33
 
34
+ // The shortest diff where its cost, lines by lines changed, allows, as --minimal
35
+ // has it; past that the search's cutoff, which can face a run with lines alike
36
+ // far from its own: old code that new code now comes before, faced with its braces.
37
+ const SHORTEST_COST = 2e8
38
+ const lineCount = (text) => text.split('\n').length
39
+ export function shortestChangeSet(a, b, options, lines = lineCount(a) + lineCount(b)) {
40
+ const quick = changeSet(a, b, options)
41
+ const changed = quick.reduce((n, { a0, a1, b0, b1 }) => n + a1 - a0 + b1 - b0, 0)
42
+ return lines * changed > SHORTEST_COST ? quick : changeSet(a, b, { ...options, minimal: true })
43
+ }
44
+
34
45
  // Binary content has no blocks and no counts, as --numstat prints `-` for it.
35
46
  export function contentOf(old, fresh, options) {
36
47
  const a = textOf(old), b = textOf(fresh)