carrick 0.3.102 → 0.3.104

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 (45) 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/api.d.ts +6 -0
  7. package/sidecar/dist/src/capture/check-classify.js +27 -0
  8. package/sidecar/dist/src/capture/check-fields.js +19 -5
  9. package/sidecar/dist/src/capture/check-probe.d.ts +6 -1
  10. package/sidecar/dist/src/capture/check-probe.js +45 -2
  11. package/sidecar/dist/src/capture/check-workspace.d.ts +3 -0
  12. package/sidecar/dist/src/capture/check-workspace.js +13 -16
  13. package/sidecar/dist/src/capture/check.js +5 -5
  14. package/sidecar/dist/src/capture/deep-walk.js +45 -12
  15. package/sidecar/dist/src/capture/deno-project.d.ts +2 -1
  16. package/sidecar/dist/src/capture/deno-project.js +22 -18
  17. package/sidecar/dist/src/capture/guarded-fs.d.ts +76 -0
  18. package/sidecar/dist/src/capture/guarded-fs.js +182 -0
  19. package/sidecar/dist/src/capture/index.d.ts +1 -0
  20. package/sidecar/dist/src/capture/index.js +65 -27
  21. package/sidecar/dist/src/capture/outside-root.d.ts +41 -0
  22. package/sidecar/dist/src/capture/outside-root.js +101 -0
  23. package/sidecar/dist/src/capture/paths-rewrite.d.ts +8 -0
  24. package/sidecar/dist/src/capture/paths-rewrite.js +9 -7
  25. package/sidecar/dist/src/capture/repair-dangling.d.ts +11 -1
  26. package/sidecar/dist/src/capture/repair-dangling.js +15 -4
  27. package/sidecar/dist/src/capture/self-check.d.ts +3 -0
  28. package/sidecar/dist/src/capture/self-check.js +3 -3
  29. package/sidecar/dist/src/capture/service-config.d.ts +19 -0
  30. package/sidecar/dist/src/capture/service-config.js +57 -0
  31. package/sidecar/dist/src/index.d.ts +1 -1
  32. package/sidecar/dist/src/index.js +4 -115
  33. package/sidecar/dist/src/origin.d.ts +49 -8
  34. package/sidecar/dist/src/origin.js +81 -8
  35. package/sidecar/dist/src/project-loader.d.ts +10 -2
  36. package/sidecar/dist/src/project-loader.js +22 -21
  37. package/sidecar/dist/src/type-inferrer.d.ts +72 -0
  38. package/sidecar/dist/src/type-inferrer.js +248 -8
  39. package/sidecar/dist/src/type-structural-expander.d.ts +5 -1
  40. package/sidecar/dist/src/type-structural-expander.js +1 -1
  41. package/sidecar/dist/src/types.d.ts +39 -178
  42. package/sidecar/dist/src/validators.d.ts +70 -914
  43. package/sidecar/dist/src/validators.js +2 -55
  44. package/sidecar/dist/src/monorepo-builder.d.ts +0 -129
  45. 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
+ }
@@ -31,6 +31,7 @@ import type { CaptureStubOptions, CaptureStubResult } from './api.js';
31
31
  export type { CaptureStubOptions, CaptureStubResult } from './api.js';
32
32
  export { DenoProject, findDenoConfig } from './deno-project.js';
33
33
  export { serviceConfigPath } from './project-references.js';
34
+ export { findServiceTsconfig } from './service-config.js';
34
35
  export { runCheck } from './check.js';
35
36
  export type { CheckProgress } from './check.js';
36
37
  export { jsonWireDeclarations } from './check-probe.js';
@@ -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,8 +39,12 @@ 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 { findServiceTsconfig } from './service-config.js';
43
+ import { placeEmittedTree } from './outside-root.js';
44
+ import { WriteGuard } from './guarded-fs.js';
43
45
  export { DenoProject, findDenoConfig } from './deno-project.js';
44
46
  export { serviceConfigPath } from './project-references.js';
47
+ export { findServiceTsconfig } from './service-config.js';
45
48
  // v2 check core ("tsc as the judge"). Same bundle, same seam: the sidecar
46
49
  // reaches it only through this door (index.js).
47
50
  export { runCheck } from './check.js';
@@ -126,9 +129,11 @@ export function captureStub(opts) {
126
129
  const packageName = `@carrick/${sanitizeServiceName(opts.serviceName)}`;
127
130
  const stubDir = path.resolve(opts.outDir);
128
131
  const errors = [];
132
+ // The named config, else the one the init'd project reads too
133
+ // (carrick#1776). A service with neither is typed under defaults below.
129
134
  const configPath = opts.tsconfigPath
130
135
  ? path.resolve(repoRoot, opts.tsconfigPath)
131
- : path.join(repoRoot, 'tsconfig.json');
136
+ : findServiceTsconfig(repoRoot, opts.scanRoot) ?? path.join(repoRoot, 'tsconfig.json');
132
137
  let parsed;
133
138
  // The config the emit's options came from: the named one, or the project
134
139
  // that owns the most anchors' files (carrick#1604).
@@ -137,6 +142,17 @@ export function captureStub(opts) {
137
142
  // references others and the anchors' files do not all belong to one project.
138
143
  let ownerGroups;
139
144
  let emitProject;
145
+ // Everything this capture writes, it writes through `guard` (carrick#1748):
146
+ // the stub dir, the staging dir, and the surface entry. A stub dir is
147
+ // emptied before it is written, so one that is or holds the repo is refused
148
+ // before anything is touched.
149
+ let guard;
150
+ try {
151
+ guard = WriteGuard.of({ dirs: [stubDir], protect: [repoRoot] });
152
+ }
153
+ catch (err) {
154
+ return fail(stubDir, packageName, [err instanceof Error ? err.message : String(err)]);
155
+ }
140
156
  let deno;
141
157
  try {
142
158
  const config = findDenoConfig(repoRoot, opts.tsconfigPath);
@@ -225,10 +241,19 @@ export function captureStub(opts) {
225
241
  const entryPath = deno
226
242
  ? path.join(deno.cacheDir, `${surfaceEntry}.ts`)
227
243
  : path.join(entryDir, `${surfaceEntry}.ts`);
228
- fs.mkdirSync(path.dirname(entryPath), { recursive: true });
244
+ // The entry is the one file a capture writes inside the repo itself: tsc
245
+ // only emits it from inside rootDir. It is written, read and deleted, and
246
+ // the guard holds it to exactly that path. A Deno entry sits in the cache.
247
+ try {
248
+ guard = guard.with(deno ? { dirs: [deno.cacheDir] } : { files: [entryPath] });
249
+ guard.mkdir(path.dirname(entryPath));
250
+ }
251
+ catch (err) {
252
+ return fail(stubDir, packageName, [err instanceof Error ? err.message : String(err)]);
253
+ }
229
254
  // ---- Phase A: analysis program over placeholder entry + anchor sources ----
230
255
  let resolved;
231
- const analysisCtx = { repoRoot, entryDir: path.dirname(entryPath), entryPath };
256
+ const analysisCtx = { repoRoot, entryDir: path.dirname(entryPath), entryPath, guard };
232
257
  try {
233
258
  resolved = ownerGroups && emitProject
234
259
  ? resolveAnchorsByOwner(opts, ownerGroups, emitProject, analysisCtx, errors)
@@ -247,7 +272,8 @@ export function captureStub(opts) {
247
272
  : '';
248
273
  entryLines.push(`export type ${anchor.request.alias} = ${anchor.aliasText};${comment}`);
249
274
  }
250
- const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'carrick-capture-v2-'));
275
+ const scratch = WriteGuard.scratch('carrick-capture-v2-');
276
+ const staging = scratch.dir;
251
277
  const emitted = new Map();
252
278
  // Input .d.ts files (ambient stubs, augmentation declarations, local
253
279
  // hand-written declarations in the import closure) are never re-emitted by
@@ -256,7 +282,7 @@ export function captureStub(opts) {
256
282
  const sourceByEmitted = new Map();
257
283
  let emitPartial = false;
258
284
  try {
259
- fs.writeFileSync(entryPath, entryLines.join('\n') + '\n');
285
+ guard.writeFile(entryPath, entryLines.join('\n') + '\n');
260
286
  const emitOptions = {
261
287
  ...parsed.options,
262
288
  // The load-bearing trio: emit declarations without checking, so
@@ -306,8 +332,8 @@ export function captureStub(opts) {
306
332
  }
307
333
  finally {
308
334
  if (fs.existsSync(entryPath))
309
- fs.unlinkSync(entryPath);
310
- fs.rmSync(staging, { recursive: true, force: true });
335
+ guard.unlink(entryPath);
336
+ scratch.guard.remove(staging);
311
337
  }
312
338
  // ---- Partial-emit recovery ----
313
339
  // The corpus-2 notifications-svc shape: one file's declaration emit was
@@ -326,22 +352,28 @@ export function captureStub(opts) {
326
352
  }
327
353
  // ---- Relocate the emitted tree into the stub package ----
328
354
  const typesDir = path.join(stubDir, 'types');
329
- fs.rmSync(stubDir, { recursive: true, force: true });
330
- fs.mkdirSync(typesDir, { recursive: true });
355
+ guard.remove(stubDir);
356
+ guard.mkdir(typesDir);
357
+ // From here on, only the stub is written.
358
+ const stubGuard = guard.narrow(stubDir);
359
+ // A declaration for a source outside rootDir arrives at the source's own
360
+ // path; it is placed under the tree too (carrick#1770).
361
+ const placed = placeEmittedTree({ emitted, staging, entryDir, surfaceDeclaration });
331
362
  const emittedFiles = [];
332
363
  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';
364
+ for (const fileName of emitted.keys()) {
365
+ const stagingRel = path.relative(staging, fileName).split(path.sep).join('/');
366
+ const rel = placed.relOf.get(fileName);
367
+ const text = placed.textOf.get(fileName);
368
+ if (rel !== stagingRel) {
369
+ const source = sourceByEmitted.get(stagingRel);
370
+ sourceByEmitted.delete(stagingRel);
339
371
  if (source)
340
372
  sourceByEmitted.set(rel, source);
341
373
  }
342
374
  const dest = path.join(typesDir, rel);
343
- fs.mkdirSync(path.dirname(dest), { recursive: true });
344
- fs.writeFileSync(dest, text);
375
+ stubGuard.mkdir(path.dirname(dest));
376
+ stubGuard.writeFile(dest, text);
345
377
  emittedFiles.push(rel);
346
378
  if (rel === 'surface.d.ts')
347
379
  surfaceAbsPath = dest;
@@ -354,8 +386,8 @@ export function captureStub(opts) {
354
386
  if (emittedFiles.includes(rel))
355
387
  continue;
356
388
  const dest = path.join(typesDir, rel);
357
- fs.mkdirSync(path.dirname(dest), { recursive: true });
358
- fs.writeFileSync(dest, text);
389
+ stubGuard.mkdir(path.dirname(dest));
390
+ stubGuard.writeFile(dest, text);
359
391
  emittedFiles.push(rel);
360
392
  }
361
393
  // Tree-relative names of augmentation files that made it into the tree
@@ -364,6 +396,9 @@ export function captureStub(opts) {
364
396
  .map((abs) => {
365
397
  const noDts = abs.replace(/\.d\.ts$/, '');
366
398
  const noExt = noDts === abs ? abs.replace(/\.(ts|tsx|mts|cts)$/, '') : noDts;
399
+ const outside = placed.outside.get(path.resolve(noExt));
400
+ if (outside !== undefined)
401
+ return outside;
367
402
  const rel = path.relative(entryDir, noExt).split(path.sep).join('/');
368
403
  return `${rel}.d.ts`;
369
404
  })
@@ -372,19 +407,21 @@ export function captureStub(opts) {
372
407
  // ---- Post-emit specifier rewrite (paths mappings + absolute internals) ----
373
408
  let denoRewrites = 0;
374
409
  try {
375
- denoRewrites = deno?.rewrite(typesDir, emittedFiles, sourceByEmitted) ?? 0;
410
+ denoRewrites = deno?.rewrite(stubGuard, typesDir, emittedFiles, sourceByEmitted) ?? 0;
376
411
  }
377
412
  catch (err) {
378
413
  return fail(stubDir, packageName, [err instanceof Error ? err.message : String(err)]);
379
414
  }
380
415
  const rewritten = rewriteEmittedSpecifiers({
416
+ guard: stubGuard,
417
+ outside: placed.outside,
381
418
  typesDir,
382
419
  files: emittedFiles,
383
420
  options: parsed.options,
384
421
  configPath: projectConfigPath,
385
422
  entryDir,
386
423
  });
387
- const specifierRewrites = denoRewrites + rewritten.rewrites;
424
+ const specifierRewrites = denoRewrites + placed.rewrites + rewritten.rewrites;
388
425
  // ---- Pin external deps: installed node_modules first, lockfile fallback ----
389
426
  // Externals are collected AFTER the rewrite pass: a rewritten paths
390
427
  // specifier is internal, not a dependency.
@@ -427,14 +464,14 @@ export function captureStub(opts) {
427
464
  }
428
465
  const dependencyRoot = deno?.config.workspaceRoot ?? repoRoot;
429
466
  const bareCheckout = !deno && !fs.existsSync(path.join(dependencyRoot, 'node_modules'));
430
- fs.writeFileSync(path.join(stubDir, 'package.json'), JSON.stringify({
467
+ stubGuard.writeFile(path.join(stubDir, 'package.json'), JSON.stringify({
431
468
  name: packageName,
432
469
  version: '0.0.0-carrick',
433
470
  private: true,
434
471
  types: './types/surface.d.ts',
435
472
  dependencies: pinned,
436
473
  }, null, 2) + '\n');
437
- fs.writeFileSync(path.join(stubDir, 'tsconfig.snapshot.json'), JSON.stringify({
474
+ stubGuard.writeFile(path.join(stubDir, 'tsconfig.snapshot.json'), JSON.stringify({
438
475
  ts_version: ts.version,
439
476
  strict: parsed.options.strict ?? false,
440
477
  strictNullChecks: parsed.options.strictNullChecks ?? parsed.options.strict ?? false,
@@ -444,6 +481,7 @@ export function captureStub(opts) {
444
481
  }, null, 2) + '\n');
445
482
  // ---- Capture-time self-check (per-alias closure attribution) ----
446
483
  const aliases = selfCheckStub({
484
+ guard: stubGuard,
447
485
  stubDir,
448
486
  surfaceAbsPath,
449
487
  resolved,
@@ -455,7 +493,7 @@ export function captureStub(opts) {
455
493
  : undefined,
456
494
  });
457
495
  const fidelity = computeFidelity(aliases);
458
- fs.writeFileSync(path.join(stubDir, 'carrick-manifest.json'), JSON.stringify({
496
+ stubGuard.writeFile(path.join(stubDir, 'carrick-manifest.json'), JSON.stringify({
459
497
  package_name: packageName,
460
498
  ts_version: ts.version,
461
499
  bare_checkout: bareCheckout,
@@ -600,7 +638,7 @@ function resolveAnchors(opts, parsed, ctx, deno) {
600
638
  for (const anchor of opts.anchors) {
601
639
  placeholderLines.push(`export type ${anchor.alias} = unknown;`);
602
640
  }
603
- fs.writeFileSync(ctx.entryPath, placeholderLines.join('\n') + '\n');
641
+ ctx.guard.writeFile(ctx.entryPath, placeholderLines.join('\n') + '\n');
604
642
  try {
605
643
  const anchorSources = [
606
644
  ...new Set(opts.anchors
@@ -638,6 +676,6 @@ function resolveAnchors(opts, parsed, ctx, deno) {
638
676
  }
639
677
  finally {
640
678
  if (fs.existsSync(ctx.entryPath))
641
- fs.unlinkSync(ctx.entryPath);
679
+ ctx.guard.unlink(ctx.entryPath);
642
680
  }
643
681
  }
@@ -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>;