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.
- package/package.json +6 -6
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/sidecar/dist/src/bundler.d.ts +3 -92
- package/sidecar/dist/src/bundler.js +5 -265
- package/sidecar/dist/src/capture/anchors.js +113 -8
- package/sidecar/dist/src/capture/api.d.ts +6 -0
- package/sidecar/dist/src/capture/check-classify.js +27 -0
- package/sidecar/dist/src/capture/check-fields.js +19 -5
- package/sidecar/dist/src/capture/check-probe.d.ts +6 -1
- package/sidecar/dist/src/capture/check-probe.js +45 -2
- package/sidecar/dist/src/capture/check-workspace.d.ts +3 -0
- package/sidecar/dist/src/capture/check-workspace.js +13 -16
- package/sidecar/dist/src/capture/check.js +5 -5
- package/sidecar/dist/src/capture/deep-walk.js +45 -12
- package/sidecar/dist/src/capture/deno-project.d.ts +2 -1
- package/sidecar/dist/src/capture/deno-project.js +22 -18
- package/sidecar/dist/src/capture/guarded-fs.d.ts +76 -0
- package/sidecar/dist/src/capture/guarded-fs.js +182 -0
- package/sidecar/dist/src/capture/index.d.ts +1 -0
- package/sidecar/dist/src/capture/index.js +65 -27
- package/sidecar/dist/src/capture/outside-root.d.ts +41 -0
- package/sidecar/dist/src/capture/outside-root.js +101 -0
- package/sidecar/dist/src/capture/paths-rewrite.d.ts +8 -0
- package/sidecar/dist/src/capture/paths-rewrite.js +9 -7
- package/sidecar/dist/src/capture/repair-dangling.d.ts +11 -1
- package/sidecar/dist/src/capture/repair-dangling.js +15 -4
- package/sidecar/dist/src/capture/self-check.d.ts +3 -0
- package/sidecar/dist/src/capture/self-check.js +3 -3
- package/sidecar/dist/src/capture/service-config.d.ts +19 -0
- package/sidecar/dist/src/capture/service-config.js +57 -0
- package/sidecar/dist/src/index.d.ts +1 -1
- package/sidecar/dist/src/index.js +4 -115
- package/sidecar/dist/src/origin.d.ts +49 -8
- package/sidecar/dist/src/origin.js +81 -8
- package/sidecar/dist/src/project-loader.d.ts +10 -2
- package/sidecar/dist/src/project-loader.js +22 -21
- package/sidecar/dist/src/type-inferrer.d.ts +72 -0
- package/sidecar/dist/src/type-inferrer.js +248 -8
- package/sidecar/dist/src/type-structural-expander.d.ts +5 -1
- package/sidecar/dist/src/type-structural-expander.js +1 -1
- package/sidecar/dist/src/types.d.ts +39 -178
- package/sidecar/dist/src/validators.d.ts +70 -914
- package/sidecar/dist/src/validators.js +2 -55
- package/sidecar/dist/src/monorepo-builder.d.ts +0 -129
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
310
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
56
|
+
export declare function repairDanglingImports(failing: Map<string, Set<string>>, tree: WriteGuard): Map<string, RepairedFile>;
|