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
@@ -23,6 +23,11 @@
23
23
  * at compare time (ts_check type-checker `unwrapGraphqlPayload`, deleted in
24
24
  * WP8); the type-level port here is its v2-native equivalent.
25
25
  *
26
+ * GraphQL pairs also read the consumer's `__typename` as optional, at any depth
27
+ * (carrick#1759): the server adds that meta-field to every object a selection
28
+ * asks for, so a resolver's return type never states it, while a consumer type
29
+ * generated from a document that selects it declares it required.
30
+ *
26
31
  * Seam: node builtins + `typescript` + this bundle only. No imports needed here.
27
32
  */
28
33
  /**
@@ -172,12 +177,36 @@ export function buildProbe(spec, packageOf) {
172
177
  push(`type GqlPayloadKeys<T> = { [K in keyof T]-?: [GqlObjLike<T[K]>] extends [true] ? K : never }[keyof T];`);
173
178
  push(`type GqlSingleKey<K> = [K] extends [never] ? never : ([GqlIsUnion<K>] extends [false] ? K : never);`);
174
179
  push(`type GqlPayloadOf<S> = [S] extends [readonly unknown[]] ? never : [GqlIsUnion<S>] extends [true] ? never : [S] extends [object] ? ([GqlSingleKey<GqlPayloadKeys<S>>] extends [never] ? never : S[GqlSingleKey<GqlPayloadKeys<S>> & keyof S]) : never;`);
175
- push(`type GqlComparand = [Sent] extends [Expected] ? Sent : ([GqlPayloadOf<Sent>] extends [never] ? Sent : GqlPayloadOf<Sent>);`);
180
+ // carrick#1759: the server supplies `__typename` on every object a
181
+ // selection asks for, so the consumer's `__typename` is read as OPTIONAL,
182
+ // at any depth. It keeps its declared type: a producer that states a
183
+ // different one is still a mismatch, and tsc stays the judge of that.
184
+ //
185
+ // - only an object that declares `__typename` is rebuilt, through one
186
+ // anonymous mapped type, so modifiers survive, the field walk sees an
187
+ // object root, and a headline prints its members, not a helper name;
188
+ // - arrays and tuples map homomorphically, so they stay arrays, and a
189
+ // function member is kept whole (a mapped function loses its call
190
+ // signature and would accept anything);
191
+ // - the depth bound ends the expansion; below it the consumer type is
192
+ // compared as declared, which can only keep a mismatch;
193
+ // - `GqlExpected` keeps the declared type whenever relaxing changes
194
+ // nothing observable, so a consumer with no `__typename` to relax is
195
+ // judged, and named in the headline, exactly as before.
196
+ //
197
+ // The envelope short-circuit below tests the RELAXED consumer: a bare
198
+ // payload with one object-shaped property would otherwise fail it on
199
+ // `__typename` alone and unwrap onto that property.
200
+ push(`type GqlDepth = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];`);
201
+ push(`type GqlTypenameParts<T, D extends number> = { [K in keyof T as K extends '__typename' ? never : K]: GqlTypenameOptional<T[K], GqlDepth[D]> } & { [K in keyof T as K extends '__typename' ? K : never]?: T[K] };`);
202
+ push(`type GqlTypenameOptional<T, D extends number = 16> = [D] extends [never] ? T : T extends (...args: any[]) => any ? T : T extends object ? ('__typename' extends keyof T ? { [K in keyof GqlTypenameParts<T, D>]: GqlTypenameParts<T, D>[K] } : { [K in keyof T]: GqlTypenameOptional<T[K], GqlDepth[D]> }) : T;`);
203
+ push(`type GqlExpected = [GqlTypenameOptional<Expected>] extends [Expected] ? Expected : GqlTypenameOptional<Expected>;`);
204
+ push(`type GqlComparand = [Sent] extends [GqlExpected] ? Sent : ([GqlPayloadOf<Sent>] extends [never] ? Sent : GqlPayloadOf<Sent>);`);
176
205
  gateLines.set(push(`type _G_comparand_any = Assert<Not<IsAny<GqlComparand>>>;`), 'sent:any');
177
206
  gateLines.set(push(`type _G_comparand_unknown = Assert<Not<IsUnknown<GqlComparand>>>;`), 'sent:unknown');
178
207
  gateLines.set(push(`type _G_comparand_never = Assert<Not<IsNever<GqlComparand>>>;`), 'sent:never');
179
208
  push(`declare const sentComparand: GqlComparand;`);
180
- assignmentLine = push(`const expected: Expected = sentComparand;`);
209
+ assignmentLine = push(`const expected: GqlExpected = sentComparand;`);
181
210
  }
182
211
  else {
183
212
  assignmentLine = push(`const expected: Expected = sent;`);
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import type { CheckStubInput } from './api.js';
16
16
  import type { ProbePlan } from './check-probe.js';
17
+ import { WriteGuard } from './guarded-fs.js';
17
18
  export interface AssembledStub {
18
19
  serviceName: string;
19
20
  /** Workspace package dir under packages/ (== the @carrick/<dir> suffix). */
@@ -23,6 +24,8 @@ export interface AssembledStub {
23
24
  }
24
25
  export interface AssembledWorkspace {
25
26
  workspaceDir: string;
27
+ /** Every write and the clean-up are held to `workspaceDir` (carrick#1748). */
28
+ guard: WriteGuard;
26
29
  probesDir: string;
27
30
  /** Relative (forward-slash) probes dir, e.g. packages/carrick-probes. */
28
31
  probesRel: string;
@@ -13,8 +13,8 @@
13
13
  * Seam: node builtins + this bundle only.
14
14
  */
15
15
  import * as fs from 'node:fs';
16
- import * as os from 'node:os';
17
16
  import * as path from 'node:path';
17
+ import { WriteGuard } from './guarded-fs.js';
18
18
  const PROBES_PACKAGE = 'carrick-probes';
19
19
  function parseVersion(v) {
20
20
  const core = v.replace(/^[^\d]*/, '').split('-')[0].split('+')[0];
@@ -108,13 +108,12 @@ function packageDirOf(packageName) {
108
108
  }
109
109
  /** Create the scratch workspace and copy in the stub packages. */
110
110
  export function assembleWorkspace(opts) {
111
- const root = opts.workspaceRoot ?? os.tmpdir();
112
- fs.mkdirSync(root, { recursive: true });
113
- const workspaceDir = fs.mkdtempSync(path.join(root, 'carrick-check-v2-'));
111
+ // A fresh directory under the caller's root (the OS temp dir by default).
112
+ const { dir: workspaceDir, guard } = WriteGuard.scratch('carrick-check-v2-', opts.workspaceRoot);
114
113
  const packagesDir = path.join(workspaceDir, 'packages');
115
- fs.mkdirSync(packagesDir, { recursive: true });
116
- fs.writeFileSync(path.join(workspaceDir, '.npmrc'), NPMRC);
117
- fs.writeFileSync(path.join(workspaceDir, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n');
114
+ guard.mkdir(packagesDir);
115
+ guard.writeFile(path.join(workspaceDir, '.npmrc'), NPMRC);
116
+ guard.writeFile(path.join(workspaceDir, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n');
118
117
  const assembled = [];
119
118
  const dependencySets = [];
120
119
  const svcToPkg = new Map();
@@ -124,10 +123,7 @@ export function assembleWorkspace(opts) {
124
123
  const packageName = readStubPackageName(stub.stub_dir);
125
124
  const packageDir = packageDirOf(packageName);
126
125
  const dest = path.join(packagesDir, packageDir);
127
- fs.cpSync(stub.stub_dir, dest, {
128
- recursive: true,
129
- filter: (src) => !src.split(path.sep).includes('node_modules'),
130
- });
126
+ guard.copyTree(stub.stub_dir, dest, (src) => !src.split(path.sep).includes('node_modules'));
131
127
  assembled.push({ serviceName: stub.service_name, packageDir, packageName });
132
128
  dependencySets.push({ dependencies: readStubDependencies(stub.stub_dir) });
133
129
  svcToPkg.set(stub.service_name, packageName);
@@ -136,26 +132,27 @@ export function assembleWorkspace(opts) {
136
132
  }
137
133
  // Root manifest carries the semver-dedupe overrides.
138
134
  const overrides = computeDedupeOverrides(dependencySets);
139
- fs.writeFileSync(path.join(workspaceDir, 'package.json'), JSON.stringify({
135
+ guard.writeFile(path.join(workspaceDir, 'package.json'), JSON.stringify({
140
136
  name: 'carrick-check-workspace',
141
137
  version: '0.0.0',
142
138
  private: true,
143
139
  ...(Object.keys(overrides).length > 0 ? { pnpm: { overrides } } : {}),
144
140
  }, null, 2) + '\n');
145
141
  const probesDir = path.join(packagesDir, PROBES_PACKAGE);
146
- fs.mkdirSync(path.join(probesDir, 'probes'), { recursive: true });
142
+ guard.mkdir(path.join(probesDir, 'probes'));
147
143
  const probeDeps = {};
148
144
  for (const stub of assembled)
149
145
  probeDeps[stub.packageName] = 'workspace:*';
150
- fs.writeFileSync(path.join(probesDir, 'package.json'), JSON.stringify({
146
+ guard.writeFile(path.join(probesDir, 'package.json'), JSON.stringify({
151
147
  name: PROBES_PACKAGE,
152
148
  version: '0.0.0',
153
149
  private: true,
154
150
  dependencies: probeDeps,
155
151
  }, null, 2) + '\n');
156
- fs.writeFileSync(path.join(probesDir, 'tsconfig.json'), CHECKER_TSCONFIG);
152
+ guard.writeFile(path.join(probesDir, 'tsconfig.json'), CHECKER_TSCONFIG);
157
153
  return {
158
154
  workspaceDir,
155
+ guard,
159
156
  probesDir,
160
157
  probesRel: `packages/${PROBES_PACKAGE}`,
161
158
  stubs: assembled,
@@ -172,7 +169,7 @@ export function assembleWorkspace(opts) {
172
169
  /** Write the generated probe files into the assembled probes package. */
173
170
  export function writeProbes(ws, plans) {
174
171
  for (const plan of plans) {
175
- fs.writeFileSync(path.join(ws.probesDir, 'probes', plan.fileName), plan.source);
172
+ ws.guard.writeFile(path.join(ws.probesDir, 'probes', plan.fileName), plan.source);
176
173
  }
177
174
  }
178
175
  const NPMRC = [
@@ -214,7 +214,7 @@ export async function runCheck(opts, onProgress) {
214
214
  ...unresolved,
215
215
  ]);
216
216
  if (cleanup)
217
- safeRm(ws.workspaceDir);
217
+ safeRm(ws);
218
218
  return {
219
219
  success: false,
220
220
  workspace_dir: cleanup ? '' : ws.workspaceDir,
@@ -254,7 +254,7 @@ export async function runCheck(opts, onProgress) {
254
254
  ...unresolved,
255
255
  ]);
256
256
  if (cleanup)
257
- safeRm(ws.workspaceDir);
257
+ safeRm(ws);
258
258
  return {
259
259
  success: false,
260
260
  workspace_dir: cleanup ? '' : ws.workspaceDir,
@@ -306,7 +306,7 @@ export async function runCheck(opts, onProgress) {
306
306
  ]);
307
307
  const workspaceDirOut = cleanup ? '' : ws.workspaceDir;
308
308
  if (cleanup)
309
- safeRm(ws.workspaceDir);
309
+ safeRm(ws);
310
310
  return {
311
311
  success: true,
312
312
  workspace_dir: workspaceDirOut,
@@ -453,9 +453,9 @@ function planPairs(opts, packageOf) {
453
453
  function fnvOfSpec(spec) {
454
454
  return buildProbe(spec, (s) => `@carrick/${s}`).pairId;
455
455
  }
456
- function safeRm(dir) {
456
+ function safeRm(ws) {
457
457
  try {
458
- fs.rmSync(dir, { recursive: true, force: true });
458
+ ws.guard.remove(ws.workspaceDir);
459
459
  }
460
460
  catch {
461
461
  /* best effort */
@@ -17,7 +17,10 @@ import ts from 'typescript';
17
17
  /** Cap on findings reported per alias. The FIRST one is what the check phase
18
18
  * pre-gates on, so verdicts never depend on this number; the rest are there to
19
19
  * tell a reader which fields are `any` (carrick#376), and a type with more
20
- * than this many is already better described by "this type is not typed". */
20
+ * than this many is already better described by "this type is not typed".
21
+ * The scanner mirrors it (`CAPTURE_FINDINGS_CAP` in
22
+ * `src/engine/type_compat_v2.rs`): a record this long may have stopped
23
+ * listing, so it cannot vouch for every position (carrick#1752). */
21
24
  const MAX_DEEP_FINDINGS = 32;
22
25
  /**
23
26
  * Depth-bounded structural walk for a disqualifying top type at ANY depth:
@@ -97,6 +100,16 @@ function walkTopTypes(root, program, checker, location, flagOf) {
97
100
  // about the whole type and makes any further finding meaningless.
98
101
  const found = [];
99
102
  let exhausted = false;
103
+ // One finding per kind and position, under the cap. Two union members that
104
+ // both type `x` reach the same position twice.
105
+ const named = new Set();
106
+ const note = (kind, path) => {
107
+ const key = `${kind}\u0000${path}`;
108
+ if (named.has(key) || found.length >= MAX_DEEP_FINDINGS)
109
+ return;
110
+ named.add(key);
111
+ found.push({ kind, path });
112
+ };
100
113
  const walk = (t, path, depth) => {
101
114
  // Genuine cycle handling — NOT fail-open. `t` is already on the walk stack
102
115
  // (or was fully explored earlier), so the owning frame completes it;
@@ -107,8 +120,25 @@ function walkTopTypes(root, program, checker, location, flagOf) {
107
120
  // any shallower re-entry — so `seen` never memoizes a truncated visit as
108
121
  // clean. And a type fully explored clean at depth d1 is clean at any d2 < d1
109
122
  // (the shallower reach is a superset of the deeper), so reusing it is safe.
110
- if (exhausted || seen.has(t))
123
+ if (exhausted)
124
+ return;
125
+ if (seen.has(t)) {
126
+ // A type met before has nothing left to walk, but a top type is a leaf
127
+ // that sits at every position typed with it, and `any` and `unknown`
128
+ // are intrinsics: the compiler hands out one object for each, however
129
+ // many members hold it. So this position is named too. Without it the
130
+ // record named the first `unknown` and the first `any` and nothing after
131
+ // them, and a member the source declares `unknown` hid a later one that
132
+ // an unresolved import left (carrick#1752). An object type met again is
133
+ // one declaration reached twice, and the first path already named
134
+ // every top type inside it, with its cause. The first finding, which
135
+ // the check phase pre-gates on, does not move: a repeat always follows
136
+ // the first meeting.
137
+ const kind = depth > 0 ? flagOf(t) : undefined;
138
+ if (kind)
139
+ note(kind, path);
111
140
  return;
141
+ }
112
142
  // Budget exhaustion FAILS CLOSED. Returning "clean" here would let an
113
143
  // `any` buried past the depth/node budget read compatible — the fail-open
114
144
  // cliff a bigger number only relocates. Instead abstain: the alias demotes
@@ -140,8 +170,7 @@ function walkTopTypes(root, program, checker, location, flagOf) {
140
170
  if (depth > 0) {
141
171
  const kind = flagOf(t);
142
172
  if (kind) {
143
- if (found.length < MAX_DEEP_FINDINGS)
144
- found.push({ kind, path });
173
+ note(kind, path);
145
174
  return;
146
175
  }
147
176
  }
@@ -170,16 +199,20 @@ function walkTopTypes(root, program, checker, location, flagOf) {
170
199
  return;
171
200
  }
172
201
  // Type arguments: arrays, tuples, Promise<T>, Map<K, V>, ...
173
- if (t.objectFlags & ts.ObjectFlags.Reference) {
174
- const args = checker.getTypeArguments(t);
175
- for (let i = 0; i < args.length; i++) {
176
- walk(args[i], `${path}<${i}>`, depth + 1);
177
- if (exhausted)
178
- return;
179
- }
202
+ const args = t.objectFlags & ts.ObjectFlags.Reference
203
+ ? checker.getTypeArguments(t)
204
+ : [];
205
+ for (let i = 0; i < args.length; i++) {
206
+ walk(args[i], `${path}<${i}>`, depth + 1);
207
+ if (exhausted)
208
+ return;
180
209
  }
181
- // Index signatures: { [k: string]: T }, Record<string, T>.
210
+ // Index signatures: { [k: string]: T }, Record<string, T>. An array's
211
+ // number index is its element type argument again, the position walked
212
+ // just above under `<0>`: walking it twice would name one element twice.
182
213
  for (const info of checker.getIndexInfosOfType(t)) {
214
+ if (args.includes(info.type))
215
+ continue;
183
216
  walk(info.type, `${path}[index]`, depth + 1);
184
217
  if (exhausted)
185
218
  return;
@@ -1,5 +1,6 @@
1
1
  /** Deno owns module resolution; both compiler frontends consume its graph. */
2
2
  import ts from 'typescript';
3
+ import { WriteGuard } from './guarded-fs.js';
3
4
  export interface DenoConfig {
4
5
  configPath: string;
5
6
  workspaceRoot: string;
@@ -48,5 +49,5 @@ export declare class DenoProject {
48
49
  /** Turn a Deno npm alias into its real package export, with the exact pin. */
49
50
  private externalName;
50
51
  /** Relocate Deno's per-file resolutions into the portable declaration tree. */
51
- rewrite(typesDir: string, files: string[], sourceByEmitted: Map<string, string>): number;
52
+ rewrite(guard: WriteGuard, typesDir: string, files: string[], sourceByEmitted: Map<string, string>): number;
52
53
  }
@@ -6,6 +6,7 @@ import { pathToFileURL, fileURLToPath } from 'node:url';
6
6
  import { createHash } from 'node:crypto';
7
7
  import { execFileSync } from 'node:child_process';
8
8
  import { rewriteSpecifiers } from './specifiers.js';
9
+ import { WriteGuard } from './guarded-fs.js';
9
10
  function readConfig(file) {
10
11
  const parsed = ts.parseConfigFileTextToJson(file, fs.readFileSync(file, 'utf8'));
11
12
  if (parsed.error)
@@ -104,7 +105,13 @@ export class DenoProject {
104
105
  this.repoRoot = repoRoot;
105
106
  const cache = path.join(config.workspaceRoot, '.carrick', 'deno', createHash('sha256').update(path.resolve(repoRoot)).digest('hex').slice(0, 16));
106
107
  this.cacheDir = cache;
107
- fs.mkdirSync(cache, { recursive: true });
108
+ // The graph root lives within the service so compilerOptions.types uses
109
+ // that member's import-map scope, just like the real source files do.
110
+ const entry = path.join(repoRoot, '.carrick', 'deno', 'graph.ts');
111
+ // Its cache and its graph root are the only places a Deno project writes,
112
+ // both inside `.carrick/deno` (carrick#1748).
113
+ const guard = WriteGuard.of({ dirs: [cache, path.dirname(entry)], protect: [repoRoot, config.workspaceRoot] });
114
+ guard.mkdir(cache);
108
115
  const raw = { ...config.compilerOptions };
109
116
  const libs = Array.isArray(raw.lib) ? raw.lib : ['deno.window'];
110
117
  const denoLibs = libs.filter(l => l.startsWith('deno.'));
@@ -138,24 +145,21 @@ export class DenoProject {
138
145
  (ts.isInterfaceDeclaration(s) && s.name.text === 'ImportMeta')).map(s => s.getFullText(source)).join('\n');
139
146
  }
140
147
  const globalPath = path.join(cache, 'runtime.d.ts');
141
- fs.writeFileSync(globalPath, text);
148
+ guard.writeFile(globalPath, text);
142
149
  this.globals.push(globalPath);
143
150
  }
144
- // The graph root lives within the service so compilerOptions.types uses
145
- // that member's import-map scope, just like the real source files do.
146
- const entry = path.join(repoRoot, '.carrick', 'deno', 'graph.ts');
147
- fs.mkdirSync(path.dirname(entry), { recursive: true });
151
+ guard.mkdir(path.dirname(entry));
148
152
  const roots = this.parsed.fileNames.map(f => pathToFileURL(f).href);
149
153
  const extraTypes = config.compilerOptions.types;
150
154
  if (Array.isArray(extraTypes))
151
155
  roots.push(...extraTypes.filter((t) => typeof t === 'string'));
152
- fs.writeFileSync(entry, roots.map(s => `import ${JSON.stringify(s)};`).join('\n'));
156
+ guard.writeFile(entry, roots.map(s => `import ${JSON.stringify(s)};`).join('\n'));
153
157
  let graph;
154
158
  try {
155
159
  graph = JSON.parse(runDeno(['info', '--json', '--frozen', '--node-modules-dir=none', '--config', config.configPath, entry], repoRoot));
156
160
  }
157
161
  finally {
158
- fs.rmSync(entry, { force: true });
162
+ guard.remove(entry);
159
163
  }
160
164
  if (!Array.isArray(graph.modules))
161
165
  throw new Error('Unsupported deno info JSON: missing modules array');
@@ -181,8 +185,8 @@ export class DenoProject {
181
185
  continue;
182
186
  }
183
187
  local = path.join(cache, 'remote', createHash('sha256').update(module.specifier).digest('hex') + extension);
184
- fs.mkdirSync(path.dirname(local), { recursive: true });
185
- fs.copyFileSync(module.local, local);
188
+ guard.mkdir(path.dirname(local));
189
+ guard.copyFile(module.local, local);
186
190
  }
187
191
  this.localPaths.set(module.specifier, path.resolve(local));
188
192
  this.specifiersByPath.set(path.resolve(local), module.specifier);
@@ -210,7 +214,7 @@ export class DenoProject {
210
214
  this.globals.push(local);
211
215
  }
212
216
  }
213
- fs.writeFileSync(path.join(cache, 'resolution-diagnostics.json'), JSON.stringify(this.diagnostics, null, 2));
217
+ guard.writeFile(path.join(cache, 'resolution-diagnostics.json'), JSON.stringify(this.diagnostics, null, 2));
214
218
  this.parsed.fileNames.push(...this.globals);
215
219
  }
216
220
  targetPath(resolution, from = this.repoRoot) {
@@ -404,7 +408,7 @@ export class DenoProject {
404
408
  return undefined;
405
409
  }
406
410
  /** Relocate Deno's per-file resolutions into the portable declaration tree. */
407
- rewrite(typesDir, files, sourceByEmitted) {
411
+ rewrite(guard, typesDir, files, sourceByEmitted) {
408
412
  let count = 0;
409
413
  const emittedBySource = new Map([...sourceByEmitted].map(([rel, source]) => [path.resolve(source), rel]));
410
414
  for (const rel of files) {
@@ -427,26 +431,26 @@ export class DenoProject {
427
431
  return relative;
428
432
  });
429
433
  if (result.rewrites)
430
- fs.writeFileSync(file, result.text);
434
+ guard.writeFile(file, result.text);
431
435
  count += result.rewrites;
432
436
  }
433
437
  const runtime = this.globals.find(file => path.basename(file) === 'runtime.d.ts');
434
438
  const runtimeRel = runtime && emittedBySource.get(path.resolve(runtime));
435
439
  if (runtimeRel)
436
- isolateRuntime(typesDir, files, runtimeRel);
440
+ isolateRuntime(guard, typesDir, files, runtimeRel);
437
441
  const references = this.globals.filter(file => file !== runtime)
438
442
  .map(file => emittedBySource.get(path.resolve(file)))
439
443
  .filter((file) => file !== undefined)
440
444
  .map(file => `/// <reference path=${JSON.stringify('./' + file)} />`);
441
445
  if (references.length) {
442
446
  const surface = path.join(typesDir, 'surface.d.ts');
443
- fs.writeFileSync(surface, references.join('\n') + '\n' + fs.readFileSync(surface, 'utf8'));
447
+ guard.writeFile(surface, references.join('\n') + '\n' + fs.readFileSync(surface, 'utf8'));
444
448
  }
445
449
  return count;
446
450
  }
447
451
  }
448
452
  /** Runtime declarations belong to their producer, not the checker's globals. */
449
- function isolateRuntime(typesDir, files, runtimeRel) {
453
+ function isolateRuntime(guard, typesDir, files, runtimeRel) {
450
454
  const runtimePath = path.join(typesDir, runtimeRel);
451
455
  const program = ts.createProgram(files.map(file => path.join(typesDir, file)), {
452
456
  strict: true, skipLibCheck: true, target: ts.ScriptTarget.ESNext,
@@ -510,7 +514,7 @@ function isolateRuntime(typesDir, files, runtimeRel) {
510
514
  for (const edit of edits.sort((a, b) => b.start - a.start))
511
515
  text = text.slice(0, edit.start) + edit.text + text.slice(edit.end);
512
516
  if (edits.length)
513
- fs.writeFileSync(file, text);
517
+ guard.writeFile(file, text);
514
518
  }
515
519
  while (pending.length) {
516
520
  const visit = (node) => {
@@ -545,5 +549,5 @@ function isolateRuntime(typesDir, files, runtimeRel) {
545
549
  }
546
550
  }
547
551
  const text = ts.createPrinter().printFile(ts.factory.updateSourceFile(runtime, statements));
548
- fs.writeFileSync(runtimePath, '// Runtime declarations from deno types. Copyright the Deno authors. MIT license.\n/// <reference lib="esnext" />\n' + text + `\nexport { ${[...names].sort().join(', ')} };\n`);
552
+ guard.writeFile(runtimePath, '// Runtime declarations from deno types. Copyright the Deno authors. MIT license.\n/// <reference lib="esnext" />\n' + text + `\nexport { ${[...names].sort().join(', ')} };\n`);
549
553
  }
@@ -0,0 +1,76 @@
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
+ /** A write or delete the guard refused, or a root it would not accept. */
26
+ export declare class WriteRefused extends Error {
27
+ constructor(message: string);
28
+ }
29
+ export interface WriteRoots {
30
+ /** Directories a run may write anywhere beneath, each one included. */
31
+ dirs?: string[];
32
+ /** Single files a run may write and delete, and nothing beside them. */
33
+ files?: string[];
34
+ /**
35
+ * Trees the run reads: no root may be one of them or contain one. A root
36
+ * that contained the scanned repo would let a stub's clean-up delete it.
37
+ */
38
+ protect?: string[];
39
+ }
40
+ export declare class WriteGuard {
41
+ private readonly dirs;
42
+ private readonly files;
43
+ private readonly protect;
44
+ private constructor();
45
+ /** A guard over `roots`. Throws when a root equals or contains a protected tree. */
46
+ static of(roots: WriteRoots): WriteGuard;
47
+ /**
48
+ * A new, empty directory under `parent` (the OS temp dir by default) and a
49
+ * guard over it alone. `mkdtemp` picks an unused name, so creating it cannot
50
+ * overwrite anything; `parent` is created when missing.
51
+ */
52
+ static scratch(prefix: string, parent?: string): {
53
+ dir: string;
54
+ guard: WriteGuard;
55
+ };
56
+ /** This guard with more roots, under the same protected trees. */
57
+ with(roots: Omit<WriteRoots, 'protect'>): WriteGuard;
58
+ /** A guard over `dir` alone, which must already be writable under this one. */
59
+ narrow(dir: string): WriteGuard;
60
+ /** Whether a write to `p` would land inside this guard's roots. */
61
+ allowsWrite(p: string): boolean;
62
+ /** Throws unless `p` (a subprocess's working directory, say) lies inside the roots. */
63
+ assertWithin(p: string): void;
64
+ writeFile(p: string, data: string | NodeJS.ArrayBufferView): void;
65
+ /** `mkdir -p`. A directory that already exists is no write and passes unchecked. */
66
+ mkdir(p: string): void;
67
+ copyFile(from: string, to: string): void;
68
+ /** `cp -R`. Links in the source are copied as links, never written through. */
69
+ copyTree(from: string, to: string, filter?: (source: string) => boolean): void;
70
+ /** `rm -rf`. A link is removed itself; its target is left alone. */
71
+ remove(p: string): void;
72
+ unlink(p: string): void;
73
+ symlink(target: string, linkPath: string, type?: fs.symlink.Type): void;
74
+ private holds;
75
+ private check;
76
+ }