@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 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 or carries a bare `export {}` a rename across formats (a `.d.mts`
46
- // to a `.d.cts`, either to a package-resolved `.d.ts` whose format is not known
47
- // here) is not stamped; a hashbang, a directive or a pragma runs the same either
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 an added or deleted file matches only an empty
50
- // runtime. A rename is otherwise taken by its matching
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%. git has no such pass, so false is git's own -M.
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) and a bare `export {}`. `export { type A }`
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 or a bare `export {}`: the runtime that runs as one module
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.1",
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, no trailing commas or arrow
3
- // parentheses are added, template literals stay), the copy used only where checks
4
- // of oxfmt's output, apart from oxfmt, find it changes the layout alone:
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 or `export {}`, the part that runs differently per
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 or carries a bare `export {}` it runs the same on both sides only
75
- // within one module format, so a rename across formats (a `.d.mts` to a `.d.cts`, either to a
76
- // package-resolved `.d.ts`) does not qualify; a hashbang, a directive or a pragma runs the same either way,
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
- // an added or deleted file matches only an empty one.
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 modification of a package-resolved `.d.ts` is taken to keep its
82
- // format, though the `package.json` that resolves it could change `type` between the two trees and flip it;
83
- // that flip is a change to `package.json`, seen in the diff on its own, and format is read from the
84
- // extension here, not resolved from a package.
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 || old.runtime !== fresh.runtime) return change
92
- if (old.loads && change.old && change.new && change.old.path !== change.new.path) {
93
- const format = fixedModule(change.old.path)
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': return statement.declaration.type === 'TSInterfaceDeclaration' || statement.declaration.type === 'TSDeclareFunction' ? ERASED : CODE
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, numbers and a regular expression's flags, and a comma before
33
- // a closing bracket, past any comments (`import { a, b, }`).
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
- const [x, y] = work.pop()
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
- // A script's types as the formatter writes them: a member's separator `;` where it was `,`
88
- // (`{ a: string, b: number }`), and no `|` or `&` before a union's or an intersection's first type
89
- // (`type P =\n | A\n | B`, `[K in\n | A\n | B]`). oxc reads a member with the separator ending it
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
- const members = node.type === 'TSTypeLiteral' ? node.members : node.type === 'TSInterfaceBody' ? node.body : []
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 [at, put] of edits.toSorted(([x], [y]) => x - y)) {
107
- out += text.slice(from, at) + put
108
- from = at + 1
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
- export function sameCode(text, formatted, extension = 'js', csstree = null) {
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] = [parsed(text, extension), parsed(formatted, extension)]
152
+ const [before, after] = [tree, parsed(formatted, extension)]
120
153
  if (before === null || after === null) return false
121
- const form = (code, { program }) => scriptForm(/^[cm]?tsx?$/u.test(extension) ? typed(code, program) : code)
122
- return form(text, before) === form(formatted, after) && sameList(comments(before), comments(after)) && sameTree(before.program, after.program)
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
- const result = await oxfmt(`pretty.${extension}`, text, PRETTY_OPTIONS).catch(() => null)
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
- // A check that fails in itself, its stack say, refuses the copy as one that finds it changed would.
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
- const code = breakAfterComments(result.code, extension)
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 told by path.
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 { MAX_SCORE, similarity, spanCounts } from './similarity.js'
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), minimum + Math.trunc(0.5 * (MAX_SCORE - minimum)))
210
+ basenameRenames(context, unpaired(context), stricter(minimum))
203
211
  const left = unpaired(context)
204
- if (left.remaining.length * left.open.length <= limit * limit) scoredRenames(context, left, minimum)
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
- // Bytes per span hash, a span ending at an LF or after 64 bytes; sorted by
16
- // hash, as git keeps them, so two files compare in one walk down both.
17
- export function spanCounts(data) {
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 !== 0x0A) continue
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
- // Two files' bytes, null for a link, which scores 0, as do sizes too far apart
40
- // for `minimum`, unread. `counts` gives span counts, cached: one file meets many.
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 or
198
- // `export {}`, the runtime that runs differently per module format -- a hashbang, directive or pragma does
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))