carrick 0.3.104 → 0.3.106

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 (33) hide show
  1. package/package.json +6 -6
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/sidecar/dist/src/capture/anchors.d.ts +13 -0
  4. package/sidecar/dist/src/capture/anchors.js +229 -37
  5. package/sidecar/dist/src/capture/api.d.ts +64 -4
  6. package/sidecar/dist/src/capture/check-classify.d.ts +10 -1
  7. package/sidecar/dist/src/capture/check-classify.js +23 -12
  8. package/sidecar/dist/src/capture/check-fields.js +4 -6
  9. package/sidecar/dist/src/capture/check-probe.js +16 -6
  10. package/sidecar/dist/src/capture/check-scrub.d.ts +3 -0
  11. package/sidecar/dist/src/capture/check-scrub.js +8 -4
  12. package/sidecar/dist/src/capture/check.js +75 -49
  13. package/sidecar/dist/src/capture/deep-walk.d.ts +20 -0
  14. package/sidecar/dist/src/capture/deep-walk.js +51 -11
  15. package/sidecar/dist/src/capture/guarded-fs.d.ts +5 -1
  16. package/sidecar/dist/src/capture/guarded-fs.js +23 -1
  17. package/sidecar/dist/src/capture/index.js +14 -2
  18. package/sidecar/dist/src/capture/member-name.d.ts +20 -0
  19. package/sidecar/dist/src/capture/member-name.js +24 -0
  20. package/sidecar/dist/src/capture/self-check.js +50 -9
  21. package/sidecar/dist/src/capture/service-config.d.ts +2 -0
  22. package/sidecar/dist/src/capture/service-config.js +1 -1
  23. package/sidecar/dist/src/failure-path.d.ts +67 -0
  24. package/sidecar/dist/src/failure-path.js +236 -0
  25. package/sidecar/dist/src/printed-names.d.ts +43 -0
  26. package/sidecar/dist/src/printed-names.js +186 -0
  27. package/sidecar/dist/src/retype.js +60 -99
  28. package/sidecar/dist/src/type-inferrer.d.ts +35 -3
  29. package/sidecar/dist/src/type-inferrer.js +208 -13
  30. package/sidecar/dist/src/type-structural-expander.js +10 -1
  31. package/sidecar/dist/src/types.d.ts +24 -0
  32. package/sidecar/dist/src/validators.d.ts +76 -0
  33. package/sidecar/dist/src/validators.js +8 -0
@@ -29,6 +29,7 @@
29
29
  * Seam: node builtins + `typescript` + this bundle only.
30
30
  */
31
31
  import ts from 'typescript';
32
+ import { memberPath } from './member-name.js';
32
33
  /**
33
34
  * Cap on named fields. A mismatch with more differing members than this is
34
35
  * better described as two unrelated shapes than as a list, and the text says
@@ -172,7 +173,7 @@ function walk(sent, expected, path, depth, ctx) {
172
173
  * makes a sender-only member worth naming beside it. */
173
174
  let absent = 0;
174
175
  for (const [name, expectedProp] of expectedProps) {
175
- const at = join(path, name);
176
+ const at = memberPath(path, expectedProp, checker);
176
177
  const sentProp = sentProps.get(name);
177
178
  const supplied = ctx.serverSupplied.has(name);
178
179
  if (!sentProp) {
@@ -226,15 +227,12 @@ function walk(sent, expected, path, depth, ctx) {
226
227
  // producer returns — nothing is absent and nothing is named.
227
228
  if (absent === 0)
228
229
  return;
229
- for (const [name] of sentProps) {
230
+ for (const [name, sentProp] of sentProps) {
230
231
  if (expectedProps.has(name))
231
232
  continue;
232
- ctx.found.push({ path: join(path, name), nature: 'extra_in_sent' });
233
+ ctx.found.push({ path: memberPath(path, sentProp, checker), nature: 'extra_in_sent' });
233
234
  }
234
235
  }
235
- function join(path, name) {
236
- return path === '' ? name : `${path}.${name}`;
237
- }
238
236
  function isOptional(symbol) {
239
237
  return (symbol.flags & ts.SymbolFlags.Optional) !== 0;
240
238
  }
@@ -155,8 +155,8 @@ export function buildProbe(spec, packageOf) {
155
155
  // them in, so a route sending a `Uint8Array` read with `.blob()` is not a
156
156
  // mismatch, and neither is the same route read as text. Either side, either
157
157
  // half: a file upload states no fields a declared body could be compared
158
- // with. The classifier lets this gate override a mismatch only, so bytes
159
- // that assign to bytes still read compatible. `null`/`undefined` are
158
+ // with. Bytes that assign to bytes are not a contract either: the check
159
+ // cannot read a file's content (carrick#1812). `null`/`undefined` are
160
160
  // dropped first, so `Blob | null` still gates, while a bare `null`,
161
161
  // `undefined` or `never` leaves nothing to test.
162
162
  if (spec.protocol === 'http') {
@@ -182,6 +182,12 @@ export function buildProbe(spec, packageOf) {
182
182
  // or several (ambiguous envelope, or a bare payload whose own fields
183
183
  // include more than one object-shaped property) keep the whole type —
184
184
  // the unwrap never fires on anything but an unambiguous envelope;
185
+ // - a consumer that names a field only the outer object has (not the
186
+ // payload under its one object-shaped property) reads that object, so
187
+ // it keeps the whole type (carrick#1764): a bare payload with one
188
+ // object-shaped property is not an envelope around it. A field both
189
+ // declare says nothing either way, and neither does `__typename`, which
190
+ // the server puts on every object (see below);
185
191
  // - `GqlObjLike` can never select `any`/`unknown`/`never` (top/bottom
186
192
  // types fail both its array and its object arm), and the comparand is
187
193
  // re-gated below anyway — the v2 port of v1's comparand re-guard, so
@@ -191,6 +197,10 @@ export function buildProbe(spec, packageOf) {
191
197
  push(`type GqlPayloadKeys<T> = { [K in keyof T]-?: [GqlObjLike<T[K]>] extends [true] ? K : never }[keyof T];`);
192
198
  push(`type GqlSingleKey<K> = [K] extends [never] ? never : ([GqlIsUnion<K>] extends [false] ? K : never);`);
193
199
  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;`);
200
+ // The field names a reading declares: the keys of every object member, so
201
+ // a nullable or union consumer names what each member names.
202
+ push(`type GqlNames<T> = T extends object ? keyof T : never;`);
203
+ push(`type GqlReadsOuter<S, E> = [Extract<Exclude<GqlNames<S>, GqlNames<GqlPayloadOf<S>>>, Exclude<GqlNames<E>, '__typename'>>] extends [never] ? false : true;`);
194
204
  // carrick#1759: the server supplies `__typename` on every object a
195
205
  // selection asks for, so the consumer's `__typename` is read as OPTIONAL,
196
206
  // at any depth. It keeps its declared type: a producer that states a
@@ -208,14 +218,14 @@ export function buildProbe(spec, packageOf) {
208
218
  // nothing observable, so a consumer with no `__typename` to relax is
209
219
  // judged, and named in the headline, exactly as before.
210
220
  //
211
- // The envelope short-circuit below tests the RELAXED consumer: a bare
212
- // payload with one object-shaped property would otherwise fail it on
213
- // `__typename` alone and unwrap onto that property.
221
+ // The envelope short-circuit below tests the RELAXED consumer: a consumer
222
+ // that names only fields the payload shares would otherwise fail it on
223
+ // `__typename` alone and unwrap onto that payload.
214
224
  push(`type GqlDepth = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];`);
215
225
  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] };`);
216
226
  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;`);
217
227
  push(`type GqlExpected = [GqlTypenameOptional<Expected>] extends [Expected] ? Expected : GqlTypenameOptional<Expected>;`);
218
- push(`type GqlComparand = [Sent] extends [GqlExpected] ? Sent : ([GqlPayloadOf<Sent>] extends [never] ? Sent : GqlPayloadOf<Sent>);`);
228
+ push(`type GqlComparand = [Sent] extends [GqlExpected] ? Sent : ([GqlPayloadOf<Sent>] extends [never] ? Sent : [GqlReadsOuter<Sent, Expected>] extends [true] ? Sent : GqlPayloadOf<Sent>);`);
219
229
  gateLines.set(push(`type _G_comparand_any = Assert<Not<IsAny<GqlComparand>>>;`), 'sent:any');
220
230
  gateLines.set(push(`type _G_comparand_unknown = Assert<Not<IsUnknown<GqlComparand>>>;`), 'sent:unknown');
221
231
  gateLines.set(push(`type _G_comparand_never = Assert<Not<IsNever<GqlComparand>>>;`), 'sent:never');
@@ -16,6 +16,9 @@
16
16
  export interface ScrubContext {
17
17
  /** Absolute path of the scratch workspace root (fully removed from output). */
18
18
  workspaceRoot: string;
19
+ /** The same root with symlinks resolved, as a tool that prints real paths
20
+ * names it (pnpm, under a macOS temp dir). Absent when there is no tree. */
21
+ workspaceRealRoot?: string;
19
22
  /** Workspace package dir (== sanitized service) -> the @carrick/<dir> label. */
20
23
  packageLabelOf: (packageDir: string) => string | undefined;
21
24
  }
@@ -46,10 +46,14 @@ export function scrubPaths(text, ctx) {
46
46
  let out = text.replace(IMPORT_PATH_RE, (_m, p1) => {
47
47
  return `import("${labelForImportPath(p1, ctx)}")`;
48
48
  });
49
- // Defensive: no raw temp path may survive anywhere in the message.
50
- if (ctx.workspaceRoot) {
51
- out = out.split(ctx.workspaceRoot).join('');
52
- out = out.split(ctx.workspaceRoot.split('/').join('\\')).join('');
49
+ // Defensive: no raw temp path may survive anywhere in the message. The real
50
+ // root goes first: the given root can be its suffix (`/private/var/...`
51
+ // against `/var/...`), and stripping that first would strand the prefix.
52
+ for (const root of [ctx.workspaceRealRoot, ctx.workspaceRoot]) {
53
+ if (!root)
54
+ continue;
55
+ out = out.split(root).join('');
56
+ out = out.split(root.split('/').join('\\')).join('');
53
57
  }
54
58
  return out;
55
59
  }
@@ -103,15 +103,18 @@ export async function runCheck(opts, onProgress) {
103
103
  // to a flat npm install that manufactures nominal false-incompatibles. Fail
104
104
  // explicitly instead (design Check step 2).
105
105
  if (!fs.existsSync(pnpmPath)) {
106
- const planned = planPairs(opts, (s) => `@carrick/${s}`);
106
+ // Capture-decay verdicts stand without isolation too (carrick#1833).
107
+ const { preGated, probing } = preGate(planPairs(opts, (s) => `@carrick/${s}`), readStubAliasRecords(opts.stubs));
107
108
  return {
108
109
  success: false,
109
110
  workspace_dir: '',
110
111
  isolation: 'unavailable',
111
112
  install_ok: false,
112
- install_error: 'vendored pnpm not found; type isolation is unavailable',
113
113
  ts_version: tsVersion,
114
- verdicts: sortVerdicts(unverifiableAll(planned, 'isolation:unavailable', 'type isolation unavailable (pnpm missing); compatibility cannot be verified.')),
114
+ verdicts: sortVerdicts([
115
+ ...unverifiableAll(probing, 'isolation:unavailable', 'type isolation unavailable (pnpm missing); compatibility cannot be verified.'),
116
+ ...preGated,
117
+ ]),
115
118
  degraded_services: opts.stubs.map((s) => ({
116
119
  service_name: s.service_name,
117
120
  reason: 'isolation unavailable',
@@ -126,6 +129,7 @@ export async function runCheck(opts, onProgress) {
126
129
  });
127
130
  const scrubCtx = {
128
131
  workspaceRoot: ws.workspaceDir,
132
+ workspaceRealRoot: fs.realpathSync(ws.workspaceDir),
129
133
  packageLabelOf: ws.packageLabelOf,
130
134
  };
131
135
  // Generate probes. A pair naming a service with no stub is unverifiable.
@@ -154,61 +158,26 @@ export async function runCheck(opts, onProgress) {
154
158
  const spec = opts.pairs.find((p) => p.pair_key === v.pair_key);
155
159
  v.pair_id = fnvOfSpec(spec);
156
160
  }
157
- // Capture-time deep-decay pre-gate (adversarial-review finding 1): a
158
- // member-level `any`/`unknown` recorded by the capture self-check with no
159
- // failing-external explanation. The probe gates below are WHOLE-type only
160
- // — `{ orderId: string; metadata: any }` sails through IsAny and the
161
- // assignment compiles clean — so such pairs must never reach a probe.
162
- // `any` routes to gate_caught_baked_any, `unknown` to unverifiable; both
163
- // read as None downstream, never compatible.
164
161
  const aliasRecords = readStubAliasRecords(opts.stubs);
165
- const preGated = [];
166
- const probing = [];
167
- for (const plan of plans) {
168
- const hit = deepDecayOf(plan, aliasRecords);
169
- if (!hit) {
170
- probing.push(plan);
171
- continue;
172
- }
173
- preGated.push({
174
- pair_id: plan.pairId,
175
- pair_key: plan.spec.pair_key,
176
- // `any` is a confirmed baked top type -> gate_caught_baked_any; `unknown`
177
- // and `budget_exhausted` (a subtree the capture walk could not finish)
178
- // are "cannot verify" -> unverifiable. All read as None downstream.
179
- bucket: hit.kind === 'any' ? 'gate_caught_baked_any' : 'unverifiable',
180
- gate: `capture:${hit.side}:${hit.kind}`,
181
- diagnostic: hit.kind === 'budget_exhausted'
182
- ? `the ${hit.side} type is too deep or wide to verify within the ` +
183
- `capture budget (at '${hit.path}'); compatibility cannot be ` +
184
- `verified — abstaining so a buried 'any' can never read compatible.`
185
- : `the ${hit.side} type carries '${hit.kind}' at '${hit.path}' from ` +
186
- `capture; compatibility cannot be verified (a partially-unresolved ` +
187
- `type would let an arbitrary shape read compatible).`,
188
- codes: [],
189
- resolved: false,
190
- unresolved_side: hit.side,
191
- unresolved_reason: hit.kind === 'budget_exhausted'
192
- ? `the ${hit.side} type is too deep or wide to verify at '${hit.path}'`
193
- : `the ${hit.side} type carries '${hit.kind}' at '${hit.path}'`,
194
- notes: [],
195
- });
196
- }
162
+ const { preGated, probing } = preGate(plans, aliasRecords);
197
163
  writeProbes(ws, probing);
198
164
  const errors = [];
199
165
  const degraded = [];
200
166
  // ---- Install (async, off the event loop) --------------------------------
201
167
  progress('installing', 'installing pinned dependencies');
202
168
  const install = await runProcess(pnpmPath, ['install'], ws.workspaceDir);
203
- const installOk = install.code === 0;
204
- let installError;
205
- if (!installOk) {
206
- installError = scrubPaths((install.stderr || install.stdout).trim().slice(0, 2000), scrubCtx);
169
+ if (install.code !== 0) {
170
+ const installError = scrubPaths((install.stderr || install.stdout).trim().slice(0, 2000), scrubCtx);
171
+ // `errors` says why the run failed (carrick#1821); each pair the install
172
+ // stopped says it too, because the client reads every pair's own verdict
173
+ // on a failed check (carrick#1833).
174
+ errors.push(`workspace dependency install failed${installError ? `: ${installError}` : ''}`);
207
175
  for (const s of opts.stubs) {
208
176
  degraded.push({ service_name: s.service_name, reason: 'workspace install failed' });
209
177
  }
210
178
  const verdicts = sortVerdicts([
211
- ...unverifiableAll(probing, 'install:failed', 'workspace dependency install failed; compatibility cannot be verified.'),
179
+ ...unverifiableAll(probing, 'install:failed', 'workspace dependency install failed; compatibility cannot be verified.' +
180
+ (installError ? ` Installer output: ${installError}` : '')),
212
181
  // Capture-decay verdicts stand regardless of the install outcome.
213
182
  ...preGated,
214
183
  ...unresolved,
@@ -220,7 +189,6 @@ export async function runCheck(opts, onProgress) {
220
189
  workspace_dir: cleanup ? '' : ws.workspaceDir,
221
190
  isolation: 'pnpm',
222
191
  install_ok: false,
223
- install_error: installError,
224
192
  ts_version: tsVersion,
225
193
  verdicts,
226
194
  degraded_services: degraded,
@@ -249,7 +217,9 @@ export async function runCheck(opts, onProgress) {
249
217
  });
250
218
  }
251
219
  const verdicts = sortVerdicts([
252
- ...unverifiableAll(probing, 'tsc:abnormal-termination', 'the type checker terminated abnormally; compatibility cannot be verified.'),
220
+ ...unverifiableAll(probing, 'tsc:abnormal-termination', `the type checker terminated abnormally (exit code ${tsc.code ?? 'null'}); ` +
221
+ 'compatibility cannot be verified.' +
222
+ (excerpt ? ` Compiler output: ${excerpt}` : '')),
253
223
  ...preGated,
254
224
  ...unresolved,
255
225
  ]);
@@ -300,6 +270,7 @@ export async function runCheck(opts, onProgress) {
300
270
  scrubCtx,
301
271
  deepFindings: deepByPair.get(plan.pairId),
302
272
  fieldReport: fieldsByPair.get(plan.pairId),
273
+ rawTextSide: rawTextSideOf(plan, aliasRecords),
303
274
  })),
304
275
  ...preGated,
305
276
  ...unresolved,
@@ -341,6 +312,61 @@ function readStubAliasRecords(stubs) {
341
312
  }
342
313
  return byService;
343
314
  }
315
+ /**
316
+ * Capture-time deep-decay pre-gate (adversarial-review finding 1): a
317
+ * member-level `any`/`unknown` recorded by the capture self-check with no
318
+ * failing-external explanation. The probe gates are WHOLE-type only —
319
+ * `{ orderId: string; metadata: any }` sails through IsAny and the assignment
320
+ * compiles clean — so such pairs must never reach a probe. `any` routes to
321
+ * gate_caught_baked_any, `unknown` to unverifiable; both read as None
322
+ * downstream, never compatible. These verdicts come from the capture alone,
323
+ * so they stand whatever the install or the compiler then does (carrick#1833).
324
+ */
325
+ function preGate(plans, aliasRecords) {
326
+ const preGated = [];
327
+ const probing = [];
328
+ for (const plan of plans) {
329
+ const hit = deepDecayOf(plan, aliasRecords);
330
+ if (!hit) {
331
+ probing.push(plan);
332
+ continue;
333
+ }
334
+ preGated.push({
335
+ pair_id: plan.pairId,
336
+ pair_key: plan.spec.pair_key,
337
+ // `any` is a confirmed baked top type -> gate_caught_baked_any; `unknown`
338
+ // and `budget_exhausted` (a subtree the capture walk could not finish)
339
+ // are "cannot verify" -> unverifiable. All read as None downstream.
340
+ bucket: hit.kind === 'any' ? 'gate_caught_baked_any' : 'unverifiable',
341
+ gate: `capture:${hit.side}:${hit.kind}`,
342
+ diagnostic: hit.kind === 'budget_exhausted'
343
+ ? `the ${hit.side} type is too deep or wide to verify within the ` +
344
+ `capture budget (at '${hit.path}'); compatibility cannot be ` +
345
+ `verified — abstaining so a buried 'any' can never read compatible.`
346
+ : `the ${hit.side} type carries '${hit.kind}' at '${hit.path}' from ` +
347
+ `capture; compatibility cannot be verified (a partially-unresolved ` +
348
+ `type would let an arbitrary shape read compatible).`,
349
+ codes: [],
350
+ resolved: false,
351
+ unresolved_side: hit.side,
352
+ unresolved_reason: hit.kind === 'budget_exhausted'
353
+ ? `the ${hit.side} type is too deep or wide to verify at '${hit.path}'`
354
+ : `the ${hit.side} type carries '${hit.kind}' at '${hit.path}'`,
355
+ notes: [],
356
+ });
357
+ }
358
+ return { preGated, probing };
359
+ }
360
+ /**
361
+ * The side whose capture record marks its body as read raw (carrick#1842):
362
+ * the sent side when both are, as the bytes gate names it.
363
+ */
364
+ function rawTextSideOf(plan, aliasRecords) {
365
+ return [plan.direction.sent, plan.direction.expected].find((side) => {
366
+ const endpoint = plan.spec[side];
367
+ return aliasRecords.get(endpoint.service_name)?.get(endpoint.alias)?.raw_text_read === true;
368
+ });
369
+ }
344
370
  /** First side (producer, then consumer) whose capture recorded a deep decay. */
345
371
  function deepDecayOf(plan, aliasRecords) {
346
372
  for (const side of ['producer', 'consumer']) {
@@ -63,6 +63,9 @@ export declare function findDisqualifyingTopTypes(root: ts.Type, program: ts.Pro
63
63
  * names. A walk that runs out of budget reports what it found before it did.
64
64
  */
65
65
  export declare function findUnresolvedPlaceholders(root: ts.Type, program: ts.Program, checker: ts.TypeChecker, location: ts.Node): string[];
66
+ /** TypeScript's unresolved-reference placeholder: `TypeFlags.Any` with the
67
+ * internal `intrinsicName === 'error'` (stable since TS 1.x; see `anchors.ts`). */
68
+ export declare function isErrorPlaceholder(t: ts.Type): boolean;
66
69
  /** What an anchor's SOURCE program could not resolve (carrick#1164). */
67
70
  export interface UnresolvedAtAnchor {
68
71
  /** Member paths holding the unresolved-reference placeholder. */
@@ -87,3 +90,20 @@ export interface UnresolvedAtAnchor {
87
90
  * one other cause the walk itself can distinguish is its own budget.
88
91
  */
89
92
  export declare function provenanceOf(finding: DeepTopType, unresolved?: UnresolvedAtAnchor): TypeProvenance;
93
+ /**
94
+ * The entry for one position at which the emitted tree holds the
95
+ * unresolved-reference placeholder (carrick#1446): `path` is `''` for the
96
+ * alias's own type. `names` are the identifiers the self-check could not find
97
+ * in this alias's statement and the files it reaches.
98
+ *
99
+ * The cause is `unresolved_import`, the word a reader already has for "a
100
+ * reference here did not resolve"; the detail says where it failed to.
101
+ */
102
+ export declare function unresolvedInTreeProvenance(path: string, names: readonly string[]): TypeProvenance;
103
+ /**
104
+ * The root entry for a literal anchor demoted because its text names a module
105
+ * whose declaration emit was skipped (carrick#1446, carrick#1165): that text is
106
+ * what the index serves for it, and nothing in the emitted tree declares what
107
+ * it imports.
108
+ */
109
+ export declare function unemittedModuleProvenance(): TypeProvenance;
@@ -14,6 +14,7 @@
14
14
  * wherever it is reported.
15
15
  */
16
16
  import ts from 'typescript';
17
+ import { memberPath } from './member-name.js';
17
18
  /** Cap on findings reported per alias. The FIRST one is what the check phase
18
19
  * pre-gates on, so verdicts never depend on this number; the rest are there to
19
20
  * tell a reader which fields are `any` (carrick#376), and a type with more
@@ -73,7 +74,7 @@ export function findUnresolvedPlaceholders(root, program, checker, location) {
73
74
  }
74
75
  /** TypeScript's unresolved-reference placeholder: `TypeFlags.Any` with the
75
76
  * internal `intrinsicName === 'error'` (stable since TS 1.x; see `anchors.ts`). */
76
- function isErrorPlaceholder(t) {
77
+ export function isErrorPlaceholder(t) {
77
78
  return ((t.flags & ts.TypeFlags.Any) !== 0 &&
78
79
  t.intrinsicName === 'error');
79
80
  }
@@ -230,7 +231,7 @@ function walkTopTypes(root, program, checker, location, flagOf) {
230
231
  continue;
231
232
  }
232
233
  const propType = checker.getTypeOfSymbolAtLocation(prop, location);
233
- walk(propType, path === '' ? prop.getName() : `${path}.${prop.getName()}`, depth + 1);
234
+ walk(propType, memberPath(path, prop, checker), depth + 1);
234
235
  if (exhausted)
235
236
  return;
236
237
  }
@@ -276,8 +277,8 @@ function disqualifyingFlag(t) {
276
277
  }
277
278
  return t.flags & ts.TypeFlags.Unknown ? 'unknown' : undefined;
278
279
  }
279
- /** How many unresolved specifiers a detail names before it counts the rest. */
280
- const MAX_NAMED_SPECIFIERS = 3;
280
+ /** How many specifiers or names a detail lists before it counts the rest. */
281
+ const MAX_NAMED_IN_DETAIL = 3;
281
282
  /**
282
283
  * Turn a deep finding into the published provenance entry (carrick#376).
283
284
  *
@@ -321,12 +322,51 @@ function unresolvedDetail(specifiers) {
321
322
  const lead = "the type at this position did not resolve on the scanned checkout, so the compiler printed a placeholder 'any' rather than a declared type";
322
323
  if (specifiers.length === 0)
323
324
  return lead;
324
- const named = specifiers
325
- .slice(0, MAX_NAMED_SPECIFIERS)
326
- .map((specifier) => `'${specifier}'`)
325
+ return `${lead}; unresolved imports reachable from the anchor: ${quotedList(specifiers)}`;
326
+ }
327
+ /**
328
+ * The entry for one position at which the emitted tree holds the
329
+ * unresolved-reference placeholder (carrick#1446): `path` is `''` for the
330
+ * alias's own type. `names` are the identifiers the self-check could not find
331
+ * in this alias's statement and the files it reaches.
332
+ *
333
+ * The cause is `unresolved_import`, the word a reader already has for "a
334
+ * reference here did not resolve"; the detail says where it failed to.
335
+ */
336
+ export function unresolvedInTreeProvenance(path, names) {
337
+ const where = path === '' ? 'this type' : 'the type at this position';
338
+ const lead = `${where} does not resolve in the declarations the capture emitted, so the ` +
339
+ "compiler reads it as 'any' although the printed text shows the name it could not follow";
340
+ return {
341
+ path,
342
+ kind: 'any',
343
+ reason: 'unresolved_import',
344
+ detail: names.length === 0
345
+ ? lead
346
+ : `${lead}; names those declarations cannot find: ${quotedList(names)}`,
347
+ };
348
+ }
349
+ /**
350
+ * The root entry for a literal anchor demoted because its text names a module
351
+ * whose declaration emit was skipped (carrick#1446, carrick#1165): that text is
352
+ * what the index serves for it, and nothing in the emitted tree declares what
353
+ * it imports.
354
+ */
355
+ export function unemittedModuleProvenance() {
356
+ return {
357
+ path: '',
358
+ kind: 'any',
359
+ reason: 'unresolved_import',
360
+ detail: 'this type names a module whose declarations the capture could not emit, so it ' +
361
+ "does not resolve in the declarations the capture emitted and the compiler reads it as 'any'",
362
+ };
363
+ }
364
+ /** `'a', 'b', 'c' and 2 more`. */
365
+ function quotedList(items) {
366
+ const named = items
367
+ .slice(0, MAX_NAMED_IN_DETAIL)
368
+ .map((item) => `'${item}'`)
327
369
  .join(', ');
328
- const more = specifiers.length > MAX_NAMED_SPECIFIERS
329
- ? ` and ${specifiers.length - MAX_NAMED_SPECIFIERS} more`
330
- : '';
331
- return `${lead}; unresolved imports reachable from the anchor: ${named}${more}`;
370
+ const more = items.length > MAX_NAMED_IN_DETAIL ? ` and ${items.length - MAX_NAMED_IN_DETAIL} more` : '';
371
+ return `${named}${more}`;
332
372
  }
@@ -42,7 +42,11 @@ export declare class WriteGuard {
42
42
  private readonly files;
43
43
  private readonly protect;
44
44
  private constructor();
45
- /** A guard over `roots`. Throws when a root equals or contains a protected tree. */
45
+ /**
46
+ * A guard over `roots`. Throws when a root equals or contains a protected
47
+ * tree, or when a directory root lies inside one anywhere but beneath a
48
+ * `.carrick` directory (carrick#1768).
49
+ */
46
50
  static of(roots: WriteRoots): WriteGuard;
47
51
  /**
48
52
  * A new, empty directory under `parent` (the OS temp dir by default) and a
@@ -40,7 +40,11 @@ export class WriteGuard {
40
40
  this.files = files;
41
41
  this.protect = protect;
42
42
  }
43
- /** A guard over `roots`. Throws when a root equals or contains a protected tree. */
43
+ /**
44
+ * A guard over `roots`. Throws when a root equals or contains a protected
45
+ * tree, or when a directory root lies inside one anywhere but beneath a
46
+ * `.carrick` directory (carrick#1768).
47
+ */
44
48
  static of(roots) {
45
49
  const protect = (roots.protect ?? []).map(landing);
46
50
  const dirs = (roots.dirs ?? []).map(landing);
@@ -51,6 +55,16 @@ export class WriteGuard {
51
55
  throw new WriteRefused(`refused write root ${root}: it is or contains the scanned tree ${covered}`);
52
56
  }
53
57
  }
58
+ // Inside a scanned tree, a directory root is Carrick's only beneath a
59
+ // `.carrick` directory. Anywhere else it is the repo's own (a sibling
60
+ // service, say), and a stub dir is emptied before it is written. A file
61
+ // root is exempt: the surface entry has to sit inside rootDir.
62
+ for (const root of dirs) {
63
+ const inside = protect.find((tree) => within(tree, root) && !carrickOwned(tree, root));
64
+ if (inside !== undefined) {
65
+ throw new WriteRefused(`refused write root ${root}: it lies inside the scanned tree ${inside}, outside a .carrick directory`);
66
+ }
67
+ }
54
68
  return new WriteGuard(dirs, files, protect);
55
69
  }
56
70
  /**
@@ -133,6 +147,14 @@ function within(root, p) {
133
147
  const rel = path.relative(root, p);
134
148
  return rel === '' || (rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
135
149
  }
150
+ /**
151
+ * Whether `root`, inside `tree`, lies beneath a `.carrick` directory between
152
+ * the two. `.carrick` itself is not enough: it holds the workspace's proposal,
153
+ * jobs and scan logs. Both are resolved paths.
154
+ */
155
+ function carrickOwned(tree, root) {
156
+ return path.relative(tree, root).split(path.sep).slice(0, -1).includes('.carrick');
157
+ }
136
158
  function isDirectory(p) {
137
159
  try {
138
160
  return fs.statSync(p).isDirectory();
@@ -145,10 +145,13 @@ export function captureStub(opts) {
145
145
  // Everything this capture writes, it writes through `guard` (carrick#1748):
146
146
  // the stub dir, the staging dir, and the surface entry. A stub dir is
147
147
  // emptied before it is written, so one that is or holds the repo is refused
148
- // before anything is touched.
148
+ // before anything is touched. The scan root is protected too: a stub dir
149
+ // in a sibling service of the same repo is refused unless it sits beneath
150
+ // a `.carrick` directory (carrick#1768).
149
151
  let guard;
150
152
  try {
151
- guard = WriteGuard.of({ dirs: [stubDir], protect: [repoRoot] });
153
+ const protect = opts.scanRoot === undefined ? [repoRoot] : [repoRoot, path.resolve(opts.scanRoot)];
154
+ guard = WriteGuard.of({ dirs: [stubDir], protect });
152
155
  }
153
156
  catch (err) {
154
157
  return fail(stubDir, packageName, [err instanceof Error ? err.message : String(err)]);
@@ -563,6 +566,7 @@ function demoteDanglingAliases(args) {
563
566
  serialization: 'structural_fallback',
564
567
  failureReason: `declaration emit was skipped for module '${dangling}'; ` +
565
568
  'alias demoted to keep the partially emitted tree usable',
569
+ namesUnemittedModule: true,
566
570
  };
567
571
  });
568
572
  if (surfaceKey !== undefined && demoted.size > 0) {
@@ -667,11 +671,19 @@ function resolveAnchors(opts, parsed, ctx, deno) {
667
671
  siblingSymbolSpecs.set(anchor.symbol_name, entryRelativeSpecifier(ctx.entryDir, ctx.repoRoot, anchor.source_file));
668
672
  }
669
673
  }
674
+ // A module specifier as the entry resolves it, under the program's own
675
+ // resolution (the Deno graph's, for a Deno service).
676
+ const entryMode = entrySource?.impliedNodeFormat;
677
+ const resolveFromEntry = (specifier) => (deno
678
+ ? deno.resolve(specifier, ctx.entryPath, options)
679
+ : ts.resolveModuleName(specifier, ctx.entryPath, options, ts.sys, undefined, undefined, entryMode)
680
+ .resolvedModule)?.resolvedFileName;
670
681
  return opts.anchors.map((request) => resolveAnchor(program, request, {
671
682
  repoRoot: ctx.repoRoot,
672
683
  entryDir: ctx.entryDir,
673
684
  placeholder: placeholders.get(request.alias),
674
685
  siblingSymbolSpecs,
686
+ resolveFromEntry,
675
687
  }));
676
688
  }
677
689
  finally {
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A member's place in a path, written the same way on every scan
3
+ * (carrick#1766).
4
+ *
5
+ * A member keyed by a unique symbol (`[Symbol.iterator]`, or a user's
6
+ * `const KEY: unique symbol`) has no name of its own. The checker escapes it as
7
+ * `__@<description>@<symbol id>`, and the id counts the symbols the process
8
+ * made before this one, so it differs between two scans of one tree and a
9
+ * stored sentence naming it changed on every scan. The checker prints such a
10
+ * member from its key instead, `[Symbol.iterator]`, which is how the source
11
+ * writes it.
12
+ *
13
+ * Every other member keeps `getName()`. A string key that starts with `__` is
14
+ * escaped with one more underscore, so it never reads as symbol-keyed here, and
15
+ * `symbolToString` would quote a key such as `content-type`, changing text that
16
+ * is already stable.
17
+ */
18
+ import ts from 'typescript';
19
+ /** `parent.name`, or `parent[KEY]` for a member keyed by a unique symbol. */
20
+ export declare function memberPath(parent: string, member: ts.Symbol, checker: ts.TypeChecker): string;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A member's place in a path, written the same way on every scan
3
+ * (carrick#1766).
4
+ *
5
+ * A member keyed by a unique symbol (`[Symbol.iterator]`, or a user's
6
+ * `const KEY: unique symbol`) has no name of its own. The checker escapes it as
7
+ * `__@<description>@<symbol id>`, and the id counts the symbols the process
8
+ * made before this one, so it differs between two scans of one tree and a
9
+ * stored sentence naming it changed on every scan. The checker prints such a
10
+ * member from its key instead, `[Symbol.iterator]`, which is how the source
11
+ * writes it.
12
+ *
13
+ * Every other member keeps `getName()`. A string key that starts with `__` is
14
+ * escaped with one more underscore, so it never reads as symbol-keyed here, and
15
+ * `symbolToString` would quote a key such as `content-type`, changing text that
16
+ * is already stable.
17
+ */
18
+ /** `parent.name`, or `parent[KEY]` for a member keyed by a unique symbol. */
19
+ export function memberPath(parent, member, checker) {
20
+ if (!member.escapedName.startsWith('__@')) {
21
+ return parent === '' ? member.getName() : `${parent}.${member.getName()}`;
22
+ }
23
+ return `${parent}${checker.symbolToString(member)}`;
24
+ }