svelte-docinfo 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +69 -50
  2. package/dist/analyze-core.d.ts +179 -24
  3. package/dist/analyze-core.d.ts.map +1 -1
  4. package/dist/analyze-core.js +462 -42
  5. package/dist/analyze.d.ts +31 -6
  6. package/dist/analyze.d.ts.map +1 -1
  7. package/dist/analyze.js +29 -9
  8. package/dist/cli.d.ts +17 -7
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +12 -9
  11. package/dist/declaration-build.d.ts +14 -4
  12. package/dist/declaration-build.d.ts.map +1 -1
  13. package/dist/declaration-helpers.d.ts +65 -42
  14. package/dist/declaration-helpers.d.ts.map +1 -1
  15. package/dist/declaration-helpers.js +167 -72
  16. package/dist/dep-resolver.d.ts +38 -13
  17. package/dist/dep-resolver.d.ts.map +1 -1
  18. package/dist/dep-resolver.js +40 -22
  19. package/dist/diagnostics.d.ts +365 -14
  20. package/dist/diagnostics.d.ts.map +1 -1
  21. package/dist/diagnostics.js +112 -28
  22. package/dist/discovery.d.ts +7 -2
  23. package/dist/discovery.d.ts.map +1 -1
  24. package/dist/discovery.js +11 -5
  25. package/dist/exports.d.ts +39 -2
  26. package/dist/exports.d.ts.map +1 -1
  27. package/dist/exports.js +251 -46
  28. package/dist/files.d.ts +14 -2
  29. package/dist/files.d.ts.map +1 -1
  30. package/dist/files.js +49 -8
  31. package/dist/index.d.ts +9 -7
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +6 -4
  34. package/dist/logo.js +3 -3
  35. package/dist/paths.d.ts +22 -1
  36. package/dist/paths.d.ts.map +1 -1
  37. package/dist/paths.js +22 -1
  38. package/dist/postprocess.d.ts +31 -10
  39. package/dist/postprocess.d.ts.map +1 -1
  40. package/dist/postprocess.js +124 -76
  41. package/dist/session.d.ts +101 -11
  42. package/dist/session.d.ts.map +1 -1
  43. package/dist/session.js +457 -127
  44. package/dist/source-config.d.ts +210 -33
  45. package/dist/source-config.d.ts.map +1 -1
  46. package/dist/source-config.js +313 -26
  47. package/dist/source.d.ts +35 -0
  48. package/dist/source.d.ts.map +1 -1
  49. package/dist/source.js +37 -0
  50. package/dist/svelte.d.ts +81 -23
  51. package/dist/svelte.d.ts.map +1 -1
  52. package/dist/svelte.js +686 -359
  53. package/dist/tsdoc.d.ts +47 -12
  54. package/dist/tsdoc.d.ts.map +1 -1
  55. package/dist/tsdoc.js +70 -17
  56. package/dist/types.d.ts +967 -45
  57. package/dist/types.d.ts.map +1 -1
  58. package/dist/types.js +259 -48
  59. package/dist/typescript-alias-registry.d.ts +76 -0
  60. package/dist/typescript-alias-registry.d.ts.map +1 -0
  61. package/dist/typescript-alias-registry.js +206 -0
  62. package/dist/typescript-exports.d.ts +20 -15
  63. package/dist/typescript-exports.d.ts.map +1 -1
  64. package/dist/typescript-exports.js +264 -137
  65. package/dist/typescript-extract-class.d.ts +3 -4
  66. package/dist/typescript-extract-class.d.ts.map +1 -1
  67. package/dist/typescript-extract-class.js +71 -52
  68. package/dist/typescript-extract-function.d.ts +6 -8
  69. package/dist/typescript-extract-function.d.ts.map +1 -1
  70. package/dist/typescript-extract-function.js +20 -15
  71. package/dist/typescript-extract-shared.d.ts +309 -29
  72. package/dist/typescript-extract-shared.d.ts.map +1 -1
  73. package/dist/typescript-extract-shared.js +957 -127
  74. package/dist/typescript-extract-type-json.d.ts +286 -0
  75. package/dist/typescript-extract-type-json.d.ts.map +1 -0
  76. package/dist/typescript-extract-type-json.js +854 -0
  77. package/dist/typescript-extract-type-properties.d.ts +2 -3
  78. package/dist/typescript-extract-type-properties.d.ts.map +1 -1
  79. package/dist/typescript-extract-type-properties.js +91 -84
  80. package/dist/typescript-extract-type.d.ts +6 -8
  81. package/dist/typescript-extract-type.d.ts.map +1 -1
  82. package/dist/typescript-extract-type.js +138 -40
  83. package/dist/typescript-program.d.ts +106 -23
  84. package/dist/typescript-program.d.ts.map +1 -1
  85. package/dist/typescript-program.js +182 -54
  86. package/dist/vite.d.ts +23 -8
  87. package/dist/vite.d.ts.map +1 -1
  88. package/dist/vite.js +34 -14
  89. package/package.json +17 -15
  90. package/virtual-svelte-docinfo.d.ts +1 -1
@@ -17,24 +17,35 @@
17
17
  * - `AnalyzeResultJson` / `OnDuplicates` / `OnDuplicatesCallback` — shared types
18
18
  * surfaced through the main barrel directly from this module.
19
19
  * - `throwOnDuplicates` — convenience callback paired with `OnDuplicates`.
20
+ * - `finalizeDiagnostics` — the diagnostic boundary passes (virtual position
21
+ * remap, then path normalization) in their required order; the one call for
22
+ * callers assembling modules themselves.
20
23
  * - `normalizeDiagnosticPaths` — boundary helper for build-tool integrations
21
24
  * that bypass the session and collect their own diagnostics.
25
+ * - `normalizeModulePathsInTypes` — output pass rewriting the absolute module
26
+ * paths the checker embeds in printed type text. Runs inside `analyzeCore`;
27
+ * exposed for the same reason as `normalizeDiagnosticPaths`, so a caller
28
+ * assembling modules itself through `analyzeModule` can meet the same
29
+ * output contract.
22
30
  *
23
31
  * @internal — module split is implementation detail; consumers go through
24
32
  * `analyze.ts` / `session.ts` for the stable surface.
25
33
  *
26
34
  * @module
27
35
  */
36
+ import { relative } from 'node:path';
28
37
  import ts from 'typescript';
29
38
  import { z } from 'zod';
30
39
  import { ModuleJson } from './types.js';
31
40
  import { Diagnostic } from './diagnostics.js';
32
41
  import { analyzeTypescriptModule } from './typescript-exports.js';
33
- import { analyzeSvelteModule } from './svelte.js';
34
- import { stripVirtualSuffix, getComponentName } from './source.js';
42
+ import { analyzeSvelteModule, remapVirtualDiagnosticPositions } from './svelte.js';
43
+ import { scrubVirtualSuffixes, stripVirtualSuffix, getComponentName } from './source.js';
35
44
  import { extractPath, extractDependencies } from './source-config.js';
36
- import { toPosixPath } from './paths.js';
37
- import { sortModules, findDuplicates, mergeReExports, resolveComponentAliases, compareStrings, } from './postprocess.js';
45
+ import { toPosixPath, isAbsolutePosixPath } from './paths.js';
46
+ import { buildAliasRegistry } from './typescript-alias-registry.js';
47
+ import { createExtractContext } from './typescript-extract-shared.js';
48
+ import { sortModules, findDuplicates, mergeReExports, resolveComponentAliases, compareStrings } from './postprocess.js';
38
49
  /**
39
50
  * Convenience `OnDuplicatesCallback` that throws on any duplicate.
40
51
  *
@@ -63,18 +74,30 @@ const formatDuplicates = (duplicates) => {
63
74
  '(2) add /** @nodocs */ to exclude from documentation.\n' +
64
75
  details);
65
76
  };
66
- const emitDuplicateDiagnostics = (diagnostics, duplicates) => {
77
+ /**
78
+ * Emit one `duplicate_declaration` per colliding name.
79
+ *
80
+ * The record carries both path bases on purpose: `file` names a file
81
+ * (project-root-relative, like every diagnostic) while `modules` and the
82
+ * message name modules (`ModuleJson.path`, relative to `sourceRoot`).
83
+ *
84
+ * @param sourceIdsByModulePath - `ModuleJson.path` → absolute source id, since
85
+ * `DuplicateDeclaration` carries only the module path. Covers every module
86
+ * `duplicates` can name by construction — `findDuplicates` reports paths of
87
+ * the modules handed to it — so the lookup's fallback never fires.
88
+ */
89
+ const emitDuplicateDiagnostics = (diagnostics, duplicates, sourceIdsByModulePath) => {
67
90
  for (const [name, occurrences] of duplicates) {
68
91
  const first = occurrences[0];
69
92
  const modules = occurrences.map((o) => o.module);
70
93
  diagnostics.push({
71
94
  kind: 'duplicate_declaration',
72
- file: first.module,
95
+ file: sourceIdsByModulePath.get(first.module) ?? first.module,
73
96
  line: first.declaration.sourceLine,
74
97
  message: `Duplicate declaration "${name}" defined in: ${modules.join(', ')}`,
75
98
  severity: 'warning',
76
99
  declarationName: name,
77
- modules,
100
+ modules
78
101
  });
79
102
  }
80
103
  };
@@ -119,7 +142,7 @@ const dispatchOnDuplicates = (mode, duplicates, log) => {
119
142
  */
120
143
  export const AnalyzeResultJson = z.strictObject({
121
144
  modules: z.array(ModuleJson).default([]),
122
- diagnostics: z.array(Diagnostic).default([]),
145
+ diagnostics: z.array(Diagnostic).default([])
123
146
  });
124
147
  const toModuleJson = (raw) => {
125
148
  const filtered = raw.declarations.filter((d) => !d.nodocs).map((d) => d.declaration);
@@ -151,7 +174,7 @@ const toModuleJson = (raw) => {
151
174
  reExports,
152
175
  externalReExports,
153
176
  externalStarExports: raw.externalStarExports,
154
- ...(raw.moduleComment ? { moduleComment: raw.moduleComment } : {}),
177
+ ...(raw.moduleComment ? { moduleComment: raw.moduleComment } : {})
155
178
  });
156
179
  };
157
180
  // Module dispatch
@@ -175,19 +198,26 @@ const toModuleJson = (raw) => {
175
198
  * `alsoExportedFrom` on returned declarations is always empty from this path —
176
199
  * cross-module re-export resolution requires all modules (`mergeReExports`
177
200
  * consumes the returned module's `reExports` in phase 2).
201
+ *
202
+ * `aliasRegistry` is optional at this boundary: direct callers assembling
203
+ * modules themselves get written-name recovery only, while `analyzeCore`
204
+ * passes the registry its pre-pass built (see `buildAliasRegistry`).
178
205
  */
179
- export const analyzeModule = (sourceFile, program, options, diagnostics, log) => {
206
+ export const analyzeModule = (sourceFile, program, options, diagnostics, log, aliasRegistry) => {
180
207
  const checker = program.getTypeChecker();
181
208
  const modulePath = extractPath(sourceFile.id, options);
209
+ // diagnostics carry the absolute id (see the `Diagnostic.file` schema doc);
210
+ // the returned `ModuleJson` keeps `modulePath`, a different base
211
+ const diagnosticFile = toPosixPath(sourceFile.id);
182
212
  const analyzerType = options.getAnalyzerType(sourceFile.id);
183
213
  let raw;
184
214
  if (analyzerType === 'svelte') {
185
215
  diagnostics.push({
186
216
  kind: 'module_skipped',
187
- file: modulePath,
217
+ file: diagnosticFile,
188
218
  message: 'Svelte files require program integration. Use createAnalysisSession or analyze()/analyzeFromFiles() instead.',
189
219
  severity: 'warning',
190
- reason: 'requires_program',
220
+ reason: 'requires_program'
191
221
  });
192
222
  log?.warn(`Svelte file skipped in analyzeModule: ${sourceFile.id}`);
193
223
  return undefined;
@@ -197,15 +227,22 @@ export const analyzeModule = (sourceFile, program, options, diagnostics, log) =>
197
227
  if (!tsSourceFile) {
198
228
  diagnostics.push({
199
229
  kind: 'module_skipped',
200
- file: modulePath,
201
- message: `Could not get source file from program: ${sourceFile.id}`,
230
+ file: diagnosticFile,
231
+ // interpolating the same id as `file` keeps the two from naming the
232
+ // file differently — the message gets the same project-root scrub.
233
+ // The log line below keeps the raw id: logs are for developers,
234
+ // diagnostics ship.
235
+ message: `Could not get source file from program: ${diagnosticFile}`,
202
236
  severity: 'warning',
203
- reason: 'not_in_program',
237
+ reason: 'not_in_program'
204
238
  });
205
239
  log?.warn(`Could not get source file from program: ${sourceFile.id}`);
206
240
  return undefined;
207
241
  }
208
- raw = analyzeTypescriptModule(sourceFile, tsSourceFile, modulePath, checker, options, diagnostics);
242
+ // the pass-scoped extraction context — constructed here because the
243
+ // dispatcher is where the program is in hand (see `analyzeExports`)
244
+ const ctx = createExtractContext(program, checker, options, diagnostics, aliasRegistry);
245
+ raw = analyzeTypescriptModule(sourceFile, tsSourceFile, modulePath, ctx, options);
209
246
  }
210
247
  else if (analyzerType === 'css' || analyzerType === 'json') {
211
248
  const { dependencies, dependents } = extractDependencies(sourceFile, options);
@@ -217,16 +254,16 @@ export const analyzeModule = (sourceFile, program, options, diagnostics, log) =>
217
254
  starExports: [],
218
255
  reExports: [],
219
256
  externalReExports: [],
220
- externalStarExports: [],
257
+ externalStarExports: []
221
258
  };
222
259
  }
223
260
  else {
224
261
  diagnostics.push({
225
262
  kind: 'module_skipped',
226
- file: modulePath,
227
- message: `No analyzer for file type: ${sourceFile.id}`,
263
+ file: diagnosticFile,
264
+ message: `No analyzer for file type: ${diagnosticFile}`,
228
265
  severity: 'warning',
229
- reason: 'no_analyzer',
266
+ reason: 'no_analyzer'
230
267
  });
231
268
  log?.warn(`No analyzer for file: ${sourceFile.id}`);
232
269
  return undefined;
@@ -250,11 +287,82 @@ export const analyzeModule = (sourceFile, program, options, diagnostics, log) =>
250
287
  * `extractDependencies`); `analyzeCore` does not compute them. `session.query`
251
288
  * runs `computeDependents` on the owned set before invoking this.
252
289
  */
290
+ /**
291
+ * Analyze the gated Svelte canonicals referenced by an emitted component
292
+ * alias, as `resolveComponentAliases` fill context only (see
293
+ * `AnalyzeCoreInputs.contextSvelteFiles`). Unreferenced files are skipped
294
+ * entirely; analysis diagnostics are dropped — the canonical's `partial`
295
+ * flag propagates through the fill, which is the emitted signal.
296
+ */
297
+ const analyzeContextComponents = (contextSvelteFiles, emittedModules, inputs, aliasRegistry) => {
298
+ const { sourceOptions, program, svelteVirtualFiles } = inputs;
299
+ const referenced = new Set();
300
+ for (const mod of emittedModules) {
301
+ for (const decl of mod.declarations) {
302
+ if (decl.kind === 'component' && decl.aliasOf)
303
+ referenced.add(decl.aliasOf.module);
304
+ }
305
+ }
306
+ const contextModules = [];
307
+ if (referenced.size === 0)
308
+ return contextModules;
309
+ for (const sourceFile of contextSvelteFiles) {
310
+ const modulePath = extractPath(sourceFile.id, sourceOptions);
311
+ if (!referenced.has(modulePath))
312
+ continue;
313
+ const virtualFile = svelteVirtualFiles.get(sourceFile.id);
314
+ if (!virtualFile)
315
+ continue;
316
+ const droppedDiagnostics = [];
317
+ const raw = analyzeSvelteModule(sourceFile, modulePath, program.getTypeChecker(), sourceOptions, droppedDiagnostics, program, virtualFile, aliasRegistry);
318
+ if (raw)
319
+ contextModules.push(toModuleJson(raw));
320
+ }
321
+ return contextModules;
322
+ };
253
323
  export const analyzeCore = (inputs) => {
254
- const { sourceFiles, sourceOptions, program, svelteVirtualFiles, transformFailedIds, onDuplicates, log, } = inputs;
324
+ const { sourceFiles, sourceOptions, program, svelteVirtualFiles, transformFailedIds, contextSvelteFiles, onDuplicates, log } = inputs;
255
325
  const checker = program.getTypeChecker();
256
326
  const diagnostics = [];
327
+ // Registry pre-pass: register the emitted set's exported lost aliases
328
+ // before any extraction, so every `resolveTypeInfo` call this cycle can
329
+ // consult a complete registry. Svelte modules are reached through their
330
+ // svelte2tsx virtuals (`program.getSourceFile(<.svelte id>)` is undefined);
331
+ // CSS/JSON have no exports and analyzer-less files no program file. Gated
332
+ // context files are deliberately absent — their aliases must not register
333
+ // (emitted references always have a doc page to link).
334
+ const registrySources = [];
335
+ for (const sourceFile of sourceFiles) {
336
+ if (transformFailedIds?.has(sourceFile.id))
337
+ continue;
338
+ const virtualFile = svelteVirtualFiles.get(sourceFile.id);
339
+ const programFile = virtualFile
340
+ ? program.getSourceFile(virtualFile.virtualPath)
341
+ : sourceOptions.getAnalyzerType(sourceFile.id) === 'typescript'
342
+ ? program.getSourceFile(sourceFile.id)
343
+ : undefined;
344
+ if (!programFile)
345
+ continue;
346
+ registrySources.push({
347
+ sourceFile: programFile,
348
+ modulePath: extractPath(sourceFile.id, sourceOptions)
349
+ });
350
+ }
351
+ const aliasRegistry = buildAliasRegistry(registrySources, checker);
257
352
  const modules = [];
353
+ // `ModuleJson.path` → the absolute id it was extracted from, so a
354
+ // module-path-keyed pass can still report a project-root-relative
355
+ // `Diagnostic.file` (`emitDuplicateDiagnostics` is the consumer). The two
356
+ // bases differ, and the reverse direction isn't reconstructible:
357
+ // `extractPath`'s no-prefix fallback returns the absolute path, so
358
+ // prepending `sourceRoot` to a module path can concatenate into nonsense.
359
+ // Recording the id the loop already holds keeps them exact.
360
+ const sourceIdsByModulePath = new Map();
361
+ // the one way to add a module, so a new branch can't record only half of it
362
+ const pushModule = (mod, sourceId) => {
363
+ sourceIdsByModulePath.set(mod.path, toPosixPath(sourceId));
364
+ modules.push(mod);
365
+ };
258
366
  // Phase 1: analyze every module (forward re-export edges land on `ModuleJson.reExports`)
259
367
  for (const sourceFile of sourceFiles) {
260
368
  // Failed-transform Svelte file: synthesize placeholder ModuleJson so
@@ -265,14 +373,14 @@ export const analyzeCore = (inputs) => {
265
373
  const modulePath = extractPath(sourceFile.id, sourceOptions);
266
374
  const componentName = getComponentName(modulePath);
267
375
  const { dependencies, dependents } = extractDependencies(sourceFile, sourceOptions);
268
- modules.push(ModuleJson.parse({
376
+ pushModule(ModuleJson.parse({
269
377
  path: modulePath,
270
378
  declarations: [],
271
379
  dependencies,
272
380
  dependents,
273
381
  starExports: [],
274
- partial: true,
275
- }));
382
+ partial: true
383
+ }), sourceFile.id);
276
384
  log?.warn(`Svelte component ${componentName} marked partial (transform failed at ingest)`);
277
385
  continue;
278
386
  }
@@ -280,7 +388,7 @@ export const analyzeCore = (inputs) => {
280
388
  const virtualFile = svelteVirtualFiles.get(sourceFile.id);
281
389
  if (virtualFile) {
282
390
  const modulePath = extractPath(sourceFile.id, sourceOptions);
283
- const raw = analyzeSvelteModule(sourceFile, modulePath, checker, sourceOptions, diagnostics, program, virtualFile);
391
+ const raw = analyzeSvelteModule(sourceFile, modulePath, checker, sourceOptions, diagnostics, program, virtualFile, aliasRegistry);
284
392
  if (raw) {
285
393
  mod = toModuleJson(raw);
286
394
  }
@@ -289,50 +397,100 @@ export const analyzeCore = (inputs) => {
289
397
  }
290
398
  }
291
399
  else {
292
- mod = analyzeModule(sourceFile, program, sourceOptions, diagnostics, log);
400
+ mod = analyzeModule(sourceFile, program, sourceOptions, diagnostics, log, aliasRegistry);
293
401
  }
294
402
  if (!mod)
295
403
  continue;
296
- modules.push(mod);
404
+ pushModule(mod, sourceFile.id);
297
405
  }
406
+ // Phase 1.5: gated Svelte canonicals as fill context — kept out of
407
+ // `mergeReExports` (their edges would back-link modules absent from
408
+ // output) and out of the emitted set.
409
+ const contextModules = contextSvelteFiles?.length
410
+ ? analyzeContextComponents(contextSvelteFiles, modules, inputs, aliasRegistry)
411
+ : [];
298
412
  // Phase 2a: build alsoExportedFrom arrays from the modules' forward edges
299
- mergeReExports(modules);
413
+ const mergedModules = mergeReExports(modules);
300
414
  // Phase 2b: fill component-only fields on renamed component aliases —
301
415
  // canonical components are only fully populated after phase 1 finishes.
302
- resolveComponentAliases(modules);
303
- const sortedModules = sortModules(modules);
416
+ // Gated canonicals extend the lookup without joining the output.
417
+ const resolvedModules = resolveComponentAliases(mergedModules, contextModules);
418
+ const sortedModules = sortModules(resolvedModules);
304
419
  // Always run duplicate detection so a `duplicate_declaration` diagnostic
305
420
  // reaches consumers regardless of `onDuplicates`. The `onDuplicates`
306
421
  // callback/shortcut still fires for callers that want fail-fast or custom
307
422
  // handling — diagnostics is the data, `onDuplicates` is the action.
308
423
  const duplicates = findDuplicates(sortedModules);
309
- emitDuplicateDiagnostics(diagnostics, duplicates);
424
+ emitDuplicateDiagnostics(diagnostics, duplicates, sourceIdsByModulePath);
310
425
  if (onDuplicates) {
311
426
  dispatchOnDuplicates(onDuplicates, duplicates, log);
312
427
  }
313
- normalizeDiagnosticPaths(diagnostics, sourceOptions.projectRoot);
428
+ normalizeModulePathsInTypes(sortedModules, sourceOptions, program);
429
+ finalizeDiagnostics(diagnostics, {
430
+ projectRoot: sourceOptions.projectRoot,
431
+ virtualFiles: svelteVirtualFiles.values()
432
+ });
314
433
  return {
315
434
  modules: sortedModules,
316
- diagnostics,
435
+ diagnostics
317
436
  };
318
437
  };
319
438
  /**
320
- * Normalize `Diagnostic.file` to project-root-relative form, in place.
439
+ * Run the diagnostic boundary passes in their required order: virtual
440
+ * position remap first (`remapVirtualDiagnosticPositions`), then path
441
+ * normalization (`normalizeDiagnosticPaths` — it strips the virtual suffix
442
+ * the remap matches `file` against, so the reverse order silently keeps
443
+ * virtual positions). The one call for callers assembling modules themselves
444
+ * through `analyzeModule` / `analyzeSvelteModule`; using it makes the
445
+ * ordering unrepresentable instead of a contract to remember. Omit
446
+ * `virtualFiles` when no Svelte virtuals are in play.
447
+ *
448
+ * @mutates diagnostics — rewrites positions, `file`, and `message`
449
+ */
450
+ export const finalizeDiagnostics = (diagnostics, options) => {
451
+ if (options.virtualFiles) {
452
+ remapVirtualDiagnosticPositions(diagnostics, options.virtualFiles);
453
+ }
454
+ normalizeDiagnosticPaths(diagnostics, options.projectRoot);
455
+ };
456
+ /**
457
+ * Normalize the paths a `Diagnostic` carries to project-root-relative form,
458
+ * in place.
321
459
  *
322
460
  * Producers inside the analysis pipeline can write absolute paths or virtual
323
461
  * paths (svelte2tsx output like `Foo.svelte.__svelte2tsx__.ts`). This pass
324
462
  * collapses both to the public contract: a path relative to `projectRoot`
325
- * with no leading slash and no `./` prefix.
463
+ * with no leading slash and no `./` prefix. A file outside the root gets the
464
+ * `../` form, matching module paths in printed type text — and making the
465
+ * documented "rejoin with `projectRoot` to get an absolute path" actually
466
+ * hold for it, which dropping the leading slash did not.
467
+ *
468
+ * `message` gets the same treatment as `file`, by textual substitution. A
469
+ * message is free-form, and not every path in one comes from a field this
470
+ * pass can see — `import_parse_failed` wraps an es-module-lexer error that
471
+ * embeds the file name itself. Scrubbing here means the contract holds for
472
+ * the whole record rather than one field, so a producer can't reintroduce an
473
+ * absolute path through prose.
326
474
  *
327
475
  * Exposed for build-tool integrations that bypass the session and collect
328
476
  * their own discovery/dep diagnostics — they need the same normalization to
329
- * match the public contract.
477
+ * match the public contract. **Hand it absolute paths.** An already-relative
478
+ * `file` is left alone (relativizing it would resolve against `cwd`), so this
479
+ * pass can only correct the absolute form — a relative path on a base other
480
+ * than the project root passes through and ships as-is.
330
481
  *
331
- * @mutates diagnostics — rewrites each diagnostic's `file` field
482
+ * @mutates diagnostics — rewrites each diagnostic's `file` and `message`
483
+ * @see `finalizeDiagnostics` — when Svelte virtuals are in play, the position
484
+ * remap must run before this pass (it strips the suffix the remap matches on)
332
485
  */
333
486
  export const normalizeDiagnosticPaths = (diagnostics, projectRoot) => {
334
- const prefix = projectRoot.endsWith('/') ? projectRoot : projectRoot + '/';
487
+ const root = projectRoot.endsWith('/') ? projectRoot.slice(0, -1) : projectRoot;
488
+ const prefix = root + '/';
335
489
  for (const d of diagnostics) {
490
+ if (d.message.includes(prefix)) {
491
+ d.message = d.message.split(prefix).join('');
492
+ }
493
+ d.message = scrubVirtualSuffixes(d.message);
336
494
  // Posixify so producers that emitted native-separator paths (e.g., a
337
495
  // custom discovery layer building `path.relative` results on Windows)
338
496
  // match the POSIX prefix derived from the normalized projectRoot.
@@ -340,11 +498,273 @@ export const normalizeDiagnosticPaths = (diagnostics, projectRoot) => {
340
498
  if (file.startsWith(prefix)) {
341
499
  file = file.slice(prefix.length);
342
500
  }
343
- else if (file.startsWith('/')) {
344
- // Absolute path outside projectRoot — drop leading slash so display
345
- // stays consistent.
346
- file = file.slice(1);
501
+ else if (isAbsolutePosixPath(file)) {
502
+ // Outside the root. Relativize rather than dropping the leading slash,
503
+ // which produced a string that reads as root-relative but resolves
504
+ // somewhere else entirely. A path already relative is left alone —
505
+ // `relative` would resolve it against `cwd`.
506
+ file = toPosixPath(relative(root, file));
347
507
  }
348
508
  d.file = file;
349
509
  }
350
510
  };
511
+ /**
512
+ * Extensions TypeScript elides when it prints a module specifier inside a type
513
+ * (`typeof import("…/dep")` for `…/dep.ts`), in module-resolution preference
514
+ * order.
515
+ *
516
+ * Order encodes preference, not match order: `elidedExtensionOf` takes the
517
+ * *longest* match, so `.d.ts` beats `.ts` on `b.d.ts` regardless of position.
518
+ * That frees the order to break the tie when two program files elide to the
519
+ * same path — which is the only thing standing between this pass and a
520
+ * dependence on `program.getSourceFiles()` iteration order, in a pass whose
521
+ * whole purpose is output that doesn't vary between machines.
522
+ */
523
+ const ELIDED_MODULE_EXTENSIONS = [
524
+ '.ts',
525
+ '.tsx',
526
+ '.mts',
527
+ '.cts',
528
+ '.d.ts',
529
+ '.d.mts',
530
+ '.d.cts',
531
+ '.js',
532
+ '.jsx',
533
+ '.mjs',
534
+ '.cjs'
535
+ ];
536
+ /** The longest elided extension `path` ends with, or `undefined`. */
537
+ const elidedExtensionOf = (path) => {
538
+ let longest;
539
+ for (const ext of ELIDED_MODULE_EXTENSIONS) {
540
+ if (path.endsWith(ext) && (longest === undefined || ext.length > longest.length)) {
541
+ longest = ext;
542
+ }
543
+ }
544
+ return longest;
545
+ };
546
+ const stripElidedExtension = (path) => {
547
+ const ext = elidedExtensionOf(path);
548
+ return ext === undefined ? path : path.slice(0, -ext.length);
549
+ };
550
+ /** Lower wins. Ranks two files that elide to the same path by resolution preference. */
551
+ const elidedExtensionRank = (path) => {
552
+ const ext = elidedExtensionOf(path);
553
+ return ext === undefined
554
+ ? ELIDED_MODULE_EXTENSIONS.length
555
+ : ELIDED_MODULE_EXTENSIONS.indexOf(ext);
556
+ };
557
+ /**
558
+ * Matches a double-quoted absolute path inside printed type text — the
559
+ * checker's `import("…")` form. Deliberately not anchored to the `import(`
560
+ * wrapper: the pass fails open, and the resolution check downstream is the
561
+ * real guard.
562
+ *
563
+ * The absolute-path alternation mirrors `isAbsolutePosixPath` — same rule, but
564
+ * this one has to match *inside* a larger string rather than test a whole one.
565
+ */
566
+ const QUOTED_ABSOLUTE_PATH_RE = /"((?:\/|[A-Za-z]:\/)[^"\n]*)"/g;
567
+ /**
568
+ * Keys holding author-written text or already-relative paths, never printed
569
+ * type text. Skipped by the walk so a doc comment, a raw `defaultValue`, or a
570
+ * written heritage field can't be rewritten — `extends` and `implements` are
571
+ * `getText()` of the written node, and `externalTypes` is written text with
572
+ * identifier-scoped splices (rename resolution, type-parameter substitution),
573
+ * never a checker print.
574
+ *
575
+ * A denylist rather than an allowlist of type-bearing keys, deliberately: the
576
+ * failure this pass exists to prevent is a *missed* type field, so it should
577
+ * fail open. A new author-text field added later gets walked, but the rewrite
578
+ * still only fires on a quoted absolute path resolving to a loaded file — the
579
+ * resolution check, not this list, is the real guard.
580
+ */
581
+ const NON_TYPE_TEXT_KEYS = new Set([
582
+ 'dependencies',
583
+ 'dependents',
584
+ 'deprecatedMessage',
585
+ 'description',
586
+ 'defaultValue',
587
+ 'docComment',
588
+ 'examples',
589
+ 'extends',
590
+ 'externalStarExports',
591
+ 'externalTypes',
592
+ 'implements',
593
+ 'internalMessage',
594
+ 'module',
595
+ 'moduleComment',
596
+ 'mutates',
597
+ 'path',
598
+ 'propertyDescriptions',
599
+ 'returnDescription',
600
+ 'seeAlso',
601
+ 'since',
602
+ 'specifier',
603
+ 'starExports',
604
+ 'throws'
605
+ ]);
606
+ /**
607
+ * Build the printed-path → public-path mapper for one analysis run.
608
+ *
609
+ * Tiers, in order: a module in this output emits its `ModuleJson.path`; a
610
+ * package emits the tail after the last `node_modules/`; everything else is
611
+ * relative to `projectRoot`, which covers both an in-project file that emits
612
+ * no module and an out-of-project one (`../sibling/src/x.ts`).
613
+ *
614
+ * The relative form is not *guaranteed* stable across machines — a target with
615
+ * no near common ancestor gets a `../` run whose length tracks how deep
616
+ * `projectRoot` happens to sit. It's still strictly better than the absolute
617
+ * path it replaces: stable whenever the layout is (the sibling-repo and
618
+ * monorepo cases this actually arises from), and it never carries a home
619
+ * directory.
620
+ */
621
+ const createModulePathNormalizer = (modules, options, program) => {
622
+ const modulePaths = new Set(modules.map((m) => m.path));
623
+ const programFiles = new Set();
624
+ // Recovers the extension the checker elided. Two program files can elide to
625
+ // the same path (`a.ts` beside `a.d.ts`), and picking the wrong one costs
626
+ // more than it looks: `byModule` is keyed on the resolved path, so a wrong
627
+ // pick *misses* tier 1 and silently degrades to a root-relative path. Ranked
628
+ // by resolution preference rather than first-wins, so the choice doesn't
629
+ // ride on `getSourceFiles()` order.
630
+ const byElidedExtension = new Map();
631
+ // Resolved absolute → `ModuleJson.path`, built from the program files that
632
+ // actually became modules. Keyed on the *resolved* path — the same form the
633
+ // lookup computes — so no extension guess enters tier 1. Keying on the
634
+ // elided form instead would collide `Foo.svelte` with a sibling
635
+ // `Foo.svelte.ts` (the Svelte 5 rune-module idiom), and let a non-module
636
+ // `.d.ts` sibling claim a module's key.
637
+ const byModule = new Map();
638
+ for (const sourceFile of program.getSourceFiles()) {
639
+ const fileName = toPosixPath(sourceFile.fileName);
640
+ programFiles.add(fileName);
641
+ const elided = stripElidedExtension(fileName);
642
+ const incumbent = byElidedExtension.get(elided);
643
+ if (incumbent === undefined || elidedExtensionRank(fileName) < elidedExtensionRank(incumbent)) {
644
+ byElidedExtension.set(elided, fileName);
645
+ }
646
+ const real = toPosixPath(stripVirtualSuffix(fileName));
647
+ const modulePath = extractPath(real, options);
648
+ if (modulePaths.has(modulePath))
649
+ byModule.set(real, modulePath);
650
+ }
651
+ const cache = new Map();
652
+ return (printed) => {
653
+ const cached = cache.get(printed);
654
+ if (cached !== undefined)
655
+ return cached;
656
+ const posix = toPosixPath(printed);
657
+ // Only rewrite a path naming a file this program actually loaded. A
658
+ // string-literal type that happens to look like one (`type P = "/usr/bin"`)
659
+ // resolves to nothing and is left alone.
660
+ const resolved = programFiles.has(posix) ? posix : byElidedExtension.get(posix);
661
+ if (resolved === undefined) {
662
+ cache.set(printed, printed);
663
+ return printed;
664
+ }
665
+ // Strip after resolving: the checker elides the virtual's `.ts`, so the
666
+ // printed form (`Foo.svelte.__svelte2tsx__`) doesn't carry the full suffix.
667
+ const real = toPosixPath(stripVirtualSuffix(resolved));
668
+ const modulePath = byModule.get(real);
669
+ // Last, not first: pnpm nests the real package under its store entry
670
+ // (`node_modules/.pnpm/pkg@1/node_modules/pkg/…`), and a hoisted monorepo
671
+ // dep can sit above `projectRoot` — both want the innermost segment. This
672
+ // tier running before the `projectRoot` one is what covers out-of-root
673
+ // dependencies, leaving only out-of-root *source* to fall through.
674
+ const packageIndex = real.lastIndexOf('/node_modules/');
675
+ let normalized;
676
+ if (modulePath !== undefined) {
677
+ normalized = modulePath;
678
+ }
679
+ else if (packageIndex !== -1) {
680
+ normalized = real.slice(packageIndex + '/node_modules/'.length);
681
+ }
682
+ else {
683
+ // One `relative` covers both remaining tiers: in-project files come
684
+ // back root-relative, out-of-project ones as `../…`. The `../` prefix
685
+ // is a usable marker — a `ModuleJson.path` can never start with it
686
+ // (out-of-root source paths throw in `normalizeSourceOptions`) and
687
+ // neither can a package specifier, so it reads unambiguously as
688
+ // "outside this project."
689
+ normalized = toPosixPath(relative(options.projectRoot, real));
690
+ }
691
+ cache.set(printed, normalized);
692
+ return normalized;
693
+ };
694
+ };
695
+ /**
696
+ * Idempotent: a rewritten string no longer matches, because every tier but the
697
+ * out-of-project one emits a relative path and the pattern requires a leading
698
+ * `/` (or drive letter). Load-bearing — phase 2 shares structure between
699
+ * modules (`mergeReExports`, `resolveComponentAliases` copy only what changes),
700
+ * so one array can be reached through more than one declaration.
701
+ */
702
+ const replaceQuotedPaths = (text, rewrite) => {
703
+ if (!text.includes('"'))
704
+ return text;
705
+ return text.replace(QUOTED_ABSOLUTE_PATH_RE, (match, path) => {
706
+ const normalized = rewrite(path);
707
+ return normalized === path ? match : `"${normalized}"`;
708
+ });
709
+ };
710
+ const rewriteTypeText = (value, rewrite) => {
711
+ if (Array.isArray(value)) {
712
+ for (let i = 0; i < value.length; i++) {
713
+ const entry = value[i];
714
+ if (typeof entry === 'string') {
715
+ value[i] = replaceQuotedPaths(entry, rewrite);
716
+ }
717
+ else {
718
+ rewriteTypeText(entry, rewrite);
719
+ }
720
+ }
721
+ return;
722
+ }
723
+ if (value === null || typeof value !== 'object')
724
+ return;
725
+ const record = value;
726
+ // A `TypeJson` literal node's `text`/`value` are the author's literal type,
727
+ // not a printed module reference.
728
+ if (record.kind === 'literal')
729
+ return;
730
+ for (const key of Object.keys(record)) {
731
+ if (NON_TYPE_TEXT_KEYS.has(key))
732
+ continue;
733
+ const entry = record[key];
734
+ if (typeof entry === 'string') {
735
+ record[key] = replaceQuotedPaths(entry, rewrite);
736
+ }
737
+ else {
738
+ rewriteTypeText(entry, rewrite);
739
+ }
740
+ }
741
+ };
742
+ /**
743
+ * Normalize the absolute module paths TypeScript embeds in printed type text,
744
+ * in place.
745
+ *
746
+ * The checker prints a module object as `typeof import("<absolute path>")`,
747
+ * which reaches output through every checker-printed field — `typeSignature`
748
+ * on declarations and members, `returnType`, and the `text`/`name` of a
749
+ * `TypeJson` node. Left alone it makes output machine-dependent (two checkouts
750
+ * of the same source produce different bytes), publishes local filesystem
751
+ * paths on any site that renders `typeSignature`, and exposes the svelte2tsx
752
+ * virtual suffix that `stripVirtualSuffix` exists to hide.
753
+ *
754
+ * A path that resolves to a module in this output is rewritten to that
755
+ * module's `ModuleJson.path`, so the string doubles as a lookup key: a
756
+ * consumer linkifies with `modules.find((m) => m.path === s)` and reads a miss
757
+ * as "not a module here." See `createModulePathNormalizer` for the remaining
758
+ * tiers.
759
+ *
760
+ * Runs as a whole-output pass rather than at the ~20 `typeToString` /
761
+ * `signatureToString` call sites, so a new printing site can't miss it.
762
+ *
763
+ * @mutates modules — rewrites printed type text on declarations and members
764
+ */
765
+ export const normalizeModulePathsInTypes = (modules, options, program) => {
766
+ const rewrite = createModulePathNormalizer(modules, options, program);
767
+ for (const mod of modules) {
768
+ rewriteTypeText(mod, rewrite);
769
+ }
770
+ };