@preventive/compare 1.2.7 → 1.2.9
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 +19 -12
- package/names.d.ts +19 -7
- package/names.js +2 -2
- package/package.json +1 -1
- package/src/chunks.js +39 -21
- package/src/marked.js +2 -3
- package/src/names/diff.js +19 -20
- package/src/names/rename.js +8 -2
- package/src/patch.js +3 -3
- package/src/pretty.js +4 -2
- package/src/renames.js +25 -9
- 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,22 @@ 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 however it scores
|
|
124
|
+
// where each is the other's only such chunk, as a pair by path is (a build's
|
|
125
|
+
// chunk rewritten: playwright's dashboard `index-*.js`, 31% alike from 1.61.1
|
|
126
|
+
// to 1.62.0), else at git's stricter score for a basename, halfway from -M's
|
|
127
|
+
// to 100%: the best pairs first, its similarity that score, giving way as a
|
|
128
|
+
// pair by path does but only where git takes the candidate it gives way to,
|
|
129
|
+
// and none past the rename limit, as they are scored. git has no such passes,
|
|
130
|
+
// so false is git's own -M.
|
|
124
131
|
//
|
|
125
132
|
// `renameLimit` is git's diff.renameLimit: when more deleted files times added
|
|
126
133
|
// 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,39 +15,55 @@ 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
|
|
32
|
-
|
|
34
|
+
// Chunks git's scored pass has yet to claim, by directory, extension and
|
|
35
|
+
// name's length, each pair at least `bar` -- or however it scores, where each
|
|
36
|
+
// is the other's only such chunk, as a pair by path is: the build's own chunk,
|
|
37
|
+
// rewritten (playwright's dashboard `index-*.js`, 31% alike from 1.61 to 1.62):
|
|
38
|
+
// the best first, as git takes its candidates, but for a pair `outdone` by a
|
|
39
|
+
// file's other candidate, set aside and returned, best first, for git to pair
|
|
40
|
+
// that candidate with another file: it holds its files, so that a later pair
|
|
41
|
+
// of either is set aside behind it.
|
|
42
|
+
export function chunkRenames({ sources, dests, pairs, used, record, punctuatedScoreOf }, { remaining, open }, bar, outdone) {
|
|
33
43
|
const names = (files, indices) => new Map(indices.filter((i) => files[i].type === 'file').map((i) => [i, nameOf(files[i].path)]))
|
|
34
44
|
const destNames = names(dests, open), sourceNames = names(sources, remaining)
|
|
35
45
|
const byKey = Map.groupBy(sourceNames.keys(), (s) => sourceNames.get(s).key)
|
|
36
46
|
const opens = Map.groupBy(destNames.keys(), (d) => destNames.get(d).key)
|
|
37
|
-
const
|
|
47
|
+
const chunks = []
|
|
38
48
|
for (const [key, ds] of opens) {
|
|
39
49
|
const ss = byKey.get(key) ?? []
|
|
40
|
-
for (const d of ds) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
for (const d of ds) for (const s of ss) if (oneChunk(sourceNames.get(s).name, destNames.get(d).name)) chunks.push({ s, d })
|
|
51
|
+
}
|
|
52
|
+
const byDest = Map.groupBy(chunks, ({ d }) => d), bySource = Map.groupBy(chunks, ({ s }) => s)
|
|
53
|
+
const candidates = []
|
|
54
|
+
for (const { s, d } of chunks) {
|
|
55
|
+
const only = bySource.get(s).length === 1 && byDest.get(d).length === 1
|
|
56
|
+
const score = punctuatedScoreOf(s, d, only ? 0 : bar)
|
|
57
|
+
if (only || score >= bar) candidates.push({ s, d, score })
|
|
47
58
|
}
|
|
48
|
-
|
|
49
|
-
|
|
59
|
+
const aside = [], heldDests = new Set(), heldSources = new Set()
|
|
60
|
+
for (const pair of candidates.sort((x, y) => y.score - x.score)) {
|
|
61
|
+
if (pairs.has(pair.d) || used.has(pair.s)) continue
|
|
62
|
+
if (heldSources.has(pair.s) || heldDests.has(pair.d) || outdone(pair)) {
|
|
63
|
+
aside.push(pair)
|
|
64
|
+
heldSources.add(pair.s)
|
|
65
|
+
heldDests.add(pair.d)
|
|
66
|
+
} else record(pair.d, pair.s, pair.score)
|
|
50
67
|
}
|
|
68
|
+
return aside
|
|
51
69
|
}
|
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/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/rename.js
CHANGED
|
@@ -42,7 +42,10 @@ function renamedNames(before, after, { extension = 'js', otherExtension = extens
|
|
|
42
42
|
// by its syntax: a bundle's chunk that one version exports from, the other not,
|
|
43
43
|
// or that neither does, its top level renamed by a minifier as a module's is.
|
|
44
44
|
// Where either version is one, a script's top level is its own too.
|
|
45
|
-
|
|
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)]
|
|
46
49
|
checkAnalysis(before, a)
|
|
47
50
|
checkAnalysis(after, b)
|
|
48
51
|
const paired = pairing(before, after, a, b)
|
|
@@ -65,7 +68,10 @@ function renamedNames(before, after, { extension = 'js', otherExtension = extens
|
|
|
65
68
|
// class's alone: one only where a name renamed is one's.
|
|
66
69
|
const naming = changed.some((variable) => variable.naming || paired.map.get(variable)?.naming)
|
|
67
70
|
const reads = changed.length === 0 ? [] : [...new Set([...a.reads, ...b.reads])].filter((read) => read !== 'name' || naming).toSorted()
|
|
68
|
-
|
|
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 }
|
|
69
75
|
}
|
|
70
76
|
|
|
71
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 }) => ({
|
|
@@ -178,7 +193,8 @@ const unpaired = ({ sources, dests, pairs, used }) => ({
|
|
|
178
193
|
})
|
|
179
194
|
|
|
180
195
|
// git's stricter score, halfway from -M's to 100%, for a pair that names alone
|
|
181
|
-
// put forward: a basename both sides have once, or a chunk's
|
|
196
|
+
// put forward: a basename both sides have once, or a chunk's of a name either
|
|
197
|
+
// side has more of.
|
|
182
198
|
const stricter = (minimum) => minimum + Math.trunc(0.5 * (MAX_SCORE - minimum))
|
|
183
199
|
|
|
184
200
|
// Each added file's rename by its index in `dests`: { source, score }. A
|
|
@@ -210,7 +226,7 @@ export function findRenames(sources, dests, { minimum, limit, byPath }) {
|
|
|
210
226
|
basenameRenames(context, unpaired(context), stricter(minimum))
|
|
211
227
|
const left = unpaired(context)
|
|
212
228
|
if (left.remaining.length * left.open.length > limit * limit) return pairs
|
|
213
|
-
|
|
214
|
-
|
|
229
|
+
const aside = byPath ? chunkRenames(context, left, stricter(minimum), (pair) => outdone(context, left, pair, minimum)) : []
|
|
230
|
+
scoredRenames(context, unpaired(context), minimum, aside)
|
|
215
231
|
return pairs
|
|
216
232
|
}
|
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
|
}
|