@preventive/compare 1.2.0 → 1.2.1
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 +64 -30
- package/package.json +1 -1
- package/src/changes.js +25 -14
- package/src/erasure.js +24 -6
- package/src/types.node.js +112 -13
package/compare.d.ts
CHANGED
|
@@ -36,15 +36,24 @@ 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
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
// a
|
|
46
|
-
//
|
|
47
|
-
//
|
|
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 or carries a bare `export {}` a rename across formats (a `.d.mts`
|
|
46
|
+
// to a `.d.cts`, either to a package-resolved `.d.ts` whose format is not known
|
|
47
|
+
// here) is not stamped; a hashbang, a directive or a pragma runs the same either
|
|
48
|
+
// way, so a runtime of only those cancels across formats. A modification keeps its
|
|
49
|
+
// path, hence its format, and an added or deleted file matches only an empty
|
|
50
|
+
// runtime. A rename is otherwise taken by its matching
|
|
51
|
+
// source, as any file's is here: a relative load may resolve to another module
|
|
52
|
+
// once the file moves, but that is shown as a rename of its own, not traced. A
|
|
53
|
+
// modification of a package-resolved `.d.ts` is taken to keep its format, though
|
|
54
|
+
// the `package.json` resolving it could change `type` between the trees and flip
|
|
55
|
+
// it -- a change seen in the diff on its own, as format is read here from the
|
|
56
|
+
// extension, not from a package. Absent off Node, and without oxc-parser.
|
|
48
57
|
export interface FileStatus {
|
|
49
58
|
status: Status
|
|
50
59
|
old: TreeFile | null
|
|
@@ -192,33 +201,58 @@ export function rootLabel(root: Root | null, prefix: string): { prefix: string;
|
|
|
192
201
|
// and is not asked of a stripping that once failed as may leave it unsound
|
|
193
202
|
// (`unsupported`); Node's stripping or oxc finds an error in it, as in
|
|
194
203
|
// `export const x: number`, which only a declaration file may write, or it
|
|
195
|
-
// nests too deep for either (`unparsed`); a statement
|
|
196
|
-
// module
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
+
// nests too deep for either (`unparsed`); a statement is neither erased nor a
|
|
205
|
+
// module load binding a type nor a bare `export {}`, as `export { type A }`,
|
|
206
|
+
// `export as namespace N` and a side-effect load that binds nothing are not
|
|
207
|
+
// (`code`); or Node's stripping, or a reading of what it leaves, does not find
|
|
208
|
+
// what oxc does (`disagree`).
|
|
209
|
+
export type TypeOnlyReason = 'unsupported' | 'unparsed' | 'code' | 'disagree'
|
|
210
|
+
|
|
211
|
+
// Whether `text`, read as TypeScript, holds types alone but for a `runtime` a
|
|
212
|
+
// change must leave byte for byte untouched, so that Node, which reads a `.d.ts`
|
|
213
|
+
// as the `.ts` it is named and runs what its type stripping leaves, runs only
|
|
214
|
+
// that of it:
|
|
204
215
|
// - oxc's tree has no statement but those stripping erases whole -- interfaces,
|
|
205
216
|
// type aliases, anything `declare`d (bodies and initializers with it), a
|
|
206
217
|
// function's signature, `import type` and `export type`, a namespace of types
|
|
207
|
-
// alone -- and
|
|
208
|
-
//
|
|
209
|
-
// `
|
|
210
|
-
//
|
|
211
|
-
//
|
|
218
|
+
// alone -- and those it leaves to run: a module load that binds from it
|
|
219
|
+
// (`import`, `export *`, `export { a } from` a module, `import { type A }` whose
|
|
220
|
+
// inline `type` loads it all the same) and a bare `export {}`. `export { type A }`
|
|
221
|
+
// is code, not a run statement: it is `export {}` only once stripping takes its
|
|
222
|
+
// specifiers out, not as the source has it; nor is `export as namespace N`, left
|
|
223
|
+
// as invalid JavaScript that throws when imported, nor a side-effect load that
|
|
224
|
+
// binds nothing -- a bare `import`, an empty `import {}`, `export {}` with a
|
|
225
|
+
// source -- which loads a module for no type;
|
|
212
226
|
// - Node's stripTypeScriptTypes erases, to the character, what oxc finds erased
|
|
213
|
-
// and leaves nothing it does not, but comments, the hashbang and
|
|
214
|
-
// - what it leaves, read token by token apart from
|
|
227
|
+
// and leaves nothing it does not, but comments, the hashbang and those;
|
|
228
|
+
// - what it leaves, the run statements set aside, read token by token apart from
|
|
229
|
+
// either, holds nothing else;
|
|
215
230
|
// - nor does what its transform mode (--experimental-transform-types) leaves.
|
|
216
|
-
// `
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
231
|
+
// `runtime` is built from those run statements and from the directive and pragma
|
|
232
|
+
// comments stripping keeps -- a `/// <reference ... />`, a `//# sourceMappingURL=`
|
|
233
|
+
// or `//# sourceURL=` and their legacy forms, comments a tool reads as more than prose --
|
|
234
|
+
// the hashbang first and the rest in source order, joined by newlines; empty where the
|
|
235
|
+
// file erases whole and carries none. A run statement is reduced: an inline type specifier
|
|
236
|
+
// stripping blanks within it (`import { type A, B }`), and any comment within it, each become
|
|
237
|
+
// a single blank, so a change to only those leaves `runtime` unchanged; its executable text and
|
|
238
|
+
// its module specifier are kept to the character, so a load of another module still differs.
|
|
239
|
+
// Known limit: that kept text is compared as written, so a change leaving a load runtime-identical
|
|
240
|
+
// yet reformatting it withholds the stamp rather than passing -- a removed trailing inline-type
|
|
241
|
+
// specifier leaving its comma (`{ B, }` where `{ B }` would stand), or a comment added where no
|
|
242
|
+
// blank sat. This errs safe: the stamp is never given wrongly, only sometimes withheld. A
|
|
243
|
+
// directive, a pragma and the hashbang stand as stripping leaves them, since what they say is the
|
|
244
|
+
// point. A triple-slash directive counts only in the prologue,
|
|
245
|
+
// before the first statement, where TypeScript honours it; below one it is prose, so moving
|
|
246
|
+
// it out of the prologue drops it from `runtime`, as removing it does. `loads` is whether any
|
|
247
|
+
// run statement is a module load or a bare `export {}`: the runtime that runs as one module
|
|
248
|
+
// format and throws, or emits a different marker, as the other -- a hashbang, directive or
|
|
249
|
+
// pragma does not. A change is types only where both sides prove and leave the same `runtime`:
|
|
250
|
+
// it then touches erased types and ordinary comments alone. Where `loads`, a rename across
|
|
251
|
+
// module formats does not cancel (see compareTrees' `typeOnly`); a runtime without it runs the
|
|
252
|
+
// same either way and cancels across them. `at` is where in `text` what is found
|
|
253
|
+
// begins, where it has a place. Throws a TypeError where `text` is not a
|
|
220
254
|
// well-formed string. Node warns once that stripTypeScriptTypes is experimental.
|
|
221
|
-
export function proveTypeOnly(text: string): { proven: true;
|
|
255
|
+
export function proveTypeOnly(text: string): { proven: true; runtime: string; loads: boolean } | { proven: false; reason: TypeOnlyReason; at: number | null }
|
|
222
256
|
|
|
223
257
|
// `path` is the name at fault as its entry spelled it, and `tree` which tree
|
|
224
258
|
// holds it.
|
package/package.json
CHANGED
package/src/changes.js
CHANGED
|
@@ -49,15 +49,18 @@ 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 {}`
|
|
53
|
-
//
|
|
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
|
|
57
|
-
//
|
|
58
|
-
//
|
|
57
|
+
// A file's proof: that it holds types alone but for its `runtime` -- a hashbang, the module loads and
|
|
58
|
+
// `export {}` stripping leaves -- which a change must leave byte for byte untouched to be types only.
|
|
59
|
+
// `loads` is whether that runtime holds a module load or `export {}`, the part that runs differently per
|
|
60
|
+
// module format; a hashbang, a directive or a pragma does not, so a runtime of only those is format-free. A
|
|
61
|
+
// side not there has none. Not proven where the proof throws, as no check should.
|
|
59
62
|
function proven(file) {
|
|
60
|
-
if (file === null) return { proven: true,
|
|
63
|
+
if (file === null) return { proven: true, runtime: '', loads: false }
|
|
61
64
|
const text = textOf(file)
|
|
62
65
|
try {
|
|
63
66
|
return text === undefined ? { proven: false } : proveTypeOnly(text)
|
|
@@ -66,19 +69,27 @@ function proven(file) {
|
|
|
66
69
|
}
|
|
67
70
|
}
|
|
68
71
|
|
|
69
|
-
// Stamped where each side there is a
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
// module format
|
|
73
|
-
//
|
|
72
|
+
// Stamped where each side there is a declaration file proven to hold types alone and the two leave the
|
|
73
|
+
// same runtime -- a change touching only erased types (and comments). A mode changing is a change of its
|
|
74
|
+
// own. Where that runtime loads a module or carries a bare `export {}` it runs the same on both sides only
|
|
75
|
+
// within one module format, so a rename across formats (a `.d.mts` to a `.d.cts`, either to a
|
|
76
|
+
// package-resolved `.d.ts`) does not qualify; a hashbang, a directive or a pragma runs the same either way,
|
|
77
|
+
// so a runtime of only those cancels across formats. A modification keeps its path, hence its format, and
|
|
78
|
+
// an added or deleted file matches only an empty one.
|
|
79
|
+
// A rename is taken by its matching source, as any file's is here: a relative load in the runtime may
|
|
80
|
+
// resolve to another module once the file moves, but that is the rename's doing, shown as a rename of its
|
|
81
|
+
// own, not traced. Known limitation: a modification of a package-resolved `.d.ts` is taken to keep its
|
|
82
|
+
// format, though the `package.json` that resolves it could change `type` between the two trees and flip it;
|
|
83
|
+
// that flip is a change to `package.json`, seen in the diff on its own, and format is read from the
|
|
84
|
+
// extension here, not resolved from a package.
|
|
85
|
+
|
|
74
86
|
function stamped(change) {
|
|
75
87
|
const sides = [change.old, change.new].filter((file) => file !== null)
|
|
76
88
|
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
89
|
if (change.old && change.new && change.old.mode !== change.new.mode) return change
|
|
79
90
|
const fresh = proven(change.new), old = proven(change.old)
|
|
80
|
-
if (!old.proven || !fresh.proven ||
|
|
81
|
-
if (old.
|
|
91
|
+
if (!old.proven || !fresh.proven || old.runtime !== fresh.runtime) return change
|
|
92
|
+
if (old.loads && change.old && change.new && change.old.path !== change.new.path) {
|
|
82
93
|
const format = fixedModule(change.old.path)
|
|
83
94
|
if (format === null || format !== fixedModule(change.new.path)) return change
|
|
84
95
|
}
|
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`);
|
|
8
|
-
// code -- `export as namespace N
|
|
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':
|
|
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,7 +68,9 @@ 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
|
-
|
|
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
75
|
case 'ExportDefaultDeclaration': return statement.declaration.type === 'TSInterfaceDeclaration' || statement.declaration.type === 'TSDeclareFunction' ? ERASED : CODE
|
|
58
76
|
// `export as namespace N` is left verbatim, invalid as JavaScript, so importing the file throws.
|
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,
|
|
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
|
|
66
|
-
//
|
|
67
|
-
|
|
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, ...
|
|
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
|
-
|
|
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 ===
|
|
101
|
-
|
|
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,
|
|
105
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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 or
|
|
198
|
+
// `export {}`, the runtime that runs differently per module format -- a hashbang, directive or pragma does
|
|
199
|
+
// 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)
|