carrick 0.3.103 → 0.3.105

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 (35) 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 +6 -0
  4. package/sidecar/dist/src/capture/anchors.js +78 -13
  5. package/sidecar/dist/src/capture/api.d.ts +27 -2
  6. package/sidecar/dist/src/capture/check-classify.d.ts +1 -0
  7. package/sidecar/dist/src/capture/check-classify.js +23 -0
  8. package/sidecar/dist/src/capture/check-fields.js +4 -6
  9. package/sidecar/dist/src/capture/check-probe.d.ts +1 -1
  10. package/sidecar/dist/src/capture/check-probe.js +28 -4
  11. package/sidecar/dist/src/capture/check-scrub.d.ts +3 -0
  12. package/sidecar/dist/src/capture/check-scrub.js +8 -4
  13. package/sidecar/dist/src/capture/check.js +64 -50
  14. package/sidecar/dist/src/capture/deep-walk.d.ts +20 -0
  15. package/sidecar/dist/src/capture/deep-walk.js +51 -11
  16. package/sidecar/dist/src/capture/guarded-fs.d.ts +5 -1
  17. package/sidecar/dist/src/capture/guarded-fs.js +23 -1
  18. package/sidecar/dist/src/capture/index.d.ts +1 -0
  19. package/sidecar/dist/src/capture/index.js +11 -3
  20. package/sidecar/dist/src/capture/member-name.d.ts +20 -0
  21. package/sidecar/dist/src/capture/member-name.js +24 -0
  22. package/sidecar/dist/src/capture/self-check.js +41 -9
  23. package/sidecar/dist/src/capture/service-config.d.ts +19 -0
  24. package/sidecar/dist/src/capture/service-config.js +57 -0
  25. package/sidecar/dist/src/failure-path.d.ts +36 -0
  26. package/sidecar/dist/src/failure-path.js +212 -0
  27. package/sidecar/dist/src/index.js +2 -0
  28. package/sidecar/dist/src/project-loader.d.ts +10 -2
  29. package/sidecar/dist/src/project-loader.js +7 -16
  30. package/sidecar/dist/src/retype.js +15 -2
  31. package/sidecar/dist/src/type-inferrer.d.ts +2 -0
  32. package/sidecar/dist/src/type-inferrer.js +17 -0
  33. package/sidecar/dist/src/types.d.ts +8 -0
  34. package/sidecar/dist/src/validators.d.ts +12 -0
  35. package/sidecar/dist/src/validators.js +2 -0
@@ -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();
@@ -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';
@@ -39,10 +39,12 @@ import { selfCheckStub } from './self-check.js';
39
39
  import { collectSpecifiers, isRelative, packageNameOf } from './specifiers.js';
40
40
  import { DenoProject, findDenoConfig } from './deno-project.js';
41
41
  import { emitsAlike, ProjectGraph } from './project-references.js';
42
+ import { findServiceTsconfig } from './service-config.js';
42
43
  import { placeEmittedTree } from './outside-root.js';
43
44
  import { WriteGuard } from './guarded-fs.js';
44
45
  export { DenoProject, findDenoConfig } from './deno-project.js';
45
46
  export { serviceConfigPath } from './project-references.js';
47
+ export { findServiceTsconfig } from './service-config.js';
46
48
  // v2 check core ("tsc as the judge"). Same bundle, same seam: the sidecar
47
49
  // reaches it only through this door (index.js).
48
50
  export { runCheck } from './check.js';
@@ -127,9 +129,11 @@ export function captureStub(opts) {
127
129
  const packageName = `@carrick/${sanitizeServiceName(opts.serviceName)}`;
128
130
  const stubDir = path.resolve(opts.outDir);
129
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.
130
134
  const configPath = opts.tsconfigPath
131
135
  ? path.resolve(repoRoot, opts.tsconfigPath)
132
- : path.join(repoRoot, 'tsconfig.json');
136
+ : findServiceTsconfig(repoRoot, opts.scanRoot) ?? path.join(repoRoot, 'tsconfig.json');
133
137
  let parsed;
134
138
  // The config the emit's options came from: the named one, or the project
135
139
  // that owns the most anchors' files (carrick#1604).
@@ -141,10 +145,13 @@ export function captureStub(opts) {
141
145
  // Everything this capture writes, it writes through `guard` (carrick#1748):
142
146
  // the stub dir, the staging dir, and the surface entry. A stub dir is
143
147
  // emptied before it is written, so one that is or holds the repo is refused
144
- // 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).
145
151
  let guard;
146
152
  try {
147
- 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 });
148
155
  }
149
156
  catch (err) {
150
157
  return fail(stubDir, packageName, [err instanceof Error ? err.message : String(err)]);
@@ -559,6 +566,7 @@ function demoteDanglingAliases(args) {
559
566
  serialization: 'structural_fallback',
560
567
  failureReason: `declaration emit was skipped for module '${dangling}'; ` +
561
568
  'alias demoted to keep the partially emitted tree usable',
569
+ namesUnemittedModule: true,
562
570
  };
563
571
  });
564
572
  if (surfaceKey !== undefined && demoted.size > 0) {
@@ -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
+ }
@@ -34,7 +34,7 @@ import * as fs from 'node:fs';
34
34
  import * as path from 'node:path';
35
35
  import { collectSpecifiers, isRelative, packageNameOf } from './specifiers.js';
36
36
  import { repairDanglingImports } from './repair-dangling.js';
37
- import { findDisqualifyingTopTypes, provenanceOf, } from './deep-walk.js';
37
+ import { findDisqualifyingTopTypes, findUnresolvedPlaceholders, isErrorPlaceholder, provenanceOf, unemittedModuleProvenance, unresolvedInTreeProvenance, } from './deep-walk.js';
38
38
  export function selfCheckStub(args) {
39
39
  const typesDir = path.join(args.stubDir, 'types');
40
40
  const treeFiles = [];
@@ -104,6 +104,7 @@ function runSelfCheck(args, treeFiles, repaired) {
104
104
  const emptyFailures = () => ({
105
105
  externalPinned: new Set(),
106
106
  internal: new Set(),
107
+ unfoundNames: new Set(),
107
108
  });
108
109
  const bucketIn = (map, key) => {
109
110
  let entry = map.get(key);
@@ -113,11 +114,24 @@ function runSelfCheck(args, treeFiles, repaired) {
113
114
  }
114
115
  return entry;
115
116
  };
117
+ // A surface diagnostic outside every alias statement (a file-level import,
118
+ // a reference directive) is attributable to no alias and keeps the
119
+ // service-wide file bucket: soundness over precision, the same fallback
120
+ // check-poison.ts makes.
121
+ const bucketFor = (abs, start) => {
122
+ const owner = abs === surfaceAbs ? aliasAtSurfacePosition(start) : undefined;
123
+ return owner ? bucketIn(surfaceFailuresByAlias, owner) : bucketIn(failuresByFile, abs);
124
+ };
116
125
  for (const d of diagnostics) {
117
126
  if (!d.file)
118
127
  continue;
119
128
  const abs = path.resolve(d.file.fileName);
120
129
  const msg = ts.flattenDiagnosticMessageText(d.messageText, ' ');
130
+ // A name the compiler cannot find is the placeholder's cause wherever a
131
+ // type uses it; the record names it beside those positions (carrick#1446).
132
+ const unfound = /Cannot find (?:name|namespace) '([^']+)'/.exec(msg);
133
+ if (unfound)
134
+ bucketFor(abs, d.start).unfoundNames.add(unfound[1]);
121
135
  // A name the repair did not reach: the import that bound it is gone, so
122
136
  // the module is no longer reported missing and only this diagnostic is
123
137
  // left to say the tree is incomplete. Blamed on the specifier that bound
@@ -133,14 +147,7 @@ function runSelfCheck(args, treeFiles, repaired) {
133
147
  if (!m)
134
148
  continue;
135
149
  const spec = m[1];
136
- // A surface diagnostic outside every alias statement (a file-level import,
137
- // a reference directive) is attributable to no alias and keeps the
138
- // service-wide file bucket: soundness over precision, the same fallback
139
- // check-poison.ts makes.
140
- const owner = abs === surfaceAbs ? aliasAtSurfacePosition(d.start) : undefined;
141
- const bucket = owner
142
- ? bucketIn(surfaceFailuresByAlias, owner)
143
- : bucketIn(failuresByFile, abs);
150
+ const bucket = bucketFor(abs, d.start);
144
151
  if (!isRelative(spec) && args.pinned[packageNameOf(spec)]) {
145
152
  bucket.externalPinned.add(spec);
146
153
  }
@@ -254,12 +261,23 @@ function demotedRecord(anchor) {
254
261
  self_check_detail: anchor.failureReason,
255
262
  capture_failure_reason: anchor.failureReason,
256
263
  top_type_at_self_check: true,
264
+ // A literal's text is what the index serves for it, and a module it
265
+ // names never reached the tree (carrick#1446). A symbol anchor's served
266
+ // declaration comes from elsewhere, so its demotion says nothing about it.
267
+ ...(anchor.request.kind === 'literal' && anchor.namesUnemittedModule
268
+ ? { unresolved_in_tree: [unemittedModuleProvenance()] }
269
+ : {}),
257
270
  };
258
271
  }
259
272
  function checkedRecord(anchor, ctx) {
260
273
  const alias = anchor.request.alias;
261
274
  let topType = true;
262
275
  let deepFindings = [];
276
+ // Where the alias's type holds the unresolved-reference placeholder in this
277
+ // tree (carrick#1446): `''` for its own type. The deep walk leaves the
278
+ // placeholder out because the check heals a pinned external; what the index
279
+ // publishes is this tree, so the record says where it does not resolve.
280
+ let unresolvedPaths = [];
263
281
  const seeds = [];
264
282
  if (ctx.surfaceSource) {
265
283
  for (const stmt of ctx.surfaceSource.statements) {
@@ -270,6 +288,10 @@ function checkedRecord(anchor, ctx) {
270
288
  (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) !== 0;
271
289
  if (!topType) {
272
290
  deepFindings = findDisqualifyingTopTypes(type, ctx.program, ctx.checker, stmt.name);
291
+ unresolvedPaths = findUnresolvedPlaceholders(type, ctx.program, ctx.checker, stmt.name);
292
+ }
293
+ else if (isErrorPlaceholder(type)) {
294
+ unresolvedPaths = [''];
273
295
  }
274
296
  // Seed the closure with the alias's own import-type targets.
275
297
  const visit = (node) => {
@@ -300,6 +322,7 @@ function checkedRecord(anchor, ctx) {
300
322
  let blamedExternal;
301
323
  let internalFailure;
302
324
  const danglingSpecifiers = new Set();
325
+ const unfoundNames = new Set();
303
326
  // This alias's own surface statement, then the closure's files. The surface
304
327
  // file bucket now holds only the diagnostics no alias statement covers.
305
328
  const closureFailures = [
@@ -315,6 +338,8 @@ function checkedRecord(anchor, ctx) {
315
338
  internalFailure = [...failures.internal][0];
316
339
  for (const specifier of failures.internal)
317
340
  danglingSpecifiers.add(specifier);
341
+ for (const name of failures.unfoundNames)
342
+ unfoundNames.add(name);
318
343
  }
319
344
  // Classification consults the closure failures REGARDLESS of the root
320
345
  // type: a dangling internal specifier means part of this alias's closure
@@ -416,6 +441,13 @@ function checkedRecord(anchor, ctx) {
416
441
  ? { dangling_specifiers: [...danglingSpecifiers].sort() }
417
442
  : {}),
418
443
  ...(anchor.undeclaredNames ? { undeclared_names: anchor.undeclaredNames } : {}),
444
+ ...(unresolvedPaths.length > 0
445
+ ? {
446
+ unresolved_in_tree: [...unresolvedPaths]
447
+ .sort()
448
+ .map((p) => unresolvedInTreeProvenance(p, [...unfoundNames].sort())),
449
+ }
450
+ : {}),
419
451
  };
420
452
  }
421
453
  /** Resolve a relative specifier from `fromAbs` to a tree file, if present. */
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Which tsconfig types a service that names none (carrick#1776).
3
+ *
4
+ * Both readers of a service, the init'd project and capture, ask this one
5
+ * function, so they type the service under the same options.
6
+ *
7
+ * The service root is searched first, for the names a service keeps its own
8
+ * config under. Above it, the search is `tsc`'s own when it is run with no
9
+ * `-p`: the nearest `tsconfig.json` in an ancestor directory. A layout that
10
+ * keeps one config above several services (their module resolution, `paths`,
11
+ * `lib`) is typed under that config, as the repo's own `tsc` types it; without
12
+ * it, every import only that config resolves read `any`.
13
+ *
14
+ * The walk stops at the scan root, inclusive: a config outside the scanned
15
+ * tree is never read. With no scan root, or a service outside it, only the
16
+ * service root is searched.
17
+ */
18
+ /** Absolute path of the config that types the service, or undefined for none. */
19
+ export declare function findServiceTsconfig(serviceRoot: string, scanRoot?: string): string | undefined;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Which tsconfig types a service that names none (carrick#1776).
3
+ *
4
+ * Both readers of a service, the init'd project and capture, ask this one
5
+ * function, so they type the service under the same options.
6
+ *
7
+ * The service root is searched first, for the names a service keeps its own
8
+ * config under. Above it, the search is `tsc`'s own when it is run with no
9
+ * `-p`: the nearest `tsconfig.json` in an ancestor directory. A layout that
10
+ * keeps one config above several services (their module resolution, `paths`,
11
+ * `lib`) is typed under that config, as the repo's own `tsc` types it; without
12
+ * it, every import only that config resolves read `any`.
13
+ *
14
+ * The walk stops at the scan root, inclusive: a config outside the scanned
15
+ * tree is never read. With no scan root, or a service outside it, only the
16
+ * service root is searched.
17
+ */
18
+ import * as fs from 'node:fs';
19
+ import * as path from 'node:path';
20
+ /** The names a service root is searched for, in order. */
21
+ const SERVICE_ROOT_NAMES = ['tsconfig.json', 'tsconfig.build.json', 'tsconfig.app.json'];
22
+ /** The name an ancestor directory is searched for, as `tsc` searches. */
23
+ const ANCESTOR_NAME = 'tsconfig.json';
24
+ /** Absolute path of the config that types the service, or undefined for none. */
25
+ export function findServiceTsconfig(serviceRoot, scanRoot) {
26
+ const root = path.resolve(serviceRoot);
27
+ for (const name of SERVICE_ROOT_NAMES) {
28
+ const candidate = path.join(root, name);
29
+ if (fs.existsSync(candidate))
30
+ return candidate;
31
+ }
32
+ if (scanRoot === undefined)
33
+ return undefined;
34
+ // Containment is decided on real paths, so a symlink on either end cannot
35
+ // hide the service inside the scan root or place it outside; the walk then
36
+ // climbs the path as given, one directory per level between them.
37
+ const fromBound = path.relative(realPath(scanRoot), realPath(root));
38
+ if (fromBound === '' || fromBound.startsWith('..') || path.isAbsolute(fromBound))
39
+ return undefined;
40
+ let dir = root;
41
+ for (let level = fromBound.split(path.sep).length; level > 0; level--) {
42
+ dir = path.dirname(dir);
43
+ const candidate = path.join(dir, ANCESTOR_NAME);
44
+ if (fs.existsSync(candidate))
45
+ return candidate;
46
+ }
47
+ return undefined;
48
+ }
49
+ /** The path with every symlink resolved; the path as given when it does not exist. */
50
+ function realPath(p) {
51
+ try {
52
+ return fs.realpathSync(p);
53
+ }
54
+ catch {
55
+ return path.resolve(p);
56
+ }
57
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Whether the source reaches a node only when an HTTP response FAILED
3
+ * (carrick#1796).
4
+ *
5
+ * A consumer reads the success body on one path and the error text or the
6
+ * error body on another. Only the first is the call's response contract, so
7
+ * the def-use walk behind a `call_result` row must not take a read from the
8
+ * second. Which path a read is on is told by the source's own tests of the
9
+ * response, read two ways:
10
+ *
11
+ * - the read sits in a branch of a test: `if (!res.ok) { ... }`, the `else`
12
+ * of `if (res.ok)`, either arm of a conditional expression;
13
+ * - the read follows an `if` whose other branch cannot complete, so the rest
14
+ * of the block runs only on the side that did not leave:
15
+ * `if (res.ok) { return ... } const text = await res.text()`.
16
+ *
17
+ * A test is read as the set of statuses it lets through. `ok` is true for
18
+ * 200-299 and false for every other status; a comparison of the status with a
19
+ * number lets through what it admits; `!`, `&&` and `||` combine the sets, and
20
+ * any other condition lets everything through. A node is on the failure path
21
+ * only when NO status in 200-299 can reach it.
22
+ *
23
+ * That is deliberately stricter than the retype check's reading of a status
24
+ * test (`okWhenTrue` in retype.ts), which reads the false side of
25
+ * `res.status === 200` as the failure path so it can find a success read to
26
+ * judge. Here a decided failure REMOVES a read, so the reading has to be sound
27
+ * the other way round: after `if (res.status === 204) return null`, 200 still
28
+ * gets through, and the json read that follows is the payload.
29
+ */
30
+ import { Node } from 'ts-morph';
31
+ /**
32
+ * True when the source reaches `node` only after the response named by
33
+ * `isResponse` failed. The walk climbs from `node` to `boundary` (the function
34
+ * the call sits in) and never looks at a test outside it.
35
+ */
36
+ export declare function reachedOnlyOnFailure(node: Node, boundary: Node, isResponse: (node: Node) => boolean): boolean;