@preventive/compare 1.2.6 → 1.2.8
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/compare.d.ts +16 -12
- package/names.d.ts +19 -7
- package/names.js +2 -2
- package/package.json +1 -1
- package/src/chunks.js +28 -14
- package/src/marked.js +2 -3
- package/src/names/check.js +4 -2
- package/src/names/diff.js +19 -20
- package/src/names/pairing.js +35 -20
- package/src/names/rename.js +15 -6
- package/src/patch.js +3 -3
- package/src/pretty.js +4 -2
- package/src/renames.js +23 -8
- package/src/text.js +59 -9
package/compare.d.ts
CHANGED
|
@@ -84,7 +84,9 @@ export interface FileChange extends FileStatus {
|
|
|
84
84
|
// `diff -U0` and the normal format, the context to match `diff -u` (diff's
|
|
85
85
|
// --horizon-lines); `whitespace` (-w as 'all', -b as 'change', -Z as
|
|
86
86
|
// 'trailing') and `ignoreCase` (-i) say which lines are the same, so that a
|
|
87
|
-
// file changed in nothing else has no blocks.
|
|
87
|
+
// file changed in nothing else has no blocks. Ignoring blanks or case never
|
|
88
|
+
// shows more lines than minding them, and where it shows no fewer, its blocks
|
|
89
|
+
// are those minding them: it does nothing there.
|
|
88
90
|
export interface LineOptions {
|
|
89
91
|
horizon?: number
|
|
90
92
|
whitespace?: 'none' | 'all' | 'change' | 'trailing'
|
|
@@ -110,17 +112,19 @@ export interface LineOptions {
|
|
|
110
112
|
// and their directories but for one -- not a package's name under
|
|
111
113
|
// node_modules -- each the other's only such partner. Either gives way only
|
|
112
114
|
// to a candidate that is a rename by itself and scores at least halfway from
|
|
113
|
-
// the pair's score to 100%.
|
|
114
|
-
//
|
|
115
|
-
// one
|
|
116
|
-
// `.`, `_`) they share,
|
|
117
|
-
//
|
|
118
|
-
// `index-
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
115
|
+
// the pair's score to 100%. And before git scores every pair left, lest a file
|
|
116
|
+
// elsewhere take either version, it pairs bundle chunks whose hash changed
|
|
117
|
+
// with their content: in one directory, names alike but for what follows the
|
|
118
|
+
// last separator (`-`, `.`, `_`) they share, up to the extensions they end in
|
|
119
|
+
// alike, six or more characters of a hash's (letters, digits, `-` and `_`, not
|
|
120
|
+
// lowercase letters alone), as `index-CyWAfh-p.js` and `index-hRvO4yU2.js`
|
|
121
|
+
// are, or `main.3f9a2c1d.chunk.js` and `main.8b7e6a5f.chunk.js`. Minified onto
|
|
122
|
+
// a line, such a chunk scores little as git splits it, so it is scored with
|
|
123
|
+
// its spans ending after `,` `;` `=` `{` `}` too, and taken at git's stricter
|
|
124
|
+
// score for a basename, halfway from -M's to 100%: the best pairs first, its
|
|
125
|
+
// similarity that score, giving way as a pair by path does but only where git
|
|
126
|
+
// takes the candidate it gives way to, and none past the rename limit, as they
|
|
127
|
+
// are scored. git has no such passes, so false is git's own -M.
|
|
124
128
|
//
|
|
125
129
|
// `renameLimit` is git's diff.renameLimit: when more deleted files times added
|
|
126
130
|
// files are left, in a group, after the renames that need no scoring than its
|
package/names.d.ts
CHANGED
|
@@ -27,35 +27,47 @@ export type RenameReason = 'unparsed' | 'dynamic' | 'jsx' | 'block-function' | '
|
|
|
27
27
|
// other script reads it: the same file loaded alike -- a name exported or
|
|
28
28
|
// imported as itself, or a shorthand property's), checked to bind
|
|
29
29
|
// every name as before by this package's scope analysis and again by oxc's
|
|
30
|
-
// minifier
|
|
31
|
-
//
|
|
32
|
-
// (
|
|
30
|
+
// minifier, line for line the old side's (`before`, each line where it was);
|
|
31
|
+
// diffed as diff -U0 has it, and as --minimal does where that is cheap
|
|
32
|
+
// (the square of the lines changed up to 2e8, or 10,000 a line of the two
|
|
33
|
+
// texts); and the blocks checked to take `before` to
|
|
33
34
|
// the new side (thrown should they not). `reason` says why no names were given;
|
|
34
35
|
// `reads` the properties either text reads names through (`name`, `toString`,
|
|
35
36
|
// `stack`, `callee`, `caller`), sorted, as code reading its names may act
|
|
36
37
|
// otherwise renamed: none where none is renamed, and `name` only where a name
|
|
37
38
|
// renamed is a function's or class's, the one name it reads (`function f`,
|
|
38
|
-
// `f = () => {}`); `
|
|
39
|
-
//
|
|
39
|
+
// `f = () => {}`); `assumed` all a script's top level was taken as its own on,
|
|
40
|
+
// where a name there changed or was given to one from there (none where
|
|
41
|
+
// none): 'module' where a version of the file is one, 'minified' where a
|
|
42
|
+
// script's top level declares 16 names or more, every one short; `marks` the
|
|
43
|
+
// plain diff's blocks that differ in renamed names alone; and `warnings` what
|
|
44
|
+
// a check that should never fail found.
|
|
40
45
|
export function renamedDiff(before: string, after: string, options?: RenameOptions & { whitespace?: Whitespace }): Promise<{
|
|
41
46
|
before: string
|
|
42
47
|
blocks: Pick<Block, 'a0' | 'a1' | 'b0' | 'b1'>[]
|
|
43
48
|
reason: RenameReason | null
|
|
44
49
|
reads: string[]
|
|
50
|
+
assumed: TopLevelAssumption[]
|
|
45
51
|
marks: Pick<Block, 'a0' | 'a1' | 'b0' | 'b1'>[]
|
|
46
52
|
warnings: string[]
|
|
47
53
|
}>
|
|
48
54
|
|
|
55
|
+
// What a script's top level renamed as a module's is taken to be on, each one apart.
|
|
56
|
+
export type TopLevelAssumption = 'module' | 'minified'
|
|
57
|
+
|
|
49
58
|
export type Whitespace = 'none' | 'all' | 'change' | 'trailing'
|
|
50
59
|
|
|
51
60
|
// renamedDiff for each of `whitespace`'s settings (`['none']` unless given),
|
|
52
61
|
// `diffs` in their order: the names found and the old side given them once
|
|
53
|
-
// for all, as are `before`, `reason`, `reads` and `warnings`; each setting's
|
|
54
|
-
// `blocks` checked as renamedDiff's are, and its `marks` read as they are.
|
|
62
|
+
// for all, as are `before`, `reason`, `reads`, `assumed` and `warnings`; each setting's
|
|
63
|
+
// `blocks` checked as renamedDiff's are, and its `marks` read as they are. A
|
|
64
|
+
// setting ignoring blanks never shows more lines than 'none', and where it
|
|
65
|
+
// shows no fewer, its blocks are those of 'none': it does nothing there.
|
|
55
66
|
export function renamedDiffs(before: string, after: string, options?: RenameOptions & { whitespace?: Whitespace[] }): Promise<{
|
|
56
67
|
before: string
|
|
57
68
|
reason: RenameReason | null
|
|
58
69
|
reads: string[]
|
|
70
|
+
assumed: TopLevelAssumption[]
|
|
59
71
|
diffs: { whitespace: Whitespace; blocks: Pick<Block, 'a0' | 'a1' | 'b0' | 'b1'>[]; marks: Pick<Block, 'a0' | 'a1' | 'b0' | 'b1'>[] }[]
|
|
60
72
|
warnings: string[]
|
|
61
73
|
}>
|
package/names.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// JavaScript minified on both sides, its renamed names told apart from its
|
|
2
2
|
// changes: the old side given the new side's names wherever a minifier
|
|
3
3
|
// renamed a variable alike throughout, so that the two then diff as they are.
|
|
4
|
-
// Built on oxc-parser and oxc-minify,
|
|
5
|
-
//
|
|
4
|
+
// Built on oxc-parser and oxc-minify, optional peers: on Node loaded on first
|
|
5
|
+
// use, elsewhere oxc with this module.
|
|
6
6
|
// compare.js loads none. Typed and described in names.d.ts.
|
|
7
7
|
|
|
8
8
|
// The diff of two such files with renamed names set aside, checked to hide no
|
package/package.json
CHANGED
package/src/chunks.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Bundle chunks a build renamed: `index-CyWAfh-p.js` for `index-hRvO4yU2.js`
|
|
2
|
-
// in one directory, the hash in a name following its content
|
|
3
|
-
//
|
|
2
|
+
// in one directory, the hash in a name following its content, and the same
|
|
3
|
+
// for `main.3f9a2c1d.chunk.js` or `index-CyWAfh-p.js.map`, extensions after
|
|
4
|
+
// the hash. Minified code is a line or a few, so a chunk is scored with spans
|
|
5
|
+
// ending after punctuation.
|
|
4
6
|
|
|
5
7
|
// A content hash: six or more of base64url's characters, not all lowercase
|
|
6
8
|
// letters, which a word is.
|
|
@@ -13,23 +15,28 @@ function nameOf(path) {
|
|
|
13
15
|
const name = path.slice(slash + 1)
|
|
14
16
|
const dot = name.lastIndexOf('.')
|
|
15
17
|
const extension = dot > 0 ? name.slice(dot) : ''
|
|
16
|
-
|
|
17
|
-
return { stem, key: `${path.slice(0, slash + 1)}\0${extension}\0${stem.length}` }
|
|
18
|
+
return { name, key: `${path.slice(0, slash + 1)}\0${extension}\0${name.length}` }
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
// Whether two
|
|
21
|
-
// what follows the last separator they share before they differ
|
|
21
|
+
// Whether two names of a length are one chunk's, its hash all that differs:
|
|
22
|
+
// what follows the last separator they share before they differ, up to the
|
|
23
|
+
// first `.` of the end they share, their extensions.
|
|
22
24
|
function oneChunk(a, b) {
|
|
23
|
-
let start = 0
|
|
25
|
+
let end = a.length, start = 0
|
|
24
26
|
while (start < a.length && a[start] === b[start]) start++
|
|
27
|
+
while (end > start && a[end - 1] === b[end - 1]) end--
|
|
25
28
|
while (start > 0 && !SEPARATORS.includes(a[start - 1])) start--
|
|
26
|
-
|
|
29
|
+
while (end < a.length && a[end] !== '.') end++
|
|
30
|
+
const x = a.slice(start, end), y = b.slice(start, end)
|
|
27
31
|
return start > 0 && HASH.test(x) && HASH.test(y) && (RANDOM.test(x) || RANDOM.test(y))
|
|
28
32
|
}
|
|
29
33
|
|
|
30
|
-
// Chunks
|
|
31
|
-
// each pair at least `bar`: the best first, as git takes its
|
|
32
|
-
|
|
34
|
+
// Chunks git's scored pass has yet to claim, by directory, extension and
|
|
35
|
+
// name's length, each pair at least `bar`: the best first, as git takes its
|
|
36
|
+
// candidates, but for a pair `outdone` by a file's other candidate, set aside
|
|
37
|
+
// and returned, best first, for git to pair that candidate with another file:
|
|
38
|
+
// it holds its files, so that a later pair of either is set aside behind it.
|
|
39
|
+
export function chunkRenames({ sources, dests, pairs, used, record, punctuatedScoreOf }, { remaining, open }, bar, outdone) {
|
|
33
40
|
const names = (files, indices) => new Map(indices.filter((i) => files[i].type === 'file').map((i) => [i, nameOf(files[i].path)]))
|
|
34
41
|
const destNames = names(dests, open), sourceNames = names(sources, remaining)
|
|
35
42
|
const byKey = Map.groupBy(sourceNames.keys(), (s) => sourceNames.get(s).key)
|
|
@@ -39,13 +46,20 @@ export function chunkRenames({ sources, dests, pairs, used, record, punctuatedSc
|
|
|
39
46
|
const ss = byKey.get(key) ?? []
|
|
40
47
|
for (const d of ds) {
|
|
41
48
|
for (const s of ss) {
|
|
42
|
-
if (!oneChunk(sourceNames.get(s).
|
|
49
|
+
if (!oneChunk(sourceNames.get(s).name, destNames.get(d).name)) continue
|
|
43
50
|
const score = punctuatedScoreOf(s, d, bar)
|
|
44
51
|
if (score >= bar) candidates.push({ s, d, score })
|
|
45
52
|
}
|
|
46
53
|
}
|
|
47
54
|
}
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
const aside = [], heldDests = new Set(), heldSources = new Set()
|
|
56
|
+
for (const pair of candidates.sort((x, y) => y.score - x.score)) {
|
|
57
|
+
if (pairs.has(pair.d) || used.has(pair.s)) continue
|
|
58
|
+
if (heldSources.has(pair.s) || heldDests.has(pair.d) || outdone(pair)) {
|
|
59
|
+
aside.push(pair)
|
|
60
|
+
heldSources.add(pair.s)
|
|
61
|
+
heldDests.add(pair.d)
|
|
62
|
+
} else record(pair.d, pair.s, pair.score)
|
|
50
63
|
}
|
|
64
|
+
return aside
|
|
51
65
|
}
|
package/src/marked.js
CHANGED
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
// (`before` renamed, line for line) has them, are their new lines: changed in
|
|
3
3
|
// renamed names alone. null where `given` has another number of lines.
|
|
4
4
|
|
|
5
|
-
import { changeSet } from '@preventive/diff'
|
|
6
5
|
import { blocksHold } from './blocks.js'
|
|
7
|
-
import { checkLineOptions } from './text.js'
|
|
6
|
+
import { checkLineOptions, lineChangeSet } from './text.js'
|
|
8
7
|
|
|
9
8
|
const records = (text) => (text === '' ? [] : text.split(/(?<=\n)/u))
|
|
10
9
|
|
|
@@ -14,7 +13,7 @@ export function markedBlocks(before, given, after, options) {
|
|
|
14
13
|
if (renamed.length !== records(before).length) return null
|
|
15
14
|
// Lines alike are those an empty change set takes from one to the other.
|
|
16
15
|
const alike = (olds, news) => blocksHold(olds.join(''), news.join(''), [], lineOptions)
|
|
17
|
-
return
|
|
16
|
+
return lineChangeSet(before, after, lineOptions)
|
|
18
17
|
.filter(({ a0, a1, b0, b1, insert }) => a1 - a0 === b1 - b0 && alike(renamed.slice(a0, a1), insert))
|
|
19
18
|
.map(({ a0, a1, b0, b1 }) => ({ a0, a1, b0, b1 }))
|
|
20
19
|
}
|
package/src/names/check.js
CHANGED
|
@@ -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
|
|
28
|
-
|
|
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,47 +1,46 @@
|
|
|
1
1
|
// A diff of a file minified on both sides with renamed names set aside: the
|
|
2
|
-
// old side renamed,
|
|
3
|
-
//
|
|
4
|
-
// checked to hide no change.
|
|
2
|
+
// old side renamed, line for line the old side's, diffed at its shortest where
|
|
3
|
+
// affordable, and the diff checked to hide no change.
|
|
5
4
|
|
|
6
5
|
import { blocksHold } from '../blocks.js'
|
|
7
6
|
import { markedBlocks } from '../marked.js'
|
|
8
|
-
import {
|
|
9
|
-
import { checkLineOptions, shortestChangeSet } from '../text.js'
|
|
10
|
-
import { bindsAlike } from './binds.js'
|
|
7
|
+
import { checkLineOptions, shownChangeSet } from '../text.js'
|
|
11
8
|
import { renameMap } from './rename.js'
|
|
12
9
|
|
|
13
10
|
const ranges = ({ a0, a1, b0, b1 }) => ({ a0, a1, b0, b1 })
|
|
14
11
|
|
|
15
12
|
// The same, diffed once for each whitespace setting given, in order: the names
|
|
16
13
|
// found and the old side given them once for all.
|
|
17
|
-
|
|
14
|
+
function diffsOf(before, after, { extension = 'js', otherExtension = extension, whitespace = ['none'] } = {}) {
|
|
18
15
|
if (!Array.isArray(whitespace)) throw new TypeError(`whitespace must be an array, not ${typeof whitespace}`)
|
|
19
16
|
const settings = whitespace.map((setting) => checkLineOptions({ horizon: 0, whitespace: setting }))
|
|
20
17
|
const warnings = []
|
|
21
18
|
const mapped = renameMap(before, after, { extension, otherExtension })
|
|
22
19
|
if (mapped.invariant) warnings.push(`a rename map check failed: ${mapped.invariant}`)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const formatted = await format(mapped.text, extension)
|
|
27
|
-
const read = 'code' in formatted && bindsAlike(before, formatted.code, mapped.kind)
|
|
28
|
-
if ('code' in formatted && !read) warnings.push("the renamed old side, formatted, read otherwise")
|
|
29
|
-
renamed = read ? formatted.code : mapped.text
|
|
30
|
-
}
|
|
20
|
+
// Not formatted again: its lines are the old side's, each where it was, and
|
|
21
|
+
// formatting a formatted text lays it out other than the new side was.
|
|
22
|
+
const renamed = mapped.reason ? before : mapped.text
|
|
31
23
|
let unmarked = false
|
|
32
24
|
const diffs = settings.map((options) => {
|
|
33
|
-
const blocks =
|
|
25
|
+
const blocks = shownChangeSet(renamed, after, options).map(ranges)
|
|
34
26
|
if (!blocksHold(renamed, after, blocks, options)) throw new Error('change blocks that do not take the old side to the new')
|
|
35
|
-
//
|
|
27
|
+
// The plain diff's blocks the renamed text, line for line the old side's, says differ in names alone.
|
|
36
28
|
const marks = mapped.reason ? [] : markedBlocks(before, mapped.text, after, options)
|
|
37
29
|
unmarked ||= marks === null
|
|
38
30
|
return { whitespace: options.whitespace, blocks, marks: marks ?? [] }
|
|
39
31
|
})
|
|
40
32
|
if (unmarked) warnings.push('a renamed text with lines the old one does not have; none marked')
|
|
41
|
-
return { before: renamed, reason: mapped.reason ?? null, reads: mapped.reads ?? [], diffs, warnings }
|
|
33
|
+
return { before: renamed, reason: mapped.reason ?? null, reads: mapped.reads ?? [], assumed: mapped.assumed ?? [], diffs, warnings }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A promise still, as callers await it: what goes wrong rejects it.
|
|
37
|
+
export function renamedDiffs(before, after, options) {
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
resolve(diffsOf(before, after, options))
|
|
40
|
+
})
|
|
42
41
|
}
|
|
43
42
|
|
|
44
43
|
export async function renamedDiff(before, after, { whitespace = 'none', ...options } = {}) {
|
|
45
|
-
const { before: renamed, reason, reads, diffs: [{ blocks, marks }], warnings } = await renamedDiffs(before, after, { ...options, whitespace: [whitespace] })
|
|
46
|
-
return { before: renamed, blocks, reason, reads, marks, warnings }
|
|
44
|
+
const { before: renamed, reason, reads, assumed, diffs: [{ blocks, marks }], warnings } = await renamedDiffs(before, after, { ...options, whitespace: [whitespace] })
|
|
45
|
+
return { before: renamed, blocks, reason, reads, assumed, marks, warnings }
|
|
47
46
|
}
|
package/src/names/pairing.js
CHANGED
|
@@ -151,13 +151,13 @@ function moves(ka, kb, blocks, alike) {
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
// Pairs of lines counted, a long run of unchanged ones apart from the rest,
|
|
155
|
-
// apart from both the variables a change's tokens pair
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
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 = ...,`).
|
|
159
159
|
function tallied(ka, kb) {
|
|
160
|
-
const changed = tallies(),
|
|
160
|
+
const changed = tallies(), tokened = tallies(), unchanged = tallies()
|
|
161
161
|
const counted = ({ backward, forward }, variable, other) => {
|
|
162
162
|
count(forward, variable, other)
|
|
163
163
|
count(backward, other, variable)
|
|
@@ -182,8 +182,19 @@ function tallied(ka, kb) {
|
|
|
182
182
|
i = block.a1
|
|
183
183
|
j = block.b1
|
|
184
184
|
}
|
|
185
|
-
|
|
186
|
-
|
|
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 }
|
|
187
198
|
}
|
|
188
199
|
|
|
189
200
|
// The most counted of those not yet paired, none where two tie.
|
|
@@ -199,8 +210,9 @@ function best(counts, taken) {
|
|
|
199
210
|
return found
|
|
200
211
|
}
|
|
201
212
|
|
|
202
|
-
// Pairs each the other's best, into `map`, until none more pair
|
|
203
|
-
|
|
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) {
|
|
204
216
|
const chosen = []
|
|
205
217
|
for (let more = true; more;) {
|
|
206
218
|
more = false
|
|
@@ -209,6 +221,7 @@ function matched({ backward, forward }, { map, paired }, eligible = () => true)
|
|
|
209
221
|
const to = best(counts, (other) => paired.has(other))
|
|
210
222
|
if (!to || best(backward.get(to), (other) => map.has(other)) !== variable || !eligible(variable, to)) continue
|
|
211
223
|
map.set(variable, to)
|
|
224
|
+
support.set(variable, counts.get(to))
|
|
212
225
|
paired.add(to)
|
|
213
226
|
chosen.push(variable)
|
|
214
227
|
more = true
|
|
@@ -217,23 +230,25 @@ function matched({ backward, forward }, { map, paired }, eligible = () => true)
|
|
|
217
230
|
return chosen
|
|
218
231
|
}
|
|
219
232
|
|
|
220
|
-
// Unchanged lines pair first
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
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.
|
|
226
241
|
export function pairing(before, after, a, b) {
|
|
227
|
-
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()
|
|
228
243
|
const passes = [[() => null, () => null], [(variable) => found.map.get(variable)?.name ?? null, (variable) => (found.paired.has(variable) ? variable.name : null)]]
|
|
229
244
|
for (const [old, now] of passes) {
|
|
230
245
|
// A pass pairs nothing where every variable of a side is paired already.
|
|
231
246
|
if (!a.variables.some((variable) => variable.aside && !found.map.has(variable)) || !b.variables.some((variable) => variable.aside && !found.paired.has(variable))) break
|
|
232
|
-
const { changed,
|
|
233
|
-
for (const [counts, tier] of [[unchanged, false], [changed, true], [tokened, 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]]) {
|
|
234
249
|
for (const variable of matched(counts, found, (from, to) => !said.has(from) && !said.has(to))) if (tier) weak.add(variable)
|
|
235
250
|
for (const variable of [...counts.forward.keys(), ...counts.backward.keys()]) said.add(variable)
|
|
236
251
|
}
|
|
237
252
|
}
|
|
238
|
-
return { map: found.map, weak }
|
|
253
|
+
return { map: found.map, support: found.support, weak }
|
|
239
254
|
}
|
package/src/names/rename.js
CHANGED
|
@@ -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
|
|
15
|
-
//
|
|
16
|
-
|
|
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
|
|
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
|
}
|
|
@@ -39,7 +42,10 @@ function renamedNames(before, after, { extension = 'js', otherExtension = extens
|
|
|
39
42
|
// by its syntax: a bundle's chunk that one version exports from, the other not,
|
|
40
43
|
// or that neither does, its top level renamed by a minifier as a module's is.
|
|
41
44
|
// Where either version is one, a script's top level is its own too.
|
|
42
|
-
|
|
45
|
+
// Each says so apart: a version a module; a script's top level a minifier's throughout.
|
|
46
|
+
const scripts = [a, b].map((side) => !side.own)
|
|
47
|
+
const why = [[a, b].some((side) => side.module) && 'module', [a, b].some((side, i) => scripts[i] && minifiedTopLevel(side)) && 'minified'].filter(Boolean)
|
|
48
|
+
if (why.length > 0) [a, b] = [ownTopLevel(a), ownTopLevel(b)]
|
|
43
49
|
checkAnalysis(before, a)
|
|
44
50
|
checkAnalysis(after, b)
|
|
45
51
|
const paired = pairing(before, after, a, b)
|
|
@@ -62,7 +68,10 @@ function renamedNames(before, after, { extension = 'js', otherExtension = extens
|
|
|
62
68
|
// class's alone: one only where a name renamed is one's.
|
|
63
69
|
const naming = changed.some((variable) => variable.naming || paired.map.get(variable)?.naming)
|
|
64
70
|
const reads = changed.length === 0 ? [] : [...new Set([...a.reads, ...b.reads])].filter((read) => read !== 'name' || naming).toSorted()
|
|
65
|
-
|
|
71
|
+
// A script's top level taken as its own, as all `why` says, where a name there changed or took one from there.
|
|
72
|
+
const root = (variable) => variable.scope.parent === null
|
|
73
|
+
const assumed = changed.some((variable) => (scripts[0] && root(variable)) || (scripts[1] && paired.map.has(variable) && root(paired.map.get(variable)))) ? why : []
|
|
74
|
+
return { text, renamed, apart: changed.length - renamed, kind: { module: a.module, commonjs: a.commonjs, own: a.own }, reads, assumed }
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
// Any failure gives no names, and tells what failed. oxc is loaded first, out
|
package/src/patch.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// blocks, so a run of lines free to move may sit elsewhere than git puts it.
|
|
4
4
|
|
|
5
5
|
import { utf8fromString, utf8toString } from '@exodus/bytes/utf8.js'
|
|
6
|
-
import {
|
|
6
|
+
import { formatChangeSet } from '@preventive/diff'
|
|
7
7
|
import { quote } from './quote.js'
|
|
8
|
-
import { checkLineOptions, textOf } from './text.js'
|
|
8
|
+
import { checkLineOptions, lineChangeSet, textOf } from './text.js'
|
|
9
9
|
import { sameContent } from './tree.js'
|
|
10
10
|
|
|
11
11
|
const STATUSES = new Set(['added', 'deleted', 'modified', 'renamed'])
|
|
@@ -45,7 +45,7 @@ function body(one, two, blocks, from, to, { context, ...options }) {
|
|
|
45
45
|
if (oldText === undefined || newText === undefined) {
|
|
46
46
|
return one !== null && two !== null && sameContent(one, two) ? '' : `Binary files ${from} and ${to} differ\n`
|
|
47
47
|
}
|
|
48
|
-
const hunks = formatChangeSet(oldText, newText, blocks ??
|
|
48
|
+
const hunks = formatChangeSet(oldText, newText, blocks ?? lineChangeSet(oldText, newText, options), { ...options, context, label: hunkNames(oldText) })
|
|
49
49
|
return hunks === '' ? '' : `--- ${label(from)}\n+++ ${label(to)}\n${hunks}`
|
|
50
50
|
}
|
|
51
51
|
|
package/src/pretty.js
CHANGED
|
@@ -31,8 +31,10 @@ const sorted = (flags) => [...flags].toSorted().join('')
|
|
|
31
31
|
// A script's text but for what the formatter may respell, beside the syntax tree compared after:
|
|
32
32
|
// blanks, parentheses, semicolons, quotes and numbers, and a comma before a closing bracket, past any
|
|
33
33
|
// comments (`import { a, b, }`). A regular expression's flags it may reorder are sorted where the
|
|
34
|
-
// syntax tree has them (respelled).
|
|
35
|
-
|
|
34
|
+
// syntax tree has them (respelled). Each comment read one way alone, to its line's end (any line
|
|
35
|
+
// terminator's) or its first `*/`: one that might end anywhere tries, where no bracket follows, every
|
|
36
|
+
// way to split a run of them.
|
|
37
|
+
const scriptForm = (text) => text.replaceAll(NUMBER, numberSpelling).replaceAll(/,(?=(?:\s|\/\/[^\n\r\u2028\u2029]*(?![^\n\r\u2028\u2029])|\/\*(?:[^*]|\*(?!\/))*\*\/)*[\])}>])/gu, '')
|
|
36
38
|
.replaceAll(/\\(?=["'])|[\s();]/gu, '').replaceAll("'", '"')
|
|
37
39
|
|
|
38
40
|
// JSON has no point without a digit either side: such a number is kept as written, or a copy would read as
|
package/src/renames.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// git's -M pairing (diffcore_rename_extended): identical content, then a
|
|
2
2
|
// basename unique among both sides' leftovers at a stricter score, then every
|
|
3
|
-
// pair left scored; between the first two and
|
|
3
|
+
// pair left scored; between the first two and before the last, ours, renames
|
|
4
4
|
// told by path, and bundle chunks (chunks.js).
|
|
5
5
|
|
|
6
6
|
import { crc32 } from '@exodus/bytes/crc.js'
|
|
@@ -71,11 +71,14 @@ function familyKeys(path) {
|
|
|
71
71
|
return keys
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
74
|
+
// What a candidate scores to outdo a pair by path or a chunk's: halfway from
|
|
75
|
+
// the pair's score to 100%, and a rename by itself.
|
|
76
|
+
const halfway = (score, minimum) => Math.max(minimum, score + Math.trunc((MAX_SCORE - score) / 2))
|
|
77
|
+
|
|
78
|
+
// Whether either file of such a pair has a candidate that outdoes it: sizes
|
|
79
|
+
// too far apart for that go unread.
|
|
77
80
|
function outdone({ pairs, used, scoreOf }, { remaining, open }, { s, d, score }, minimum) {
|
|
78
|
-
const better =
|
|
81
|
+
const better = halfway(score, minimum)
|
|
79
82
|
return open.some((o) => o !== d && !pairs.has(o) && scoreOf(s, o, better) >= better)
|
|
80
83
|
|| remaining.some((r) => r !== s && !used.has(r) && scoreOf(r, d, better) >= better)
|
|
81
84
|
}
|
|
@@ -159,17 +162,29 @@ function keepIfBetter(kept, candidate) {
|
|
|
159
162
|
if (byScore(kept[worst], candidate) > 0) kept[worst] = candidate
|
|
160
163
|
}
|
|
161
164
|
|
|
162
|
-
|
|
165
|
+
// git's walk down every pair left, best first. A chunk set aside (`aside`,
|
|
166
|
+
// best first) is taken where its files are both free once the walk is past
|
|
167
|
+
// what could outdo it, before a pair scoring less takes either.
|
|
168
|
+
function scoredRenames({ sourceNames, destNames, pairs, used, record, scoreOf }, { remaining, open }, minimum, aside) {
|
|
163
169
|
const all = []
|
|
164
170
|
for (const d of open) {
|
|
165
171
|
const kept = []
|
|
166
172
|
for (const s of remaining) keepIfBetter(kept, { score: scoreOf(s, d, minimum), nameScore: sourceNames[s] === destNames[d] ? 1 : 0, dst: d, src: s })
|
|
167
173
|
all.push(...kept)
|
|
168
174
|
}
|
|
175
|
+
let next = 0
|
|
176
|
+
const settle = (below) => {
|
|
177
|
+
for (; next < aside.length && halfway(aside[next].score, minimum) > below; next++) {
|
|
178
|
+
const { s, d, score } = aside[next]
|
|
179
|
+
if (!pairs.has(d) && !used.has(s)) record(d, s, score)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
169
182
|
for (const { dst, src, score } of all.sort(byScore)) {
|
|
170
183
|
if (score < minimum) break
|
|
184
|
+
settle(score)
|
|
171
185
|
if (!pairs.has(dst) && !used.has(src)) record(dst, src, score)
|
|
172
186
|
}
|
|
187
|
+
settle(-Infinity)
|
|
173
188
|
}
|
|
174
189
|
|
|
175
190
|
const unpaired = ({ sources, dests, pairs, used }) => ({
|
|
@@ -210,7 +225,7 @@ export function findRenames(sources, dests, { minimum, limit, byPath }) {
|
|
|
210
225
|
basenameRenames(context, unpaired(context), stricter(minimum))
|
|
211
226
|
const left = unpaired(context)
|
|
212
227
|
if (left.remaining.length * left.open.length > limit * limit) return pairs
|
|
213
|
-
|
|
214
|
-
|
|
228
|
+
const aside = byPath ? chunkRenames(context, left, stricter(minimum), (pair) => outdone(context, left, pair, minimum)) : []
|
|
229
|
+
scoredRenames(context, unpaired(context), minimum, aside)
|
|
215
230
|
return pairs
|
|
216
231
|
}
|
package/src/text.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// first 8000 bytes, so a file git diffs as bytes may be binary here.
|
|
4
4
|
|
|
5
5
|
import { utf8toString } from '@exodus/bytes/utf8.js'
|
|
6
|
-
import { changeSet } from '@preventive/diff'
|
|
6
|
+
import { DiffError, changeSet, formatChangeSet } from '@preventive/diff'
|
|
7
7
|
|
|
8
8
|
// A link's content is its target, as git stores it; a missing side is empty.
|
|
9
9
|
export function textOf(file) {
|
|
@@ -31,22 +31,72 @@ export function checkLineOptions({ horizon = Infinity, whitespace = 'none', igno
|
|
|
31
31
|
return { horizon, whitespace, ignoreCase }
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
34
|
+
const changes = (blocks) => blocks.reduce((n, { a0, a1, b0, b1 }) => n + a1 - a0 + b1 - b0, 0)
|
|
35
|
+
const records = (text) => (text === '' ? [] : text.split(/(?<=\n)/u))
|
|
36
|
+
const minding = (options) => ({ ...options, ignoreCase: false, whitespace: 'none' })
|
|
37
|
+
const ignoring = (options) => (options.whitespace ?? 'none') !== 'none' || Boolean(options.ignoreCase)
|
|
38
|
+
|
|
39
|
+
// Ignoring blanks or case keeps every line minding them keeps, so it never
|
|
40
|
+
// shows more: past the search's cutoff it may, and then it is the diff minding
|
|
41
|
+
// them, its blocks diffed again ignoring them. Where it shows no fewer lines,
|
|
42
|
+
// it is that diff, block for block: ignoring them does nothing there.
|
|
43
|
+
function noLonger(a, b, options, blocks, minded) {
|
|
44
|
+
const A = records(a), B = records(b)
|
|
45
|
+
const block = (a0, a1, b0, b1) => ({ a0, a1, b0, b1, remove: A.slice(a0, a1), insert: B.slice(b0, b1) })
|
|
46
|
+
const again = minded.flatMap(({ a0, a1, b0, b1 }) => (a0 === a1 || b0 === b1
|
|
47
|
+
? [block(a0, a1, b0, b1)]
|
|
48
|
+
: changeSet(A.slice(a0, a1).join(''), B.slice(b0, b1).join(''), options).map((part) => block(a0 + part.a0, a0 + part.a1, b0 + part.b0, b0 + part.b1))))
|
|
49
|
+
// Blocks put together from diffs of their own are held to what diff holds its own to.
|
|
50
|
+
const holds = (set) => {
|
|
51
|
+
try {
|
|
52
|
+
formatChangeSet(a, b, set, { format: 'normal', ignoreCase: options.ignoreCase, whitespace: options.whitespace })
|
|
53
|
+
return true
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error instanceof DiffError) return false
|
|
56
|
+
throw error
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const best = changes(again) < changes(blocks) && holds(again) ? again : blocks
|
|
60
|
+
return changes(best) < changes(minded) ? best : minded
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The diff as diff finds it, ignoring blanks or case never showing more.
|
|
64
|
+
export function lineChangeSet(a, b, options) {
|
|
65
|
+
const blocks = changeSet(a, b, options)
|
|
66
|
+
return ignoring(options) ? noLonger(a, b, options, blocks, changeSet(a, b, minding(options))) : blocks
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The shortest diff where its cost allows, as --minimal has it; past that the
|
|
70
|
+
// search's cutoff, which can face a run with lines alike far from its own: old
|
|
71
|
+
// code that new code now comes before, faced with its braces. The search's work
|
|
72
|
+
// grows as the square of the lines it changes: at most the quick diff's, or one
|
|
73
|
+
// minding blanks and case, which changes no fewer. It may cost 2e8, or 10,000 a
|
|
74
|
+
// line of the two texts, as their lines cost already to read.
|
|
75
|
+
const SHORTEST_A_LINE = 1e4, SHORTEST_COST = 2e8
|
|
38
76
|
const lineCount = (text) => text.split('\n').length
|
|
77
|
+
function shortest(a, b, options, lines, bound) {
|
|
78
|
+
const budget = Math.max(SHORTEST_COST, SHORTEST_A_LINE * lines), quick = changeSet(a, b, options)
|
|
79
|
+
let changed = changes(quick)
|
|
80
|
+
if (changed ** 2 > budget) changed = Math.min(changed, bound())
|
|
81
|
+
return changed ** 2 > budget ? quick : changeSet(a, b, { ...options, minimal: true })
|
|
82
|
+
}
|
|
83
|
+
// For lines read, as names are paired by: ignoring blanks or case, what it
|
|
84
|
+
// finds, its lines alike kept where minding them ties.
|
|
39
85
|
export function shortestChangeSet(a, b, options, lines = lineCount(a) + lineCount(b)) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
86
|
+
return shortest(a, b, options, lines, () => (ignoring(options) ? changes(changeSet(a, b, minding(options))) : Infinity))
|
|
87
|
+
}
|
|
88
|
+
// For lines shown: ignoring blanks or case, never more than minding them.
|
|
89
|
+
export function shownChangeSet(a, b, options, lines = lineCount(a) + lineCount(b)) {
|
|
90
|
+
if (!ignoring(options)) return shortestChangeSet(a, b, options, lines)
|
|
91
|
+
const minded = shortestChangeSet(a, b, minding(options), lines)
|
|
92
|
+
return noLonger(a, b, options, shortest(a, b, options, lines, () => changes(minded)), minded)
|
|
43
93
|
}
|
|
44
94
|
|
|
45
95
|
// Binary content has no blocks and no counts, as --numstat prints `-` for it.
|
|
46
96
|
export function contentOf(old, fresh, options) {
|
|
47
97
|
const a = textOf(old), b = textOf(fresh)
|
|
48
98
|
if (a === undefined || b === undefined) return { binary: true, blocks: [], insertions: null, deletions: null }
|
|
49
|
-
const blocks = a === b ? [] :
|
|
99
|
+
const blocks = a === b ? [] : lineChangeSet(a, b, options)
|
|
50
100
|
const lines = (from, to) => blocks.reduce((n, block) => n + block[to] - block[from], 0)
|
|
51
101
|
return { binary: false, blocks, insertions: lines('b0', 'b1'), deletions: lines('a0', 'a1') }
|
|
52
102
|
}
|