@preventive/compare 1.2.1 → 1.2.2

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
@@ -217,12 +220,15 @@ export type TypeOnlyReason = 'unsupported' | 'unparsed' | 'code' | 'disagree'
217
220
  // function's signature, `import type` and `export type`, a namespace of types
218
221
  // alone -- and those it leaves to run: a module load that binds from it
219
222
  // (`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 }`
223
+ // inline `type` loads it all the same), a bare `export {}`, and `export default E`
224
+ // on a bare identifier (the idiom a declaration file's default export takes,
225
+ // `declare const _d: T; export default _d`). `export { type A }`
221
226
  // is code, not a run statement: it is `export {}` only once stripping takes its
222
227
  // specifiers out, not as the source has it; nor is `export as namespace N`, left
223
228
  // as invalid JavaScript that throws when imported, nor a side-effect load that
224
229
  // binds nothing -- a bare `import`, an empty `import {}`, `export {}` with a
225
- // source -- which loads a module for no type;
230
+ // source -- which loads a module for no type, nor an `export default` of anything
231
+ // but a bare identifier (a value expression, an object, a class body);
226
232
  // - Node's stripTypeScriptTypes erases, to the character, what oxc finds erased
227
233
  // and leaves nothing it does not, but comments, the hashbang and those;
228
234
  // - what it leaves, the run statements set aside, read token by token apart from
@@ -244,7 +250,7 @@ export type TypeOnlyReason = 'unsupported' | 'unparsed' | 'code' | 'disagree'
244
250
  // point. A triple-slash directive counts only in the prologue,
245
251
  // before the first statement, where TypeScript honours it; below one it is prose, so moving
246
252
  // 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
253
+ // run statement is a module load, a bare `export {}`, or an `export default` on an identifier: the runtime that runs as one module
248
254
  // format and throws, or emits a different marker, as the other -- a hashbang, directive or
249
255
  // pragma does not. A change is types only where both sides prove and leave the same `runtime`:
250
256
  // 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.2",
4
4
  "description": "Git-compatible file tree diffs and minified JavaScript comparison",
5
5
  "type": "module",
6
6
  "main": "./compare.js",
package/pretty.d.ts CHANGED
@@ -1,7 +1,8 @@
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), the copy used only where checks of oxfmt's output, apart
5
+ // from oxfmt, find it changes the layout alone:
5
6
  // - a script's (`js`, `mjs`, `cjs`, `jsx`, `ts`, `mts`, `cts`, `tsx`) syntax tree as
6
7
  // oxc parses it, its comments and its text;
7
8
  // - 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/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.
@@ -84,32 +84,48 @@ function sameTree(a, b) {
84
84
  return true
85
85
  }
86
86
 
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]
87
+ // Each node of a syntax tree given to `each`.
88
+ function visit(program, each) {
89
+ const work = [program]
93
90
  while (work.length > 0) {
94
91
  const node = work.pop()
95
92
  // Pushed one by one: a spread of a file's statements could pass more arguments than a call takes.
96
93
  if (Array.isArray(node)) for (const item of node) work.push(item)
97
94
  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, ''])
95
+ each(node)
102
96
  for (const key of Object.keys(node)) if (!POSITION.has(key) && typeof node[key] === 'object') work.push(node[key])
103
97
  }
104
98
  }
99
+ }
100
+
101
+ // `text` with each of `edits`, `[from, to, put]`, none overlapping another.
102
+ function edited(text, edits) {
105
103
  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
104
+ for (const [start, end, put] of edits.toSorted(([x], [y]) => x - y)) {
105
+ out += text.slice(from, start) + put
106
+ from = end
109
107
  }
110
108
  return out + text.slice(from)
111
109
  }
112
110
 
111
+ // A script as the formatter may write it, read off its syntax tree: each regular expression's flags
112
+ // sorted (`/x/yu` as `/x/uy`), and where it has `types`, a member's separator `;` where it was `,`
113
+ // (`{ a: string, b: number }`), and no `|` or `&` before a union's or an intersection's first type
114
+ // (`type P =\n | A\n | B`, `[K in\n | A\n | B]`). oxc reads a member with the separator ending it
115
+ // and a union from the mark leading it, so each is taken where it is, in whatever context.
116
+ function respelled(text, program, types) {
117
+ const edits = []
118
+ visit(program, (node) => {
119
+ if (node.regex) edits.push([node.end - node.regex.flags.length, node.end, sorted(node.regex.flags)])
120
+ if (!types) return
121
+ const members = node.type === 'TSTypeLiteral' ? node.members : node.type === 'TSInterfaceBody' ? node.body : []
122
+ for (const member of members) if (text[member.end - 1] === ',') edits.push([member.end - 1, member.end, ';'])
123
+ const mark = node.type === 'TSUnionType' ? '|' : node.type === 'TSIntersectionType' ? '&' : null
124
+ if (mark && text[node.start] === mark && node.types[0]?.start > node.start) edits.push([node.start, node.start + 1, ''])
125
+ })
126
+ return edited(text, edits)
127
+ }
128
+
113
129
  // A safeguard on oxfmt's own output, not a comparison of any two texts: exported
114
130
  // for tests alone, not by the package. CSS is read by `csstree` where given.
115
131
  export function sameCode(text, formatted, extension = 'js', csstree = null) {
@@ -118,10 +134,76 @@ export function sameCode(text, formatted, extension = 'js', csstree = null) {
118
134
  if (!SCRIPTS.has(extension)) return false
119
135
  const [before, after] = [parsed(text, extension), parsed(formatted, extension)]
120
136
  if (before === null || after === null) return false
121
- const form = (code, { program }) => scriptForm(/^[cm]?tsx?$/u.test(extension) ? typed(code, program) : code)
137
+ const form = (code, { program }) => scriptForm(respelled(code, program, /^[cm]?tsx?$/u.test(extension)))
122
138
  return form(text, before) === form(formatted, after) && sameList(comments(before), comments(after)) && sameTree(before.program, after.program)
123
139
  }
124
140
 
141
+ // A name as written from where it starts, escapes and all.
142
+ const NAME = /(?:\\u\{[\dA-Fa-f]+\}|\\u[\dA-Fa-f]{4}|[$\p{ID_Continue}\u{200C}\u{200D}])+/uy
143
+ // What oxfmt may write after a block comment the file has after it: an operator, `,`, `?` or `:`.
144
+ const MOVED = /[\t ]*([!%&*+,\-/:<=>?^|~]+|in(?:stanceof)?(?![$\p{ID_Continue}]))/uy
145
+ const MARK = /[!%&*+,\-/:<=>?^|~]/u
146
+ const NAME_PART = /[$\p{ID_Continue}]/u
147
+
148
+ // Each name of a syntax tree, `[where it starts, what it reads, as written]`, in order: a shorthand
149
+ // property's key and value, one name, once.
150
+ function names(text, program) {
151
+ const found = new Map()
152
+ visit(program, (node) => {
153
+ if (node.type !== 'Identifier' && node.type !== 'PrivateIdentifier') return
154
+ const at = node.type === 'Identifier' ? node.start : node.start + 1
155
+ NAME.lastIndex = at
156
+ found.set(at, [at, node.name, NAME.exec(text)?.[0] ?? ''])
157
+ })
158
+ return [...found.values()].toSorted(([x], [y]) => x - y)
159
+ }
160
+
161
+ // Where the blanks on its line before `at` start.
162
+ function blanksFrom(text, at) {
163
+ let from = at
164
+ while (from > 0 && (text[from - 1] === ' ' || text[from - 1] === '\t')) from--
165
+ return from
166
+ }
167
+
168
+ // The copy as the file writes what oxfmt writes otherwise, where that changes no more than its layout,
169
+ // before it is checked as any copy is:
170
+ // - a name's escapes, which oxfmt reads (`\u{61}` as `a`): a copy shows no character the file only
171
+ // escapes, one unseen, a joiner in a name say, or one like another;
172
+ // - a block comment after an operator, a `,`, `?` or `:`, which oxfmt moves before it where the comment
173
+ // ends its line (`a &&/* c */\nb` as `a /* c */ && b`): where a comment is may matter to what reads
174
+ // it, a bundler a `#__PURE__` mark say, and a reader sees it where the file has it.
175
+ // Names and comments are paired in order, as the check finds them alike or refuses the copy.
176
+ function asWritten(text, code, extension) {
177
+ if (!SCRIPTS.has(extension) || !/\\u|\/\*/u.test(text)) return code
178
+ const [before, after] = [parsed(text, extension), parsed(code, extension)]
179
+ if (before === null || after === null) return code
180
+ const edits = []
181
+ const [olds, news] = [names(text, before.program), names(code, after.program)]
182
+ if (olds.length === news.length) {
183
+ olds.forEach(([, name, written], i) => {
184
+ const [at, read, spelled] = news[i]
185
+ if (written !== spelled && written.includes('\\') && name === read) edits.push([at, at + spelled.length, written])
186
+ })
187
+ }
188
+ const [was, now] = [before.comments, after.comments]
189
+ for (let i = 0; was.length === now.length && i < now.length; i++) {
190
+ // A run of comments on one line moves as one.
191
+ const first = i
192
+ while (i + 1 < now.length && /^[\t ]*$/u.test(code.slice(now[i].end, now[i + 1].start))) i++
193
+ MOVED.lastIndex = now[i].end
194
+ const from = blanksFrom(code, now[first].start), moved = MOVED.exec(code)
195
+ if (moved === null || from === 0 || code[from - 1] === '\n') continue
196
+ const [, mark] = moved
197
+ // One the copy has before the comment too stayed, the one after another (`a + /* c */ +b`).
198
+ if (code.startsWith(mark, from - mark.length)) continue
199
+ const at = blanksFrom(text, was[first].start) - mark.length
200
+ if (at >= 0 && text.startsWith(mark, at) && !(mark.startsWith('i') ? NAME_PART : MARK).test(text[at - 1] ?? '')) {
201
+ edits.push([from, now[first].start, `${mark === ',' ? '' : ' '}${mark} `], [now[i].end, now[i].end + moved[0].length, ''])
202
+ }
203
+ }
204
+ return edited(code, edits)
205
+ }
206
+
125
207
  // A script's block comments over more than one line, a license's say, each end their line: what oxfmt
126
208
  // leaves after one on its last line (`*/ class A {`) goes to the next, indented as the comment's first
127
209
  // line is. A comment holding a line break ends a line to the parser already (`return /*\n*/ 1` returns),
@@ -151,10 +233,12 @@ export async function format(text, extension) {
151
233
  const oxfmt = await loadFormatter()
152
234
  const result = await oxfmt(`pretty.${extension}`, text, PRETTY_OPTIONS).catch(() => null)
153
235
  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.
236
+ // oxfmt's copy as it is, or where the check refuses it, as the file writes what oxfmt respelled: a
237
+ // copy the check takes respelled nothing so, as its text would differ. A check that fails in itself,
238
+ // its stack say, refuses the copy as one that finds it changed would.
239
+ const checked = (code) => (sameCode(text, code, extension, csstree) ? { code } : null)
155
240
  try {
156
- const code = breakAfterComments(result.code, extension)
157
- return sameCode(text, code, extension, csstree) ? { code } : { error: 'pretty-mismatch' }
241
+ return checked(breakAfterComments(result.code, extension)) ?? checked(breakAfterComments(asWritten(text, result.code, extension), extension)) ?? { error: 'pretty-mismatch' }
158
242
  } catch {
159
243
  return { error: 'pretty-mismatch' }
160
244
  }
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))