@preventive/compare 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/compare.d.ts +187 -0
- package/compare.js +29 -0
- package/names.d.ts +44 -0
- package/names.js +9 -0
- package/package.json +131 -4
- package/pretty.d.ts +16 -0
- package/pretty.js +4 -0
- package/src/blocks.js +17 -0
- package/src/changes.js +90 -0
- package/src/css.js +70 -0
- package/src/error.js +12 -0
- package/src/layout.js +94 -0
- package/src/marked.js +20 -0
- package/src/names/analyze.js +235 -0
- package/src/names/binds.js +20 -0
- package/src/names/check.js +68 -0
- package/src/names/diff.js +33 -0
- package/src/names/misread.js +38 -0
- package/src/names/pairing.js +111 -0
- package/src/names/rename.js +69 -0
- package/src/names/syntax.js +63 -0
- package/src/oxc.js +6 -0
- package/src/oxc.node.js +16 -0
- package/src/pairs.js +100 -0
- package/src/patch.js +78 -0
- package/src/paths.js +22 -0
- package/src/peers.js +18 -0
- package/src/pretty.js +106 -0
- package/src/quote.js +16 -0
- package/src/renames.js +205 -0
- package/src/same-bytes.js +18 -0
- package/src/same-bytes.node.js +7 -0
- package/src/script.js +57 -0
- package/src/similarity.js +53 -0
- package/src/text.js +41 -0
- package/src/tree.js +98 -0
- package/src/whitespace.js +44 -0
- package/src/words.js +57 -0
- package/README.md +0 -3
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// A diff of a file minified on both sides with renamed names set aside: the
|
|
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.
|
|
4
|
+
|
|
5
|
+
import { changeSet } from '@preventive/diff'
|
|
6
|
+
import { blocksHold } from '../blocks.js'
|
|
7
|
+
import { markedBlocks } from '../marked.js'
|
|
8
|
+
import { format } from '../pretty.js'
|
|
9
|
+
import { bindsAlike } from './binds.js'
|
|
10
|
+
import { renameMap } from './rename.js'
|
|
11
|
+
|
|
12
|
+
const ranges = ({ a0, a1, b0, b1 }) => ({ a0, a1, b0, b1 })
|
|
13
|
+
|
|
14
|
+
export async function renamedDiff(before, after, { extension = 'js', otherExtension = extension, whitespace = 'none' } = {}) {
|
|
15
|
+
const warnings = []
|
|
16
|
+
const mapped = renameMap(before, after, { extension, otherExtension })
|
|
17
|
+
if (mapped.invariant) warnings.push(`a rename map check failed: ${mapped.invariant}`)
|
|
18
|
+
let renamed = before
|
|
19
|
+
if (!mapped.reason) {
|
|
20
|
+
// A name's new length may move the lines it is on.
|
|
21
|
+
const formatted = await format(mapped.text, extension)
|
|
22
|
+
const read = 'code' in formatted && bindsAlike(before, formatted.code, mapped.kind)
|
|
23
|
+
if ('code' in formatted && !read) warnings.push("the renamed old side, formatted, read otherwise")
|
|
24
|
+
renamed = read ? formatted.code : mapped.text
|
|
25
|
+
}
|
|
26
|
+
const options = { horizon: 0, whitespace }
|
|
27
|
+
const blocks = changeSet(renamed, after, options).map(ranges)
|
|
28
|
+
if (!blocksHold(renamed, after, blocks, options)) throw new Error('change blocks that do not take the old side to the new')
|
|
29
|
+
// Read off the renamed text before formatting, line for line the old side's.
|
|
30
|
+
const marks = mapped.reason ? [] : markedBlocks(before, mapped.text, after, options)
|
|
31
|
+
if (marks === null) warnings.push('a renamed text with lines the old one does not have; none marked')
|
|
32
|
+
return { before: renamed, blocks, reason: mapped.reason ?? null, reads: mapped.reads ?? [], marks: marks ?? [], warnings }
|
|
33
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The variables the names given would read wrongly: two of a scope named
|
|
2
|
+
// alike, a `var` named as a block's it hoists past, a body's as its parameter,
|
|
3
|
+
// or a name read resolving to another variable, a global's to any.
|
|
4
|
+
|
|
5
|
+
import { hoistedPast, resolve } from './syntax.js'
|
|
6
|
+
|
|
7
|
+
export function misread({ scopes, variables, occurrences, globals }, given) {
|
|
8
|
+
const names = new Map(scopes.map((at) => [at, new Map()])), wrong = new Set()
|
|
9
|
+
const named = (at) => names.get(at)
|
|
10
|
+
for (const at of scopes) {
|
|
11
|
+
for (const variable of at.names.values()) {
|
|
12
|
+
const taken = named(at).get(given.get(variable))
|
|
13
|
+
if (taken) wrong.add(taken).add(variable)
|
|
14
|
+
else named(at).set(given.get(variable), variable)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
for (const variable of variables) {
|
|
18
|
+
for (const path of variable.through ?? []) for (const there of hoistedPast(variable, path, given.get(variable), named)) wrong.add(there).add(variable)
|
|
19
|
+
}
|
|
20
|
+
for (const at of scopes) {
|
|
21
|
+
if (!at.parameters) continue
|
|
22
|
+
for (const variable of new Set(at.names.values())) {
|
|
23
|
+
const parameter = named(at.parameters).get(given.get(variable))
|
|
24
|
+
if (parameter && parameter !== variable) wrong.add(parameter).add(variable)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
for (const [, variable, where] of occurrences) {
|
|
28
|
+
const found = resolve(given.get(variable), where, named)
|
|
29
|
+
if (found === variable) continue
|
|
30
|
+
wrong.add(variable)
|
|
31
|
+
if (found) wrong.add(found)
|
|
32
|
+
}
|
|
33
|
+
for (const [node, where] of globals) {
|
|
34
|
+
const found = resolve(node.name, where, named)
|
|
35
|
+
if (found) wrong.add(found)
|
|
36
|
+
}
|
|
37
|
+
return wrong
|
|
38
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Which old variable became which new one: each pair of lines, as the diff of
|
|
2
|
+
// their keys (names set aside) pairs them, counts its variables as standing for
|
|
3
|
+
// each other, and two each the other's most counted pair, a tie pairing neither.
|
|
4
|
+
|
|
5
|
+
import { changeSet } from '@preventive/diff'
|
|
6
|
+
|
|
7
|
+
// Unchanged lines between changes that pair as unchanged ones, not as changed.
|
|
8
|
+
const ANCHORED_RUN = 3
|
|
9
|
+
|
|
10
|
+
// Whitespace alone never tells a name apart.
|
|
11
|
+
const changes = (a, b) => changeSet(a, b, { horizon: 0, whitespace: 'all' })
|
|
12
|
+
|
|
13
|
+
// A text's lines with its renamable variables' names set aside (`key`), and
|
|
14
|
+
// each line's such variables in order (`lines`).
|
|
15
|
+
function keyed(text, { occurrences }) {
|
|
16
|
+
const starts = [0]
|
|
17
|
+
for (let at = text.indexOf('\n'); at !== -1; at = text.indexOf('\n', at + 1)) starts.push(at + 1)
|
|
18
|
+
const lines = starts.map(() => [])
|
|
19
|
+
let at = 0, key = '', line = 0
|
|
20
|
+
for (const [node, variable] of occurrences) {
|
|
21
|
+
if (!variable.aside) continue
|
|
22
|
+
key += text.slice(at, node.start)
|
|
23
|
+
at = node.end
|
|
24
|
+
while (line + 1 < starts.length && starts[line + 1] <= node.start) line++
|
|
25
|
+
lines[line].push(variable)
|
|
26
|
+
}
|
|
27
|
+
return { key: key + text.slice(at), lines }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const tallies = () => ({ backward: new Map(), forward: new Map() })
|
|
31
|
+
function count(map, from, to) {
|
|
32
|
+
if (!map.has(from)) map.set(from, new Map())
|
|
33
|
+
map.get(from).set(to, (map.get(from).get(to) ?? 0) + 1)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Pairs of lines counted, a long run of unchanged ones apart from the rest. In
|
|
37
|
+
// a change of unlike lengths, lines alike but for a final `;` or `,` pair (a
|
|
38
|
+
// declaration others now follow: `let t = ...;` -> `let t = ...,`).
|
|
39
|
+
function tallied(ka, kb) {
|
|
40
|
+
const changed = tallies(), unchanged = tallies()
|
|
41
|
+
// Lines reading variables one side has and the other not say nothing.
|
|
42
|
+
const tally = ({ backward, forward }, pa, pb) => {
|
|
43
|
+
const news = kb.lines[pb], olds = ka.lines[pa]
|
|
44
|
+
if (olds.length !== news.length) return
|
|
45
|
+
olds.forEach((variable, k) => {
|
|
46
|
+
count(forward, variable, news[k])
|
|
47
|
+
count(backward, news[k], variable)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
const keysA = ka.key.split('\n'), keysB = kb.key.split('\n')
|
|
51
|
+
const trimmed = (lines, from, to) => `${lines.slice(from, to).map((line) => line.replace(/[;,]\s*$/u, '')).join('\n')}\n`
|
|
52
|
+
let i = 0, j = 0
|
|
53
|
+
for (const block of [...changes(ka.key, kb.key), { a0: ka.lines.length, a1: ka.lines.length, b0: kb.lines.length, b1: kb.lines.length }]) {
|
|
54
|
+
const anchored = block.a0 - i >= ANCHORED_RUN
|
|
55
|
+
while (i < block.a0) tally(anchored ? unchanged : changed, i++, j++)
|
|
56
|
+
const [olds, news] = [block.a1 - block.a0, block.b1 - block.b0]
|
|
57
|
+
if (olds === news) for (let k = 0; k < olds; k++) tally(changed, block.a0 + k, block.b0 + k)
|
|
58
|
+
else if (olds > 0 && news > 0) {
|
|
59
|
+
let x = 0, y = 0
|
|
60
|
+
for (const inner of [...changes(trimmed(keysA, block.a0, block.a1), trimmed(keysB, block.b0, block.b1)), { a0: olds, a1: olds, b0: news, b1: news }]) {
|
|
61
|
+
while (x < inner.a0) tally(changed, block.a0 + x++, block.b0 + y++)
|
|
62
|
+
x = inner.a1
|
|
63
|
+
y = inner.b1
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
i = block.a1
|
|
67
|
+
j = block.b1
|
|
68
|
+
}
|
|
69
|
+
return { changed, unchanged }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The most counted of those not yet paired, none where two tie.
|
|
73
|
+
function best(counts, taken) {
|
|
74
|
+
let found = null, most = 0
|
|
75
|
+
for (const [other, n] of counts) {
|
|
76
|
+
if (taken(other)) continue
|
|
77
|
+
if (n > most) {
|
|
78
|
+
found = other
|
|
79
|
+
most = n
|
|
80
|
+
} else if (n === most) found = null
|
|
81
|
+
}
|
|
82
|
+
return found
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Pairs each the other's best, into `map`, until none more pair.
|
|
86
|
+
function matched({ backward, forward }, { map, paired }, eligible = () => true) {
|
|
87
|
+
const chosen = []
|
|
88
|
+
for (let more = true; more;) {
|
|
89
|
+
more = false
|
|
90
|
+
for (const [variable, counts] of forward) {
|
|
91
|
+
if (map.has(variable)) continue
|
|
92
|
+
const to = best(counts, (other) => paired.has(other))
|
|
93
|
+
if (!to || best(backward.get(to), (other) => map.has(other)) !== variable || !eligible(variable, to)) continue
|
|
94
|
+
map.set(variable, to)
|
|
95
|
+
paired.add(to)
|
|
96
|
+
chosen.push(variable)
|
|
97
|
+
more = true
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return chosen
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Unchanged lines pair first; changed ones then pair only what those say
|
|
104
|
+
// nothing of, as `weak` pairs, the first to give way where names read wrongly.
|
|
105
|
+
export function pairing(before, after, a, b) {
|
|
106
|
+
const { changed, unchanged } = tallied(keyed(before, a), keyed(after, b))
|
|
107
|
+
const found = { map: new Map(), paired: new Set() }
|
|
108
|
+
matched(unchanged, found)
|
|
109
|
+
const weak = new Set(matched(changed, found, (old, to) => !unchanged.forward.has(old) && !unchanged.backward.has(to)))
|
|
110
|
+
return { map: found.map, weak }
|
|
111
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The old side of a file minified on both sides given the new side's names:
|
|
2
|
+
// both analyzed, variables paired by their lines, the names given checked not
|
|
3
|
+
// to read wrongly, and the result checked again by oxc (bindsAlike).
|
|
4
|
+
|
|
5
|
+
import { getOxc } from '#oxc'
|
|
6
|
+
import { analyze } from './analyze.js'
|
|
7
|
+
import { bindsAlike } from './binds.js'
|
|
8
|
+
import { checkAnalysis, checkGiven, checkPairing, checkRenamed } from './check.js'
|
|
9
|
+
import { misread } from './misread.js'
|
|
10
|
+
import { pairing } from './pairing.js'
|
|
11
|
+
import { APART, Broken, holds } from './syntax.js'
|
|
12
|
+
|
|
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 }) {
|
|
17
|
+
const apart = (name) => {
|
|
18
|
+
let named = name + APART
|
|
19
|
+
for (let n = 2; a.names.has(named) || b.names.has(named); n++) named = `${name}${APART}${n}`
|
|
20
|
+
return named
|
|
21
|
+
}
|
|
22
|
+
const given = new Map()
|
|
23
|
+
for (;;) {
|
|
24
|
+
for (const variable of a.variables) {
|
|
25
|
+
const own = variable.aside && !map.has(variable) && b.names.has(variable.name) ? apart(variable.name) : variable.name
|
|
26
|
+
given.set(variable, variable.aside && map.has(variable) ? map.get(variable).name : own)
|
|
27
|
+
}
|
|
28
|
+
const wrong = [...misread(a, given)]
|
|
29
|
+
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
|
+
if (yielding.filter((variable) => map.delete(variable)).length === 0) return null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renamedNames(before, after, { extension = 'js', otherExtension = extension }) {
|
|
36
|
+
const a = analyze(before, extension), b = analyze(after, otherExtension)
|
|
37
|
+
if (a.reason || b.reason) return { reason: a.reason ?? b.reason }
|
|
38
|
+
checkAnalysis(before, a)
|
|
39
|
+
checkAnalysis(after, b)
|
|
40
|
+
const paired = pairing(before, after, a, b)
|
|
41
|
+
checkPairing(a, b, paired)
|
|
42
|
+
const given = givenNames(a, b, paired)
|
|
43
|
+
if (!given) return { reason: 'unverified' }
|
|
44
|
+
checkGiven(a, b, paired.map, given)
|
|
45
|
+
let at = 0, text = ''
|
|
46
|
+
for (const [node, variable] of a.occurrences) {
|
|
47
|
+
text += before.slice(at, node.start) + given.get(variable)
|
|
48
|
+
at = node.end
|
|
49
|
+
}
|
|
50
|
+
text += before.slice(at)
|
|
51
|
+
holds(before !== after || text === before, 'a text renamed as itself changed')
|
|
52
|
+
checkRenamed(a, given, text, extension)
|
|
53
|
+
if (!bindsAlike(before, text, a)) return { reason: 'unverified' }
|
|
54
|
+
const changed = a.variables.filter((variable) => given.get(variable) !== variable.name)
|
|
55
|
+
const renamed = changed.filter((variable) => paired.map.has(variable)).length
|
|
56
|
+
const reads = [...new Set([...a.reads, ...b.reads])].toSorted()
|
|
57
|
+
return { text, renamed, apart: changed.length - renamed, kind: { module: a.module, commonjs: a.commonjs, own: a.own }, reads }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Any failure gives no names, and tells what failed. oxc is loaded first, out
|
|
61
|
+
// of the try: one missing is thrown, not taken for a text it could not read.
|
|
62
|
+
export function renameMap(before, after, options = {}) {
|
|
63
|
+
getOxc()
|
|
64
|
+
try {
|
|
65
|
+
return renamedNames(before, after, options)
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return { reason: 'unverified', invariant: error instanceof Broken ? error.message : String(error) }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Which names renaming touches, and the rules of the language it leans on.
|
|
2
|
+
|
|
3
|
+
// A minifier's names: short and ASCII, so that none is renamed to or from one
|
|
4
|
+
// a reader can't tell apart (a Cyrillic a, U+0430, or a zero-width joiner).
|
|
5
|
+
export const RENAMED_MAX_LENGTH = 3
|
|
6
|
+
export const MINIFIED = /^[A-Za-z_$][\w$]*$/u
|
|
7
|
+
// Properties code reads names through, which renaming changes: as a member's
|
|
8
|
+
// or a pattern's key, or any string written out (not `'' + f`).
|
|
9
|
+
export const NAME_READS = new Set(['name', 'toString', 'stack', 'callee', 'caller'])
|
|
10
|
+
// What a name renamed apart ends in: `e_old`, then `e_old2`.
|
|
11
|
+
export const APART = '_old'
|
|
12
|
+
// Words no variable may be named, in strict code or in a module.
|
|
13
|
+
export const RESERVED = new Set(`await break case catch class const continue debugger default delete do else enum export extends false finally for
|
|
14
|
+
function if implements import in instanceof interface let new null package private protected public return static super switch this throw
|
|
15
|
+
true try typeof var void while with yield arguments eval`.split(/\s+/u))
|
|
16
|
+
|
|
17
|
+
// A check that should never fail: renameMap then gives no names, and tells why.
|
|
18
|
+
export class Broken extends Error {}
|
|
19
|
+
export function holds(condition, what) {
|
|
20
|
+
if (!condition) throw new Broken(what)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The names a pattern declares: `{ a, b: [c, ...d] = e }` declares a, c and d.
|
|
24
|
+
export function declared(pattern, out = []) {
|
|
25
|
+
switch (pattern?.type) {
|
|
26
|
+
case 'Identifier': out.push(pattern); break
|
|
27
|
+
case 'ObjectPattern': for (const property of pattern.properties) declared(property.type === 'RestElement' ? property.argument : property.value, out); break
|
|
28
|
+
case 'ArrayPattern': for (const element of pattern.elements) declared(element, out); break
|
|
29
|
+
case 'RestElement': declared(pattern.argument, out); break
|
|
30
|
+
case 'AssignmentPattern': declared(pattern.left, out); break
|
|
31
|
+
default: break
|
|
32
|
+
}
|
|
33
|
+
return out
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function saysStrict(statements) {
|
|
37
|
+
for (const statement of statements) {
|
|
38
|
+
if (typeof statement.directive !== 'string') return false
|
|
39
|
+
if (statement.directive === 'use strict') return true
|
|
40
|
+
}
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The variable a name read in `from` stands for, or null for a global's;
|
|
45
|
+
// `names` gives a scope's names, its own unless others are asked after.
|
|
46
|
+
export function resolve(name, from, names = (at) => at.names) {
|
|
47
|
+
for (let at = from; at; at = at.parent) if (names(at).has(name)) return names(at).get(name)
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The variables of `name` a `var` hoists past up to its function: each a
|
|
52
|
+
// redeclaration that does not compile, but past a `catch` whose simple
|
|
53
|
+
// parameter it is, or one of its name beyond such a one.
|
|
54
|
+
export function hoistedPast(variable, path, name, named) {
|
|
55
|
+
const met = []
|
|
56
|
+
let caught = false
|
|
57
|
+
for (const at of path) {
|
|
58
|
+
const there = named(at).get(name)
|
|
59
|
+
if (there === variable) caught ||= Boolean(at.caught)
|
|
60
|
+
else if (there && !(at.caught && at.simple && caught)) met.push(there)
|
|
61
|
+
}
|
|
62
|
+
return met
|
|
63
|
+
}
|
package/src/oxc.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// oxc-parser and oxc-minify where Node does not run this: their own builds for
|
|
2
|
+
// it (WASM, by their `browser` condition), loaded with names.js.
|
|
3
|
+
import { minifySync } from 'oxc-minify'
|
|
4
|
+
import { parseSync } from 'oxc-parser'
|
|
5
|
+
|
|
6
|
+
export const getOxc = () => ({ parseSync, minifySync })
|
package/src/oxc.node.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
|
|
3
|
+
// oxc-parser and oxc-minify, optional peers, required on first use; one missing
|
|
4
|
+
// is thrown as the environment's error, not taken for a text that won't parse.
|
|
5
|
+
const require = createRequire(import.meta.url)
|
|
6
|
+
let oxc
|
|
7
|
+
|
|
8
|
+
function load(name) {
|
|
9
|
+
try {
|
|
10
|
+
return require(name)
|
|
11
|
+
} catch (cause) {
|
|
12
|
+
throw new Error(`@preventive/compare/names.js needs the optional '${name}' peer dependency; install it (e.g. \`npm i ${name}\`)`, { cause })
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const getOxc = () => (oxc ??= { parseSync: load('oxc-parser').parseSync, minifySync: load('oxc-minify').minifySync })
|
package/src/pairs.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Which of a change's removed lines was edited into which added one: the best
|
|
2
|
+
// pairing in order where weighing every pair is cheap enough, else a lookahead.
|
|
3
|
+
|
|
4
|
+
import { MAX_CHANGED, tokenized, wordView } from './words.js'
|
|
5
|
+
|
|
6
|
+
// The longest change whose lines are paired at all; the most work weighing all
|
|
7
|
+
// its pairs may take, in steps; and past it, how far ahead a removed line looks
|
|
8
|
+
// for an added one instead.
|
|
9
|
+
const MAX_LINES = 200
|
|
10
|
+
const BUDGET = 5_000_000
|
|
11
|
+
const LOOKAHEAD = 6
|
|
12
|
+
// What a pair weighed and a word's count compared cost, in steps of a script.
|
|
13
|
+
const PAIR = 60
|
|
14
|
+
const VISIT = 3
|
|
15
|
+
|
|
16
|
+
function counted(read) {
|
|
17
|
+
const counts = new Map()
|
|
18
|
+
for (const token of read?.tokens ?? []) counts.set(token, (counts.get(token) ?? 0) + 1)
|
|
19
|
+
return counts
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// How much of each pair of lines changed, as wordRanges marks it, Infinity
|
|
23
|
+
// where they are no line edited; and the work weighing them has taken.
|
|
24
|
+
function weigher(olds, news) {
|
|
25
|
+
const ids = new Map(), reads = [olds, news].map((side) => side.map((line) => tokenized(line, ids)))
|
|
26
|
+
const counts = reads.map((side) => side.map(counted))
|
|
27
|
+
let work = 0
|
|
28
|
+
// The share of `line` in words `others` holds fewer of, changed whatever the script finds; past what a
|
|
29
|
+
// word view takes for one line edited, Infinity.
|
|
30
|
+
const unmatched = (mine, others, line) => {
|
|
31
|
+
let changed = 0
|
|
32
|
+
for (const [token, count] of mine) {
|
|
33
|
+
work += VISIT
|
|
34
|
+
if ((changed += token.length * Math.max(0, count - (others.get(token) ?? 0))) > MAX_CHANGED * line.length) return Infinity
|
|
35
|
+
}
|
|
36
|
+
return changed / Math.max(1, line.length)
|
|
37
|
+
}
|
|
38
|
+
// The least share of the pair changed, as their counts alone tell.
|
|
39
|
+
const floor = (i, j) => (reads[0][i] === null || reads[1][j] === null ? Infinity
|
|
40
|
+
: Math.min(unmatched(counts[0][i], counts[1][j], olds[i]), unmatched(counts[1][j], counts[0][i], news[j])))
|
|
41
|
+
const weighed = (i, j) => {
|
|
42
|
+
work += PAIR
|
|
43
|
+
if (floor(i, j) === Infinity) return Infinity
|
|
44
|
+
const { share, steps } = wordView(reads[0][i], reads[1][j])
|
|
45
|
+
work += steps
|
|
46
|
+
return share <= MAX_CHANGED ? share : Infinity
|
|
47
|
+
}
|
|
48
|
+
const shares = new Float64Array(olds.length * news.length).fill(Number.NaN)
|
|
49
|
+
const share = (i, j) => {
|
|
50
|
+
const key = i * news.length + j
|
|
51
|
+
if (Number.isNaN(shares[key])) shares[key] = olds[i] === news[j] ? 0 : weighed(i, j)
|
|
52
|
+
return shares[key]
|
|
53
|
+
}
|
|
54
|
+
return { share, floor, spent: () => work }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The pairs in order that are the most, and of those the most alike, a pair
|
|
58
|
+
// weighed 1000 less its share; null where weighing them all passes the budget.
|
|
59
|
+
function aligned(n, m, { share, spent }) {
|
|
60
|
+
const value = Array.from({ length: n + 1 }, () => new Float64Array(m + 1))
|
|
61
|
+
const take = (i, j) => (share(i, j) === Infinity ? -Infinity : 1000 - share(i, j) + value[i][j])
|
|
62
|
+
for (let i = 0; i < n; i++) {
|
|
63
|
+
for (let j = 0; j < m; j++) {
|
|
64
|
+
value[i + 1][j + 1] = Math.max(value[i][j + 1], value[i + 1][j], take(i, j))
|
|
65
|
+
if (spent() > BUDGET) return null
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const pairs = []
|
|
69
|
+
for (let i = n, j = m; i > 0 && j > 0;) {
|
|
70
|
+
if (value[i][j] === value[i][j - 1]) j--
|
|
71
|
+
else if (value[i][j] === value[i - 1][j]) i--
|
|
72
|
+
else pairs.push([--i, --j])
|
|
73
|
+
}
|
|
74
|
+
return pairs.toReversed()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Each removed line in turn paired with the most alike of the next added lines
|
|
78
|
+
// after the last one paired, those whose counts show less changed weighed first.
|
|
79
|
+
function lookedAhead(n, m, { share, floor }) {
|
|
80
|
+
const pairs = []
|
|
81
|
+
for (let i = 0, next = 0; i < n && next < m; i++) {
|
|
82
|
+
let best = null, least = Infinity
|
|
83
|
+
const ahead = Array.from({ length: Math.min(m, next + LOOKAHEAD) - next }, (_, at) => [next + at, floor(i, next + at)])
|
|
84
|
+
for (const [j, low] of ahead.toSorted((p, q) => p[1] - q[1] || p[0] - q[0])) {
|
|
85
|
+
if (low > least) break
|
|
86
|
+
if (share(i, j) < least || (share(i, j) === least && j < best)) [best, least] = [j, share(i, j)]
|
|
87
|
+
}
|
|
88
|
+
if (best !== null) {
|
|
89
|
+
pairs.push([i, best])
|
|
90
|
+
next = best + 1
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return pairs
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function pairLines(olds, news) {
|
|
97
|
+
if (olds.length > MAX_LINES || news.length > MAX_LINES) return []
|
|
98
|
+
const weigh = weigher(olds, news)
|
|
99
|
+
return aligned(olds.length, news.length, weigh) ?? lookedAhead(olds.length, news.length, weigh)
|
|
100
|
+
}
|
package/src/patch.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The patch `git diff` writes (builtin_diff, fill_metainfo in diff.c), less its
|
|
2
|
+
// `index` lines, which need git's object hashes. Hunks print each change's own
|
|
3
|
+
// blocks, so a run of lines free to move may sit elsewhere than git puts it.
|
|
4
|
+
|
|
5
|
+
import { utf8fromString, utf8toString } from '@exodus/bytes/utf8.js'
|
|
6
|
+
import { changeSet, formatChangeSet } from '@preventive/diff'
|
|
7
|
+
import { quote } from './quote.js'
|
|
8
|
+
import { checkLineOptions, textOf } from './text.js'
|
|
9
|
+
import { sameContent } from './tree.js'
|
|
10
|
+
|
|
11
|
+
const STATUSES = new Set(['added', 'deleted', 'modified', 'renamed'])
|
|
12
|
+
const gitMode = (file) => (file.type === 'symlink' ? '120000' : (0o100000 | file.mode).toString(8))
|
|
13
|
+
|
|
14
|
+
// git ends a label holding a space with a tab, so patch reads the whole name.
|
|
15
|
+
const label = (name) => (name.includes(' ') ? `${name}\t` : name)
|
|
16
|
+
|
|
17
|
+
// git's default hunk name (def_ff in xemit.c): the nearest line above the hunk
|
|
18
|
+
// starting with a letter, `_` or `$`, cut to 80 bytes, less trailing blanks.
|
|
19
|
+
const FUNCTION_LINE = /^[A-Za-z_$]/u
|
|
20
|
+
const BLANKS = /[\t\n\r ]+$/u
|
|
21
|
+
|
|
22
|
+
// git may cut a character in two; this cuts before it.
|
|
23
|
+
function cut(line) {
|
|
24
|
+
const bytes = utf8fromString(line)
|
|
25
|
+
let end = Math.min(bytes.length, 80)
|
|
26
|
+
while ((bytes[end] & 0xC0) === 0x80) end--
|
|
27
|
+
return utf8toString(bytes.subarray(0, end)).replace(BLANKS, '')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// git searches back only to the previous hunk's start, keeping what it found.
|
|
31
|
+
function hunkNames(text) {
|
|
32
|
+
const lines = text.split('\n')
|
|
33
|
+
let found = null, searched = -1
|
|
34
|
+
return (start) => {
|
|
35
|
+
const line = lines.slice(searched + 1, start).findLast((candidate) => FUNCTION_LINE.test(candidate))
|
|
36
|
+
if (line !== undefined) found = cut(line)
|
|
37
|
+
searched = start - 1
|
|
38
|
+
return found
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// `blocks` are the change's, found under `options`, or are found here.
|
|
43
|
+
function body(one, two, blocks, from, to, { context, ...options }) {
|
|
44
|
+
const newText = textOf(two), oldText = textOf(one)
|
|
45
|
+
if (oldText === undefined || newText === undefined) {
|
|
46
|
+
return one !== null && two !== null && sameContent(one, two) ? '' : `Binary files ${from} and ${to} differ\n`
|
|
47
|
+
}
|
|
48
|
+
const hunks = formatChangeSet(oldText, newText, blocks ?? changeSet(oldText, newText, options), { ...options, context, label: hunkNames(oldText) })
|
|
49
|
+
return hunks === '' ? '' : `--- ${label(from)}\n+++ ${label(to)}\n${hunks}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// One `diff --git` section; `similarity` is a rename's. As git does, a header
|
|
53
|
+
// with nothing under it is left out.
|
|
54
|
+
function section(one, two, similarity, blocks, options) {
|
|
55
|
+
const a = quote((one ?? two).path, 'a/'), b = quote((two ?? one).path, 'b/')
|
|
56
|
+
let meta = ''
|
|
57
|
+
if (one === null) meta += `new file mode ${gitMode(two)}\n`
|
|
58
|
+
else if (two === null) meta += `deleted file mode ${gitMode(one)}\n`
|
|
59
|
+
else if (gitMode(one) !== gitMode(two)) meta += `old mode ${gitMode(one)}\nnew mode ${gitMode(two)}\n`
|
|
60
|
+
if (similarity !== null) meta += `similarity index ${similarity}%\nrename from ${quote(one.path)}\nrename to ${quote(two.path)}\n`
|
|
61
|
+
const rest = meta + body(one, two, blocks, one === null ? '/dev/null' : a, two === null ? '/dev/null' : b, options)
|
|
62
|
+
return rest === '' ? '' : `diff --git ${a} ${b}\n${rest}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The changes were found under the line options given (text.js), as their blocks
|
|
66
|
+
// are held to them; a change found without lines has them found here.
|
|
67
|
+
export function formatPatch(changes, { context = 3, ...lineOptions } = {}) {
|
|
68
|
+
if (!(Number.isInteger(context) && context >= 0)) throw new RangeError(`context must be a whole number of lines, not ${context}`)
|
|
69
|
+
const options = { context, ...checkLineOptions(lineOptions) }
|
|
70
|
+
let out = ''
|
|
71
|
+
for (const { status, old, new: fresh, similarity, blocks } of changes) {
|
|
72
|
+
// git writes a type change as a deletion and a creation.
|
|
73
|
+
if (status === 'type-changed') out += section(old, null, null, null, options) + section(null, fresh, null, null, options)
|
|
74
|
+
else if (STATUSES.has(status)) out += section(old, fresh, status === 'renamed' ? similarity : null, blocks, options)
|
|
75
|
+
else throw new TypeError(`${JSON.stringify(status)} is not a change's status`)
|
|
76
|
+
}
|
|
77
|
+
return out
|
|
78
|
+
}
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// A rename written as `git diff --stat` writes one: the directories both share,
|
|
2
|
+
// what changed, and the rest.
|
|
3
|
+
|
|
4
|
+
export function renameParts(from, to, { extension = true } = {}) {
|
|
5
|
+
const a = from.split('/'), b = to.split('/')
|
|
6
|
+
let head = 0
|
|
7
|
+
while (head < a.length - 1 && head < b.length - 1 && a[head] === b[head]) head++
|
|
8
|
+
let tail = 0
|
|
9
|
+
while (tail < a.length - head - 1 && tail < b.length - head - 1 && a.at(-1 - tail) === b.at(-1 - tail)) tail++
|
|
10
|
+
const parts = {
|
|
11
|
+
head: head ? `${a.slice(0, head).join('/')}/` : '',
|
|
12
|
+
from: a.slice(head, a.length - tail).join('/'),
|
|
13
|
+
to: b.slice(head, b.length - tail).join('/'),
|
|
14
|
+
tail: tail ? `/${a.slice(-tail).join('/')}` : '',
|
|
15
|
+
}
|
|
16
|
+
const dot = parts.from.lastIndexOf('.')
|
|
17
|
+
if (extension && !parts.from.includes('/') && !parts.to.includes('/') && dot > 0
|
|
18
|
+
&& parts.to.lastIndexOf('.') === dot && parts.from.slice(0, dot) === parts.to.slice(0, dot)) {
|
|
19
|
+
return { ...parts, head: parts.head + parts.from.slice(0, dot), from: parts.from.slice(dot), to: parts.to.slice(dot) }
|
|
20
|
+
}
|
|
21
|
+
return parts
|
|
22
|
+
}
|
package/src/peers.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// pretty.js's optional peers, loaded on first use. oxfmt it can't do without:
|
|
2
|
+
// one missing is thrown as the environment's error, not taken for a file that
|
|
3
|
+
// won't format. css-tree checks CSS: null where missing, so CSS is refused.
|
|
4
|
+
let csstree, format
|
|
5
|
+
|
|
6
|
+
export async function loadFormatter() {
|
|
7
|
+
try {
|
|
8
|
+
format ??= (await import('oxfmt')).format
|
|
9
|
+
} catch (cause) {
|
|
10
|
+
throw new Error("@preventive/compare/pretty.js needs the optional 'oxfmt' peer dependency; install it (e.g. `npm i oxfmt`)", { cause })
|
|
11
|
+
}
|
|
12
|
+
return format
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function loadCssTree() {
|
|
16
|
+
csstree ??= await import('css-tree').catch(() => null)
|
|
17
|
+
return csstree
|
|
18
|
+
}
|