Repository navigation
Expand file tree
/
Copy pathjavascript-module-utils.ts
More file actions
263 lines (251 loc) · 11.1 KB
/
Copy pathjavascript-module-utils.ts
File metadata and controls
263 lines (251 loc) · 11.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
import { JS_COMPONENT_EXTENSIONS, JS_SOURCE_EXTENSIONS } from '@/constants/javascript-constants';
/**
* What counts as a platform module, in one place.
*
* ## Three copies of this list was the real risk
*
* `js-module-edge-extractor` used it to decide
* `resolutionOutcome = RESOLVED_BUILTIN`, `js-expression-extractor` to decide
* `receiverTypeSource = NODE_BUILTIN`, and `js-ir-completeness` reads the
* results of both. They were byte-identical when this was extracted — checked,
* not assumed — and nothing would have reported it if one had gained an entry
* and the others had not.
*
* The failure that drift produces is not a missing row. It is a call site
* saying `AMBIENT_BUILTIN_TARGET` while its own import row says
* `RESOLVED_EXTERNAL`: two relations disagreeing about the same edge, both
* populated, both plausible.
*
* ## Why the list decides something that matters
*
* **24.4% of the oracle's own declines** are calls whose receiver came from a
* Node builtin with no ambient declarations. That is the single largest
* environmental class in JavaScript — larger than missing `node_modules`, which
* is 1.5% — and `js-oracle` classifies it as *environmental but unfixable*:
* nothing installable in the repository under analysis supplies those
* declarations.
*
* So the list is not a convenience. It is what separates "the engine should look
* in `lib_*`" from "this package was not installed", and those are different
* instructions to a consumer.
*
* ## Recognised by NAME, never resolved
*
* Resolving `path` needs `@types/node` present, and its **absence is the
* measured condition** — `js-oracle` moved one web framework from 12.9% to 21.9% resolved
* by forcing `types: ["node"]`. A parser whose classification depends on whether
* someone installed a types package reports a property of the machine.
*/
/**
* Node's own modules, as of Node 22.
*
* Deliberately a closed list rather than a pattern. A new builtin is a Node
* release, which is a decision someone should make explicitly — and a pattern
* loose enough to catch future builtins is loose enough to catch an npm package
* named `fsx`.
*/
const NODE_BUILTIN_MODULES: ReadonlySet<string> = new Set([
'assert', 'async_hooks', 'buffer', 'child_process', 'cluster', 'console',
'constants', 'crypto', 'dgram', 'diagnostics_channel', 'dns', 'domain',
'events', 'fs', 'http', 'http2', 'https', 'inspector', 'module', 'net', 'os',
'path', 'perf_hooks', 'process', 'punycode', 'querystring', 'readline', 'repl',
'stream', 'string_decoder', 'timers', 'tls', 'trace_events', 'tty', 'url',
'util', 'v8', 'vm', 'wasi', 'worker_threads', 'zlib',
]);
/**
* Is this specifier a Node builtin?
*
* Handles both spellings and the subpath form: `node:fs`, `fs`, and
* `fs/promises` are all the same module for this purpose, because the
* declarations that are missing are missing for all three.
*/
export function isNodeBuiltinSpecifier(specifier: string): boolean {
if (specifier.startsWith('node:')) {
return true;
}
return NODE_BUILTIN_MODULES.has(specifier.split('/')[0] ?? specifier);
}
/** The set itself, for a caller that needs to enumerate rather than test. */
export function nodeBuiltinModules(): ReadonlySet<string> {
return NODE_BUILTIN_MODULES;
}
/**
* A relative specifier: `./x`, `../x`.
*
* Worth its own predicate because its failure mode is the opposite of every
* other specifier's. A bare package specifier that does not resolve is environmental —
* the package is not installed. **A relative path that does not resolve is a
* real broken edge**, and `js-oracle` measured three of them where it had first
* reported zero. A consumer that cannot tell the two apart classifies a genuine
* defect as a checkout property.
*/
export function isRelativeSpecifier(specifier: string): boolean {
return specifier.startsWith('./') || specifier.startsWith('../');
}
/**
* The JavaScript extension a file name carries, ignoring a `.flow` suffix.
*
* ## `path.extname` returns the LAST part, and JavaScript is full of names with more
*
* This is the general form of a trap that has now bitten four different call
* sites. `path.extname('a.js.flow')` is `.flow` — not `.js` — so:
*
* - the project walker tested `JS_SOURCE_EXTENSIONS.includes(extname(name))` and
* **never discovered a single Flow declaration file**, which made the
* extension half of the Flow detector unreachable code that read as supported;
* - `package-json-resolver` tests `extname === '.mjs' || '.cjs'`, so
* `a.cjs.flow` misses the module-system override that `.cjs` is supposed to
* make TOTAL;
* - `stemOf` and `stripExtension` both cut at the last dot, so `a.js.flow`
* produced `name = "a.js"` and `qualifiedName = "a.js"` — a name column
* carrying an extension.
*
* And fixing `.js.flow` by NAME rather than by class left the same hole one
* layer out: `b.cjs.flow` was still never discovered, because the list held the
* literal string `.js.flow` and nothing else. Flow's convention is
* `<name>.<ext>.flow` shadowing `<name>.<ext>`, so `.cjs.flow`, `.mjs.flow` and
* `.jsx.flow` are all legal and all were invisible.
*
* So the question every site actually wants to ask is this one: **what is this
* file's JavaScript extension**, with a `.flow` suffix stripped first. One
* function, and the multi-part handling happens once.
*/
export function jsExtensionOf(fileName: string): string {
const name = fileName.toLowerCase();
const base = name.endsWith('.flow') ? name.slice(0, -'.flow'.length) : name;
const dot = base.lastIndexOf('.');
const extension = dot < 0 ? '' : base.slice(dot);
// A JAVASCRIPT extension or nothing, because that is what the name promises.
//
// It first returned whatever the last part was, which made
// `stripJsExtension('a.ts')` return `a` — cutting an extension off a file that
// has no JavaScript extension to cut. Caught by this util's own behaviour
// table, which is the argument for having written one: the function was in use
// at eight call sites and every one of them happened to pass a `.js` family
// name, so nothing else would ever have shown it.
if ((JS_SOURCE_EXTENSIONS as readonly string[]).includes(extension)) {
return extension;
}
// A component has no `.flow` spelling: `a.vue.flow` is not a declaration file.
return base === name && (JS_COMPONENT_EXTENSIONS as readonly string[]).includes(extension)
? extension
: '';
}
/** Is this a single-file component (`.vue`, `.svelte`, `.astro`), by name? */
export function isJsComponentFile(fileName: string): boolean {
return (JS_COMPONENT_EXTENSIONS as readonly string[]).includes(jsExtensionOf(fileName));
}
/** One `<script>` block of a single-file component, as {@link sfcScriptText} read it. */
export interface SfcScriptBlock {
/** 1-based line of the block's opening tag. */
readonly line: number;
/** The `lang` attribute, lower-cased; '' when there is none. */
readonly lang: string;
/** Whether its body was kept as JavaScript. */
readonly kept: boolean;
}
/**
* The JavaScript of a single-file component, at the offsets it has on disk.
*
* Every character outside a JavaScript `<script>` body (and, for `.astro`, the
* leading `---` frontmatter fence) becomes a space; line breaks are kept. The
* text is the same length as the file, so every line and column the extractor
* records is the file's own, and a reader opening `Price.vue:6` sees the call.
*
* A block whose `lang` is not JavaScript (`ts`, `typescript`, `coffee`) is
* blanked like markup: the JavaScript front end cannot read it, and parsing it
* as JavaScript would invent a wrong tree rather than an honest absence. The
* blocks come back so the caller can say what was left out. `<script>` inside
* an HTML comment is markup too.
*/
export function sfcScriptText(
fileName: string,
text: string
): { text: string; blocks: SfcScriptBlock[] } {
const keep: Array<[number, number]> = [];
const blocks: SfcScriptBlock[] = [];
const lineAt = (offset: number): number => text.slice(0, offset).split('\n').length;
let from = 0;
if (jsExtensionOf(fileName) === '.astro') {
// The frontmatter: a `---` line before anything else but whitespace, up to the next `---` line.
const open = /^\s*---[ \t]*\r?\n/.exec(text);
if (open !== null) {
const bodyStart = open[0].length;
const close = /^---[ \t]*$/m.exec(text.slice(bodyStart));
if (close !== null) {
keep.push([bodyStart, bodyStart + close.index]);
from = bodyStart + close.index + close[0].length;
blocks.push({ line: lineAt(open.index), lang: '', kept: true });
}
}
}
const tag = /<!--[\s\S]*?-->|<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi;
tag.lastIndex = from;
for (let match = tag.exec(text); match !== null; match = tag.exec(text)) {
if (match[0].startsWith('<!--')) {
continue;
}
const attributes = match[1] ?? '';
const lang = (/\blang\s*=\s*["']?([\w-]+)/i.exec(attributes)?.[1] ?? '').toLowerCase();
const type = (/\btype\s*=\s*["']?([\w/+.-]+)/i.exec(attributes)?.[1] ?? '').toLowerCase();
const javascript = ['', 'js', 'javascript', 'jsx'].includes(lang)
&& ['', 'module', 'text/javascript', 'application/javascript'].includes(type);
const bodyStart = match.index + match[0].indexOf('>') + 1;
const bodyEnd = bodyStart + (match[2] ?? '').length;
if (javascript) {
keep.push([bodyStart, bodyEnd]);
}
blocks.push({ line: lineAt(match.index), lang: lang || type, kept: javascript });
}
const out: string[] = [];
let at = 0;
const blank = (end: number): void => {
out.push(text.slice(at, end).replace(/[^\r\n]/g, ' '));
at = end;
};
for (const [start, end] of keep) {
blank(start);
out.push(text.slice(start, end));
at = end;
}
blank(text.length);
return { text: out.join(''), blocks };
}
/**
* Is this file name JavaScript source the analyzer walks?
*
* Includes Flow declaration files, which are walked in order to be DECLINED —
* a recorded rejection rather than a silent absence. See {@link jsExtensionOf}
* for why this cannot be `path.extname`.
*/
export function isJavaScriptSourceFile(fileName: string): boolean {
return jsExtensionOf(fileName) !== '';
}
/**
* Is this a Flow DECLARATION file, by name alone?
*
* `<name>.<ext>.flow` — Flow's convention for a file that declares the types of
* the sibling it shadows. The name is the declaration, so these commonly carry
* no `@flow` pragma at all and the extension is the only signal.
*/
export function isFlowDeclarationFileName(fileName: string): boolean {
return fileName.toLowerCase().endsWith('.flow')
&& isJavaScriptSourceFile(fileName);
}
/**
* The file name with its JavaScript extension removed, `.flow` included.
*
* `a.js` and `a.js.flow` both stem to `a`. Cutting at the last dot gave `a.js`
* for the second, putting an extension inside a `name` column.
*/
export function stripJsExtension(fileName: string): string {
const extension = jsExtensionOf(fileName);
if (extension === '') {
return fileName;
}
const lower = fileName.toLowerCase();
const cut = lower.endsWith('.flow')
? lower.length - '.flow'.length - extension.length
: lower.length - extension.length;
return fileName.slice(0, cut);
}