@preventive/compare 1.0.2 → 1.1.0

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
@@ -34,12 +34,23 @@ export type Status = 'added' | 'deleted' | 'modified' | 'renamed' | 'type-change
34
34
  // file and `new` for a deleted one; a renamed file has both, under their
35
35
  // two paths. `similarity` is a rename's, as git's similarity index: the
36
36
  // percentage of the new file the old one accounts for, 100 where the content
37
- // is unchanged; null for anything else.
37
+ // is unchanged; null for anything else. `typeOnly`, on Node alone, stamps a
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.
38
48
  export interface FileStatus {
39
49
  status: Status
40
50
  old: TreeFile | null
41
51
  new: TreeFile | null
42
52
  similarity: number | null
53
+ typeOnly?: true
43
54
  }
44
55
 
45
56
  // A status with what happened to the content: `blocks`, @preventive/diff's
@@ -176,6 +187,39 @@ export function layoutPackageDirs(baseDirs: Map<string, string> | null, otherDir
176
187
  // (`./dir/`, `./src/dir/`); a prefix the root doesn't lead is dropped.
177
188
  export function rootLabel(root: Root | null, prefix: string): { prefix: string; from: string | null; to: string | null }
178
189
 
190
+ // Why a text is not proven to hold types alone: proving needs Node, whose type
191
+ // stripping is held to oxc's tree, and oxc-parser, an optional peer, there,
192
+ // and is not asked of a stripping that once failed as may leave it unsound
193
+ // (`unsupported`); Node's stripping or oxc finds an error in it, as in
194
+ // `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:
204
+ // - oxc's tree has no statement but those stripping erases whole -- interfaces,
205
+ // type aliases, anything `declare`d (bodies and initializers with it), a
206
+ // 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;
212
+ // - 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;
215
+ // - 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
220
+ // 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 }
222
+
179
223
  // `path` is the name at fault as its entry spelled it, and `tree` which tree
180
224
  // holds it.
181
225
  export class CompareError extends Error {
package/compare.js CHANGED
@@ -25,5 +25,10 @@ export { renameParts } from './src/paths.js'
25
25
  // each old path, directory and package directory as the new tree lays it out.
26
26
  export { layoutDir, layoutPackageDirs, rootLabel, treeLayout } from './src/layout.js'
27
27
 
28
+ // Whether a TypeScript text holds types alone, so that Node runs nothing of it,
29
+ // as compareTrees stamps a declaration file's change: proven on Node alone, where
30
+ // oxc-parser, an optional peer, is loaded on first use.
31
+ export { proveTypeOnly } from '#types'
32
+
28
33
  // What a tree that does not hold together is refused with.
29
34
  export { CompareError } from './src/error.js'
package/names.d.ts CHANGED
@@ -28,7 +28,9 @@ export type RenameReason = 'unparsed' | 'dynamic' | 'jsx' | 'block-function' | '
28
28
  // it; and the blocks checked to take `before` to the new side (thrown should
29
29
  // they not). `reason` says why no names were given; `reads` the properties
30
30
  // either text reads names through (`name`, `toString`, `stack`, `callee`,
31
- // `caller`), sorted, as code reading its names may act otherwise renamed;
31
+ // `caller`), sorted, as code reading its names may act otherwise renamed:
32
+ // none where none is renamed, and `name` only where a name renamed is a
33
+ // function's or class's, the one name it reads (`function f`, `f = () => {}`);
32
34
  // `marks` the plain diff's blocks that differ in renamed names alone; and
33
35
  // `warnings` what a check that should never fail found.
34
36
  export function renamedDiff(before: string, after: string, options?: RenameOptions & { whitespace?: 'none' | 'all' | 'change' | 'trailing' }): Promise<{
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@preventive/compare",
3
- "version": "1.0.2",
4
- "description": "Two file trees compared as git diff compares them, with their line change sets and the patch git writes; and minified JavaScript compared with its layout and its renamed names set aside, each step checked to hide no change",
3
+ "version": "1.1.0",
4
+ "description": "Git-compatible file tree diffs and minified JavaScript comparison",
5
5
  "type": "module",
6
6
  "main": "./compare.js",
7
7
  "types": "./compare.d.ts",
@@ -31,6 +31,10 @@
31
31
  "#oxc": {
32
32
  "node": "./src/oxc.node.js",
33
33
  "default": "./src/oxc.js"
34
+ },
35
+ "#types": {
36
+ "node": "./src/types.node.js",
37
+ "default": "./src/types.js"
34
38
  }
35
39
  },
36
40
  "files": [
@@ -45,6 +49,7 @@
45
49
  "src/blocks.js",
46
50
  "src/changes.js",
47
51
  "src/css.js",
52
+ "src/erasure.js",
48
53
  "src/error.js",
49
54
  "src/layout.js",
50
55
  "src/marked.js",
@@ -71,6 +76,8 @@
71
76
  "src/similarity.js",
72
77
  "src/text.js",
73
78
  "src/tree.js",
79
+ "src/types.js",
80
+ "src/types.node.js",
74
81
  "src/whitespace.js",
75
82
  "src/words.js"
76
83
  ],
package/src/changes.js CHANGED
@@ -1,10 +1,12 @@
1
1
  // compareTrees: a path in both trees is modified or type-changed, a path in one
2
2
  // added or deleted unless renames.js pairs it with another, as git diff -M does
3
- // and, unless `renamesByPath` is false, by the paths a build moves files to.
3
+ // and, unless `renamesByPath` is false, by the paths a build moves files to; on
4
+ // Node, a change to a declaration file holding types alone on each side stamped so.
4
5
 
6
+ import { proveTypeOnly } from '#types'
5
7
  import { MAX_SCORE } from './similarity.js'
6
8
  import { findRenames } from './renames.js'
7
- import { checkLineOptions, contentOf } from './text.js'
9
+ import { checkLineOptions, contentOf, textOf } from './text.js'
8
10
  import { readTree, sameContent } from './tree.js'
9
11
 
10
12
  // git's defaults: -M at 50%, diff.renameLimit at 1000.
@@ -42,6 +44,47 @@ const sameFile = (a, b) => sameContent(a, b) && (a.type !== 'file' || a.mode ===
42
44
 
43
45
  const status = (kind, old, fresh, similarity = null) => ({ status: kind, old, new: fresh, similarity })
44
46
 
47
+ // A declaration file as TypeScript names one: `.d.ts`, `.d.mts`, `.d.cts`, and
48
+ // `.d.css.ts` for a `.css` file's.
49
+ const DECLARATION = /\.d\.(?:[^./]+\.)?[cm]?ts$/u
50
+
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.
54
+ const fixedModule = (path) => (path.endsWith('.mts') ? 'module' : path.endsWith('.cts') ? 'commonjs' : null)
55
+
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.
59
+ function proven(file) {
60
+ if (file === null) return { proven: true, inert: false }
61
+ const text = textOf(file)
62
+ try {
63
+ return text === undefined ? { proven: false } : proveTypeOnly(text)
64
+ } catch {
65
+ return { proven: false }
66
+ }
67
+ }
68
+
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.
74
+ function stamped(change) {
75
+ const sides = [change.old, change.new].filter((file) => file !== null)
76
+ 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
+ if (change.old && change.new && change.old.mode !== change.new.mode) return change
79
+ 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
+ }
85
+ return { ...change, typeOnly: true }
86
+ }
87
+
45
88
  function pathChanges(old, fresh) {
46
89
  const added = [], changes = [], deleted = []
47
90
  for (const [path, file] of old) {
@@ -86,5 +129,5 @@ export function compareTrees(a, b, { renames = true, renamesByPath = true, renam
86
129
  for (const [s, file] of deleted.entries()) if (!moved.has(s)) changes.push(status('deleted', file, null))
87
130
  // A rename stands at its new path, as git lists it.
88
131
  changes.sort((x, y) => byPath((x.new ?? x.old).path, (y.new ?? y.old).path))
89
- return lines ? changes.map((change) => ({ ...change, ...contentOf(change.old, change.new, lineOptions) })) : changes
132
+ return (lines ? changes.map((change) => ({ ...change, ...contentOf(change.old, change.new, lineOptions) })) : changes).map(stamped)
90
133
  }
package/src/erasure.js ADDED
@@ -0,0 +1,109 @@
1
+ // What Node's type stripping erases of TypeScript, as oxc's syntax tree has it, and what a text it
2
+ // stripped may hold: Node reads a `.d.ts` as the `.ts` it is named and runs what it does not erase,
3
+ // so a declaration file runs nothing only where stripping leaves nothing to run. Nothing here
4
+ // parses; types.node.js gives both the tree and Node's text.
5
+
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.
10
+ export const CODE = 'code', ERASED = 'erased', IMPORT = 'imports', INERT = 'inert'
11
+
12
+ const TYPES = new Set(['TSInterfaceDeclaration', 'TSTypeAliasDeclaration'])
13
+
14
+ // A namespace not declared is erased where TypeScript emits nothing for it: it holds interfaces,
15
+ // type aliases and such namespaces alone, exported or not. One declared within it is held to the
16
+ // same, as Node holds it: `namespace N { declare namespace M { const x = 1 } }` is refused.
17
+ function typesAlone(namespace) {
18
+ if (namespace.kind !== 'namespace') return false
19
+ if (namespace.body?.type === 'TSModuleDeclaration') return typesAlone(namespace.body)
20
+ if (namespace.body?.type !== 'TSModuleBlock') return false
21
+ return namespace.body.body.every((statement) => {
22
+ const node = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement
23
+ return TYPES.has(node?.type) || (node?.type === 'TSModuleDeclaration' && typesAlone(node))
24
+ })
25
+ }
26
+
27
+ // A declaration erased whole: a type, an interface, a function's signature, anything `declare`d
28
+ // (its initializers and bodies with it), a namespace of types. A decorator is code, kept where
29
+ // the class it decorates is erased (`@d declare class A {}` leaves `@d`).
30
+ function erased(node) {
31
+ switch (node.type) {
32
+ case 'TSInterfaceDeclaration': case 'TSTypeAliasDeclaration': case 'TSDeclareFunction': return true
33
+ case 'VariableDeclaration': case 'TSEnumDeclaration': return node.declare === true
34
+ case 'ClassDeclaration': return node.declare === true && !(node.decorators?.length > 0)
35
+ case 'TSModuleDeclaration': return node.declare === true || typesAlone(node)
36
+ default: return false
37
+ }
38
+ }
39
+
40
+ // A statement of a program's, as stripping leaves it. `import type` and `export type` are erased whole,
41
+ // loading nothing; `import { type A }` from a module loads it all the same, inline `type` or not, so it
42
+ // is a module load. A bare `export {}` is inert; `export { type A }` is code, not inert: it is `export {}`
43
+ // 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.
45
+ export function strippedAs(statement) {
46
+ switch (statement.type) {
47
+ case 'ImportDeclaration': return statement.importKind === 'type' ? ERASED : IMPORT
48
+ case 'ExportAllDeclaration': return statement.exportKind === 'type' ? ERASED : IMPORT
49
+ case 'TSImportEqualsDeclaration':
50
+ if (statement.importKind === 'type') return ERASED
51
+ return statement.moduleReference.type === 'TSExternalModuleReference' ? IMPORT : CODE
52
+ case 'ExportNamedDeclaration':
53
+ if (statement.declaration) return erased(statement.declaration) ? ERASED : strippedAs(statement.declaration)
54
+ if (statement.exportKind === 'type') return ERASED
55
+ if (statement.source) return IMPORT
56
+ return statement.specifiers.length === 0 ? INERT : CODE
57
+ case 'ExportDefaultDeclaration': return statement.declaration.type === 'TSInterfaceDeclaration' || statement.declaration.type === 'TSDeclareFunction' ? ERASED : CODE
58
+ // `export as namespace N` is left verbatim, invalid as JavaScript, so importing the file throws.
59
+ case 'TSNamespaceExportDeclaration': return CODE
60
+ default: return erased(statement) ? ERASED : CODE
61
+ }
62
+ }
63
+
64
+ // What a stripped text may hold, read token by token as no parser reads it: blanks and line breaks
65
+ // (JavaScript's, `\s`), comments, a hashbang first, and `export {}` (empty, or what was all type
66
+ // specifiers), with any trailing `;`.
67
+ const LINE_END = /[\n\r\u2028\u2029]/gu
68
+ const BLANK = /\s/u
69
+ const EXPORT_EMPTY = [/export(?![\w$])/uy, /\{/uy, /\}/uy]
70
+
71
+ function lineEnd(text, at) {
72
+ LINE_END.lastIndex = at
73
+ return LINE_END.test(text) ? LINE_END.lastIndex - 1 : text.length
74
+ }
75
+
76
+ // Past blanks and comments; an unclosed comment stays, for what follows to refuse.
77
+ function skip(text, at) {
78
+ for (;;) {
79
+ const close = text.startsWith('/*', at) ? text.indexOf('*/', at + 2) : -1
80
+ if (BLANK.test(text[at] ?? '')) at++
81
+ else if (text.startsWith('//', at)) at = lineEnd(text, at)
82
+ else if (close === -1) return at
83
+ else at = close + 2
84
+ }
85
+ }
86
+
87
+ // Past an `export {}` from `from`, with any trailing `;`, or -1 where none starts there.
88
+ function exportEmptyEnd(text, from) {
89
+ let at = from
90
+ for (const token of EXPORT_EMPTY) {
91
+ token.lastIndex = skip(text, at)
92
+ at = token.test(text) ? token.lastIndex : -1
93
+ if (at === -1) return -1
94
+ }
95
+ const semicolon = skip(text, at)
96
+ return text[semicolon] === ';' ? semicolon + 1 : at
97
+ }
98
+
99
+ // Where a stripped text first holds anything else, or -1 where it holds nothing else.
100
+ export function leftover(text) {
101
+ let at = text.startsWith('#!') ? lineEnd(text, 0) : 0
102
+ for (;;) {
103
+ at = skip(text, at)
104
+ if (at === text.length) return -1
105
+ const end = exportEmptyEnd(text, at)
106
+ if (end === -1) return at
107
+ at = end
108
+ }
109
+ }
@@ -10,6 +10,9 @@ const FUNCTIONS = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFu
10
10
  const BLOCKS = new Set(['BlockStatement', 'ForStatement', 'ForInStatement', 'ForOfStatement', 'SwitchStatement'])
11
11
  // Globals that make a script a CommonJS module, its top level its own.
12
12
  const COMMONJS = new Set(['exports', 'module', 'require'])
13
+ // A function or class with no name of its own, which takes the one it is bound to.
14
+ const ANONYMOUS = new Set(['FunctionExpression', 'ArrowFunctionExpression', 'ClassExpression'])
15
+ const NAMING_ASSIGNMENTS = new Set(['=', '&&=', '||=', '??='])
13
16
 
14
17
  // oxc is loaded outside the try: one missing is no text that won't parse. A text
15
18
  // oxc's semantic check finds an error in is one it can't vouch for.
@@ -90,6 +93,24 @@ function moduleNames(node, s) {
90
93
  else if (node.declaration?.id) s.keeping.add(node.declaration.id)
91
94
  }
92
95
 
96
+ function anonymous(node) {
97
+ while (node?.type === 'ParenthesizedExpression') node = node.expression
98
+ return ANONYMOUS.has(node?.type) && !node.id
99
+ }
100
+
101
+ // The names functions and classes are named by, as `.name` reads them: a
102
+ // declaration's or an expression's own, or the one an unnamed one is bound to
103
+ // (`var f = () => {}`, `f = function () {}`, `(f = class {}) => f`).
104
+ function namingNames(node, s) {
105
+ switch (node.type) {
106
+ case 'FunctionDeclaration': case 'FunctionExpression': case 'ClassDeclaration': case 'ClassExpression': if (node.id) s.naming.add(node.id); break
107
+ case 'VariableDeclarator': if (node.id.type === 'Identifier' && anonymous(node.init)) s.naming.add(node.id); break
108
+ case 'AssignmentPattern': if (node.left.type === 'Identifier' && anonymous(node.right)) s.naming.add(node.left); break
109
+ case 'AssignmentExpression': if (NAMING_ASSIGNMENTS.has(node.operator) && node.left.type === 'Identifier' && anonymous(node.right)) s.naming.add(node.left); break
110
+ default: break
111
+ }
112
+ }
113
+
93
114
  // Names that are no variable's (keys, labels), names kept, and the properties
94
115
  // names are read through.
95
116
  function otherNames(node, s) {
@@ -181,7 +202,7 @@ function enter(node, where, s) {
181
202
 
182
203
  // A work list rather than a recursion: a minified file's expressions nest deeply.
183
204
  function walk(program) {
184
- const s = { declarations: new Map(), scopes: [], variables: [], keeping: new Set(), others: new Set(), reads: new Set(), references: [], work: [] }
205
+ const s = { declarations: new Map(), scopes: [], variables: [], keeping: new Set(), others: new Set(), naming: new Set(), reads: new Set(), references: [], work: [] }
185
206
  s.root = Object.assign(scope(s, null, true), { strict: program.sourceType === 'module' || saysStrict(program.body) })
186
207
  s.work.push([program, s.root])
187
208
  while (s.work.length > 0) {
@@ -190,6 +211,7 @@ function walk(program) {
190
211
  if (!s.declarations.has(node) && !s.others.has(node)) s.references.push([node, where])
191
212
  continue
192
213
  }
214
+ namingNames(node, s)
193
215
  const reason = visit(node, where, s)
194
216
  if (reason) return { reason }
195
217
  enter(node, where, s)
@@ -207,7 +229,10 @@ function resolved(program, s) {
207
229
  if (variable) occurrences.push([node, variable, where])
208
230
  else globals.push([node, where])
209
231
  }
210
- for (const [node, variable] of occurrences) if (s.keeping.has(node)) variable.kept = true
232
+ for (const [node, variable] of occurrences) {
233
+ if (s.keeping.has(node)) variable.kept = true
234
+ if (s.naming.has(node)) variable.naming = true
235
+ }
211
236
  const own = program.sourceType === 'module' || program.sourceType === 'commonjs' || globals.some(([node]) => COMMONJS.has(node.name))
212
237
  for (const variable of s.variables) variable.aside = variable.name.length <= RENAMED_MAX_LENGTH && MINIFIED.test(variable.name) && !variable.kept && (own || variable.scope !== s.root)
213
238
  // A shorthand property's two names are one, at one place.
@@ -3,6 +3,7 @@
3
3
  // each other, and two each the other's most counted pair, a tie pairing neither.
4
4
 
5
5
  import { changeSet } from '@preventive/diff'
6
+ import { RESERVED } from './syntax.js'
6
7
 
7
8
  // Unchanged lines between changes that pair as unchanged ones, not as changed.
8
9
  const ANCHORED_RUN = 3
@@ -10,21 +11,125 @@ const ANCHORED_RUN = 3
10
11
  // Whitespace alone never tells a name apart.
11
12
  const changes = (a, b) => changeSet(a, b, { horizon: 0, whitespace: 'all' })
12
13
 
13
- // A text's lines with its renamable variables' names set aside (`key`), and
14
- // each line's such variables in order (`lines`).
15
- function keyed(text, { occurrences }) {
14
+ // A text's lines with its renamable variables' names set aside (`key`), but
15
+ // those `written` names (a pair found), each line's variables set aside in
16
+ // order (`lines`), and where in its key each stood (`places`).
17
+ function keyed(text, { occurrences }, written = () => null) {
16
18
  const starts = [0]
17
19
  for (let at = text.indexOf('\n'); at !== -1; at = text.indexOf('\n', at + 1)) starts.push(at + 1)
18
- const lines = starts.map(() => [])
19
- let at = 0, key = '', line = 0
20
+ const lines = starts.map(() => []), places = starts.map(() => [])
21
+ let at = 0, key = '', line = 0, removed = 0
20
22
  for (const [node, variable] of occurrences) {
21
23
  if (!variable.aside) continue
22
- key += text.slice(at, node.start)
24
+ const name = written(variable)
25
+ for (; line + 1 < starts.length && starts[line + 1] <= node.start; line++) removed = 0
26
+ if (name === null) {
27
+ lines[line].push(variable)
28
+ places[line].push(node.start - starts[line] - removed)
29
+ }
30
+ key += text.slice(at, node.start) + (name ?? '')
31
+ removed += node.end - node.start - (name?.length ?? 0)
23
32
  at = node.end
24
- while (line + 1 < starts.length && starts[line + 1] <= node.start) line++
25
- lines[line].push(variable)
26
33
  }
27
- return { key: key + text.slice(at), lines }
34
+ key += text.slice(at)
35
+ return { key, keys: key.split('\n'), lines, places }
36
+ }
37
+
38
+ // Lines as a diff that sets whitespace aside compares them.
39
+ const SPACE = /[ \t\n\v\f\r]/gu
40
+ const plain = (lines) => lines.map((line) => line.replace(SPACE, ''))
41
+
42
+ // The kept lines of each side a run of changes could stand in place of: a run
43
+ // bordered by lines alike its own may sit anywhere along them short of the next
44
+ // run, as diff slides it, so which of those it leaves kept, facing which, is
45
+ // the diff's pick and says nothing (a function added before one written alike
46
+ // with names set aside: either's header may face the old one's) -- unless the
47
+ // line it would keep instead reads the same variables (`same`), which say
48
+ // the same either way.
49
+ function slack(a, b, blocks, sameA, sameB) {
50
+ const loose = (lines, runs, same) => {
51
+ const flags = new Uint8Array(lines.length)
52
+ runs.forEach(([start, end], k) => {
53
+ const ceiling = runs[k + 1]?.[0] ?? lines.length, floor = k > 0 ? runs[k - 1][1] : 0
54
+ for (let e = end, s = start; s > floor && lines[s - 1] === lines[e - 1]; s--, e--) if (!same(s - 1, e - 1)) flags[s - 1] = 1
55
+ for (let e = end, s = start; e < ceiling && lines[s] === lines[e]; s++, e++) if (!same(e, s)) flags[e] = 1
56
+ })
57
+ return flags
58
+ }
59
+ return [loose(a, blocks.filter(({ a0, a1 }) => a1 > a0).map(({ a0, a1 }) => [a0, a1]), sameA),
60
+ loose(b, blocks.filter(({ b0, b1 }) => b1 > b0).map(({ b0, b1 }) => [b0, b1]), sameB)]
61
+ }
62
+
63
+ // Whether two of a run's lines, from `from`, read the same variables in order.
64
+ const reading = (k, from = 0) => (x, y) => {
65
+ const one = k.lines[from + x], other = k.lines[from + y]
66
+ return one.length === other.length && one.every((variable, n) => variable === other[n])
67
+ }
68
+
69
+ // The pairs of lines a diff of `a` and `b` keeps, each told whether slack.
70
+ function* kept(a, b, sameA, sameB) {
71
+ const text = (lines) => lines.map((line) => `${line}\n`).join('')
72
+ const blocks = changes(text(a), text(b))
73
+ const [looseA, looseB] = slack(a, b, blocks, sameA, sameB)
74
+ let i = 0, j = 0
75
+ for (const { a0, a1, b1 } of [...blocks, { a0: a.length, a1: a.length, b1: b.length }]) {
76
+ for (; i < a0; i++, j++) yield [i, j, looseA[i] || looseB[j]]
77
+ i = a1
78
+ j = b1
79
+ }
80
+ }
81
+
82
+ // A run of lines' tokens (`list`) and the variable each stands for (`at`): the
83
+ // place a variable stood one token alike any other's. Null past MAX_TOKENS.
84
+ const PLACE = '*', TOKEN = /[\p{ID_Continue}$\u200C\u200D]+|\S/gu
85
+ // A diff's cost grows faster than its length: a change of more tokens a side
86
+ // than this pairs by its lines alone.
87
+ const MAX_TOKENS = 20000
88
+ function tokens(k, from, to) {
89
+ const at = [], list = []
90
+ const add = (token, variable) => {
91
+ list.push(token)
92
+ at.push(variable)
93
+ return list.length <= MAX_TOKENS
94
+ }
95
+ const text = (line, start, end) => {
96
+ for (const [token] of k.keys[line].slice(start, end).matchAll(TOKEN)) if (!add(`=${token}`, null)) return false
97
+ return true
98
+ }
99
+ for (let line = from; line < to; line++) {
100
+ let start = 0
101
+ for (const [n, place] of k.places[line].entries()) {
102
+ if (!text(line, start, place) || !add(PLACE, k.lines[line][n])) return null
103
+ start = place
104
+ }
105
+ if (!text(line, start)) return null
106
+ }
107
+ return { list, at }
108
+ }
109
+
110
+ // A word but a keyword: what ties tokens kept together to their own place, as
111
+ // punctuation and keywords alone line up any statement with any other.
112
+ const WORD = /^=[\p{ID_Start}$_]/u
113
+ const ties = (token) => WORD.test(token) && !RESERVED.has(token.slice(1))
114
+
115
+ // The variables of a change that its tokens, diffed, keep facing each other in
116
+ // a run of tokens kept together holding a word: its lines written anew, wrapped
117
+ // or ended otherwise (`let t = ...;` -> `let t = ...,`), or reading more
118
+ // variables or fewer (`f(a)` -> `f(b, c)`: none, `b` or `c` alike). Lines
119
+ // reordered line up by punctuation alone, `aa(a, b); bb(b);` ->
120
+ // `bb(y, z); aa(x);` facing `(a, b)` with `(y, z)`, which pairs nothing.
121
+ function placed(ka, a0, a1, kb, b0, b1) {
122
+ const reads = (k, from, to) => k.lines.slice(from, to).some((line) => line.length > 0)
123
+ if (!reads(ka, a0, a1) || !reads(kb, b0, b1)) return []
124
+ const a = tokens(ka, a0, a1), b = a && tokens(kb, b0, b1)
125
+ if (!b) return []
126
+ const found = [], pairs = [...kept(a.list, b.list, (x, y) => a.at[x] === a.at[y], (x, y) => b.at[x] === b.at[y])]
127
+ for (let end = 1, start = 0; start < pairs.length; start = end++) {
128
+ while (end < pairs.length && pairs[end][0] === pairs[end - 1][0] + 1 && pairs[end][1] === pairs[end - 1][1] + 1) end++
129
+ const run = pairs.slice(start, end)
130
+ if (run.some(([x]) => ties(a.list[x]))) for (const [x, y, loose] of run) if (a.at[x] && !loose) found.push([a.at[x], b.at[y]])
131
+ }
132
+ return found
28
133
  }
29
134
 
30
135
  const tallies = () => ({ backward: new Map(), forward: new Map() })
@@ -33,40 +138,38 @@ function count(map, from, to) {
33
138
  map.get(from).set(to, (map.get(from).get(to) ?? 0) + 1)
34
139
  }
35
140
 
36
- // Pairs of lines counted, a long run of unchanged ones apart from the rest. In
37
- // a change of unlike lengths, lines alike but for a final `;` or `,` pair (a
38
- // declaration others now follow: `let t = ...;` -> `let t = ...,`).
141
+ // Pairs of lines counted, a long run of unchanged ones apart from the rest, and
142
+ // apart from both the variables a change's tokens pair (`tokened`). Lines
143
+ // reading variables one side has and the other not say nothing. In a change of
144
+ // unlike lengths, lines alike but for a final `;` or `,` pair (a declaration
145
+ // others now follow: `let t = ...;` -> `let t = ...,`).
39
146
  function tallied(ka, kb) {
40
- const changed = tallies(), unchanged = tallies()
41
- // Lines reading variables one side has and the other not say nothing.
42
- const tally = ({ backward, forward }, pa, pb) => {
43
- const news = kb.lines[pb], olds = ka.lines[pa]
44
- if (olds.length !== news.length) return
45
- olds.forEach((variable, k) => {
46
- count(forward, variable, news[k])
47
- count(backward, news[k], variable)
48
- })
147
+ const changed = tallies(), tokened = tallies(), unchanged = tallies()
148
+ const counted = ({ backward, forward }, variable, other) => {
149
+ count(forward, variable, other)
150
+ count(backward, other, variable)
49
151
  }
50
- const keysA = ka.key.split('\n'), keysB = kb.key.split('\n')
51
- const trimmed = (lines, from, to) => `${lines.slice(from, to).map((line) => line.replace(/[;,]\s*$/u, '')).join('\n')}\n`
152
+ const alike = (counts, olds, news) => olds.length === news.length && olds.forEach((variable, k) => counted(counts, variable, news[k]))
153
+ const trimmed = (lines, from, to) => plain(lines.slice(from, to).map((line) => line.replace(/[;,]\s*$/u, '')))
154
+ const blocks = changes(ka.key, kb.key)
155
+ const [looseA, looseB] = slack(plain(ka.keys), plain(kb.keys), blocks, reading(ka), reading(kb))
52
156
  let i = 0, j = 0
53
- for (const block of [...changes(ka.key, kb.key), { a0: ka.lines.length, a1: ka.lines.length, b0: kb.lines.length, b1: kb.lines.length }]) {
54
- const anchored = block.a0 - i >= ANCHORED_RUN
55
- while (i < block.a0) tally(anchored ? unchanged : changed, i++, j++)
157
+ for (const block of [...blocks, { a0: ka.lines.length, a1: ka.lines.length, b0: kb.lines.length, b1: kb.lines.length }]) {
158
+ const counts = block.a0 - i >= ANCHORED_RUN ? unchanged : changed
159
+ for (; i < block.a0; i++, j++) if (!looseA[i] && !looseB[j]) alike(counts, ka.lines[i], kb.lines[j])
56
160
  const [olds, news] = [block.a1 - block.a0, block.b1 - block.b0]
57
- if (olds === news) for (let k = 0; k < olds; k++) tally(changed, block.a0 + k, block.b0 + k)
58
- else if (olds > 0 && news > 0) {
59
- let x = 0, y = 0
60
- for (const inner of [...changes(trimmed(keysA, block.a0, block.a1), trimmed(keysB, block.b0, block.b1)), { a0: olds, a1: olds, b0: news, b1: news }]) {
61
- while (x < inner.a0) tally(changed, block.a0 + x++, block.b0 + y++)
62
- x = inner.a1
63
- y = inner.b1
161
+ if (olds > 0 && news > 0) {
162
+ if (olds === news) for (let k = 0; k < olds; k++) alike(changed, ka.lines[block.a0 + k], kb.lines[block.b0 + k])
163
+ else {
164
+ const pairs = kept(trimmed(ka.keys, block.a0, block.a1), trimmed(kb.keys, block.b0, block.b1), reading(ka, block.a0), reading(kb, block.b0))
165
+ for (const [x, y] of [...pairs].filter((pair) => !pair[2])) alike(changed, ka.lines[block.a0 + x], kb.lines[block.b0 + y])
64
166
  }
167
+ for (const [variable, other] of placed(ka, block.a0, block.a1, kb, block.b0, block.b1)) counted(tokened, variable, other)
65
168
  }
66
169
  i = block.a1
67
170
  j = block.b1
68
171
  }
69
- return { changed, unchanged }
172
+ return { changed, tokened, unchanged }
70
173
  }
71
174
 
72
175
  // The most counted of those not yet paired, none where two tie.
@@ -101,11 +204,22 @@ function matched({ backward, forward }, { map, paired }, eligible = () => true)
101
204
  }
102
205
 
103
206
  // Unchanged lines pair first; changed ones then pair only what those say
104
- // nothing of, as `weak` pairs, the first to give way where names read wrongly.
207
+ // nothing of, and a change's tokens only what no line says anything of: lines
208
+ // reordered, a change's tokens may line up across them. Both are `weak` pairs,
209
+ // the first to give way where names read wrongly. A second pass reads the
210
+ // pairs found as names, so lines alike with every name set aside and diffed
211
+ // against the wrong one line up by those.
105
212
  export function pairing(before, after, a, b) {
106
- const { changed, unchanged } = tallied(keyed(before, a), keyed(after, b))
107
- const found = { map: new Map(), paired: new Set() }
108
- matched(unchanged, found)
109
- const weak = new Set(matched(changed, found, (old, to) => !unchanged.forward.has(old) && !unchanged.backward.has(to)))
213
+ const found = { map: new Map(), paired: new Set() }, said = new Set(), weak = new Set()
214
+ const passes = [[() => null, () => null], [(variable) => found.map.get(variable)?.name ?? null, (variable) => (found.paired.has(variable) ? variable.name : null)]]
215
+ for (const [old, now] of passes) {
216
+ // A pass pairs nothing where every variable of a side is paired already.
217
+ if (!a.variables.some((variable) => variable.aside && !found.map.has(variable)) || !b.variables.some((variable) => variable.aside && !found.paired.has(variable))) break
218
+ const { changed, tokened, unchanged } = tallied(keyed(before, a, old), keyed(after, b, now))
219
+ for (const [counts, tier] of [[unchanged, false], [changed, true], [tokened, true]]) {
220
+ for (const variable of matched(counts, found, (from, to) => !said.has(from) && !said.has(to))) if (tier) weak.add(variable)
221
+ for (const variable of [...counts.forward.keys(), ...counts.backward.keys()]) said.add(variable)
222
+ }
223
+ }
110
224
  return { map: found.map, weak }
111
225
  }
@@ -53,7 +53,10 @@ function renamedNames(before, after, { extension = 'js', otherExtension = extens
53
53
  if (!bindsAlike(before, text, a)) return { reason: 'unverified' }
54
54
  const changed = a.variables.filter((variable) => given.get(variable) !== variable.name)
55
55
  const renamed = changed.filter((variable) => paired.map.has(variable)).length
56
- const reads = [...new Set([...a.reads, ...b.reads])].toSorted()
56
+ // A read of names can act otherwise only where one is renamed, and `.name` reads a function's or
57
+ // class's alone: one only where a name renamed is one's.
58
+ const naming = changed.some((variable) => variable.naming || paired.map.get(variable)?.naming)
59
+ const reads = changed.length === 0 ? [] : [...new Set([...a.reads, ...b.reads])].filter((read) => read !== 'name' || naming).toSorted()
57
60
  return { text, renamed, apart: changed.length - renamed, kind: { module: a.module, commonjs: a.commonjs, own: a.own }, reads }
58
61
  }
59
62
 
package/src/oxc.js CHANGED
@@ -4,3 +4,4 @@ import { minifySync } from 'oxc-minify'
4
4
  import { parseSync } from 'oxc-parser'
5
5
 
6
6
  export const getOxc = () => ({ parseSync, minifySync })
7
+ export const getParser = () => parseSync
package/src/oxc.node.js CHANGED
@@ -3,7 +3,7 @@ import { createRequire } from 'node:module'
3
3
  // oxc-parser and oxc-minify, optional peers, required on first use; one missing
4
4
  // is thrown as the environment's error, not taken for a text that won't parse.
5
5
  const require = createRequire(import.meta.url)
6
- let oxc
6
+ let oxc, parser
7
7
 
8
8
  function load(name) {
9
9
  try {
@@ -14,3 +14,5 @@ function load(name) {
14
14
  }
15
15
 
16
16
  export const getOxc = () => (oxc ??= { parseSync: load('oxc-parser').parseSync, minifySync: load('oxc-minify').minifySync })
17
+ // The parser alone, for what needs no minifier: a declaration file proven to hold types alone.
18
+ export const getParser = () => (parser ??= load('oxc-parser').parseSync)
package/src/pretty.js CHANGED
@@ -28,7 +28,11 @@ function numberSpelling(text) {
28
28
  }
29
29
 
30
30
  const sorted = (flags) => [...flags].toSorted().join('')
31
- const scriptForm = (text) => text.replaceAll(NUMBER, numberSpelling).replaceAll(/\\(?=["'])|[\s();]/gu, '').replaceAll("'", '"')
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, }`).
34
+ const scriptForm = (text) => text.replaceAll(NUMBER, numberSpelling).replaceAll(/,(?=(?:\s|\/\/[^\n]*|\/\*[^]*?\*\/)*[\])}>])/gu, '')
35
+ .replaceAll(/\\(?=["'])|[\s();]/gu, '').replaceAll("'", '"')
32
36
  .replaceAll(/(?<=\/)[dgimsuvy]{2,}(?![\p{L}\p{N}_$])/gu, sorted)
33
37
 
34
38
  // JSON has no point without a digit either side: such a number is kept as written, or a copy would read as
@@ -45,7 +49,8 @@ function parsed(text, extension) {
45
49
  return result.errors.length > 0 ? null : result
46
50
  }
47
51
 
48
- const comments = ({ comments: list }) => list.map(({ type, value }) => `${type} ${indentless(value)}`)
52
+ // A comment as written, but for its indentation and the blanks ending it, which formatting trims.
53
+ const comments = ({ comments: list }) => list.map(({ type, value }) => `${type} ${indentless(value).replace(/[\t ]+$/u, '')}`)
49
54
  // `a && (b && c)` and `(a && b) && c` read alike, as do `||` and `??` chains.
50
55
  function chain(node, operator) {
51
56
  const operands = [], rest = [node]
@@ -79,14 +84,42 @@ function sameTree(a, b) {
79
84
  return true
80
85
  }
81
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]
93
+ while (work.length > 0) {
94
+ const node = work.pop()
95
+ // Pushed one by one: a spread of a file's statements could pass more arguments than a call takes.
96
+ if (Array.isArray(node)) for (const item of node) work.push(item)
97
+ 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, ''])
102
+ for (const key of Object.keys(node)) if (!POSITION.has(key) && typeof node[key] === 'object') work.push(node[key])
103
+ }
104
+ }
105
+ 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
109
+ }
110
+ return out + text.slice(from)
111
+ }
112
+
82
113
  // A safeguard on oxfmt's own output, not a comparison of any two texts: exported
83
114
  // for tests alone, not by the package. CSS is read by `csstree` where given.
84
115
  export function sameCode(text, formatted, extension = 'js', csstree = null) {
85
116
  if (extension === 'css') return csstree !== null && sameCss(csstree, text, formatted)
86
117
  if (extension === 'json') return sameList(jsonForm(text), jsonForm(formatted))
87
- if (!SCRIPTS.has(extension) || scriptForm(text) !== scriptForm(formatted)) return false
118
+ if (!SCRIPTS.has(extension)) return false
88
119
  const [before, after] = [parsed(text, extension), parsed(formatted, extension)]
89
- return before !== null && after !== null && sameList(comments(before), comments(after)) && sameTree(before.program, after.program)
120
+ 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)
90
123
  }
91
124
 
92
125
  // A script's block comments over more than one line, a license's say, each end their line: what oxfmt
package/src/types.js ADDED
@@ -0,0 +1,8 @@
1
+ // types.node.js where Node does not run this: with no type stripping of Node's to hold oxc's tree to,
2
+ // no text is proven to hold types alone, and neither oxc nor anything else is loaded for it.
3
+
4
+ export function proveTypeOnly(text) {
5
+ if (typeof text !== 'string') throw new TypeError(`a text must be a string, not ${typeof text}`)
6
+ if (!text.isWellFormed()) throw new TypeError('a text must be a well-formed string')
7
+ return { proven: false, reason: 'unsupported', at: null }
8
+ }
@@ -0,0 +1,114 @@
1
+ // Whether a text holds types alone, proven on Node: Node runs a `.d.ts` it imports as the `.ts` it is
2
+ // named, what its type stripping leaves of it, so oxc's syntax tree and that stripping itself must each
3
+ // find nothing but types in it, the same to the character; what stripping leaves is read again apart from
4
+ // either, and so is what its transform mode (--experimental-transform-types) leaves. The portable twin,
5
+ // types.js, proves nothing and loads nothing. oxc-parser, an optional peer, is loaded on first use, and
6
+ // without it nothing is proven: compareTrees, which stamps declaration files, needs no peer.
7
+
8
+ import * as nodeModule from 'node:module'
9
+ import { getParser } from '#oxc'
10
+ import { CODE, ERASED, IMPORT, INERT, leftover, strippedAs } from './erasure.js'
11
+
12
+ // Each a fresh result, never a shared object a caller could mutate into another's.
13
+ const unsupported = () => ({ proven: false, reason: 'unsupported', at: null })
14
+ const refused = (reason, at) => ({ proven: false, reason, at })
15
+
16
+ // What stripping writes in place of what it erases is a blank of as many UTF-8 bytes (a space, a no-break
17
+ // space, an en space; a space and a byte order mark for four), unit for unit; line breaks and other blanks
18
+ // it keeps. Any blank but a line break is taken for one.
19
+ const WRITTEN = /[^\S\n\r\u2028\u2029]/u
20
+ const LINE_BREAK = /[\n\r\u2028\u2029]/u
21
+ const BLANK = /\s/u
22
+
23
+ // Node's stripping is one instance for the process, its loader of `.ts` files' too, and one stopped partway
24
+ // (a stack overflowed, execution terminated) is not trusted again: one that threw here anything but its
25
+ // refusal of a text, or that does not strip the canary as it should, as one stopped elsewhere may not.
26
+ const untrusted = new WeakSet()
27
+ const REFUSALS = new Set(['ERR_INVALID_TYPESCRIPT_SYNTAX', 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX'])
28
+ const CANARY = ['let a: "\u00E9\u4E00\u{1F600}" = 1 // c\n', 'let a \u00A0\u2002 \uFEFF = 1 // c\n']
29
+
30
+ function parser() {
31
+ try {
32
+ return getParser()
33
+ } catch {
34
+ return null
35
+ }
36
+ }
37
+
38
+ // Node's stripping in either mode: null where this Node has none, built without it.
39
+ function strip(stripTypeScriptTypes, text, mode) {
40
+ try {
41
+ return { code: stripTypeScriptTypes(text, { mode }) }
42
+ } catch (error) {
43
+ if (error?.code === 'ERR_NO_TYPESCRIPT') return null
44
+ if (!REFUSALS.has(error?.code)) untrusted.add(stripTypeScriptTypes)
45
+ return { error }
46
+ }
47
+ }
48
+
49
+ function trusted(stripTypeScriptTypes) {
50
+ if (!untrusted.has(stripTypeScriptTypes) && strip(stripTypeScriptTypes, CANARY[0], 'strip')?.code === CANARY[1]) return true
51
+ untrusted.add(stripTypeScriptTypes)
52
+ return false
53
+ }
54
+
55
+ // oxc's tree, or null where oxc throws rather than tells an error.
56
+ function parsed(parseSync, text) {
57
+ try {
58
+ return parseSync('file.ts', text, { lang: 'ts', sourceType: 'module' })
59
+ } catch {
60
+ return null
61
+ }
62
+ }
63
+
64
+ // 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 }) {
68
+ const kept = new Uint8Array(text.length)
69
+ for (const { start, end } of [...comments, ...inert, ...(program.hashbang ? [program.hashbang] : [])]) kept.fill(1, start, end)
70
+ for (const { start, end } of erased) kept.fill(0, start, end)
71
+ // A character stripping changed must become a blank, not a line break written over one; one it kept
72
+ // must be a blank, or lie in a span.
73
+ for (let i = 0; i < text.length; i++) {
74
+ if (code[i] === text[i] ? !kept[i] && !BLANK.test(code[i]) : !WRITTEN.test(code[i]) || LINE_BREAK.test(text[i])) return i
75
+ }
76
+ return -1
77
+ }
78
+
79
+ // proveTypeOnly with the stripping given: exported for tests alone, not by the package.
80
+ export function proveWith(text, stripTypeScriptTypes) {
81
+ if (typeof text !== 'string') throw new TypeError(`a text must be a string, not ${typeof text}`)
82
+ if (!text.isWellFormed()) throw new TypeError('a text must be a well-formed string')
83
+ const parseSync = parser()
84
+ if (typeof stripTypeScriptTypes !== 'function' || parseSync === null || !trusted(stripTypeScriptTypes)) return unsupported()
85
+ // Stripping drops a byte order mark the text starts with.
86
+ const shift = text.startsWith('\uFEFF') ? 1 : 0
87
+ const body = text.slice(shift)
88
+ // Stripping goes first: oxc's parser, native, overflows its stack on nesting deep enough and ends the
89
+ // process, where stripping throws first, at less than half the depth on an 8 MB stack. oxc reads only
90
+ // what stripping read whole: it either strips it, or refuses what it does not erase once it has read it all.
91
+ const stripped = strip(stripTypeScriptTypes, body, 'strip')
92
+ if (stripped === null) return unsupported()
93
+ if ('error' in stripped && stripped.error?.code !== 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX') return refused('unparsed', null)
94
+ const tree = parsed(parseSync, body)
95
+ if (tree === null) return refused('unparsed', null)
96
+ if (tree.errors.length > 0) return refused('unparsed', shift + (tree.errors[0].labels?.[0]?.start ?? 0))
97
+ const spans = { [ERASED]: [], [INERT]: [] }
98
+ for (const statement of tree.program.body) {
99
+ const kind = strippedAs(statement)
100
+ if (kind === IMPORT || kind === CODE) return refused(kind, shift + statement.start)
101
+ spans[kind].push(statement)
102
+ }
103
+ 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
106
+ if (left !== -1) return refused('disagree', shift + left)
107
+ const transformed = strip(stripTypeScriptTypes, body, 'transform')
108
+ 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)
112
+ }
113
+
114
+ export const proveTypeOnly = (text) => proveWith(text, nodeModule.stripTypeScriptTypes)