carrick 0.3.102 → 0.3.103

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 (39) hide show
  1. package/package.json +6 -6
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/sidecar/dist/src/bundler.d.ts +3 -92
  4. package/sidecar/dist/src/bundler.js +5 -265
  5. package/sidecar/dist/src/capture/anchors.js +113 -8
  6. package/sidecar/dist/src/capture/check-fields.js +19 -5
  7. package/sidecar/dist/src/capture/check-probe.d.ts +5 -0
  8. package/sidecar/dist/src/capture/check-probe.js +31 -2
  9. package/sidecar/dist/src/capture/check-workspace.d.ts +3 -0
  10. package/sidecar/dist/src/capture/check-workspace.js +13 -16
  11. package/sidecar/dist/src/capture/check.js +5 -5
  12. package/sidecar/dist/src/capture/deep-walk.js +45 -12
  13. package/sidecar/dist/src/capture/deno-project.d.ts +2 -1
  14. package/sidecar/dist/src/capture/deno-project.js +22 -18
  15. package/sidecar/dist/src/capture/guarded-fs.d.ts +76 -0
  16. package/sidecar/dist/src/capture/guarded-fs.js +182 -0
  17. package/sidecar/dist/src/capture/index.js +60 -26
  18. package/sidecar/dist/src/capture/outside-root.d.ts +41 -0
  19. package/sidecar/dist/src/capture/outside-root.js +101 -0
  20. package/sidecar/dist/src/capture/paths-rewrite.d.ts +8 -0
  21. package/sidecar/dist/src/capture/paths-rewrite.js +9 -7
  22. package/sidecar/dist/src/capture/repair-dangling.d.ts +11 -1
  23. package/sidecar/dist/src/capture/repair-dangling.js +15 -4
  24. package/sidecar/dist/src/capture/self-check.d.ts +3 -0
  25. package/sidecar/dist/src/capture/self-check.js +3 -3
  26. package/sidecar/dist/src/index.d.ts +1 -1
  27. package/sidecar/dist/src/index.js +2 -115
  28. package/sidecar/dist/src/origin.d.ts +49 -8
  29. package/sidecar/dist/src/origin.js +81 -8
  30. package/sidecar/dist/src/project-loader.js +15 -5
  31. package/sidecar/dist/src/type-inferrer.d.ts +72 -0
  32. package/sidecar/dist/src/type-inferrer.js +248 -8
  33. package/sidecar/dist/src/type-structural-expander.d.ts +5 -1
  34. package/sidecar/dist/src/type-structural-expander.js +1 -1
  35. package/sidecar/dist/src/types.d.ts +31 -178
  36. package/sidecar/dist/src/validators.d.ts +58 -914
  37. package/sidecar/dist/src/validators.js +0 -55
  38. package/sidecar/dist/src/monorepo-builder.d.ts +0 -129
  39. package/sidecar/dist/src/monorepo-builder.js +0 -584
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Every file the sidecar writes or deletes goes through a {@link WriteGuard}
3
+ * (carrick#1748).
4
+ *
5
+ * A scan reads the repo it scans and never writes it. carrick#1742 showed how
6
+ * easily that breaks: the capture self-check reaches the repo's own sources
7
+ * through a `node_modules` link, and a pass that "only rewrites its own
8
+ * declarations" rewrote them. Each write site used to be safe only by its own
9
+ * reasoning. Now each run names the directories it owns (its scratch, the stub
10
+ * it emits, a runtime cache) and every write and delete is checked against
11
+ * them, on the RESOLVED path, before it happens.
12
+ *
13
+ * Resolution follows the operation:
14
+ * - a write lands where the path's links lead, so the whole path is resolved,
15
+ * and a path that does not exist yet resolves through its deepest existing
16
+ * ancestor (a dangling link resolves through its target);
17
+ * - a delete, or a new link, touches the directory entry itself, so only the
18
+ * parent is resolved. Unlinking the stub's `node_modules` link is allowed;
19
+ * writing through it into the repo's packages is refused.
20
+ *
21
+ * `test/no-raw-writes.test.ts` fails when any other source file calls a
22
+ * mutating `fs` API.
23
+ */
24
+ import * as fs from 'node:fs';
25
+ import * as os from 'node:os';
26
+ import * as path from 'node:path';
27
+ /** A write or delete the guard refused, or a root it would not accept. */
28
+ export class WriteRefused extends Error {
29
+ constructor(message) {
30
+ super(`${message} (carrick#1748)`);
31
+ this.name = 'WriteRefused';
32
+ }
33
+ }
34
+ export class WriteGuard {
35
+ dirs;
36
+ files;
37
+ protect;
38
+ constructor(dirs, files, protect) {
39
+ this.dirs = dirs;
40
+ this.files = files;
41
+ this.protect = protect;
42
+ }
43
+ /** A guard over `roots`. Throws when a root equals or contains a protected tree. */
44
+ static of(roots) {
45
+ const protect = (roots.protect ?? []).map(landing);
46
+ const dirs = (roots.dirs ?? []).map(landing);
47
+ const files = (roots.files ?? []).map(entry);
48
+ for (const root of [...dirs, ...files]) {
49
+ const covered = protect.find((tree) => within(root, tree));
50
+ if (covered !== undefined) {
51
+ throw new WriteRefused(`refused write root ${root}: it is or contains the scanned tree ${covered}`);
52
+ }
53
+ }
54
+ return new WriteGuard(dirs, files, protect);
55
+ }
56
+ /**
57
+ * A new, empty directory under `parent` (the OS temp dir by default) and a
58
+ * guard over it alone. `mkdtemp` picks an unused name, so creating it cannot
59
+ * overwrite anything; `parent` is created when missing.
60
+ */
61
+ static scratch(prefix, parent = os.tmpdir()) {
62
+ fs.mkdirSync(parent, { recursive: true });
63
+ const dir = fs.mkdtempSync(path.join(parent, prefix));
64
+ return { dir, guard: WriteGuard.of({ dirs: [dir] }) };
65
+ }
66
+ /** This guard with more roots, under the same protected trees. */
67
+ with(roots) {
68
+ return WriteGuard.of({
69
+ dirs: [...this.dirs, ...(roots.dirs ?? [])],
70
+ files: [...this.files, ...(roots.files ?? [])],
71
+ protect: [...this.protect],
72
+ });
73
+ }
74
+ /** A guard over `dir` alone, which must already be writable under this one. */
75
+ narrow(dir) {
76
+ this.check(dir, landing(dir), 'narrow to');
77
+ return WriteGuard.of({ dirs: [dir], protect: [...this.protect] });
78
+ }
79
+ /** Whether a write to `p` would land inside this guard's roots. */
80
+ allowsWrite(p) {
81
+ return this.holds(landing(p));
82
+ }
83
+ /** Throws unless `p` (a subprocess's working directory, say) lies inside the roots. */
84
+ assertWithin(p) {
85
+ this.check(p, landing(p), 'work in');
86
+ }
87
+ writeFile(p, data) {
88
+ this.check(p, landing(p), 'write');
89
+ fs.writeFileSync(p, data);
90
+ }
91
+ /** `mkdir -p`. A directory that already exists is no write and passes unchecked. */
92
+ mkdir(p) {
93
+ if (isDirectory(p))
94
+ return;
95
+ this.check(p, landing(p), 'create directory');
96
+ fs.mkdirSync(p, { recursive: true });
97
+ }
98
+ copyFile(from, to) {
99
+ this.check(to, landing(to), 'copy into');
100
+ fs.copyFileSync(from, to);
101
+ }
102
+ /** `cp -R`. Links in the source are copied as links, never written through. */
103
+ copyTree(from, to, filter) {
104
+ this.check(to, landing(to), 'copy into');
105
+ fs.cpSync(from, to, { recursive: true, filter });
106
+ }
107
+ /** `rm -rf`. A link is removed itself; its target is left alone. */
108
+ remove(p) {
109
+ this.check(p, entry(p), 'delete');
110
+ fs.rmSync(p, { recursive: true, force: true });
111
+ }
112
+ unlink(p) {
113
+ this.check(p, entry(p), 'delete');
114
+ fs.unlinkSync(p);
115
+ }
116
+ symlink(target, linkPath, type) {
117
+ this.check(linkPath, entry(linkPath), 'link');
118
+ fs.symlinkSync(target, linkPath, type);
119
+ }
120
+ holds(resolved) {
121
+ return this.files.includes(resolved) || this.dirs.some((root) => within(root, resolved));
122
+ }
123
+ check(p, resolved, verb) {
124
+ if (this.holds(resolved))
125
+ return;
126
+ throw new WriteRefused(`refused to ${verb} ${p}` +
127
+ (resolved === path.resolve(p) ? '' : ` (resolves to ${resolved})`) +
128
+ ': outside this run\'s scratch, stub and cache roots');
129
+ }
130
+ }
131
+ /** Whether `p` is `root` or lies beneath it. Both are resolved paths. */
132
+ function within(root, p) {
133
+ const rel = path.relative(root, p);
134
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
135
+ }
136
+ function isDirectory(p) {
137
+ try {
138
+ return fs.statSync(p).isDirectory();
139
+ }
140
+ catch {
141
+ return false;
142
+ }
143
+ }
144
+ /**
145
+ * Where a write to `p` lands: every link resolved, through the deepest
146
+ * existing ancestor when `p` does not exist yet, and through a dangling link's
147
+ * target (writing to a dangling link creates its target).
148
+ */
149
+ function landing(p, hops = 0) {
150
+ const abs = path.resolve(p);
151
+ try {
152
+ return fs.realpathSync.native(abs);
153
+ }
154
+ catch {
155
+ // Missing, or a link whose target is missing.
156
+ }
157
+ let link;
158
+ try {
159
+ if (fs.lstatSync(abs).isSymbolicLink())
160
+ link = fs.readlinkSync(abs);
161
+ }
162
+ catch {
163
+ // Not there at all.
164
+ }
165
+ if (link !== undefined) {
166
+ if (hops > 40)
167
+ throw new WriteRefused(`refused ${abs}: too many symbolic links`);
168
+ return landing(path.resolve(path.dirname(abs), link), hops + 1);
169
+ }
170
+ const parent = path.dirname(abs);
171
+ if (parent === abs)
172
+ return abs;
173
+ return path.join(landing(parent, hops), path.basename(abs));
174
+ }
175
+ /** The directory entry `p` names: its parent resolved, its own name kept. */
176
+ function entry(p) {
177
+ const abs = path.resolve(p);
178
+ const parent = path.dirname(abs);
179
+ if (parent === abs)
180
+ return abs;
181
+ return path.join(landing(parent), path.basename(abs));
182
+ }
@@ -30,7 +30,6 @@
30
30
  import ts from 'typescript';
31
31
  import * as fs from 'node:fs';
32
32
  import * as path from 'node:path';
33
- import * as os from 'node:os';
34
33
  import { entryRelativeSpecifier, resolveAnchor } from './anchors.js';
35
34
  import { findAugmentationFiles } from './augmentations.js';
36
35
  import { installedVersions, lockfileVersions } from './lockfile.js';
@@ -40,6 +39,8 @@ import { selfCheckStub } from './self-check.js';
40
39
  import { collectSpecifiers, isRelative, packageNameOf } from './specifiers.js';
41
40
  import { DenoProject, findDenoConfig } from './deno-project.js';
42
41
  import { emitsAlike, ProjectGraph } from './project-references.js';
42
+ import { placeEmittedTree } from './outside-root.js';
43
+ import { WriteGuard } from './guarded-fs.js';
43
44
  export { DenoProject, findDenoConfig } from './deno-project.js';
44
45
  export { serviceConfigPath } from './project-references.js';
45
46
  // v2 check core ("tsc as the judge"). Same bundle, same seam: the sidecar
@@ -137,6 +138,17 @@ export function captureStub(opts) {
137
138
  // references others and the anchors' files do not all belong to one project.
138
139
  let ownerGroups;
139
140
  let emitProject;
141
+ // Everything this capture writes, it writes through `guard` (carrick#1748):
142
+ // the stub dir, the staging dir, and the surface entry. A stub dir is
143
+ // emptied before it is written, so one that is or holds the repo is refused
144
+ // before anything is touched.
145
+ let guard;
146
+ try {
147
+ guard = WriteGuard.of({ dirs: [stubDir], protect: [repoRoot] });
148
+ }
149
+ catch (err) {
150
+ return fail(stubDir, packageName, [err instanceof Error ? err.message : String(err)]);
151
+ }
140
152
  let deno;
141
153
  try {
142
154
  const config = findDenoConfig(repoRoot, opts.tsconfigPath);
@@ -225,10 +237,19 @@ export function captureStub(opts) {
225
237
  const entryPath = deno
226
238
  ? path.join(deno.cacheDir, `${surfaceEntry}.ts`)
227
239
  : path.join(entryDir, `${surfaceEntry}.ts`);
228
- fs.mkdirSync(path.dirname(entryPath), { recursive: true });
240
+ // The entry is the one file a capture writes inside the repo itself: tsc
241
+ // only emits it from inside rootDir. It is written, read and deleted, and
242
+ // the guard holds it to exactly that path. A Deno entry sits in the cache.
243
+ try {
244
+ guard = guard.with(deno ? { dirs: [deno.cacheDir] } : { files: [entryPath] });
245
+ guard.mkdir(path.dirname(entryPath));
246
+ }
247
+ catch (err) {
248
+ return fail(stubDir, packageName, [err instanceof Error ? err.message : String(err)]);
249
+ }
229
250
  // ---- Phase A: analysis program over placeholder entry + anchor sources ----
230
251
  let resolved;
231
- const analysisCtx = { repoRoot, entryDir: path.dirname(entryPath), entryPath };
252
+ const analysisCtx = { repoRoot, entryDir: path.dirname(entryPath), entryPath, guard };
232
253
  try {
233
254
  resolved = ownerGroups && emitProject
234
255
  ? resolveAnchorsByOwner(opts, ownerGroups, emitProject, analysisCtx, errors)
@@ -247,7 +268,8 @@ export function captureStub(opts) {
247
268
  : '';
248
269
  entryLines.push(`export type ${anchor.request.alias} = ${anchor.aliasText};${comment}`);
249
270
  }
250
- const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'carrick-capture-v2-'));
271
+ const scratch = WriteGuard.scratch('carrick-capture-v2-');
272
+ const staging = scratch.dir;
251
273
  const emitted = new Map();
252
274
  // Input .d.ts files (ambient stubs, augmentation declarations, local
253
275
  // hand-written declarations in the import closure) are never re-emitted by
@@ -256,7 +278,7 @@ export function captureStub(opts) {
256
278
  const sourceByEmitted = new Map();
257
279
  let emitPartial = false;
258
280
  try {
259
- fs.writeFileSync(entryPath, entryLines.join('\n') + '\n');
281
+ guard.writeFile(entryPath, entryLines.join('\n') + '\n');
260
282
  const emitOptions = {
261
283
  ...parsed.options,
262
284
  // The load-bearing trio: emit declarations without checking, so
@@ -306,8 +328,8 @@ export function captureStub(opts) {
306
328
  }
307
329
  finally {
308
330
  if (fs.existsSync(entryPath))
309
- fs.unlinkSync(entryPath);
310
- fs.rmSync(staging, { recursive: true, force: true });
331
+ guard.unlink(entryPath);
332
+ scratch.guard.remove(staging);
311
333
  }
312
334
  // ---- Partial-emit recovery ----
313
335
  // The corpus-2 notifications-svc shape: one file's declaration emit was
@@ -326,22 +348,28 @@ export function captureStub(opts) {
326
348
  }
327
349
  // ---- Relocate the emitted tree into the stub package ----
328
350
  const typesDir = path.join(stubDir, 'types');
329
- fs.rmSync(stubDir, { recursive: true, force: true });
330
- fs.mkdirSync(typesDir, { recursive: true });
351
+ guard.remove(stubDir);
352
+ guard.mkdir(typesDir);
353
+ // From here on, only the stub is written.
354
+ const stubGuard = guard.narrow(stubDir);
355
+ // A declaration for a source outside rootDir arrives at the source's own
356
+ // path; it is placed under the tree too (carrick#1770).
357
+ const placed = placeEmittedTree({ emitted, staging, entryDir, surfaceDeclaration });
331
358
  const emittedFiles = [];
332
359
  let surfaceAbsPath = '';
333
- for (const [fileName, text] of emitted) {
334
- let rel = path.relative(staging, fileName).split(path.sep).join('/');
335
- if (path.basename(rel) === surfaceDeclaration) {
336
- const source = sourceByEmitted.get(rel);
337
- sourceByEmitted.delete(rel);
338
- rel = 'surface.d.ts';
360
+ for (const fileName of emitted.keys()) {
361
+ const stagingRel = path.relative(staging, fileName).split(path.sep).join('/');
362
+ const rel = placed.relOf.get(fileName);
363
+ const text = placed.textOf.get(fileName);
364
+ if (rel !== stagingRel) {
365
+ const source = sourceByEmitted.get(stagingRel);
366
+ sourceByEmitted.delete(stagingRel);
339
367
  if (source)
340
368
  sourceByEmitted.set(rel, source);
341
369
  }
342
370
  const dest = path.join(typesDir, rel);
343
- fs.mkdirSync(path.dirname(dest), { recursive: true });
344
- fs.writeFileSync(dest, text);
371
+ stubGuard.mkdir(path.dirname(dest));
372
+ stubGuard.writeFile(dest, text);
345
373
  emittedFiles.push(rel);
346
374
  if (rel === 'surface.d.ts')
347
375
  surfaceAbsPath = dest;
@@ -354,8 +382,8 @@ export function captureStub(opts) {
354
382
  if (emittedFiles.includes(rel))
355
383
  continue;
356
384
  const dest = path.join(typesDir, rel);
357
- fs.mkdirSync(path.dirname(dest), { recursive: true });
358
- fs.writeFileSync(dest, text);
385
+ stubGuard.mkdir(path.dirname(dest));
386
+ stubGuard.writeFile(dest, text);
359
387
  emittedFiles.push(rel);
360
388
  }
361
389
  // Tree-relative names of augmentation files that made it into the tree
@@ -364,6 +392,9 @@ export function captureStub(opts) {
364
392
  .map((abs) => {
365
393
  const noDts = abs.replace(/\.d\.ts$/, '');
366
394
  const noExt = noDts === abs ? abs.replace(/\.(ts|tsx|mts|cts)$/, '') : noDts;
395
+ const outside = placed.outside.get(path.resolve(noExt));
396
+ if (outside !== undefined)
397
+ return outside;
367
398
  const rel = path.relative(entryDir, noExt).split(path.sep).join('/');
368
399
  return `${rel}.d.ts`;
369
400
  })
@@ -372,19 +403,21 @@ export function captureStub(opts) {
372
403
  // ---- Post-emit specifier rewrite (paths mappings + absolute internals) ----
373
404
  let denoRewrites = 0;
374
405
  try {
375
- denoRewrites = deno?.rewrite(typesDir, emittedFiles, sourceByEmitted) ?? 0;
406
+ denoRewrites = deno?.rewrite(stubGuard, typesDir, emittedFiles, sourceByEmitted) ?? 0;
376
407
  }
377
408
  catch (err) {
378
409
  return fail(stubDir, packageName, [err instanceof Error ? err.message : String(err)]);
379
410
  }
380
411
  const rewritten = rewriteEmittedSpecifiers({
412
+ guard: stubGuard,
413
+ outside: placed.outside,
381
414
  typesDir,
382
415
  files: emittedFiles,
383
416
  options: parsed.options,
384
417
  configPath: projectConfigPath,
385
418
  entryDir,
386
419
  });
387
- const specifierRewrites = denoRewrites + rewritten.rewrites;
420
+ const specifierRewrites = denoRewrites + placed.rewrites + rewritten.rewrites;
388
421
  // ---- Pin external deps: installed node_modules first, lockfile fallback ----
389
422
  // Externals are collected AFTER the rewrite pass: a rewritten paths
390
423
  // specifier is internal, not a dependency.
@@ -427,14 +460,14 @@ export function captureStub(opts) {
427
460
  }
428
461
  const dependencyRoot = deno?.config.workspaceRoot ?? repoRoot;
429
462
  const bareCheckout = !deno && !fs.existsSync(path.join(dependencyRoot, 'node_modules'));
430
- fs.writeFileSync(path.join(stubDir, 'package.json'), JSON.stringify({
463
+ stubGuard.writeFile(path.join(stubDir, 'package.json'), JSON.stringify({
431
464
  name: packageName,
432
465
  version: '0.0.0-carrick',
433
466
  private: true,
434
467
  types: './types/surface.d.ts',
435
468
  dependencies: pinned,
436
469
  }, null, 2) + '\n');
437
- fs.writeFileSync(path.join(stubDir, 'tsconfig.snapshot.json'), JSON.stringify({
470
+ stubGuard.writeFile(path.join(stubDir, 'tsconfig.snapshot.json'), JSON.stringify({
438
471
  ts_version: ts.version,
439
472
  strict: parsed.options.strict ?? false,
440
473
  strictNullChecks: parsed.options.strictNullChecks ?? parsed.options.strict ?? false,
@@ -444,6 +477,7 @@ export function captureStub(opts) {
444
477
  }, null, 2) + '\n');
445
478
  // ---- Capture-time self-check (per-alias closure attribution) ----
446
479
  const aliases = selfCheckStub({
480
+ guard: stubGuard,
447
481
  stubDir,
448
482
  surfaceAbsPath,
449
483
  resolved,
@@ -455,7 +489,7 @@ export function captureStub(opts) {
455
489
  : undefined,
456
490
  });
457
491
  const fidelity = computeFidelity(aliases);
458
- fs.writeFileSync(path.join(stubDir, 'carrick-manifest.json'), JSON.stringify({
492
+ stubGuard.writeFile(path.join(stubDir, 'carrick-manifest.json'), JSON.stringify({
459
493
  package_name: packageName,
460
494
  ts_version: ts.version,
461
495
  bare_checkout: bareCheckout,
@@ -600,7 +634,7 @@ function resolveAnchors(opts, parsed, ctx, deno) {
600
634
  for (const anchor of opts.anchors) {
601
635
  placeholderLines.push(`export type ${anchor.alias} = unknown;`);
602
636
  }
603
- fs.writeFileSync(ctx.entryPath, placeholderLines.join('\n') + '\n');
637
+ ctx.guard.writeFile(ctx.entryPath, placeholderLines.join('\n') + '\n');
604
638
  try {
605
639
  const anchorSources = [
606
640
  ...new Set(opts.anchors
@@ -638,6 +672,6 @@ function resolveAnchors(opts, parsed, ctx, deno) {
638
672
  }
639
673
  finally {
640
674
  if (fs.existsSync(ctx.entryPath))
641
- fs.unlinkSync(ctx.entryPath);
675
+ ctx.guard.unlink(ctx.entryPath);
642
676
  }
643
677
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Place every emitted declaration inside the stub's types tree
3
+ * (carrick#1770).
4
+ *
5
+ * The tree mirrors the emit's rootDir. A program can also reach a source
6
+ * outside it: a service that reads a sibling package's SOURCE through a
7
+ * `paths` mapping or a relative import. tsc does not emit that file's
8
+ * declaration under `outDir`; it hands the write callback the source's own
9
+ * path with `.d.ts`. Such a file is placed under `OUTSIDE_DIR`, mirroring its
10
+ * path from the deepest directory it shares with rootDir, and every relative
11
+ * specifier that crosses between the two parts of the tree is rewritten to
12
+ * where its target now sits. Specifiers are read the way tsc wrote them:
13
+ * relative to the SOURCE location of the file that holds them.
14
+ *
15
+ * A capture whose program stays inside rootDir is placed exactly as before.
16
+ */
17
+ /** Tree directory holding declarations of sources outside rootDir. */
18
+ export declare const OUTSIDE_DIR = "__outside__";
19
+ export interface PlacedTree {
20
+ /** Emitted file name (as tsc gave it) -> tree-relative POSIX path. */
21
+ relOf: Map<string, string>;
22
+ /** Emitted file name -> declaration text, cross-root specifiers rewritten. */
23
+ textOf: Map<string, string>;
24
+ /**
25
+ * Absolute source-side path of each outside declaration, extension
26
+ * stripped -> its tree-relative path. Empty when nothing was outside.
27
+ */
28
+ outside: Map<string, string>;
29
+ /** Relative specifiers rewritten across the root. */
30
+ rewrites: number;
31
+ }
32
+ /**
33
+ * Where each emitted declaration goes. `emitted` maps tsc's file name to its
34
+ * text; `surfaceDeclaration` is renamed to `surface.d.ts` at the tree root.
35
+ */
36
+ export declare function placeEmittedTree(args: {
37
+ emitted: Map<string, string>;
38
+ staging: string;
39
+ entryDir: string;
40
+ surfaceDeclaration: string;
41
+ }): PlacedTree;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Place every emitted declaration inside the stub's types tree
3
+ * (carrick#1770).
4
+ *
5
+ * The tree mirrors the emit's rootDir. A program can also reach a source
6
+ * outside it: a service that reads a sibling package's SOURCE through a
7
+ * `paths` mapping or a relative import. tsc does not emit that file's
8
+ * declaration under `outDir`; it hands the write callback the source's own
9
+ * path with `.d.ts`. Such a file is placed under `OUTSIDE_DIR`, mirroring its
10
+ * path from the deepest directory it shares with rootDir, and every relative
11
+ * specifier that crosses between the two parts of the tree is rewritten to
12
+ * where its target now sits. Specifiers are read the way tsc wrote them:
13
+ * relative to the SOURCE location of the file that holds them.
14
+ *
15
+ * A capture whose program stays inside rootDir is placed exactly as before.
16
+ */
17
+ import * as path from 'node:path';
18
+ import { rewriteSpecifiers } from './specifiers.js';
19
+ /** Tree directory holding declarations of sources outside rootDir. */
20
+ export const OUTSIDE_DIR = '__outside__';
21
+ const DECLARATION_EXT = /\.d\.(ts|mts|cts)$/;
22
+ /**
23
+ * Where each emitted declaration goes. `emitted` maps tsc's file name to its
24
+ * text; `surfaceDeclaration` is renamed to `surface.d.ts` at the tree root.
25
+ */
26
+ export function placeEmittedTree(args) {
27
+ const relOf = new Map();
28
+ const textOf = new Map(args.emitted);
29
+ // Where tsc would have put each file in the source tree: the place its
30
+ // relative specifiers are written from.
31
+ const sourceSideOf = new Map();
32
+ const outsideFiles = [];
33
+ for (const fileName of args.emitted.keys()) {
34
+ const rel = posix(path.relative(args.staging, fileName));
35
+ if (escapes(rel)) {
36
+ outsideFiles.push(fileName);
37
+ sourceSideOf.set(fileName, path.resolve(fileName));
38
+ continue;
39
+ }
40
+ sourceSideOf.set(fileName, path.join(args.entryDir, rel));
41
+ relOf.set(fileName, path.basename(rel) === args.surfaceDeclaration ? 'surface.d.ts' : rel);
42
+ }
43
+ const outside = new Map();
44
+ if (outsideFiles.length === 0)
45
+ return { relOf, textOf, outside, rewrites: 0 };
46
+ const shared = commonDirectory([args.entryDir, ...outsideFiles.map((f) => path.dirname(path.resolve(f)))]);
47
+ for (const fileName of outsideFiles) {
48
+ const rel = path.posix.join(OUTSIDE_DIR, posix(path.relative(shared, path.resolve(fileName))));
49
+ relOf.set(fileName, rel);
50
+ outside.set(path.resolve(fileName).replace(DECLARATION_EXT, ''), rel);
51
+ }
52
+ // Source-side module id (absolute, extensionless) -> tree path.
53
+ const treeOf = new Map();
54
+ for (const [fileName, rel] of relOf) {
55
+ treeOf.set(sourceSideOf.get(fileName).replace(DECLARATION_EXT, ''), rel);
56
+ }
57
+ let rewrites = 0;
58
+ for (const [fileName, rel] of relOf) {
59
+ const fromSource = path.dirname(sourceSideOf.get(fileName));
60
+ const fromTree = path.posix.dirname(rel);
61
+ const result = rewriteSpecifiers(textOf.get(fileName), (spec) => {
62
+ if (!spec.startsWith('./') && !spec.startsWith('../'))
63
+ return undefined;
64
+ const extension = /\.(m|c)?js$/.exec(spec)?.[0] ?? '';
65
+ const target = path.resolve(fromSource, spec.slice(0, spec.length - extension.length));
66
+ const targetRel = treeOf.get(target) ?? treeOf.get(path.join(target, 'index'));
67
+ if (targetRel === undefined)
68
+ return undefined;
69
+ const targetId = targetRel.replace(DECLARATION_EXT, '');
70
+ // Unchanged when the specifier, read from the file's place in the tree,
71
+ // already reaches the same declaration.
72
+ const literal = path.posix.normalize(path.posix.join(fromTree, spec.slice(0, spec.length - extension.length)));
73
+ if (literal === targetId || `${literal}/index` === targetId)
74
+ return undefined;
75
+ let next = path.posix.relative(fromTree, targetId);
76
+ if (!next.startsWith('.'))
77
+ next = `./${next}`;
78
+ return next + extension;
79
+ });
80
+ textOf.set(fileName, result.text);
81
+ rewrites += result.rewrites;
82
+ }
83
+ return { relOf, textOf, outside, rewrites };
84
+ }
85
+ function posix(p) {
86
+ return p.split(path.sep).join('/');
87
+ }
88
+ function escapes(rel) {
89
+ return rel === '..' || rel.startsWith('../') || path.isAbsolute(rel);
90
+ }
91
+ /** The deepest directory containing every one of `dirs` (absolute). */
92
+ function commonDirectory(dirs) {
93
+ let shared = path.resolve(dirs[0]);
94
+ for (const dir of dirs.slice(1)) {
95
+ const resolved = path.resolve(dir);
96
+ while (shared !== path.dirname(shared) && path.relative(shared, resolved).startsWith('..')) {
97
+ shared = path.dirname(shared);
98
+ }
99
+ }
100
+ return shared;
101
+ }
@@ -13,8 +13,11 @@
13
13
  * as dangling internals with a recorded reason, which is the honest outcome.
14
14
  */
15
15
  import ts from 'typescript';
16
+ import type { WriteGuard } from './guarded-fs.js';
16
17
  import { type PathsPattern } from './specifiers.js';
17
18
  export interface RewriteArgs {
19
+ /** Writes are held to the stub (carrick#1748). */
20
+ guard: WriteGuard;
18
21
  /** Absolute path of the stub's types/ directory. */
19
22
  typesDir: string;
20
23
  /** Tree-relative emitted file paths (as recorded in emitted_files, without
@@ -26,6 +29,11 @@ export interface RewriteArgs {
26
29
  configPath: string;
27
30
  /** Effective rootDir the emit ran with (tree layout mirrors it). */
28
31
  entryDir: string;
32
+ /**
33
+ * Declarations of sources outside rootDir, placed under the tree
34
+ * (carrick#1770): absolute source-side path without extension -> tree path.
35
+ */
36
+ outside: Map<string, string>;
29
37
  }
30
38
  export declare function parsePathsPatterns(options: ts.CompilerOptions, configPath: string): PathsPattern[];
31
39
  export interface RewriteResult {
@@ -37,11 +37,13 @@ export function parsePathsPatterns(options, configPath) {
37
37
  return patterns;
38
38
  }
39
39
  /** Map an absolute source path to its tree-relative emitted file, if any. */
40
- function treeFileFor(absSource, entryDir, emitted) {
40
+ function treeFileFor(absSource, entryDir, emitted, outside) {
41
41
  const noExt = absSource.replace(/\.(d\.ts|ts|tsx|mts|cts)$/, '');
42
42
  const rel = path.relative(entryDir, noExt).split(path.sep).join('/');
43
- if (rel.startsWith('..'))
44
- return undefined;
43
+ if (rel.startsWith('..')) {
44
+ const resolved = path.resolve(noExt);
45
+ return outside.get(resolved) ?? outside.get(path.join(resolved, 'index'));
46
+ }
45
47
  for (const candidate of [`${rel}.d.ts`, `${rel}/index.d.ts`]) {
46
48
  if (emitted.has(candidate))
47
49
  return candidate;
@@ -89,7 +91,7 @@ export function rewriteEmittedSpecifiers(args) {
89
91
  // Import types first, while the member name is still attached to its
90
92
  // specifier; an in-tree target is left for the general pass below.
91
93
  const text = original.replace(ABSOLUTE_IMPORT_TYPE, (whole, open, quote, spec, close, dot, name) => {
92
- if (treeFileFor(spec, args.entryDir, emitted))
94
+ if (treeFileFor(spec, args.entryDir, emitted, args.outside))
93
95
  return whole;
94
96
  const replacement = installed(spec, name);
95
97
  if (replacement === undefined)
@@ -100,7 +102,7 @@ export function rewriteEmittedSpecifiers(args) {
100
102
  const { text: rewritten, rewrites } = rewriteSpecifiers(text, (spec) => {
101
103
  // Absolute paths: an emitted tree file, else an installed package.
102
104
  if (spec.startsWith('/')) {
103
- const target = treeFileFor(spec, args.entryDir, emitted);
105
+ const target = treeFileFor(spec, args.entryDir, emitted, args.outside);
104
106
  return target ? relativeSpecifier(file, target) : installed(spec);
105
107
  }
106
108
  if (isRelative(spec))
@@ -113,7 +115,7 @@ export function rewriteEmittedSpecifiers(args) {
113
115
  continue;
114
116
  for (const targetTemplate of pattern.targets) {
115
117
  const absTarget = targetTemplate.replace('*', star);
116
- const target = treeFileFor(absTarget, args.entryDir, emitted);
118
+ const target = treeFileFor(absTarget, args.entryDir, emitted, args.outside);
117
119
  if (target)
118
120
  return relativeSpecifier(file, target);
119
121
  }
@@ -124,7 +126,7 @@ export function rewriteEmittedSpecifiers(args) {
124
126
  return undefined;
125
127
  });
126
128
  if (rewrites + importTypeRewrites > 0) {
127
- fs.writeFileSync(absFile, rewritten);
129
+ args.guard.writeFile(absFile, rewritten);
128
130
  total += rewrites + importTypeRewrites;
129
131
  }
130
132
  }
@@ -28,6 +28,7 @@
28
28
  *
29
29
  * Seam: node builtins + `typescript`, like the rest of this directory.
30
30
  */
31
+ import type { WriteGuard } from './guarded-fs.js';
31
32
  /** What one file's repair removed, for the caller's re-check. */
32
33
  export interface RepairedFile {
33
34
  /** Specifiers whose import statements were dropped. */
@@ -42,5 +43,14 @@ export interface RepairedFile {
42
43
  * `failing` maps an absolute file path to the specifiers the stub could not
43
44
  * resolve from it. Returns the files actually rewritten; a file whose names
44
45
  * cannot all be replaced by `unknown` is left exactly as it was.
46
+ *
47
+ * Only a file `tree` (a guard over the stub's types tree) lets it write is
48
+ * repaired (carrick#1742). The self-check program also loads files the stub
49
+ * merely reaches: the repo's own sources, through a workspace package linked
50
+ * into the `node_modules` the stub borrows, or a runtime's dependency cache.
51
+ * Those report their missing modules exactly as an emitted declaration does,
52
+ * and they belong to the user: a scan reads them and never writes them. The
53
+ * guard decides on the resolved path, so a file reached through the link is
54
+ * the repo's, whatever path the program loaded it by.
45
55
  */
46
- export declare function repairDanglingImports(failing: Map<string, Set<string>>): Map<string, RepairedFile>;
56
+ export declare function repairDanglingImports(failing: Map<string, Set<string>>, tree: WriteGuard): Map<string, RepairedFile>;