@preventive/compare 1.2.0 → 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
@@ -36,15 +36,27 @@ export type Status = 'added' | 'deleted' | 'modified' | 'renamed' | 'type-change
36
36
  // percentage of the new file the old one accounts for, 100 where the content
37
37
  // is unchanged; null for anything else. `typeOnly`, on Node alone, stamps a
38
38
  // change whose every side there is a file named as a declaration file
39
- // (`.d.ts`, `.d.mts`, `.d.cts`, `.d.css.ts`) and holds types alone, as
40
- // proveTypeOnly proves: one that runs nothing on either side, as a side not
41
- // there runs nothing. A retained `export {}` runs nothing as a module but emits
42
- // a marker transpiled to CommonJS, so it is allowed only on both sides at once
43
- // or neither, never added or removed alone, and only where the two extensions
44
- // fix the same module format (`.mts` a module, `.cts` CommonJS) -- a rename from
45
- // a `.d.mts` to a `.d.cts` runs it, then throws on it, and a `.d.ts`, its format
46
- // its package's to say and not known here, never cancels one. Absent off Node,
47
- // and without oxc-parser.
39
+ // (`.d.ts`, `.d.mts`, `.d.cts`, `.d.css.ts`) and holds types alone but for a
40
+ // runtime both sides leave the same, as proveTypeOnly proves: one touching
41
+ // erased types and comments alone, a side not there leaving an empty runtime. A
42
+ // retained module load or `export {}` runs the same on both sides only within
43
+ // one module format -- an `export {}` emits a marker transpiled to CommonJS, a
44
+ // module load runs as one format and throws as the other -- so where the runtime
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
49
+ // way, so a runtime of only those cancels across formats. A modification keeps its
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
54
+ // source, as any file's is here: a relative load may resolve to another module
55
+ // once the file moves, but that is shown as a rename of its own, not traced. A
56
+ // modification of a package-resolved `.d.ts` is taken to keep its format, though
57
+ // the `package.json` resolving it could change `type` between the trees and flip
58
+ // it -- a change seen in the diff on its own, as format is read here from the
59
+ // extension, not from a package. Absent off Node, and without oxc-parser.
48
60
  export interface FileStatus {
49
61
  status: Status
50
62
  old: TreeFile | null
@@ -192,33 +204,61 @@ export function rootLabel(root: Root | null, prefix: string): { prefix: string;
192
204
  // and is not asked of a stripping that once failed as may leave it unsound
193
205
  // (`unsupported`); Node's stripping or oxc finds an error in it, as in
194
206
  // `export const x: number`, which only a declaration file may write, or it
195
- // nests too deep for either (`unparsed`); a statement stripping leaves loads a
196
- // module, as `import { type A }` and `export *` from one do (`imports`), or is
197
- // anything else (`code`); or Node's stripping, or a reading of what it leaves,
198
- // does not find what oxc does (`disagree`).
199
- export type TypeOnlyReason = 'unsupported' | 'unparsed' | 'imports' | 'code' | 'disagree'
200
-
201
- // Whether `text`, read as TypeScript, holds types alone, so that Node, which
202
- // reads a `.d.ts` as the `.ts` it is named and runs what its type stripping
203
- // leaves, runs nothing of it:
207
+ // nests too deep for either (`unparsed`); a statement is neither erased nor a
208
+ // module load binding a type nor a bare `export {}`, as `export { type A }`,
209
+ // `export as namespace N` and a side-effect load that binds nothing are not
210
+ // (`code`); or Node's stripping, or a reading of what it leaves, does not find
211
+ // what oxc does (`disagree`).
212
+ export type TypeOnlyReason = 'unsupported' | 'unparsed' | 'code' | 'disagree'
213
+
214
+ // Whether `text`, read as TypeScript, holds types alone but for a `runtime` a
215
+ // change must leave byte for byte untouched, so that Node, which reads a `.d.ts`
216
+ // as the `.ts` it is named and runs what its type stripping leaves, runs only
217
+ // that of it:
204
218
  // - oxc's tree has no statement but those stripping erases whole -- interfaces,
205
219
  // type aliases, anything `declare`d (bodies and initializers with it), a
206
220
  // function's signature, `import type` and `export type`, a namespace of types
207
- // alone -- and a bare `export {}`. `import { type A }` loads its module like
208
- // any import, inline `type` or not, so it is not type-only; nor is
209
- // `export { type A }`, which is only `export {}` once stripping takes its
210
- // specifiers out; nor `export as namespace N`, left as invalid JavaScript that
211
- // throws when imported;
221
+ // alone -- and those it leaves to run: a module load that binds from it
222
+ // (`import`, `export *`, `export { a } from` a module, `import { type A }` whose
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 }`
226
+ // is code, not a run statement: it is `export {}` only once stripping takes its
227
+ // specifiers out, not as the source has it; nor is `export as namespace N`, left
228
+ // as invalid JavaScript that throws when imported, nor a side-effect load that
229
+ // binds nothing -- a bare `import`, an empty `import {}`, `export {}` with a
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);
212
232
  // - Node's stripTypeScriptTypes erases, to the character, what oxc finds erased
213
- // and leaves nothing it does not, but comments, the hashbang and that one;
214
- // - what it leaves, read token by token apart from either, holds nothing else;
233
+ // and leaves nothing it does not, but comments, the hashbang and those;
234
+ // - what it leaves, the run statements set aside, read token by token apart from
235
+ // either, holds nothing else;
215
236
  // - nor does what its transform mode (--experimental-transform-types) leaves.
216
- // `inert` is true where a bare `export {}` is kept: valid JavaScript that loads
217
- // and runs nothing as a module, but a marker transpiled to CommonJS, so a change
218
- // is types only only where both sides match in it. `at` is where in `text` what
219
- // is found begins, where it has a place. Throws a TypeError where `text` is not a
237
+ // `runtime` is built from those run statements and from the directive and pragma
238
+ // comments stripping keeps -- a `/// <reference ... />`, a `//# sourceMappingURL=`
239
+ // or `//# sourceURL=` and their legacy forms, comments a tool reads as more than prose --
240
+ // the hashbang first and the rest in source order, joined by newlines; empty where the
241
+ // file erases whole and carries none. A run statement is reduced: an inline type specifier
242
+ // stripping blanks within it (`import { type A, B }`), and any comment within it, each become
243
+ // a single blank, so a change to only those leaves `runtime` unchanged; its executable text and
244
+ // its module specifier are kept to the character, so a load of another module still differs.
245
+ // Known limit: that kept text is compared as written, so a change leaving a load runtime-identical
246
+ // yet reformatting it withholds the stamp rather than passing -- a removed trailing inline-type
247
+ // specifier leaving its comma (`{ B, }` where `{ B }` would stand), or a comment added where no
248
+ // blank sat. This errs safe: the stamp is never given wrongly, only sometimes withheld. A
249
+ // directive, a pragma and the hashbang stand as stripping leaves them, since what they say is the
250
+ // point. A triple-slash directive counts only in the prologue,
251
+ // before the first statement, where TypeScript honours it; below one it is prose, so moving
252
+ // it out of the prologue drops it from `runtime`, as removing it does. `loads` is whether any
253
+ // run statement is a module load, a bare `export {}`, or an `export default` on an identifier: the runtime that runs as one module
254
+ // format and throws, or emits a different marker, as the other -- a hashbang, directive or
255
+ // pragma does not. A change is types only where both sides prove and leave the same `runtime`:
256
+ // it then touches erased types and ordinary comments alone. Where `loads`, a rename across
257
+ // module formats does not cancel (see compareTrees' `typeOnly`); a runtime without it runs the
258
+ // same either way and cancels across them. `at` is where in `text` what is found
259
+ // begins, where it has a place. Throws a TypeError where `text` is not a
220
260
  // well-formed string. Node warns once that stripTypeScriptTypes is experimental.
221
- export function proveTypeOnly(text: string): { proven: true; inert: boolean } | { proven: false; reason: TypeOnlyReason; at: number | null }
261
+ export function proveTypeOnly(text: string): { proven: true; runtime: string; loads: boolean } | { proven: false; reason: TypeOnlyReason; at: number | null }
222
262
 
223
263
  // `path` is the name at fault as its entry spelled it, and `tree` which tree
224
264
  // holds it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/compare",
3
- "version": "1.2.0",
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
@@ -49,15 +49,22 @@ const status = (kind, old, fresh, similarity = null) => ({ status: kind, old, ne
49
49
  const DECLARATION = /\.d\.(?:[^./]+\.)?[cm]?ts$/u
50
50
 
51
51
  // The module system a declaration file's extension fixes, or null where its package says and this cannot:
52
- // `.mts` an ES module, `.cts` a CommonJS one, a `.ts` either. `export {}` runs nothing as a module and
53
- // throws as CommonJS, so where the format is null, or the two sides' differ, it does not cancel.
52
+ // `.mts` an ES module, `.cts` a CommonJS one, a `.ts` either. A module load and `export {}` run as one and
53
+ // throw as the other, so where a side's runtime is not empty and the format is null, or the two differ,
54
+ // a change across them is not types only.
54
55
  const fixedModule = (path) => (path.endsWith('.mts') ? 'module' : path.endsWith('.cts') ? 'commonjs' : null)
55
56
 
56
- // A file's proof: that it holds types alone, and whether that rests on a retained `export {}` (`inert`),
57
- // which runs nothing as a module but emits a marker transpiled to CommonJS. A side not there holds
58
- // nothing. Not proven where the proof throws, as no check should.
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
+
61
+ // A file's proof: that it holds types alone but for its `runtime` -- a hashbang, the module loads and
62
+ // `export {}` stripping leaves -- which a change must leave byte for byte untouched to be types only.
63
+ // `loads` is whether that runtime holds a module load, `export {}`, or an `export default` on an identifier, the part that runs differently per
64
+ // module format; a hashbang, a directive or a pragma does not, so a runtime of only those is format-free. A
65
+ // side not there has none. Not proven where the proof throws, as no check should.
59
66
  function proven(file) {
60
- if (file === null) return { proven: true, inert: false }
67
+ if (file === null) return { proven: true, runtime: '', loads: false }
61
68
  const text = textOf(file)
62
69
  try {
63
70
  return text === undefined ? { proven: false } : proveTypeOnly(text)
@@ -66,22 +73,34 @@ function proven(file) {
66
73
  }
67
74
  }
68
75
 
69
- // Stamped where each side there is a file named as a declaration file and proven to hold types alone, and
70
- // a retained `export {}` is on both sides or neither: on one alone it is a change in what runs once
71
- // transpiled, so the change is not types only. For it to cancel, the two extensions must fix the same
72
- // module format: a rename from a `.d.mts` to a `.d.cts` runs it, then throws on it, and a `.d.ts` leaves
73
- // the format to its package, which this cannot read, so a retained `export {}` in one never cancels.
76
+ // Stamped where each side there is a declaration file proven to hold types alone and the two leave the
77
+ // same runtime -- a change touching only erased types (and comments). A mode changing is a change of its
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,
83
+ // so a runtime of only those cancels across formats. A modification keeps its path, hence its format, and
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.
88
+ // A rename is taken by its matching source, as any file's is here: a relative load in the runtime may
89
+ // resolve to another module once the file moves, but that is the rename's doing, shown as a rename of its
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.
95
+
74
96
  function stamped(change) {
75
97
  const sides = [change.old, change.new].filter((file) => file !== null)
76
98
  if (!sides.every((file) => file.type === 'file' && DECLARATION.test(file.path))) return change
77
- // A mode changed, an executable bit set or cleared, is a change in its own right, types aside.
78
99
  if (change.old && change.new && change.old.mode !== change.new.mode) return change
79
100
  const fresh = proven(change.new), old = proven(change.old)
80
- if (!old.proven || !fresh.proven || Boolean(old.inert) !== Boolean(fresh.inert)) return change
81
- if (old.inert) {
82
- const format = fixedModule(change.old.path)
83
- if (format === null || format !== fixedModule(change.new.path)) return change
84
- }
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
85
104
  return { ...change, typeOnly: true }
86
105
  }
87
106
 
package/src/erasure.js CHANGED
@@ -4,9 +4,9 @@
4
4
  // parses; types.node.js gives both the tree and Node's text.
5
5
 
6
6
  // What stripping makes of a statement: nothing (`erased`); a bare `export {}` the source writes, left as
7
- // valid JavaScript that loads and runs nothing (`inert`); a statement that loads a module (`imports`); or
8
- // code -- `export as namespace N`, left verbatim as invalid JavaScript that throws when the file is
9
- // imported, among it.
7
+ // valid JavaScript that loads and runs nothing (`inert`); an import or re-export that loads a module and
8
+ // takes a binding from it (`imports`); or code -- `export as namespace N` left verbatim as invalid
9
+ // JavaScript that throws when the file is imported, and a side-effect load binding nothing, among it.
10
10
  export const CODE = 'code', ERASED = 'erased', IMPORT = 'imports', INERT = 'inert'
11
11
 
12
12
  const TYPES = new Set(['TSInterfaceDeclaration', 'TSTypeAliasDeclaration'])
@@ -37,14 +37,30 @@ function erased(node) {
37
37
  }
38
38
  }
39
39
 
40
+ // An import or re-export that binds nothing and only loads its module: a side-effect `import`, an empty
41
+ // `import {}`, or `export {}` with a source. Code in a declaration file's source -- it loads for effects
42
+ // alone, naming no type -- but also what transform mode emits when it elides an inline-type import or
43
+ // re-export, so the transformed-output check reads it as that elision, not as source code.
44
+ export function bindingless(statement) {
45
+ if (statement.type === 'ImportDeclaration') return statement.importKind !== 'type' && statement.specifiers.length === 0
46
+ if (statement.type === 'ExportNamedDeclaration') return !statement.declaration && statement.exportKind !== 'type' && Boolean(statement.source) && statement.specifiers.length === 0
47
+ return false
48
+ }
49
+
40
50
  // A statement of a program's, as stripping leaves it. `import type` and `export type` are erased whole,
41
51
  // loading nothing; `import { type A }` from a module loads it all the same, inline `type` or not, so it
42
52
  // is a module load. A bare `export {}` is inert; `export { type A }` is code, not inert: it is `export {}`
43
53
  // only once stripping takes its specifiers out, not as the source has it, and that a stripped no-op would
44
- // run nothing is no reason to call the source type-only.
54
+ // run nothing is no reason to call the source type-only. An import or re-export that binds nothing from its
55
+ // module -- a side-effect `import`, an empty `import {}`, or `export {}` with a source -- loads it for its
56
+ // side effects alone: code a declaration file has no reason to hold, not a module named for a type.
45
57
  export function strippedAs(statement) {
46
58
  switch (statement.type) {
47
- case 'ImportDeclaration': return statement.importKind === 'type' ? ERASED : IMPORT
59
+ case 'ImportDeclaration':
60
+ if (statement.importKind === 'type') return ERASED
61
+ // A side-effect import binds and names nothing: code a declaration file has no reason to hold, not a
62
+ // module a type comes from.
63
+ return bindingless(statement) ? CODE : IMPORT
48
64
  case 'ExportAllDeclaration': return statement.exportKind === 'type' ? ERASED : IMPORT
49
65
  case 'TSImportEqualsDeclaration':
50
66
  if (statement.importKind === 'type') return ERASED
@@ -52,9 +68,17 @@ export function strippedAs(statement) {
52
68
  case 'ExportNamedDeclaration':
53
69
  if (statement.declaration) return erased(statement.declaration) ? ERASED : strippedAs(statement.declaration)
54
70
  if (statement.exportKind === 'type') return ERASED
55
- if (statement.source) return IMPORT
71
+ // A re-export with a source but no specifiers re-exports nothing yet loads the module, a side-effect
72
+ // load; `export { a } from` a module re-exports a binding. A bare `export {}`, no source, is a marker.
73
+ if (statement.source) return bindingless(statement) ? CODE : IMPORT
56
74
  return statement.specifiers.length === 0 ? INERT : CODE
57
- 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
58
82
  // `export as namespace N` is left verbatim, invalid as JavaScript, so importing the file throws.
59
83
  case 'TSNamespaceExportDeclaration': return CODE
60
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
@@ -7,7 +7,7 @@
7
7
 
8
8
  import * as nodeModule from 'node:module'
9
9
  import { getParser } from '#oxc'
10
- import { CODE, ERASED, IMPORT, INERT, leftover, strippedAs } from './erasure.js'
10
+ import { CODE, ERASED, bindingless, leftover, strippedAs } from './erasure.js'
11
11
 
12
12
  // Each a fresh result, never a shared object a caller could mutate into another's.
13
13
  const unsupported = () => ({ proven: false, reason: 'unsupported', at: null })
@@ -18,8 +18,29 @@ const refused = (reason, at) => ({ proven: false, reason, at })
18
18
  // it keeps. Any blank but a line break is taken for one.
19
19
  const WRITTEN = /[^\S\n\r\u2028\u2029]/u
20
20
  const LINE_BREAK = /[\n\r\u2028\u2029]/u
21
+ const NOT_LINE_BREAK = /[^\n\r\u2028\u2029]/gu
21
22
  const BLANK = /\s/u
22
23
 
24
+ // Comments a tool reads as more than prose, which stripping keeps verbatim: a triple-slash directive, which
25
+ // pulls other declarations into the file's types, and a source map or source URL pragma, which names where
26
+ // a tool looks (its legacy forms, `//@` and a `/*# */` block, among it). They join the runtime, so a change
27
+ // to one shows there rather than passing as types only. A directive is one of TypeScript's named tags
28
+ // (`reference`, `amd-module`, `amd-dependency`) carrying an attribute, so whitespace follows the name;
29
+ // TypeScript lowercases the tag before matching, so the name is case-insensitive (`/// <REFERENCE .. />` is a
30
+ // directive). A `/// <summary>`, a bare `/// <reference>` or `/// <reference/>` without an attribute, or any
31
+ // other triple-slash comment is prose. A pragma likewise needs its grammar: the engine reads
32
+ // `//# sourceMappingURL=` only with whitespace after the `#` or `@`, then blanks it skips, then a non-empty
33
+ // value -- so `//#sourceURL=x` and an empty `//# sourceURL=` are prose, while `//# sourceURL= x` names `x`
34
+ // and is a pragma. The pragma name, unlike the directive tag, is case-sensitive: V8 reads only the exact
35
+ // `sourceURL`/`sourceMappingURL`, so `//# SOURCEURL=x` is prose. Erring wide is safe here (a prose change
36
+ // merely withholds a stamp), but matching every real directive or pragma is not optional: missing one would
37
+ // stamp its change, so each is kept as broad as its effective grammar allows, no stricter. A directive steers
38
+ // only where it leads the file, `prologue` true: TypeScript honours it before the first statement and reads
39
+ // it as prose below one, so moving it out of the prologue drops it, as removing it does.
40
+ const DIRECTIVE = /^\/\/\/\s*<(?:reference|amd-module|amd-dependency)\s/iu
41
+ const PRAGMA = /^\/[/*][#@]\s+source(?:Mapping)?URL=\s*\S/u
42
+ const steers = (text, prologue) => (DIRECTIVE.test(text) && prologue) || PRAGMA.test(text)
43
+
23
44
  // Node's stripping is one instance for the process, its loader of `.ts` files' too, and one stopped partway
24
45
  // (a stack overflowed, execution terminated) is not trusted again: one that threw here anything but its
25
46
  // refusal of a text, or that does not strip the canary as it should, as one stopped elsewhere may not.
@@ -62,11 +83,13 @@ function parsed(parseSync, text) {
62
83
  }
63
84
 
64
85
  // Where stripping and oxc's tree do not agree, or -1: stripping blanks what it erases and keeps line
65
- // breaks, and what it keeps that is no blank lies in a comment or the hashbang outside the statements
66
- // oxc finds erased, or in an inert one. A comment within an erased statement goes with it.
67
- function disagreement(text, code, { comments, program }, { erased, inert }) {
86
+ // breaks, and what it keeps that is no blank lies in a comment, the hashbang, or a statement it leaves to
87
+ // run -- a module load or `export {}` -- not in one oxc finds erased. A comment within an erased statement
88
+ // (strip blanks it) goes with it. A type-only specifier within a kept import blanks, and the else branch
89
+ // below allows it.
90
+ function disagreement(text, code, { comments, program }, erased, runtime) {
68
91
  const kept = new Uint8Array(text.length)
69
- for (const { start, end } of [...comments, ...inert, ...(program.hashbang ? [program.hashbang] : [])]) kept.fill(1, start, end)
92
+ for (const { start, end } of [...comments, ...runtime, ...(program.hashbang ? [program.hashbang] : [])]) kept.fill(1, start, end)
70
93
  for (const { start, end } of erased) kept.fill(0, start, end)
71
94
  // A character stripping changed must become a blank, not a line break written over one; one it kept
72
95
  // must be a blank, or lie in a span.
@@ -76,6 +99,54 @@ function disagreement(text, code, { comments, program }, { erased, inert }) {
76
99
  return -1
77
100
  }
78
101
 
102
+ // `code` with each span blanked to spaces, its line breaks kept, so what is left of the stripped text
103
+ // once the statements it leaves to run are set aside can be read for anything else.
104
+ function blankedOutside(code, spans) {
105
+ let out = code
106
+ for (const { start, end } of spans) out = out.slice(0, start) + out.slice(start, end).replace(NOT_LINE_BREAK, ' ') + out.slice(end)
107
+ return out
108
+ }
109
+
110
+ // A run statement's text for the signature, with what stripping erased within it and the comments it holds
111
+ // reduced, so a change touching only those does not change the signature: an inline type specifier (`import
112
+ // { type A, B }`) blanks to a run of blanks whose width tracks the type's name, and a comment within an
113
+ // otherwise unchanged load (`import /* c */ { B }`) stands as text amid blanks. A gap between tokens is any
114
+ // maximal run of blanks and comments; one holding a comment, or a character stripping erased (where `body`
115
+ // and `code` differ), collapses whole to a single blank, comment and adjacent blanks together, so adding or
116
+ // changing either does not shift the rest. A gap of only source blanks that stripping left (plain spacing
117
+ // between tokens) is kept as is, so such a difference withholds the stamp rather than passing. Every
118
+ // character stripping kept otherwise stays byte for byte: the executable syntax and, with their blanks and
119
+ // all, the string literals -- a module specifier's quotes bound any blank run inside it and hold no comment,
120
+ // and stripping never wrote there, so it is kept, and a load of another module (even one whose specifier
121
+ // differs only in a blank) still differs here. Known limit: the kept text is read as written, so a load left
122
+ // runtime-identical but reformatted still differs -- a removed trailing inline-type specifier leaving its
123
+ // comma (`{ B, }` for `{ B }`), or a comment added where no blank sat. Closing that would mean canonicalizing
124
+ // the executable text (Node's transform mode does, eliding the specifier and its comma), a wider signature
125
+ // left for another change; erring this way only withholds a stamp, never gives a wrong one.
126
+ function runtimeText(body, code, comments, { start, end }) {
127
+ const commented = new Uint8Array(end - start)
128
+ for (const comment of comments) {
129
+ if (comment.start >= start && comment.end <= end) commented.fill(1, comment.start - start, comment.end - start)
130
+ }
131
+ const reducible = (at) => BLANK.test(code[at]) || commented[at - start] === 1
132
+ let at = start, out = ''
133
+ while (at < end) {
134
+ if (reducible(at)) {
135
+ let drop = false, to = at
136
+ while (to < end && reducible(to)) {
137
+ if (body[to] !== code[to] || commented[to - start] === 1) drop = true
138
+ to++
139
+ }
140
+ out += drop ? ' ' : code.slice(at, to)
141
+ at = to
142
+ } else {
143
+ out += code[at]
144
+ at++
145
+ }
146
+ }
147
+ return out
148
+ }
149
+
79
150
  // proveTypeOnly with the stripping given: exported for tests alone, not by the package.
80
151
  export function proveWith(text, stripTypeScriptTypes) {
81
152
  if (typeof text !== 'string') throw new TypeError(`a text must be a string, not ${typeof text}`)
@@ -94,21 +165,49 @@ export function proveWith(text, stripTypeScriptTypes) {
94
165
  const tree = parsed(parseSync, body)
95
166
  if (tree === null) return refused('unparsed', null)
96
167
  if (tree.errors.length > 0) return refused('unparsed', shift + (tree.errors[0].labels?.[0]?.start ?? 0))
97
- const spans = { [ERASED]: [], [INERT]: [] }
168
+ // `erased` statements strip to blanks; `runtime` ones stripping leaves to run -- a module load or
169
+ // `export {}`. Anything else is code, which no declaration file of types alone holds.
170
+ const erased = [], runtime = []
98
171
  for (const statement of tree.program.body) {
99
172
  const kind = strippedAs(statement)
100
- if (kind === IMPORT || kind === CODE) return refused(kind, shift + statement.start)
101
- spans[kind].push(statement)
173
+ if (kind === CODE) return refused('code', shift + statement.start)
174
+ ;(kind === ERASED ? erased : runtime).push(statement)
102
175
  }
103
176
  if ('error' in stripped || stripped.code.length !== body.length) return refused('disagree', null)
104
- const at = disagreement(body, stripped.code, tree, spans)
105
- const left = at === -1 ? leftover(stripped.code) : at
177
+ const at = disagreement(body, stripped.code, tree, erased, runtime)
178
+ if (at !== -1) return refused('disagree', shift + at)
179
+ // Set the run statements aside; the rest of the stripped text must be blanks, comments and a hashbang.
180
+ const left = leftover(blankedOutside(stripped.code, runtime))
106
181
  if (left !== -1) return refused('disagree', shift + left)
182
+ // Transform mode (--experimental-transform-types) reveals a construct strip under-erases as code: its
183
+ // output, read again, must hold nothing but run statements and what erased to nothing. A bindingless load
184
+ // is not under-erasure here: transform emits one where it elides an inline-type import or re-export (an
185
+ // `import { type A }` becomes a bare side-effect `import`), so it is allowed though its source form is code.
107
186
  const transformed = strip(stripTypeScriptTypes, body, 'transform')
108
187
  if (transformed === null) return unsupported()
109
- // A retained `export {}` runs nothing as a module but emits a marker transpiled to CommonJS, so the file
110
- // is types-but-for-it: `inert`, stamped by compareTrees only where both sides of a change match.
111
- return 'code' in transformed && leftover(transformed.code) === -1 ? { proven: true, inert: spans[INERT].length > 0 } : refused('disagree', null)
188
+ if ('error' in transformed) return refused('disagree', null)
189
+ const after = parsed(parseSync, transformed.code)
190
+ if (after === null || after.errors.length > 0 || after.program.body.some((statement) => strippedAs(statement) === CODE && !bindingless(statement))) return refused('disagree', null)
191
+ // The runtime a change must leave untouched to be types only: the hashbang, the module loads and
192
+ // `export {}` stripping leaves, and the directive and pragma comments it keeps -- in source order. Empty
193
+ // where the file erases whole and carries none. A run statement is read through runtimeText, so an inline
194
+ // type specifier or a comment within it does not count; the hashbang, a directive and a pragma stand
195
+ // verbatim, since what they say is the point. A triple-slash directive counts only in the prologue, before
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, 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
+ const prologue = tree.program.body.length > 0 ? tree.program.body[0].start : stripped.code.length
201
+ const hashbang = tree.program.hashbang ? [tree.program.hashbang] : []
202
+ const steering = tree.comments.filter(({ start, end }) => steers(stripped.code.slice(start, end), start < prologue))
203
+ const verbatim = ({ start, end }) => stripped.code.slice(start, end)
204
+ const parts = [
205
+ ...hashbang.map((node) => ({ start: node.start, text: verbatim(node) })),
206
+ ...runtime.map((node) => ({ start: node.start, text: runtimeText(body, stripped.code, tree.comments, node) })),
207
+ ...steering.map((node) => ({ start: node.start, text: verbatim(node) })),
208
+ ]
209
+ const signature = parts.sort((a, b) => a.start - b.start).map((part) => part.text).join('\n')
210
+ return { proven: true, runtime: signature, loads: runtime.length > 0 }
112
211
  }
113
212
 
114
213
  export const proveTypeOnly = (text) => proveWith(text, nodeModule.stripTypeScriptTypes)