@preventive/compare 1.2.1 → 1.2.3
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 +25 -9
- package/package.json +2 -1
- package/pretty.d.ts +6 -3
- package/src/changes.js +22 -14
- package/src/chunks.js +51 -0
- package/src/erasure.js +7 -1
- package/src/pretty.js +153 -27
- package/src/renames.js +17 -7
- package/src/similarity.js +43 -12
- package/src/types.node.js +3 -3
package/compare.d.ts
CHANGED
|
@@ -42,12 +42,15 @@ export type Status = 'added' | 'deleted' | 'modified' | 'renamed' | 'type-change
|
|
|
42
42
|
// retained module load or `export {}` runs the same on both sides only within
|
|
43
43
|
// one module format -- an `export {}` emits a marker transpiled to CommonJS, a
|
|
44
44
|
// module load runs as one format and throws as the other -- so where the runtime
|
|
45
|
-
// loads a module
|
|
46
|
-
//
|
|
47
|
-
//
|
|
45
|
+
// loads a module, carries a bare `export {}`, or default-exports an identifier a
|
|
46
|
+
// rename whose extensions fix different formats (a `.d.mts` to a `.d.cts`, or a
|
|
47
|
+
// `.d.ts` to or from either) is not stamped, while a rename between two of the same
|
|
48
|
+
// extension (two `.d.ts`, two `.d.mts`, two `.d.cts`) is; a hashbang, a directive or a pragma runs the same either
|
|
48
49
|
// way, so a runtime of only those cancels across formats. A modification keeps its
|
|
49
|
-
// path, hence its format, and
|
|
50
|
-
// runtime
|
|
50
|
+
// path, hence its format, and a deleted file matches only an empty runtime; an
|
|
51
|
+
// added one an empty runtime or a single bare `export {}`, as nothing loaded it
|
|
52
|
+
// before and what now does runs nothing of that alone, as a module or not. A
|
|
53
|
+
// rename is otherwise taken by its matching
|
|
51
54
|
// source, as any file's is here: a relative load may resolve to another module
|
|
52
55
|
// once the file moves, but that is shown as a rename of its own, not traced. A
|
|
53
56
|
// modification of a package-resolved `.d.ts` is taken to keep its format, though
|
|
@@ -107,7 +110,17 @@ export interface LineOptions {
|
|
|
107
110
|
// and their directories but for one -- not a package's name under
|
|
108
111
|
// node_modules -- each the other's only such partner. Either gives way only
|
|
109
112
|
// to a candidate that is a rename by itself and scores at least halfway from
|
|
110
|
-
// the pair's score to 100%.
|
|
113
|
+
// the pair's score to 100%. Last, of the files no other pass took, it pairs
|
|
114
|
+
// bundle chunks whose hash changed with their content: in one directory, of
|
|
115
|
+
// one extension, names alike but for what follows the last separator (`-`,
|
|
116
|
+
// `.`, `_`) they share, six or more characters of a hash's (letters, digits,
|
|
117
|
+
// `-` and `_`, not lowercase letters alone), as `index-CyWAfh-p.js` and
|
|
118
|
+
// `index-hRvO4yU2.js` are. Minified onto a line, such a chunk scores little
|
|
119
|
+
// as git splits it, so it is scored with its spans ending after `,` `;` `=`
|
|
120
|
+
// `{` `}` too, and taken at git's stricter score for a basename, halfway from
|
|
121
|
+
// -M's to 100%: the best pairs first, its similarity that score, and none
|
|
122
|
+
// past the rename limit, as they are scored. git has no such passes, so false
|
|
123
|
+
// is git's own -M.
|
|
111
124
|
//
|
|
112
125
|
// `renameLimit` is git's diff.renameLimit: when more deleted files times added
|
|
113
126
|
// files are left, in a group, after the renames that need no scoring than its
|
|
@@ -217,12 +230,15 @@ export type TypeOnlyReason = 'unsupported' | 'unparsed' | 'code' | 'disagree'
|
|
|
217
230
|
// function's signature, `import type` and `export type`, a namespace of types
|
|
218
231
|
// alone -- and those it leaves to run: a module load that binds from it
|
|
219
232
|
// (`import`, `export *`, `export { a } from` a module, `import { type A }` whose
|
|
220
|
-
// inline `type` loads it all the same)
|
|
233
|
+
// inline `type` loads it all the same), a bare `export {}`, and `export default E`
|
|
234
|
+
// on a bare identifier (the idiom a declaration file's default export takes,
|
|
235
|
+
// `declare const _d: T; export default _d`). `export { type A }`
|
|
221
236
|
// is code, not a run statement: it is `export {}` only once stripping takes its
|
|
222
237
|
// specifiers out, not as the source has it; nor is `export as namespace N`, left
|
|
223
238
|
// as invalid JavaScript that throws when imported, nor a side-effect load that
|
|
224
239
|
// binds nothing -- a bare `import`, an empty `import {}`, `export {}` with a
|
|
225
|
-
// source -- which loads a module for no type
|
|
240
|
+
// source -- which loads a module for no type, nor an `export default` of anything
|
|
241
|
+
// but a bare identifier (a value expression, an object, a class body);
|
|
226
242
|
// - Node's stripTypeScriptTypes erases, to the character, what oxc finds erased
|
|
227
243
|
// and leaves nothing it does not, but comments, the hashbang and those;
|
|
228
244
|
// - what it leaves, the run statements set aside, read token by token apart from
|
|
@@ -244,7 +260,7 @@ export type TypeOnlyReason = 'unsupported' | 'unparsed' | 'code' | 'disagree'
|
|
|
244
260
|
// point. A triple-slash directive counts only in the prologue,
|
|
245
261
|
// before the first statement, where TypeScript honours it; below one it is prose, so moving
|
|
246
262
|
// it out of the prologue drops it from `runtime`, as removing it does. `loads` is whether any
|
|
247
|
-
// run statement is a module load
|
|
263
|
+
// run statement is a module load, a bare `export {}`, or an `export default` on an identifier: the runtime that runs as one module
|
|
248
264
|
// format and throws, or emits a different marker, as the other -- a hashbang, directive or
|
|
249
265
|
// pragma does not. A change is types only where both sides prove and leave the same `runtime`:
|
|
250
266
|
// it then touches erased types and ordinary comments alone. Where `loads`, a rename across
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/compare",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.3",
|
|
4
4
|
"description": "Git-compatible file tree diffs and minified JavaScript comparison",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./compare.js",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"pretty.js",
|
|
49
49
|
"src/blocks.js",
|
|
50
50
|
"src/changes.js",
|
|
51
|
+
"src/chunks.js",
|
|
51
52
|
"src/css.js",
|
|
52
53
|
"src/erasure.js",
|
|
53
54
|
"src/error.js",
|
package/pretty.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// `text` formatted by oxfmt as a file with `extension`, changing what is written
|
|
2
|
-
// no more than layout needs (keys keep their quotes,
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// no more than layout needs (keys keep their quotes, names their escapes, a comment
|
|
3
|
+
// its place after an operator; no trailing commas or arrow parentheses are added,
|
|
4
|
+
// template literals stay), but for an untyped script's (`js`, `mjs`, `cjs`, `jsx`)
|
|
5
|
+
// `!0` and `!1`, as a minifier writes `true` and `false`, written so, as TypeScript
|
|
6
|
+
// types them otherwise; the copy used only where checks of oxfmt's output, apart
|
|
7
|
+
// from oxfmt, find it changes the layout alone:
|
|
5
8
|
// - a script's (`js`, `mjs`, `cjs`, `jsx`, `ts`, `mts`, `cts`, `tsx`) syntax tree as
|
|
6
9
|
// oxc parses it, its comments and its text;
|
|
7
10
|
// - a style sheet's (`css`) tree as css-tree, an optional peer, parses it, but for
|
package/src/changes.js
CHANGED
|
@@ -54,9 +54,13 @@ const DECLARATION = /\.d\.(?:[^./]+\.)?[cm]?ts$/u
|
|
|
54
54
|
// a change across them is not types only.
|
|
55
55
|
const fixedModule = (path) => (path.endsWith('.mts') ? 'module' : path.endsWith('.cts') ? 'commonjs' : null)
|
|
56
56
|
|
|
57
|
+
// A runtime of a single bare `export {}`, any trailing `;`, as proveTypeOnly spells one: its blanks as the
|
|
58
|
+
// source has them, a comment within it a single blank.
|
|
59
|
+
const EXPORT_EMPTY = /^export\s*\{\s*\}\s*;?$/u
|
|
60
|
+
|
|
57
61
|
// A file's proof: that it holds types alone but for its `runtime` -- a hashbang, the module loads and
|
|
58
62
|
// `export {}` stripping leaves -- which a change must leave byte for byte untouched to be types only.
|
|
59
|
-
// `loads` is whether that runtime holds a module load
|
|
63
|
+
// `loads` is whether that runtime holds a module load, `export {}`, or an `export default` on an identifier, the part that runs differently per
|
|
60
64
|
// module format; a hashbang, a directive or a pragma does not, so a runtime of only those is format-free. A
|
|
61
65
|
// side not there has none. Not proven where the proof throws, as no check should.
|
|
62
66
|
function proven(file) {
|
|
@@ -71,28 +75,32 @@ function proven(file) {
|
|
|
71
75
|
|
|
72
76
|
// Stamped where each side there is a declaration file proven to hold types alone and the two leave the
|
|
73
77
|
// same runtime -- a change touching only erased types (and comments). A mode changing is a change of its
|
|
74
|
-
// own. Where that runtime loads a module
|
|
75
|
-
// within one module format, so a rename
|
|
76
|
-
//
|
|
78
|
+
// own. Where that runtime loads a module, carries a bare `export {}`, or default-exports an identifier it runs
|
|
79
|
+
// the same on both sides only within one module format, so a rename cancels it only between two extensions
|
|
80
|
+
// that fix the same format -- two `.d.ts` (both package-resolved), two `.d.mts`, or two `.d.cts`; a rename
|
|
81
|
+
// whose extensions differ (a `.d.mts` to a `.d.cts`, or a `.d.ts` to or from either) does not qualify, since
|
|
82
|
+
// an extension pins a format where a package-resolved `.d.ts` leaves it open. A hashbang, a directive or a pragma runs the same either way,
|
|
77
83
|
// so a runtime of only those cancels across formats. A modification keeps its path, hence its format, and
|
|
78
|
-
//
|
|
84
|
+
// a deleted file matches only an empty runtime; an added one an empty runtime or a single bare `export {}`
|
|
85
|
+
// (EXPORT_EMPTY). Nothing loaded an added file before, so only what else the diff changes can, shown as a
|
|
86
|
+
// change of its own, and that loading runs nothing of a bare `export {}` alone: it runs nothing as a module
|
|
87
|
+
// and throws on it as CommonJS, whatever the extension. A module load beside it would run another module.
|
|
79
88
|
// A rename is taken by its matching source, as any file's is here: a relative load in the runtime may
|
|
80
89
|
// resolve to another module once the file moves, but that is the rename's doing, shown as a rename of its
|
|
81
|
-
// own, not traced. Known limitation: a
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
90
|
+
// own, not traced. Known limitation: a package-resolved `.d.ts` is taken to keep its format across a
|
|
91
|
+
// modification (same path) and to share one across a rename to another `.d.ts`, since format is read from
|
|
92
|
+
// the extension, not resolved from a package. The governing `package.json` could differ -- edited between
|
|
93
|
+
// the two trees, or a different one at the rename's destination -- and flip the format; that flip rides the
|
|
94
|
+
// `package.json` change or the move, each seen in the diff on its own, and is not traced here.
|
|
85
95
|
|
|
86
96
|
function stamped(change) {
|
|
87
97
|
const sides = [change.old, change.new].filter((file) => file !== null)
|
|
88
98
|
if (!sides.every((file) => file.type === 'file' && DECLARATION.test(file.path))) return change
|
|
89
99
|
if (change.old && change.new && change.old.mode !== change.new.mode) return change
|
|
90
100
|
const fresh = proven(change.new), old = proven(change.old)
|
|
91
|
-
if (!old.proven || !fresh.proven
|
|
92
|
-
if (old.
|
|
93
|
-
|
|
94
|
-
if (format === null || format !== fixedModule(change.new.path)) return change
|
|
95
|
-
}
|
|
101
|
+
if (!old.proven || !fresh.proven) return change
|
|
102
|
+
if (old.runtime !== fresh.runtime && !(change.old === null && EXPORT_EMPTY.test(fresh.runtime))) return change
|
|
103
|
+
if (old.loads && change.old && change.new && change.old.path !== change.new.path && fixedModule(change.old.path) !== fixedModule(change.new.path)) return change
|
|
96
104
|
return { ...change, typeOnly: true }
|
|
97
105
|
}
|
|
98
106
|
|
package/src/chunks.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
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. Minified code
|
|
3
|
+
// is a line or a few, so a chunk is scored with spans ending after punctuation.
|
|
4
|
+
|
|
5
|
+
// A content hash: six or more of base64url's characters, not all lowercase
|
|
6
|
+
// letters, which a word is.
|
|
7
|
+
const HASH = /^[\w-]{6,}$/u
|
|
8
|
+
const RANDOM = /[\dA-Z]/u
|
|
9
|
+
const SEPARATORS = '-._'
|
|
10
|
+
|
|
11
|
+
function nameOf(path) {
|
|
12
|
+
const slash = path.lastIndexOf('/')
|
|
13
|
+
const name = path.slice(slash + 1)
|
|
14
|
+
const dot = name.lastIndexOf('.')
|
|
15
|
+
const extension = dot > 0 ? name.slice(dot) : ''
|
|
16
|
+
const stem = name.slice(0, name.length - extension.length)
|
|
17
|
+
return { stem, key: `${path.slice(0, slash + 1)}\0${extension}\0${stem.length}` }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Whether two stems of a length are one chunk's, its hash all that differs:
|
|
21
|
+
// what follows the last separator they share before they differ.
|
|
22
|
+
function oneChunk(a, b) {
|
|
23
|
+
let start = 0
|
|
24
|
+
while (start < a.length && a[start] === b[start]) start++
|
|
25
|
+
while (start > 0 && !SEPARATORS.includes(a[start - 1])) start--
|
|
26
|
+
const x = a.slice(start), y = b.slice(start)
|
|
27
|
+
return start > 0 && HASH.test(x) && HASH.test(y) && (RANDOM.test(x) || RANDOM.test(y))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Chunks no other pass claimed, by directory, extension and name's length,
|
|
31
|
+
// each pair at least `bar`: the best first, as git takes its candidates.
|
|
32
|
+
export function chunkRenames({ sources, dests, pairs, used, record, punctuatedScoreOf }, { remaining, open }, bar) {
|
|
33
|
+
const names = (files, indices) => new Map(indices.filter((i) => files[i].type === 'file').map((i) => [i, nameOf(files[i].path)]))
|
|
34
|
+
const destNames = names(dests, open), sourceNames = names(sources, remaining)
|
|
35
|
+
const byKey = Map.groupBy(sourceNames.keys(), (s) => sourceNames.get(s).key)
|
|
36
|
+
const opens = Map.groupBy(destNames.keys(), (d) => destNames.get(d).key)
|
|
37
|
+
const candidates = []
|
|
38
|
+
for (const [key, ds] of opens) {
|
|
39
|
+
const ss = byKey.get(key) ?? []
|
|
40
|
+
for (const d of ds) {
|
|
41
|
+
for (const s of ss) {
|
|
42
|
+
if (!oneChunk(sourceNames.get(s).stem, destNames.get(d).stem)) continue
|
|
43
|
+
const score = punctuatedScoreOf(s, d, bar)
|
|
44
|
+
if (score >= bar) candidates.push({ s, d, score })
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
for (const { s, d, score } of candidates.sort((x, y) => y.score - x.score)) {
|
|
49
|
+
if (!pairs.has(d) && !used.has(s)) record(d, s, score)
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/erasure.js
CHANGED
|
@@ -72,7 +72,13 @@ export function strippedAs(statement) {
|
|
|
72
72
|
// load; `export { a } from` a module re-exports a binding. A bare `export {}`, no source, is a marker.
|
|
73
73
|
if (statement.source) return bindingless(statement) ? CODE : IMPORT
|
|
74
74
|
return statement.specifiers.length === 0 ? INERT : CODE
|
|
75
|
-
case 'ExportDefaultDeclaration':
|
|
75
|
+
case 'ExportDefaultDeclaration':
|
|
76
|
+
if (statement.declaration.type === 'TSInterfaceDeclaration' || statement.declaration.type === 'TSDeclareFunction') return ERASED
|
|
77
|
+
// `export default E`, a bare identifier, re-exports a binding by name: a run statement that runs as an
|
|
78
|
+
// ES module and throws as CommonJS, like `export {}`, so it is inert here, not code -- it is the idiom a
|
|
79
|
+
// declaration file's default export takes (`declare const _d: T; export default _d`). Any other default,
|
|
80
|
+
// a value expression or a class body among it, is code a declaration file has no reason to hold.
|
|
81
|
+
return statement.declaration.type === 'Identifier' ? INERT : CODE
|
|
76
82
|
// `export as namespace N` is left verbatim, invalid as JavaScript, so importing the file throws.
|
|
77
83
|
case 'TSNamespaceExportDeclaration': return CODE
|
|
78
84
|
default: return erased(statement) ? ERASED : CODE
|
package/src/pretty.js
CHANGED
|
@@ -29,11 +29,11 @@ function numberSpelling(text) {
|
|
|
29
29
|
|
|
30
30
|
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
|
-
// blanks, parentheses, semicolons, quotes
|
|
33
|
-
//
|
|
32
|
+
// blanks, parentheses, semicolons, quotes and numbers, and a comma before a closing bracket, past any
|
|
33
|
+
// comments (`import { a, b, }`). A regular expression's flags it may reorder are sorted where the
|
|
34
|
+
// syntax tree has them (respelled).
|
|
34
35
|
const scriptForm = (text) => text.replaceAll(NUMBER, numberSpelling).replaceAll(/,(?=(?:\s|\/\/[^\n]*|\/\*[^]*?\*\/)*[\])}>])/gu, '')
|
|
35
36
|
.replaceAll(/\\(?=["'])|[\s();]/gu, '').replaceAll("'", '"')
|
|
36
|
-
.replaceAll(/(?<=\/)[dgimsuvy]{2,}(?![\p{L}\p{N}_$])/gu, sorted)
|
|
37
37
|
|
|
38
38
|
// JSON has no point without a digit either side: such a number is kept as written, or a copy would read as
|
|
39
39
|
// valid, as would one with other blanks than JSON's four, which stay.
|
|
@@ -61,12 +61,25 @@ function chain(node, operator) {
|
|
|
61
61
|
}
|
|
62
62
|
return operands
|
|
63
63
|
}
|
|
64
|
+
// `!0` and `!1`, as a minifier writes `true` and `false`: the literal each reads as, which nothing binds
|
|
65
|
+
// otherwise, so that either reads alike wherever it is; in a script without types alone, as TypeScript
|
|
66
|
+
// types `!0` as `boolean` and `true` as `true`, and refuses `!0 as const`.
|
|
67
|
+
const typed = (extension) => /^[cm]?tsx?$/u.test(extension)
|
|
68
|
+
const BOOLEANS = new Map([['0', true], ['1', false]])
|
|
69
|
+
const BOOLEAN_LITERALS = new Map([...BOOLEANS].map(([raw, value]) => [raw, { type: 'Literal', value }]))
|
|
70
|
+
const booleanRaw = (node) => (node?.type === 'UnaryExpression' && node.operator === '!' && node.argument?.type === 'Literal'
|
|
71
|
+
&& typeof node.argument.value === 'number' && BOOLEANS.has(node.argument.raw) ? node.argument.raw : null)
|
|
72
|
+
const literal = (node) => BOOLEAN_LITERALS.get(booleanRaw(node)) ?? node
|
|
64
73
|
const fields = (node) => Object.keys(node).filter((key) => !POSITION.has(key) && !(node.type === 'Literal' && (key === 'raw' || key === 'regex' || (key === 'value' && node.regex))))
|
|
65
74
|
|
|
66
|
-
function sameTree(a, b) {
|
|
75
|
+
function sameTree(a, b, booleans) {
|
|
67
76
|
const work = [[a, b]]
|
|
68
77
|
while (work.length > 0) {
|
|
69
|
-
|
|
78
|
+
let [x, y] = work.pop()
|
|
79
|
+
if (booleans) {
|
|
80
|
+
x = literal(x)
|
|
81
|
+
y = literal(y)
|
|
82
|
+
}
|
|
70
83
|
if (x === y) continue
|
|
71
84
|
if (typeof x !== 'object' || typeof y !== 'object' || x === null || y === null || Array.isArray(x) !== Array.isArray(y)) return false
|
|
72
85
|
let pairs
|
|
@@ -84,42 +97,145 @@ function sameTree(a, b) {
|
|
|
84
97
|
return true
|
|
85
98
|
}
|
|
86
99
|
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
// and a union from the mark leading it, so each is taken where it is, in whatever context.
|
|
91
|
-
function typed(text, program) {
|
|
92
|
-
const edits = [], work = [program]
|
|
100
|
+
// Each node of a syntax tree given to `each`.
|
|
101
|
+
function visit(program, each) {
|
|
102
|
+
const work = [program]
|
|
93
103
|
while (work.length > 0) {
|
|
94
104
|
const node = work.pop()
|
|
95
105
|
// Pushed one by one: a spread of a file's statements could pass more arguments than a call takes.
|
|
96
106
|
if (Array.isArray(node)) for (const item of node) work.push(item)
|
|
97
107
|
else if (node !== null && typeof node === 'object') {
|
|
98
|
-
|
|
99
|
-
for (const member of members) if (text[member.end - 1] === ',') edits.push([member.end - 1, ';'])
|
|
100
|
-
const mark = node.type === 'TSUnionType' ? '|' : node.type === 'TSIntersectionType' ? '&' : null
|
|
101
|
-
if (mark && text[node.start] === mark && node.types[0]?.start > node.start) edits.push([node.start, ''])
|
|
108
|
+
each(node)
|
|
102
109
|
for (const key of Object.keys(node)) if (!POSITION.has(key) && typeof node[key] === 'object') work.push(node[key])
|
|
103
110
|
}
|
|
104
111
|
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// `text` with each of `edits`, `[from, to, put]`, none overlapping another.
|
|
115
|
+
function edited(text, edits) {
|
|
105
116
|
let from = 0, out = ''
|
|
106
|
-
for (const [
|
|
107
|
-
out += text.slice(from,
|
|
108
|
-
from =
|
|
117
|
+
for (const [start, end, put] of edits.toSorted(([x], [y]) => x - y)) {
|
|
118
|
+
out += text.slice(from, start) + put
|
|
119
|
+
from = end
|
|
109
120
|
}
|
|
110
121
|
return out + text.slice(from)
|
|
111
122
|
}
|
|
112
123
|
|
|
124
|
+
// A script as the formatter may write it, read off its syntax tree: each regular expression's flags
|
|
125
|
+
// sorted (`/x/yu` as `/x/uy`), `!0` and `!1` as `true` and `false` where it reads `booleans` so, and
|
|
126
|
+
// where it has `types`, a member's separator `;` where it was `,` (`{ a: string, b: number }`), and
|
|
127
|
+
// no `|` or `&` before a union's or an intersection's first type (`type P =\n | A\n | B`,
|
|
128
|
+
// `[K in\n | A\n | B]`). oxc reads a member with the separator ending it and a union from the mark
|
|
129
|
+
// leading it, so each is taken where it is, in whatever context.
|
|
130
|
+
function respelled(text, program, types, booleans) {
|
|
131
|
+
const edits = []
|
|
132
|
+
visit(program, (node) => {
|
|
133
|
+
if (node.regex) edits.push([node.end - node.regex.flags.length, node.end, sorted(node.regex.flags)])
|
|
134
|
+
const raw = booleans ? booleanRaw(node) : null
|
|
135
|
+
if (raw !== null) edits.push([node.start, node.end, String(BOOLEANS.get(raw))])
|
|
136
|
+
if (!types) return
|
|
137
|
+
const members = node.type === 'TSTypeLiteral' ? node.members : node.type === 'TSInterfaceBody' ? node.body : []
|
|
138
|
+
for (const member of members) if (text[member.end - 1] === ',') edits.push([member.end - 1, member.end, ';'])
|
|
139
|
+
const mark = node.type === 'TSUnionType' ? '|' : node.type === 'TSIntersectionType' ? '&' : null
|
|
140
|
+
if (mark && text[node.start] === mark && node.types[0]?.start > node.start) edits.push([node.start, node.start + 1, ''])
|
|
141
|
+
})
|
|
142
|
+
return edited(text, edits)
|
|
143
|
+
}
|
|
144
|
+
|
|
113
145
|
// A safeguard on oxfmt's own output, not a comparison of any two texts: exported
|
|
114
|
-
// for tests alone, not by the package. CSS is read by `csstree` where given
|
|
115
|
-
|
|
146
|
+
// for tests alone, not by the package. CSS is read by `csstree` where given; a
|
|
147
|
+
// script's text by `tree`, its syntax tree as parsed, where given.
|
|
148
|
+
export function sameCode(text, formatted, extension = 'js', csstree = null, tree = parsed(text, extension)) {
|
|
116
149
|
if (extension === 'css') return csstree !== null && sameCss(csstree, text, formatted)
|
|
117
150
|
if (extension === 'json') return sameList(jsonForm(text), jsonForm(formatted))
|
|
118
151
|
if (!SCRIPTS.has(extension)) return false
|
|
119
|
-
const [before, after] = [
|
|
152
|
+
const [before, after] = [tree, parsed(formatted, extension)]
|
|
120
153
|
if (before === null || after === null) return false
|
|
121
|
-
|
|
122
|
-
|
|
154
|
+
// `!0` and `!1` read as `true` and `false` only where the text writes one so: format() writes them so
|
|
155
|
+
// nowhere else, so a copy of any other has none.
|
|
156
|
+
const types = typed(extension)
|
|
157
|
+
const booleans = !types && /![01]/u.test(text)
|
|
158
|
+
const form = (code, { program }) => scriptForm(respelled(code, program, types, booleans))
|
|
159
|
+
return form(text, before) === form(formatted, after) && sameList(comments(before), comments(after)) && sameTree(before.program, after.program, booleans)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// A name as written from where it starts, escapes and all.
|
|
163
|
+
const NAME = /(?:\\u\{[\dA-Fa-f]+\}|\\u[\dA-Fa-f]{4}|[$\p{ID_Continue}\u{200C}\u{200D}])+/uy
|
|
164
|
+
// What oxfmt may write after a block comment the file has after it: an operator, `,`, `?` or `:`.
|
|
165
|
+
const MOVED = /[\t ]*([!%&*+,\-/:<=>?^|~]+|in(?:stanceof)?(?![$\p{ID_Continue}]))/uy
|
|
166
|
+
const MARK = /[!%&*+,\-/:<=>?^|~]/u
|
|
167
|
+
const NAME_PART = /[$\p{ID_Continue}]/u
|
|
168
|
+
|
|
169
|
+
// Each name of a syntax tree, `[where it starts, what it reads, as written]`, in order: a shorthand
|
|
170
|
+
// property's key and value, one name, once.
|
|
171
|
+
function names(text, program) {
|
|
172
|
+
const found = new Map()
|
|
173
|
+
visit(program, (node) => {
|
|
174
|
+
if (node.type !== 'Identifier' && node.type !== 'PrivateIdentifier') return
|
|
175
|
+
const at = node.type === 'Identifier' ? node.start : node.start + 1
|
|
176
|
+
NAME.lastIndex = at
|
|
177
|
+
found.set(at, [at, node.name, NAME.exec(text)?.[0] ?? ''])
|
|
178
|
+
})
|
|
179
|
+
return [...found.values()].toSorted(([x], [y]) => x - y)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Where the blanks on its line before `at` start.
|
|
183
|
+
function blanksFrom(text, at) {
|
|
184
|
+
let from = at
|
|
185
|
+
while (from > 0 && (text[from - 1] === ' ' || text[from - 1] === '\t')) from--
|
|
186
|
+
return from
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The copy as the file writes what oxfmt writes otherwise, where that changes no more than its layout,
|
|
190
|
+
// before it is checked as any copy is:
|
|
191
|
+
// - a name's escapes, which oxfmt reads (`\u{61}` as `a`): a copy shows no character the file only
|
|
192
|
+
// escapes, one unseen, a joiner in a name say, or one like another;
|
|
193
|
+
// - a block comment after an operator, a `,`, `?` or `:`, which oxfmt moves before it where the comment
|
|
194
|
+
// ends its line (`a &&/* c */\nb` as `a /* c */ && b`): where a comment is may matter to what reads
|
|
195
|
+
// it, a bundler a `#__PURE__` mark say, and a reader sees it where the file has it.
|
|
196
|
+
// Names and comments are paired in order, as the check finds them alike or refuses the copy.
|
|
197
|
+
function asWritten(text, code, extension, tree) {
|
|
198
|
+
if (!SCRIPTS.has(extension) || !/\\u|\/\*/u.test(text)) return code
|
|
199
|
+
const [before, after] = [tree, parsed(code, extension)]
|
|
200
|
+
if (before === null || after === null) return code
|
|
201
|
+
const edits = []
|
|
202
|
+
const [olds, news] = [names(text, before.program), names(code, after.program)]
|
|
203
|
+
if (olds.length === news.length) {
|
|
204
|
+
olds.forEach(([, name, written], i) => {
|
|
205
|
+
const [at, read, spelled] = news[i]
|
|
206
|
+
if (written !== spelled && written.includes('\\') && name === read) edits.push([at, at + spelled.length, written])
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
const [was, now] = [before.comments, after.comments]
|
|
210
|
+
for (let i = 0; was.length === now.length && i < now.length; i++) {
|
|
211
|
+
// A run of comments on one line moves as one.
|
|
212
|
+
const first = i
|
|
213
|
+
while (i + 1 < now.length && /^[\t ]*$/u.test(code.slice(now[i].end, now[i + 1].start))) i++
|
|
214
|
+
MOVED.lastIndex = now[i].end
|
|
215
|
+
const from = blanksFrom(code, now[first].start), moved = MOVED.exec(code)
|
|
216
|
+
if (moved === null || from === 0 || code[from - 1] === '\n') continue
|
|
217
|
+
const [, mark] = moved
|
|
218
|
+
// One the copy has before the comment too stayed, the one after another (`a + /* c */ +b`).
|
|
219
|
+
if (code.startsWith(mark, from - mark.length)) continue
|
|
220
|
+
const at = blanksFrom(text, was[first].start) - mark.length
|
|
221
|
+
if (at >= 0 && text.startsWith(mark, at) && !(mark.startsWith('i') ? NAME_PART : MARK).test(text[at - 1] ?? '')) {
|
|
222
|
+
edits.push([from, now[first].start, `${mark === ',' ? '' : ' '}${mark} `], [now[i].end, now[i].end + moved[0].length, ''])
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return edited(code, edits)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// A script's `!0` and `!1` written as `true` and `false`, where it writes them so, as they read, from its
|
|
229
|
+
// syntax tree as parsed (`tree`): set apart from a word it follows, as a minifier writes `return!0`.
|
|
230
|
+
function withBooleans(text, tree) {
|
|
231
|
+
if (tree === null || !/![01]/u.test(text)) return text
|
|
232
|
+
const edits = []
|
|
233
|
+
visit(tree.program, (node) => {
|
|
234
|
+
const raw = booleanRaw(node)
|
|
235
|
+
if (raw === null || text.slice(node.start, node.end) !== `!${raw}`) return
|
|
236
|
+
edits.push([node.start, node.end, `${NAME_PART.test(text[node.start - 1] ?? '') ? ' ' : ''}${BOOLEANS.get(raw)}`])
|
|
237
|
+
})
|
|
238
|
+
return edited(text, edits)
|
|
123
239
|
}
|
|
124
240
|
|
|
125
241
|
// A script's block comments over more than one line, a license's say, each end their line: what oxfmt
|
|
@@ -149,12 +265,22 @@ export async function format(text, extension) {
|
|
|
149
265
|
const csstree = extension === 'css' ? await loadCssTree() : null
|
|
150
266
|
if (!(SCRIPTS.has(extension) || extension === 'json' || csstree)) return { error: 'unsupported' }
|
|
151
267
|
const oxfmt = await loadFormatter()
|
|
152
|
-
|
|
268
|
+
// A script's syntax tree, read once for its copy's check: its `!0` and `!1` written as `true` and
|
|
269
|
+
// `false` before oxfmt formats it, where it has no types. A check that fails in itself, its stack say, refuses the copy as
|
|
270
|
+
// one that finds it changed would.
|
|
271
|
+
let tree
|
|
272
|
+
try {
|
|
273
|
+
tree = SCRIPTS.has(extension) ? parsed(text, extension) : null
|
|
274
|
+
} catch {
|
|
275
|
+
return { error: 'pretty-mismatch' }
|
|
276
|
+
}
|
|
277
|
+
const result = await oxfmt(`pretty.${extension}`, withBooleans(text, typed(extension) ? null : tree), PRETTY_OPTIONS).catch(() => null)
|
|
153
278
|
if (result === null || result.errors.length > 0) return { error: 'unformattable' }
|
|
154
|
-
//
|
|
279
|
+
// oxfmt's copy as it is, or where the check refuses it, as the file writes what oxfmt respelled: a
|
|
280
|
+
// copy the check takes respelled nothing so, as its text would differ.
|
|
281
|
+
const checked = (code) => (sameCode(text, code, extension, csstree, tree) ? { code } : null)
|
|
155
282
|
try {
|
|
156
|
-
|
|
157
|
-
return sameCode(text, code, extension, csstree) ? { code } : { error: 'pretty-mismatch' }
|
|
283
|
+
return checked(breakAfterComments(result.code, extension)) ?? checked(breakAfterComments(asWritten(text, result.code, extension, tree), extension)) ?? { error: 'pretty-mismatch' }
|
|
158
284
|
} catch {
|
|
159
285
|
return { error: 'pretty-mismatch' }
|
|
160
286
|
}
|
package/src/renames.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
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, ours, renames
|
|
3
|
+
// pair left scored; between the first two and after the last, ours, renames
|
|
4
|
+
// told by path, and bundle chunks (chunks.js).
|
|
4
5
|
|
|
5
6
|
import { crc32 } from '@exodus/bytes/crc.js'
|
|
6
|
-
import {
|
|
7
|
+
import { chunkRenames } from './chunks.js'
|
|
8
|
+
import { MAX_SCORE, PUNCTUATION, similarity, spanCounts } from './similarity.js'
|
|
7
9
|
import { bytesOf, sameContent } from './tree.js'
|
|
8
10
|
|
|
9
11
|
// git's limits: identical files weighed per destination, scores kept per one.
|
|
@@ -175,16 +177,21 @@ const unpaired = ({ sources, dests, pairs, used }) => ({
|
|
|
175
177
|
open: dests.keys().filter((d) => !pairs.has(d)).toArray(),
|
|
176
178
|
})
|
|
177
179
|
|
|
180
|
+
// 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.
|
|
182
|
+
const stricter = (minimum) => minimum + Math.trunc(0.5 * (MAX_SCORE - minimum))
|
|
183
|
+
|
|
178
184
|
// Each added file's rename by its index in `dests`: { source, score }. A
|
|
179
185
|
// `minimum` of MAX_SCORE takes identical content alone, but for `byPath`, and
|
|
180
|
-
// more than `limit` squared pairs left skips the scoring.
|
|
186
|
+
// more than `limit` squared pairs left skips the scoring, chunks' too.
|
|
181
187
|
export function findRenames(sources, dests, { minimum, limit, byPath }) {
|
|
182
188
|
const pairs = new Map(), used = new Set()
|
|
183
189
|
if (sources.length === 0 || dests.length === 0) return pairs
|
|
184
|
-
// A file's bytes, its text encoded once, and their span counts once.
|
|
185
|
-
const counted = new Map(), encoded = new Map()
|
|
190
|
+
// A file's bytes, its text encoded once, and their span counts once each way.
|
|
191
|
+
const counted = new Map(), encoded = new Map(), punctuated = new Map()
|
|
186
192
|
const bytes = (file) => (file.type === 'file' ? encoded.get(file) ?? encoded.set(file, bytesOf(file.data)).get(file) : null)
|
|
187
193
|
const counts = (data) => counted.get(data) ?? counted.set(data, spanCounts(data)).get(data)
|
|
194
|
+
const punctuatedCounts = (data) => punctuated.get(data) ?? punctuated.set(data, spanCounts(data, PUNCTUATION)).get(data)
|
|
188
195
|
const context = {
|
|
189
196
|
sources, dests, pairs, used,
|
|
190
197
|
sourceNames: sources.map((file) => basename(file.path)),
|
|
@@ -195,12 +202,15 @@ export function findRenames(sources, dests, { minimum, limit, byPath }) {
|
|
|
195
202
|
},
|
|
196
203
|
bytes,
|
|
197
204
|
scoreOf: (s, d, least) => similarity(bytes(sources[s]), bytes(dests[d]), least, counts),
|
|
205
|
+
punctuatedScoreOf: (s, d, least) => similarity(bytes(sources[s]), bytes(dests[d]), least, punctuatedCounts, true),
|
|
198
206
|
}
|
|
199
207
|
exactRenames(context)
|
|
200
208
|
if (byPath) pathRenames(context, minimum, limit)
|
|
201
209
|
if (minimum >= MAX_SCORE) return pairs
|
|
202
|
-
basenameRenames(context, unpaired(context),
|
|
210
|
+
basenameRenames(context, unpaired(context), stricter(minimum))
|
|
203
211
|
const left = unpaired(context)
|
|
204
|
-
if (left.remaining.length * left.open.length
|
|
212
|
+
if (left.remaining.length * left.open.length > limit * limit) return pairs
|
|
213
|
+
scoredRenames(context, left, minimum)
|
|
214
|
+
if (byPath) chunkRenames(context, unpaired(context), stricter(minimum))
|
|
205
215
|
return pairs
|
|
206
216
|
}
|
package/src/similarity.js
CHANGED
|
@@ -12,9 +12,15 @@ const looksBinary = (data) => data.subarray(0, 8000).includes(0)
|
|
|
12
12
|
|
|
13
13
|
const spanHash = (accum1, accum2) => ((accum1 + Math.imul(accum2, 0x61)) >>> 0) % HASHBASE
|
|
14
14
|
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
15
|
+
// The bytes a span ends after: git's LF, and for code minified onto a line or
|
|
16
|
+
// a few (chunks.js), punctuation too, so that an edit shifts no span past it.
|
|
17
|
+
const endingAfter = (chars) => Uint8Array.from({ length: 256 }, (_, c) => Number(c === 0x0A || chars.includes(String.fromCodePoint(c))))
|
|
18
|
+
export const LINES = endingAfter('')
|
|
19
|
+
export const PUNCTUATION = endingAfter(',;={}')
|
|
20
|
+
|
|
21
|
+
// Bytes per span hash, a span ending after a byte `ends` has or 64 bytes;
|
|
22
|
+
// sorted by hash, as git keeps them, so two files compare in one walk down both.
|
|
23
|
+
export function spanCounts(data, ends = LINES) {
|
|
18
24
|
const text = !looksBinary(data)
|
|
19
25
|
const counts = new Map()
|
|
20
26
|
const add = (hash, n) => counts.set(hash, (counts.get(hash) ?? 0) + n)
|
|
@@ -25,7 +31,7 @@ export function spanCounts(data) {
|
|
|
25
31
|
const old1 = accum1
|
|
26
32
|
accum1 = (((accum1 << 7) ^ (accum2 >>> 25)) + c) >>> 0
|
|
27
33
|
accum2 = ((accum2 << 7) ^ (old1 >>> 25)) >>> 0
|
|
28
|
-
if (++n < 64 && c
|
|
34
|
+
if (++n < 64 && ends[c] === 0) continue
|
|
29
35
|
add(spanHash(accum1, accum2), n)
|
|
30
36
|
n = 0
|
|
31
37
|
accum1 = 0
|
|
@@ -33,21 +39,46 @@ export function spanCounts(data) {
|
|
|
33
39
|
}
|
|
34
40
|
if (n > 0) add(spanHash(accum1, accum2), n)
|
|
35
41
|
const hashes = Uint32Array.from(counts.keys()).sort()
|
|
36
|
-
return { hashes, bytes: Float64Array.from(hashes, (hash) => counts.get(hash)) }
|
|
42
|
+
return { hashes, bytes: Float64Array.from(hashes, (hash) => counts.get(hash)), total: counts.values().reduce((sum, bytes) => sum + bytes, 0) }
|
|
37
43
|
}
|
|
38
44
|
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
export function similarity(src, dst, minimum, counts) {
|
|
42
|
-
if (src === null || dst === null || dst.length === 0) return 0
|
|
43
|
-
const delta = Math.abs(src.length - dst.length), max = Math.max(src.length, dst.length)
|
|
44
|
-
if (max * (MAX_SCORE - minimum) < delta * MAX_SCORE) return 0
|
|
45
|
-
const a = counts(src), b = counts(dst)
|
|
45
|
+
// Bytes of spans both have: git's walk down both.
|
|
46
|
+
function copiedBytes(a, b) {
|
|
46
47
|
let copied = 0
|
|
47
48
|
for (let i = 0, j = 0; i < a.hashes.length && j < b.hashes.length;) {
|
|
48
49
|
if (a.hashes[i] < b.hashes[j]) i++
|
|
49
50
|
else if (a.hashes[i] > b.hashes[j]) j++
|
|
50
51
|
else copied += Math.min(a.bytes[i++], b.bytes[j++])
|
|
51
52
|
}
|
|
53
|
+
return copied
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The same, or 0 once the spans left could no longer bring it to `least`: a
|
|
57
|
+
// score times the larger file's size, as the bytes copied times MAX_SCORE are.
|
|
58
|
+
function copiedReaching(a, b, least) {
|
|
59
|
+
let copied = 0, restA = a.total, restB = b.total
|
|
60
|
+
for (let i = 0, j = 0; i < a.hashes.length && j < b.hashes.length;) {
|
|
61
|
+
if (a.hashes[i] < b.hashes[j]) restA -= a.bytes[i++]
|
|
62
|
+
else if (a.hashes[i] > b.hashes[j]) restB -= b.bytes[j++]
|
|
63
|
+
else {
|
|
64
|
+
copied += Math.min(a.bytes[i], b.bytes[j])
|
|
65
|
+
restA -= a.bytes[i++]
|
|
66
|
+
restB -= b.bytes[j++]
|
|
67
|
+
}
|
|
68
|
+
if ((copied + Math.min(restA, restB)) * MAX_SCORE < least) return 0
|
|
69
|
+
}
|
|
70
|
+
return copied
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Two files' bytes, null for a link, which scores 0, as do sizes too far apart
|
|
74
|
+
// for `minimum`, unread. `counts` gives span counts, cached: one file meets
|
|
75
|
+
// many. `early`, spans that can no longer reach `minimum` score 0 unwalked: not
|
|
76
|
+
// for git's passes, whose candidates below it break ties by their scores.
|
|
77
|
+
export function similarity(src, dst, minimum, counts, early = false) {
|
|
78
|
+
if (src === null || dst === null || dst.length === 0) return 0
|
|
79
|
+
const delta = Math.abs(src.length - dst.length), max = Math.max(src.length, dst.length)
|
|
80
|
+
if (max * (MAX_SCORE - minimum) < delta * MAX_SCORE) return 0
|
|
81
|
+
const a = counts(src), b = counts(dst)
|
|
82
|
+
const copied = early ? copiedReaching(a, b, minimum * max) : copiedBytes(a, b)
|
|
52
83
|
return Math.trunc(copied * MAX_SCORE / max)
|
|
53
84
|
}
|
package/src/types.node.js
CHANGED
|
@@ -194,9 +194,9 @@ export function proveWith(text, stripTypeScriptTypes) {
|
|
|
194
194
|
// type specifier or a comment within it does not count; the hashbang, a directive and a pragma stand
|
|
195
195
|
// verbatim, since what they say is the point. A triple-slash directive counts only in the prologue, before
|
|
196
196
|
// the first statement, where TypeScript honours it; a comment blanked within an erased statement reads as
|
|
197
|
-
// blanks here, so only a kept one steers. `loads` is whether any run statement is a module load
|
|
198
|
-
// `export {}`, the runtime that runs differently per module format
|
|
199
|
-
// not.
|
|
197
|
+
// blanks here, so only a kept one steers. `loads` is whether any run statement is a module load, a bare
|
|
198
|
+
// `export {}`, or an `export default` on an identifier, the runtime that runs differently per module format
|
|
199
|
+
// -- a hashbang, directive or pragma does not.
|
|
200
200
|
const prologue = tree.program.body.length > 0 ? tree.program.body[0].start : stripped.code.length
|
|
201
201
|
const hashbang = tree.program.hashbang ? [tree.program.hashbang] : []
|
|
202
202
|
const steering = tree.comments.filter(({ start, end }) => steers(stripped.code.slice(start, end), start < prologue))
|