@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.
@@ -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
+ }