@dependably/npm-check 1.9.0 → 1.10.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/README.md +47 -0
- package/bin/cli.js +93 -1
- package/package.json +20 -3
- package/src/audit-config.js +98 -68
- package/src/audit.js +198 -58
- package/src/exceptions.js +82 -66
- package/src/facts/collect.js +201 -0
- package/src/facts/document.js +151 -0
- package/src/facts/errors.js +19 -0
- package/src/facts/index.js +37 -0
- package/src/facts/lockfile-graph.js +467 -0
- package/src/facts/modulegraph.js +289 -0
- package/src/facts/resolve.js +493 -0
- package/src/facts/scan.js +755 -0
- package/src/facts/sourcescan.js +221 -0
- package/src/facts/specifier.js +89 -0
- package/src/facts/ts.js +47 -0
- package/src/facts/types.d.ts +579 -0
- package/src/facts/version.js +16 -0
- package/src/facts/workspace.js +278 -0
- package/src/npmrc-validator.js +2 -1
- package/src/pnpm-workspace-validator.js +8 -2
- package/src/report.js +12 -6
- package/src/schema.js +54 -0
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
// src/facts/scan.js
|
|
2
|
+
// Parse-only import scan of ONE source file with the TypeScript compiler API:
|
|
3
|
+
// every module reference (import / export-from / import-equals / require() /
|
|
4
|
+
// dynamic import()) with its line and snippet, the binding names each site
|
|
5
|
+
// introduces, which of those the module body actually references, and whether
|
|
6
|
+
// the site's binding escapes in a way a parse-level scan cannot follow
|
|
7
|
+
// (`opaque`). Language facts only — nothing here knows what a package IS.
|
|
8
|
+
//
|
|
9
|
+
// Ported verbatim from sbom-reach's `packages/analyzer-npm/src/scan.ts`; the
|
|
10
|
+
// invariants that travel with it (see CLAUDE.md, "Import facts"):
|
|
11
|
+
// - over-report USE, never under-report it: any occurrence of a binding
|
|
12
|
+
// counts as "referenced" (call, argument, spread, shorthand property), and
|
|
13
|
+
// shadowing locals are deliberately NOT excluded;
|
|
14
|
+
// - `opaque` is fail-safe: a binding this scan cannot follow "could use
|
|
15
|
+
// anything", so a consumer must never read an opaque site as evidence that
|
|
16
|
+
// some symbol was NOT used;
|
|
17
|
+
// - a `.svelte` file's extraction problems are REPORTED (`parseErrors`), so
|
|
18
|
+
// the absence of evidence from that file is never silently read as absence.
|
|
19
|
+
import { loadTypeScript } from './ts.js';
|
|
20
|
+
|
|
21
|
+
/** @typedef {import('./types.d.ts').ImportKind} ImportKind */
|
|
22
|
+
/** @typedef {import('./types.d.ts').ImportSite} ImportSite */
|
|
23
|
+
/** @typedef {import('./types.d.ts').ScanResult} ScanResult */
|
|
24
|
+
/** @typedef {import('typescript').Node} TsNode */
|
|
25
|
+
/** @typedef {import('typescript').SourceFile} TsSourceFile */
|
|
26
|
+
/** @typedef {import('typescript').Identifier} TsIdentifier */
|
|
27
|
+
|
|
28
|
+
// `export` is in the prefilter on purpose: a pure re-export barrel
|
|
29
|
+
// (`export * from './main.js'`) has no `import`/`require` substring, and the
|
|
30
|
+
// module-graph walk must follow it or the whole package behind it goes dark —
|
|
31
|
+
// svelte-eslint-parser's entry is exactly two `export … from` lines.
|
|
32
|
+
const PREFILTER = /import|require|export/;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse-only scan of one source file with the TypeScript compiler: collects
|
|
36
|
+
* import declarations, `export … from`, `import x = require()`, and
|
|
37
|
+
* string-literal `require()` / dynamic `import()` calls. No type checker,
|
|
38
|
+
* no ts.Program — this is the fast path.
|
|
39
|
+
*
|
|
40
|
+
* Binding collection runs as two extra passes over the same already-parsed
|
|
41
|
+
* tree, both still parse-only:
|
|
42
|
+
* - Pass 1 (folded into the declaration walk): records bindings visible
|
|
43
|
+
* directly at the declaration (named imports/exports, require()/import()
|
|
44
|
+
* destructuring) and registers the local identifier of every
|
|
45
|
+
* default/namespace-style binding for pass 2 to resolve.
|
|
46
|
+
* - Pass 2: walks the whole tree again looking for uses of those tracked
|
|
47
|
+
* identifiers, resolving one level of property access to a binding name
|
|
48
|
+
* and flagging anything else that escapes this scan's visibility as
|
|
49
|
+
* `opaque` on that import site (fail-safe: opaque usage "could use
|
|
50
|
+
* anything").
|
|
51
|
+
*
|
|
52
|
+
* @param {string} fileName - used for the script kind (`.tsx`, `.svelte`, …) and nothing else
|
|
53
|
+
* @param {string} content
|
|
54
|
+
* @returns {ScanResult}
|
|
55
|
+
*/
|
|
56
|
+
export function scanSource(fileName, content) {
|
|
57
|
+
const ts = loadTypeScript();
|
|
58
|
+
/** @type {ImportSite[]} */
|
|
59
|
+
const sites = [];
|
|
60
|
+
let dynamicUnknown = 0;
|
|
61
|
+
|
|
62
|
+
const isSvelte = fileName.endsWith('.svelte');
|
|
63
|
+
const svelteExtraction = isSvelte ? extractSvelteScript(content) : undefined;
|
|
64
|
+
const parseText = svelteExtraction ? svelteExtraction.code : content;
|
|
65
|
+
|
|
66
|
+
// `.svelte` files must never take the prefilter fast path: a mis-detected
|
|
67
|
+
// script boundary can extract text with no `import`/`require` substring
|
|
68
|
+
// left in it at all (the failure mode this prefilter exists to skip past
|
|
69
|
+
// quickly is indistinguishable, at the text level, from "extraction ate
|
|
70
|
+
// the whole script"). Skipping `ts.createSourceFile` here would also skip
|
|
71
|
+
// `collectSvelteParseErrors` below, silencing the one signal that catches
|
|
72
|
+
// that failure. `.svelte` files are source components, not bundles, so the
|
|
73
|
+
// performance case for the fast path doesn't apply the same way it does
|
|
74
|
+
// for large plain `.ts`/`.js` files.
|
|
75
|
+
if (!isSvelte && !PREFILTER.test(parseText)) return { sites, dynamicUnknown };
|
|
76
|
+
|
|
77
|
+
const sourceFile = ts.createSourceFile(
|
|
78
|
+
fileName,
|
|
79
|
+
parseText,
|
|
80
|
+
ts.ScriptTarget.Latest,
|
|
81
|
+
/* setParentNodes */ false,
|
|
82
|
+
svelteExtraction ? (svelteExtraction.hasTs ? ts.ScriptKind.TS : ts.ScriptKind.JS) : scriptKindOf(ts, fileName)
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// `.svelte` script extraction is regex-based, not a real HTML parser: a
|
|
86
|
+
// mis-detected script boundary feeds the TS parser text it can't make
|
|
87
|
+
// sense of (e.g. leftover markup), and that shows up here as syntax
|
|
88
|
+
// errors even though `scanSource` itself never throws. `parseDiagnostics`
|
|
89
|
+
// isn't part of the public `ts.SourceFile` typings, but TypeScript always
|
|
90
|
+
// populates it during `createSourceFile` -- it's the standard way to get
|
|
91
|
+
// syntactic-only diagnostics without building a full `ts.Program`.
|
|
92
|
+
const tsParseErrors = isSvelte ? collectSvelteParseErrors(ts, sourceFile) : undefined;
|
|
93
|
+
|
|
94
|
+
// Second, independent backstop: a script swallowed whole by a false
|
|
95
|
+
// comment match (or left dangling by an unterminated `<script>`/`<!--`)
|
|
96
|
+
// leaves nothing malformed behind for `tsParseErrors` to catch -- the
|
|
97
|
+
// extracted text for that stretch is just whitespace, which is valid
|
|
98
|
+
// (empty) TypeScript. `extractSvelteScript` tracks every span of the raw
|
|
99
|
+
// file it genuinely accounted for as it runs, and reports any
|
|
100
|
+
// `<script`/`</script>`-shaped text left outside every one of those spans
|
|
101
|
+
// -- that is exactly the silent-loss shape this backstop exists to catch.
|
|
102
|
+
const parseErrors = [...(svelteExtraction ? svelteExtraction.lostScriptWarnings : []), ...(tsParseErrors ?? [])];
|
|
103
|
+
|
|
104
|
+
/** @param {number} pos */
|
|
105
|
+
const lineOf = (pos) => sourceFile.getLineAndCharacterOfPosition(pos).line + 1;
|
|
106
|
+
/** @param {TsNode} node */
|
|
107
|
+
const snippetOf = (node) => {
|
|
108
|
+
const text = node.getText(sourceFile).replace(/\s+/g, ' ').trim();
|
|
109
|
+
return text.length > 200 ? `${text.slice(0, 199)}…` : text;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// --- binding-collection bookkeeping (site index -> discovered state) ---
|
|
113
|
+
/** Local identifier (default/namespace import, import-equals, or a
|
|
114
|
+
* `const x = require(...)`-style single-identifier binding) -> the site
|
|
115
|
+
* index pass 2 should attribute its property-access/opaque findings to.
|
|
116
|
+
* @type {Map<string, number>} */
|
|
117
|
+
const trackedIdentifiers = new Map();
|
|
118
|
+
/** The exact declaration-occurrence node for each tracked identifier, so
|
|
119
|
+
* pass 2 never mistakes the declaration itself for a "use".
|
|
120
|
+
* @type {Set<TsNode>} */
|
|
121
|
+
const declarationNodes = new Set();
|
|
122
|
+
/** @type {Map<number, Set<string>>} */
|
|
123
|
+
const bindingSets = new Map();
|
|
124
|
+
/** @type {Map<number, Set<string>>} */
|
|
125
|
+
const referencedSets = new Map();
|
|
126
|
+
/** @type {Set<number>} */
|
|
127
|
+
const opaqueSiteIdx = new Set();
|
|
128
|
+
/** Local identifier of a named import / destructured require -> EVERY site
|
|
129
|
+
* (and original export name) that binds that local, so pass 2 can record
|
|
130
|
+
* references to it. A list, not one entry: two function bodies can each
|
|
131
|
+
* destructure the same name from different packages, and a reference is
|
|
132
|
+
* then credited to both (over-counting, the loud direction) rather than
|
|
133
|
+
* to whichever declaration came last.
|
|
134
|
+
* @type {Map<string, { idx: number; original: string }[]>} */
|
|
135
|
+
const namedLocals = new Map();
|
|
136
|
+
|
|
137
|
+
/** @param {number} idx @param {string} name */
|
|
138
|
+
const addBinding = (idx, name) => {
|
|
139
|
+
const set = bindingSets.get(idx) ?? new Set();
|
|
140
|
+
set.add(name);
|
|
141
|
+
bindingSets.set(idx, set);
|
|
142
|
+
};
|
|
143
|
+
/** @param {number} idx @param {string} name */
|
|
144
|
+
const addReferenced = (idx, name) => {
|
|
145
|
+
const set = referencedSets.get(idx) ?? new Set();
|
|
146
|
+
set.add(name);
|
|
147
|
+
referencedSets.set(idx, set);
|
|
148
|
+
};
|
|
149
|
+
/** A named binding: recorded as imported now, and as referenced by pass 2
|
|
150
|
+
* if its local identifier shows up anywhere in the body.
|
|
151
|
+
* @param {number} idx @param {string} original @param {TsNode} local */
|
|
152
|
+
const addNamedBinding = (idx, original, local) => {
|
|
153
|
+
addBinding(idx, original);
|
|
154
|
+
if (ts.isIdentifier(local)) {
|
|
155
|
+
const list = namedLocals.get(local.text) ?? [];
|
|
156
|
+
list.push({ idx, original });
|
|
157
|
+
namedLocals.set(local.text, list);
|
|
158
|
+
declarationNodes.add(local);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* @param {import('typescript').Expression | undefined} specNode
|
|
164
|
+
* @param {TsNode} node
|
|
165
|
+
* @param {ImportKind} kind
|
|
166
|
+
* @returns {number | undefined}
|
|
167
|
+
*/
|
|
168
|
+
const add = (specNode, node, kind) => {
|
|
169
|
+
if (!specNode || !ts.isStringLiteralLike(specNode)) return undefined;
|
|
170
|
+
const idx = sites.length;
|
|
171
|
+
sites.push({
|
|
172
|
+
specifier: specNode.text,
|
|
173
|
+
line: lineOf(node.getStart(sourceFile)),
|
|
174
|
+
snippet: snippetOf(node),
|
|
175
|
+
kind
|
|
176
|
+
});
|
|
177
|
+
return idx;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* @param {{ propertyName?: TsNode; name: TsNode }} el
|
|
182
|
+
* @returns {string | undefined}
|
|
183
|
+
*/
|
|
184
|
+
const originalName = (el) => {
|
|
185
|
+
const n = el.propertyName ?? el.name;
|
|
186
|
+
return ts.isIdentifier(n) ? n.text : undefined;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/** Registers destructuring/property-chaining off a `require()`/awaited
|
|
190
|
+
* `import()` call result: `const { a, b: c } = require('x')`,
|
|
191
|
+
* `const pkg = require('x')`, `require('x').foo()`. Anything else the
|
|
192
|
+
* call result feeds into (an argument, a larger expression, a bare
|
|
193
|
+
* expression statement) is left alone rather than guessed at.
|
|
194
|
+
* @param {number} idx @param {TsNode} callNode @param {TsNode | undefined} parent */
|
|
195
|
+
const trackCallResult = (idx, callNode, parent) => {
|
|
196
|
+
if (parent && ts.isVariableDeclaration(parent) && parent.initializer === callNode) {
|
|
197
|
+
if (ts.isIdentifier(parent.name)) {
|
|
198
|
+
trackedIdentifiers.set(parent.name.text, idx);
|
|
199
|
+
declarationNodes.add(parent.name);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (ts.isObjectBindingPattern(parent.name)) {
|
|
203
|
+
for (const el of parent.name.elements) {
|
|
204
|
+
if (el.dotDotDotToken) {
|
|
205
|
+
opaqueSiteIdx.add(idx); // rest destructure: could grab anything.
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const name = originalName(el);
|
|
209
|
+
if (name !== undefined) addNamedBinding(idx, name, el.name);
|
|
210
|
+
}
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
// Array binding pattern or another shape we don't resolve.
|
|
214
|
+
opaqueSiteIdx.add(idx);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (parent && ts.isPropertyAccessExpression(parent) && parent.expression === callNode && !parent.questionDotToken) {
|
|
218
|
+
addBinding(idx, parent.name.text);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (parent && ts.isExpressionStatement(parent)) {
|
|
222
|
+
return; // side-effect-only require('x'); nothing consumed.
|
|
223
|
+
}
|
|
224
|
+
// Any other shape (argument to a call, part of a larger expression,
|
|
225
|
+
// etc.) is unresolvable at parse level — fail-safe opaque.
|
|
226
|
+
opaqueSiteIdx.add(idx);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @param {TsNode} node
|
|
231
|
+
* @param {TsNode | undefined} parent
|
|
232
|
+
* @param {TsNode | undefined} grandparent
|
|
233
|
+
*/
|
|
234
|
+
const visit = (node, parent, grandparent) => {
|
|
235
|
+
if (ts.isImportDeclaration(node)) {
|
|
236
|
+
const typeOnly = node.importClause?.isTypeOnly === true;
|
|
237
|
+
const idx = add(node.moduleSpecifier, node, typeOnly ? 'type-only-import' : 'import');
|
|
238
|
+
// Type-only imports never bind a runtime value; nothing here can be
|
|
239
|
+
// "called", so binding collection is skipped for them.
|
|
240
|
+
if (idx !== undefined && node.importClause && !typeOnly) {
|
|
241
|
+
const clause = node.importClause;
|
|
242
|
+
if (clause.name) {
|
|
243
|
+
trackedIdentifiers.set(clause.name.text, idx);
|
|
244
|
+
declarationNodes.add(clause.name);
|
|
245
|
+
}
|
|
246
|
+
if (clause.namedBindings) {
|
|
247
|
+
if (ts.isNamespaceImport(clause.namedBindings)) {
|
|
248
|
+
trackedIdentifiers.set(clause.namedBindings.name.text, idx);
|
|
249
|
+
declarationNodes.add(clause.namedBindings.name);
|
|
250
|
+
} else if (ts.isNamedImports(clause.namedBindings)) {
|
|
251
|
+
for (const el of clause.namedBindings.elements) {
|
|
252
|
+
const name = originalName(el);
|
|
253
|
+
if (name !== undefined) addNamedBinding(idx, name, el.name);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
} else if (ts.isExportDeclaration(node) && node.moduleSpecifier) {
|
|
259
|
+
const idx = add(node.moduleSpecifier, node, node.isTypeOnly ? 'type-only-import' : 'export-from');
|
|
260
|
+
if (idx !== undefined && !node.isTypeOnly) {
|
|
261
|
+
if (!node.exportClause) {
|
|
262
|
+
// `export * from 'mod'`: a bare namespace re-export — opaque, no
|
|
263
|
+
// local binding name is even syntactically available.
|
|
264
|
+
opaqueSiteIdx.add(idx);
|
|
265
|
+
} else if (ts.isNamedExports(node.exportClause)) {
|
|
266
|
+
for (const el of node.exportClause.elements) {
|
|
267
|
+
const name = originalName(el);
|
|
268
|
+
if (name !== undefined) {
|
|
269
|
+
addBinding(idx, name);
|
|
270
|
+
addReferenced(idx, name); // handed straight to this module's importers
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
// `export * as ns from 'mod'`: still a namespace merge with no
|
|
275
|
+
// property-access evidence available at the declaration itself.
|
|
276
|
+
opaqueSiteIdx.add(idx);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
} else if (ts.isImportEqualsDeclaration(node)) {
|
|
280
|
+
if (ts.isExternalModuleReference(node.moduleReference)) {
|
|
281
|
+
const idx = add(node.moduleReference.expression, node, 'require');
|
|
282
|
+
if (idx !== undefined) {
|
|
283
|
+
trackedIdentifiers.set(node.name.text, idx);
|
|
284
|
+
declarationNodes.add(node.name);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
} else if (ts.isCallExpression(node)) {
|
|
288
|
+
const callee = node.expression;
|
|
289
|
+
const isRequire = ts.isIdentifier(callee) && callee.text === 'require';
|
|
290
|
+
const isDynamicImport = callee.kind === ts.SyntaxKind.ImportKeyword;
|
|
291
|
+
if (isRequire || isDynamicImport) {
|
|
292
|
+
const arg = node.arguments[0];
|
|
293
|
+
if (arg && ts.isStringLiteralLike(arg)) {
|
|
294
|
+
const idx = add(arg, node, isRequire ? 'require' : 'dynamic-import');
|
|
295
|
+
if (idx !== undefined) {
|
|
296
|
+
if (isRequire) {
|
|
297
|
+
trackCallResult(idx, node, parent);
|
|
298
|
+
} else {
|
|
299
|
+
// Dynamic import() returns a Promise; the only statically
|
|
300
|
+
// visible destructuring shape is `await import('x')`, so
|
|
301
|
+
// unwrap exactly one AwaitExpression before applying the same
|
|
302
|
+
// destructure/property-chain resolution.
|
|
303
|
+
const effectiveParent = parent && ts.isAwaitExpression(parent) ? grandparent : parent;
|
|
304
|
+
trackCallResult(idx, parent && ts.isAwaitExpression(parent) ? parent : node, effectiveParent);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
dynamicUnknown++;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
ts.forEachChild(node, (child) => visit(child, node, parent));
|
|
313
|
+
};
|
|
314
|
+
visit(sourceFile, undefined, undefined);
|
|
315
|
+
|
|
316
|
+
// Pass 2: resolve one level of property access (or flag opaque escape)
|
|
317
|
+
// for every tracked default/namespace-style identifier, over the whole
|
|
318
|
+
// tree. A second pass (rather than folding into the walk above) is
|
|
319
|
+
// deliberate: a use can textually precede its declaration's processing
|
|
320
|
+
// order in edge cases, and this keeps the two concerns simple to reason
|
|
321
|
+
// about independently.
|
|
322
|
+
if (trackedIdentifiers.size > 0 || namedLocals.size > 0) {
|
|
323
|
+
// A property-access binding is a reference by construction.
|
|
324
|
+
/** @param {number} idx @param {string} name */
|
|
325
|
+
const addUsedBinding = (idx, name) => {
|
|
326
|
+
addBinding(idx, name);
|
|
327
|
+
addReferenced(idx, name);
|
|
328
|
+
};
|
|
329
|
+
/** @param {TsNode} node @param {TsNode | undefined} parent */
|
|
330
|
+
const visitUses = (node, parent) => {
|
|
331
|
+
if (ts.isIdentifier(node) && !declarationNodes.has(node)) {
|
|
332
|
+
const idx = trackedIdentifiers.get(node.text);
|
|
333
|
+
if (idx !== undefined) {
|
|
334
|
+
classifyUse(ts, node, parent, idx, addUsedBinding, opaqueSiteIdx);
|
|
335
|
+
}
|
|
336
|
+
const named = namedLocals.get(node.text);
|
|
337
|
+
if (named !== undefined && parent !== undefined && isValueReference(ts, node, parent)) {
|
|
338
|
+
for (const { idx: siteIdx, original } of named) addReferenced(siteIdx, original);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
ts.forEachChild(node, (child) => visitUses(child, node));
|
|
342
|
+
};
|
|
343
|
+
visitUses(sourceFile, undefined);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
for (let i = 0; i < sites.length; i++) {
|
|
347
|
+
const bindings = bindingSets.get(i);
|
|
348
|
+
if (bindings && bindings.size > 0) sites[i].bindings = [...bindings];
|
|
349
|
+
const referenced = referencedSets.get(i);
|
|
350
|
+
if (referenced && referenced.size > 0) sites[i].referenced = [...referenced];
|
|
351
|
+
if (opaqueSiteIdx.has(i)) sites[i].opaque = true;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return { sites, dynamicUnknown, ...(parseErrors.length > 0 ? { parseErrors } : {}) };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Renders a `.svelte` file's TS syntax-error diagnostics as plain strings,
|
|
359
|
+
* or `undefined` when there were none.
|
|
360
|
+
* @param {typeof import('typescript')} ts
|
|
361
|
+
* @param {TsSourceFile} sourceFile
|
|
362
|
+
* @returns {string[] | undefined}
|
|
363
|
+
*/
|
|
364
|
+
function collectSvelteParseErrors(ts, sourceFile) {
|
|
365
|
+
const diags = /** @type {{ parseDiagnostics?: import('typescript').Diagnostic[] }} */ (
|
|
366
|
+
/** @type {unknown} */ (sourceFile)
|
|
367
|
+
).parseDiagnostics;
|
|
368
|
+
if (!diags || diags.length === 0) return undefined;
|
|
369
|
+
return diags.map((d) => {
|
|
370
|
+
const message = ts.flattenDiagnosticMessageText(d.messageText, ' ');
|
|
371
|
+
const line = d.start !== undefined ? sourceFile.getLineAndCharacterOfPosition(d.start).line + 1 : undefined;
|
|
372
|
+
return line !== undefined ? `line ${line}: ${message}` : message;
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Whether an identifier occurrence that happens to spell a named-import
|
|
378
|
+
* local is a use of that local, as opposed to a property NAME that merely
|
|
379
|
+
* shares the spelling (`obj.template`, `{ template: 1 }`, `class { template() {} }`,
|
|
380
|
+
* a type position). Shadowing declarations (a nested `const template = …`)
|
|
381
|
+
* are NOT excluded — that over-counts references, which is the safe
|
|
382
|
+
* direction for a "was the vulnerable symbol used" question.
|
|
383
|
+
* @param {typeof import('typescript')} ts
|
|
384
|
+
* @param {TsIdentifier} node
|
|
385
|
+
* @param {TsNode} parent
|
|
386
|
+
* @returns {boolean}
|
|
387
|
+
*/
|
|
388
|
+
function isValueReference(ts, node, parent) {
|
|
389
|
+
if (ts.isPropertyAccessExpression(parent) && parent.name === node) return false;
|
|
390
|
+
if (ts.isPropertyAssignment(parent) && parent.name === node) return false;
|
|
391
|
+
if ((ts.isMethodDeclaration(parent) || ts.isPropertyDeclaration(parent) || ts.isPropertySignature(parent) || ts.isMethodSignature(parent)) && parent.name === node) return false;
|
|
392
|
+
if (ts.isBindingElement(parent) && parent.propertyName === node) return false;
|
|
393
|
+
if (ts.isImportSpecifier(parent) || ts.isExportSpecifier(parent)) return false;
|
|
394
|
+
if (ts.isTypeReferenceNode(parent) || ts.isQualifiedName(parent)) return false;
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Classifies one reference to a tracked default/namespace-style identifier:
|
|
400
|
+
* resolves exactly one level of (non-computed, non-chained) property access
|
|
401
|
+
* to a binding name, recognizes a small set of usage shapes that plainly
|
|
402
|
+
* don't touch any named property ("safe, no binding" — `void`/`typeof`/
|
|
403
|
+
* `delete`, a bare expression statement, `instanceof`/`in`/equality
|
|
404
|
+
* comparisons), and treats everything else — assignment, being passed as an
|
|
405
|
+
* argument, spread, export, return, computed access, AND calling/
|
|
406
|
+
* constructing/tagging/rendering the identifier itself — as an opaque
|
|
407
|
+
* escape this parse-level scan cannot rule out (fail-safe: "could use
|
|
408
|
+
* anything").
|
|
409
|
+
*
|
|
410
|
+
* **Calling the identifier itself is deliberately opaque, not safe**
|
|
411
|
+
* (adversarial-review finding): `axios(...)`, `new X(...)`, `` tag`...` ``,
|
|
412
|
+
* and `<X/>` all execute the module's **default/namespace identity** —
|
|
413
|
+
* package code no member-name intersection can reason about. A manifest
|
|
414
|
+
* naming that identity (e.g. `"name": "axios"` or `"name": "default"`)
|
|
415
|
+
* would otherwise be structurally unmatchable (the local identifier is
|
|
416
|
+
* user-chosen, not the export name), so a direct call/construct/tag/render
|
|
417
|
+
* is never treated as "no binding" the way `void x`/`x;`/`typeof x` are —
|
|
418
|
+
* those inspect the reference without invoking anything, while calling it
|
|
419
|
+
* runs it.
|
|
420
|
+
*
|
|
421
|
+
* @param {typeof import('typescript')} ts
|
|
422
|
+
* @param {TsIdentifier} node
|
|
423
|
+
* @param {TsNode | undefined} parent
|
|
424
|
+
* @param {number} idx
|
|
425
|
+
* @param {(idx: number, name: string) => void} addBinding
|
|
426
|
+
* @param {Set<number>} opaqueSiteIdx
|
|
427
|
+
*/
|
|
428
|
+
function classifyUse(ts, node, parent, idx, addBinding, opaqueSiteIdx) {
|
|
429
|
+
if (!parent) return;
|
|
430
|
+
|
|
431
|
+
if (ts.isPropertyAccessExpression(parent) && parent.expression === node && !parent.questionDotToken) {
|
|
432
|
+
addBinding(idx, parent.name.text);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
// Optional-chained property access (`local?.foo`) is the same one-level
|
|
436
|
+
// resolution as plain property access.
|
|
437
|
+
if (ts.isPropertyAccessExpression(parent) && parent.expression === node && parent.questionDotToken) {
|
|
438
|
+
addBinding(idx, parent.name.text);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (isSafeNonEscapingUse(ts, node, parent)) return;
|
|
442
|
+
|
|
443
|
+
opaqueSiteIdx.add(idx);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* @param {typeof import('typescript')} ts
|
|
448
|
+
* @param {TsIdentifier} node
|
|
449
|
+
* @param {TsNode} parent
|
|
450
|
+
* @returns {boolean}
|
|
451
|
+
*/
|
|
452
|
+
function isSafeNonEscapingUse(ts, node, parent) {
|
|
453
|
+
if (ts.isExpressionStatement(parent)) return true; // bare `local;`
|
|
454
|
+
if (ts.isVoidExpression(parent) || ts.isTypeOfExpression(parent) || ts.isDeleteExpression(parent)) return true;
|
|
455
|
+
if (ts.isPrefixUnaryExpression(parent) && parent.operand === node) return true; // !local, -local, +local, ~local
|
|
456
|
+
// NOTE: calling the identifier itself (`local(...)`) is INTENTIONALLY NOT
|
|
457
|
+
// here — see the doc comment above classifyUse. It falls through to
|
|
458
|
+
// opaque below, same as `new local(...)`, tagged templates, and JSX.
|
|
459
|
+
if (ts.isBinaryExpression(parent)) {
|
|
460
|
+
const SAFE_OPS = new Set([
|
|
461
|
+
ts.SyntaxKind.InstanceOfKeyword,
|
|
462
|
+
ts.SyntaxKind.InKeyword,
|
|
463
|
+
ts.SyntaxKind.EqualsEqualsToken,
|
|
464
|
+
ts.SyntaxKind.EqualsEqualsEqualsToken,
|
|
465
|
+
ts.SyntaxKind.ExclamationEqualsToken,
|
|
466
|
+
ts.SyntaxKind.ExclamationEqualsEqualsToken
|
|
467
|
+
]);
|
|
468
|
+
if (SAFE_OPS.has(parent.operatorToken.kind)) return true;
|
|
469
|
+
}
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Matches a `<script ...>` tag's attribute text, treating a double- or
|
|
474
|
+
// single-quoted run as opaque so a `>` inside a quoted attribute value (e.g.
|
|
475
|
+
// `generics="T extends Record<string, unknown>"`, or `data-x="/>"`) does not
|
|
476
|
+
// terminate the match early. Shared by every place in this file that needs
|
|
477
|
+
// to find where a `<script` opening tag actually ends.
|
|
478
|
+
//
|
|
479
|
+
// Each quoted-run alternative is capped at a newline (`[^"\n]*`, `[^'\n]*`)
|
|
480
|
+
// rather than left unbounded. An unbounded run is a bug, not just a
|
|
481
|
+
// narrower heuristic: a malformed tag with a quote that never closes on its
|
|
482
|
+
// own line (`<script lang="ts>`) would otherwise pair with the next quote
|
|
483
|
+
// of the same kind ANYWHERE later in the file -- unrelated prose, a
|
|
484
|
+
// different attribute, whatever comes first -- and the "opening tag" match
|
|
485
|
+
// would balloon to swallow every real `<script>` block in between,
|
|
486
|
+
// including its own `</script>`. Because that inflated span is then
|
|
487
|
+
// recorded as consumed, the range-tracking backstop below is blind to it
|
|
488
|
+
// too: the exact silent-loss failure this whole extraction/backstop design
|
|
489
|
+
// exists to prevent. Capping at a newline does cost real (if unusual)
|
|
490
|
+
// coverage -- a legally multi-line attribute value containing a stray `>`
|
|
491
|
+
// is no longer matched as part of the tag -- but that narrower case still
|
|
492
|
+
// surfaces as a backstop warning rather than silent loss; see the trace in
|
|
493
|
+
// the doc comment above `extractSvelteScript`.
|
|
494
|
+
const SCRIPT_OPEN_ATTRS = `(?:[^>"']|"[^"\\n]*"|'[^'\\n]*')*`;
|
|
495
|
+
|
|
496
|
+
// Finds the next thing that matters while scanning markup: either a comment
|
|
497
|
+
// opener, or a genuine `<script...>` opening tag (group 1 captures its
|
|
498
|
+
// attributes; undefined means the match was the comment opener instead).
|
|
499
|
+
const SVELTE_MARKUP_TOKEN_RE = new RegExp(`<!--|<script\\b(${SCRIPT_OPEN_ATTRS})>`, 'gi');
|
|
500
|
+
const SVELTE_SCRIPT_CLOSE_RE = /<\/script\s*>/gi;
|
|
501
|
+
const SVELTE_TS_LANG_RE = /lang\s*=\s*(["'])(?:ts|typescript)\1/i;
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* A `.svelte` file's markup and `<style>` block are not TS/JS and would
|
|
505
|
+
* abort `ts.createSourceFile`; only the `<script>` (module and/or instance,
|
|
506
|
+
* Svelte 4's `context="module"` or Svelte 5's `module`) tag bodies are.
|
|
507
|
+
* Rather than slice those bodies out (which would shift every subsequent
|
|
508
|
+
* line number relative to the real file — the evidence a consumer reports
|
|
509
|
+
* must point at the `.svelte` file's actual lines), this replaces everything
|
|
510
|
+
* *outside* script tags with spaces while preserving every newline, so the
|
|
511
|
+
* returned string has exactly the same line layout as `content` and can be
|
|
512
|
+
* fed straight to `ts.createSourceFile` with line numbers already correct.
|
|
513
|
+
*
|
|
514
|
+
* This is a single left-to-right pass with an explicit "scanning markup" vs
|
|
515
|
+
* "inside an open `<script>` element" state, not two independently-composed
|
|
516
|
+
* regex passes (a global comment mask, then a separate script-tag pass) over
|
|
517
|
+
* the whole file. That composition was tried and is wrong: a real `<script>`
|
|
518
|
+
* element's content is raw text per HTML/Svelte parsing rules, not markup, so
|
|
519
|
+
* `<!--` appearing literally in a script body (a string literal, a template
|
|
520
|
+
* literal building HTML, a JS Annex-B line comment) is not a comment opener
|
|
521
|
+
* at all -- masking it as one lets it pair with an unrelated `-->` anywhere
|
|
522
|
+
* later in the file, swallowing straight through the script's own
|
|
523
|
+
* `</script>` tag and losing every import in it. Comment syntax is therefore
|
|
524
|
+
* only ever recognized while the cursor is in "markup" state; once a genuine
|
|
525
|
+
* script opening is found, everything up to its own first `</script>` is
|
|
526
|
+
* copied verbatim with no comment handling of any kind.
|
|
527
|
+
*
|
|
528
|
+
* Known residual gaps (regex-based extraction, not a real HTML parser):
|
|
529
|
+
* a `<script>`-shaped string sitting inside a markup attribute value (e.g.
|
|
530
|
+
* `<div title="<script>...</script>">`) is not distinguished from a real
|
|
531
|
+
* tag, because this scan finds `<script` by raw substring search and has no
|
|
532
|
+
* notion of already being inside another tag's attribute when it does. The
|
|
533
|
+
* opening-tag match itself, though, is quote-aware: a `"..."`- or
|
|
534
|
+
* `'...'`-quoted run in a `<script>` tag's own attributes is treated as an
|
|
535
|
+
* opaque unit, so a `>` inside one (`generics="T extends Record<string,
|
|
536
|
+
* unknown>"`, `data-x="/>"`) never terminates the match early the way an
|
|
537
|
+
* unquoted `>` does. That quoted-run is itself capped at a newline (see the
|
|
538
|
+
* doc comment on `SCRIPT_OPEN_ATTRS`) so a quote left open by a malformed
|
|
539
|
+
* tag can never pair with an unrelated quote elsewhere in the file and
|
|
540
|
+
* swallow everything in between; the cap trades away one narrower thing in
|
|
541
|
+
* return, described next.
|
|
542
|
+
*
|
|
543
|
+
* A quote that fails to find a same-line closing counterpart makes the
|
|
544
|
+
* attribute-capturing repetition unable to advance past it at all, so this
|
|
545
|
+
* scan recognizes NO `<script` opening tag at that position -- not the main
|
|
546
|
+
* loop, and not the backstop's open-tag scan below, which is byte-for-byte
|
|
547
|
+
* the same pattern by construction. This is usually still safe: an orphaned
|
|
548
|
+
* `</script>` left over from that same malformed tag is a separate,
|
|
549
|
+
* quote-oblivious pattern (`SVELTE_SCRIPT_CLOSE_RE`), and the backstop's
|
|
550
|
+
* independent close-tag scan flags it exactly as it would any other
|
|
551
|
+
* unaccounted-for closing tag -- which is why `<script lang="ts>` followed
|
|
552
|
+
* later by a real `</script>` still produces a warning. The gap is the
|
|
553
|
+
* combination of both: a `<script` tag whose attributes contain a
|
|
554
|
+
* same-line-unterminated quote AND that has no `</script>` anywhere else in
|
|
555
|
+
* the file for that independent scan to catch either. In that specific
|
|
556
|
+
* combination nothing in the file is left looking unaccounted-for, so
|
|
557
|
+
* extraction masks the malformed tag through end of file with zero
|
|
558
|
+
* diagnostics -- the same silent-loss shape as the `<!--`/prose-`-->` gap
|
|
559
|
+
* described next, and, like it, deliberately accepted rather than chased
|
|
560
|
+
* further here.
|
|
561
|
+
*
|
|
562
|
+
* `<!--` recognition has an analogous gap, by a different mechanism:
|
|
563
|
+
* HTML/Svelte does not treat `<!--` as a comment-opener when it appears
|
|
564
|
+
* inside a quoted attribute value (`<div title="...<!--...">`) or inside a
|
|
565
|
+
* `{...}` expression (`{'<!--'}`), but this scan does, purely lexically --
|
|
566
|
+
* so a `<!--`-looking substring in either position, if it later pairs with a
|
|
567
|
+
* real `-->` anywhere else in the file (or never closes at all), can mask
|
|
568
|
+
* real markup, or even a whole real `<script>` block, as if it were
|
|
569
|
+
* commentary. A false `<!--` match is caught whenever the real `-->` it
|
|
570
|
+
* pairs with turns out to belong to a later, genuinely separate comment (the
|
|
571
|
+
* common shape in practice). The accepted trade-off is a false `<!--` match
|
|
572
|
+
* whose found `-->` is neither part of a subsequent real comment nor absent
|
|
573
|
+
* -- e.g. a `-->`-shaped substring sitting in later markup PROSE
|
|
574
|
+
* (`<p>a --> b</p>`), with no second `<!--` anywhere in between. This is
|
|
575
|
+
* structurally indistinguishable, by this scan, from a deliberately-authored
|
|
576
|
+
* comment that happens to wrap real code, so it is trusted the same way, and
|
|
577
|
+
* the script(s) after it are masked as commentary with nothing left behind
|
|
578
|
+
* to flag.
|
|
579
|
+
*
|
|
580
|
+
* Rather than re-derive a second, independently-heuristic "expected count"
|
|
581
|
+
* to compare against, this loop tracks its OWN work: every span of the file
|
|
582
|
+
* it genuinely accounted for -- a real script's open-tag-through-close-tag,
|
|
583
|
+
* a self-closing tag, or a comment that closed on its own `-->` with
|
|
584
|
+
* nothing suspicious in its interior -- is recorded as it goes. Once the
|
|
585
|
+
* loop finishes, `findLostScriptWarnings` below asks a much narrower
|
|
586
|
+
* question of the raw file: does a `<script` opening or `</script>` closing
|
|
587
|
+
* tag appear ANYWHERE the loop never recorded as accounted for? If so,
|
|
588
|
+
* something that should have been paired up during the pass above never
|
|
589
|
+
* was, which is exactly the shape a swallowed-whole script leaves behind.
|
|
590
|
+
*
|
|
591
|
+
* A comment is trusted enough to record as accounted-for only when it
|
|
592
|
+
* actually closes AND its own interior contains no second `<!--` -- real
|
|
593
|
+
* HTML/Svelte comments never nest, so a second `<!--` inside one means the
|
|
594
|
+
* `-->` this scan paired it with almost certainly belongs to a later,
|
|
595
|
+
* unrelated comment instead, and nothing in between should be trusted. An
|
|
596
|
+
* unterminated comment or an unterminated `<script>` tag is never trusted
|
|
597
|
+
* either, for the same reason: there is no way to know what, if anything,
|
|
598
|
+
* was swallowed past the point where the file ran out.
|
|
599
|
+
*
|
|
600
|
+
* @param {string} content
|
|
601
|
+
* @returns {{ code: string; hasTs: boolean; lostScriptWarnings: string[] }}
|
|
602
|
+
*/
|
|
603
|
+
function extractSvelteScript(content) {
|
|
604
|
+
// Index by UTF-16 code unit (not code point) to stay aligned with
|
|
605
|
+
// RegExp#exec's `match.index`, which is itself UTF-16-code-unit-based.
|
|
606
|
+
// `new Array(n)` is the LENGTH form, and the loop fills every slot.
|
|
607
|
+
/** @type {string[]} */
|
|
608
|
+
const out = new Array(content.length);
|
|
609
|
+
for (let i = 0; i < content.length; i++) out[i] = content[i] === '\n' ? '\n' : ' ';
|
|
610
|
+
let hasTs = false;
|
|
611
|
+
|
|
612
|
+
// [start, end) spans of `content` this loop genuinely recognized and
|
|
613
|
+
// accounted for -- see the doc comment above for exactly what qualifies.
|
|
614
|
+
// Ranges are pushed in strictly increasing order (the cursor only ever
|
|
615
|
+
// moves forward), so no sorting is needed before `findLostScriptWarnings`
|
|
616
|
+
// consults this list.
|
|
617
|
+
/** @type {Array<[number, number]>} */
|
|
618
|
+
const consumedRanges = [];
|
|
619
|
+
|
|
620
|
+
let cursor = 0;
|
|
621
|
+
for (;;) {
|
|
622
|
+
SVELTE_MARKUP_TOKEN_RE.lastIndex = cursor;
|
|
623
|
+
const match = SVELTE_MARKUP_TOKEN_RE.exec(content);
|
|
624
|
+
if (!match) break;
|
|
625
|
+
|
|
626
|
+
const attrs = match[1];
|
|
627
|
+
if (attrs === undefined) {
|
|
628
|
+
// Matched a literal `<!--`: a markup comment. Only reachable while
|
|
629
|
+
// scanning markup -- a script's own body is consumed whole below,
|
|
630
|
+
// without ever passing back through this branch, so a `<!--` inside
|
|
631
|
+
// a script never lands here.
|
|
632
|
+
const openIdx = match.index;
|
|
633
|
+
const closeIdx = content.indexOf('-->', openIdx + 4);
|
|
634
|
+
if (closeIdx === -1) {
|
|
635
|
+
// Unterminated comment: mask to end of file rather than erroring
|
|
636
|
+
// (an unclosed `<!--` still isn't code, whatever's after it in the
|
|
637
|
+
// file), but don't trust any of it as accounted-for -- there is no
|
|
638
|
+
// way to know what was in there.
|
|
639
|
+
cursor = content.length;
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
const nestedOpenIdx = content.indexOf('<!--', openIdx + 4);
|
|
643
|
+
if (nestedOpenIdx === -1 || nestedOpenIdx >= closeIdx) {
|
|
644
|
+
consumedRanges.push([openIdx, closeIdx + 3]);
|
|
645
|
+
}
|
|
646
|
+
cursor = closeIdx + 3;
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// A self-closing opening tag (`<script src="..." />`, valid e.g. inside
|
|
651
|
+
// <svelte:head> to load a third-party script by URL) has no body and no
|
|
652
|
+
// closing tag of its own -- treat it as ordinary markup (already
|
|
653
|
+
// masked) and keep scanning after it, rather than pairing it with the
|
|
654
|
+
// next `</script>` in the file and swallowing everything in between.
|
|
655
|
+
if (attrs.trimEnd().endsWith('/')) {
|
|
656
|
+
const tagEnd = match.index + match[0].length;
|
|
657
|
+
consumedRanges.push([match.index, tagEnd]);
|
|
658
|
+
cursor = tagEnd;
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (SVELTE_TS_LANG_RE.test(attrs)) hasTs = true;
|
|
663
|
+
|
|
664
|
+
const bodyStart = match.index + match[0].length;
|
|
665
|
+
SVELTE_SCRIPT_CLOSE_RE.lastIndex = bodyStart;
|
|
666
|
+
const closeMatch = SVELTE_SCRIPT_CLOSE_RE.exec(content);
|
|
667
|
+
if (!closeMatch) {
|
|
668
|
+
// No closing tag for this opening -- malformed input. Leave this
|
|
669
|
+
// block unextracted (masked) rather than guessing where it ends, and
|
|
670
|
+
// keep scanning after the opening tag for any further script blocks.
|
|
671
|
+
// The open tag itself is not recorded as accounted-for either, so the
|
|
672
|
+
// backstop below still sees it.
|
|
673
|
+
cursor = bodyStart;
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Script content is raw text: copy it verbatim, with no comment
|
|
678
|
+
// handling applied even if it contains `<!--` or `-->` (see the doc
|
|
679
|
+
// comment above).
|
|
680
|
+
for (let i = bodyStart; i < closeMatch.index; i++) out[i] = content[i];
|
|
681
|
+
const closeEnd = closeMatch.index + closeMatch[0].length;
|
|
682
|
+
consumedRanges.push([match.index, closeEnd]);
|
|
683
|
+
cursor = closeEnd;
|
|
684
|
+
}
|
|
685
|
+
return { code: out.join(''), hasTs, lostScriptWarnings: findLostScriptWarnings(content, consumedRanges) };
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Backstop tokens: any `<script` opening (self-closing or not -- a genuinely
|
|
689
|
+
// self-closing tag the loop recognized is always recorded as its own
|
|
690
|
+
// consumed range, so it never shows up as "outside" anything) or `</script>`
|
|
691
|
+
// closing tag, scanned across the whole raw file. Deliberately kept
|
|
692
|
+
// byte-for-byte the same quote-aware open-tag pattern as
|
|
693
|
+
// `SVELTE_MARKUP_TOKEN_RE`'s `<script...>` alternative above, so this
|
|
694
|
+
// backstop's notion of where a `<script>` tag ends always agrees with what
|
|
695
|
+
// the main loop recognized.
|
|
696
|
+
const SVELTE_SCRIPT_OPEN_TOKEN_RE = new RegExp(`<script\\b${SCRIPT_OPEN_ATTRS}>`, 'gi');
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Finds every `<script` opening or `</script>` closing tag in the raw file
|
|
700
|
+
* that `extractSvelteScript`'s main loop did not record as part of a
|
|
701
|
+
* consumed range -- i.e. text that looks like it belongs to a script tag
|
|
702
|
+
* the loop never recognized as one.
|
|
703
|
+
* @param {string} content
|
|
704
|
+
* @param {Array<[number, number]>} consumedRanges
|
|
705
|
+
* @returns {string[]}
|
|
706
|
+
*/
|
|
707
|
+
function findLostScriptWarnings(content, consumedRanges) {
|
|
708
|
+
/** @param {number} start @param {number} end */
|
|
709
|
+
const isAccountedFor = (start, end) =>
|
|
710
|
+
consumedRanges.some(([rangeStart, rangeEnd]) => rangeStart <= start && end <= rangeEnd);
|
|
711
|
+
|
|
712
|
+
/** @param {number} pos */
|
|
713
|
+
const lineOf = (pos) => {
|
|
714
|
+
let line = 1;
|
|
715
|
+
for (let i = 0; i < pos; i++) if (content[i] === '\n') line++;
|
|
716
|
+
return line;
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
/** @type {string[]} */
|
|
720
|
+
const warnings = [];
|
|
721
|
+
/** @param {RegExp} re @param {string} tokenLabel */
|
|
722
|
+
const scanFor = (re, tokenLabel) => {
|
|
723
|
+
re.lastIndex = 0;
|
|
724
|
+
/** @type {RegExpExecArray | null} */
|
|
725
|
+
let match;
|
|
726
|
+
while ((match = re.exec(content))) {
|
|
727
|
+
const start = match.index;
|
|
728
|
+
const end = start + match[0].length;
|
|
729
|
+
if (!isAccountedFor(start, end)) {
|
|
730
|
+
warnings.push(
|
|
731
|
+
`line ${lineOf(start)}: found a ${tokenLabel} outside every <script> element or comment this scan ` +
|
|
732
|
+
'recognized; a script block may have been lost during extraction'
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
scanFor(SVELTE_SCRIPT_OPEN_TOKEN_RE, "'<script' opening tag");
|
|
739
|
+
scanFor(SVELTE_SCRIPT_CLOSE_RE, "'</script>' closing tag");
|
|
740
|
+
return warnings;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* @param {typeof import('typescript')} ts
|
|
745
|
+
* @param {string} fileName
|
|
746
|
+
* @returns {import('typescript').ScriptKind}
|
|
747
|
+
*/
|
|
748
|
+
function scriptKindOf(ts, fileName) {
|
|
749
|
+
if (fileName.endsWith('.tsx')) return ts.ScriptKind.TSX;
|
|
750
|
+
if (fileName.endsWith('.jsx')) return ts.ScriptKind.JSX;
|
|
751
|
+
if (fileName.endsWith('.ts') || fileName.endsWith('.mts') || fileName.endsWith('.cts')) {
|
|
752
|
+
return ts.ScriptKind.TS;
|
|
753
|
+
}
|
|
754
|
+
return ts.ScriptKind.JS;
|
|
755
|
+
}
|