carrick 0.3.109 → 0.3.110

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carrick",
3
- "version": "0.3.109",
3
+ "version": "0.3.110",
4
4
  "description": "Maps your entire TypeScript codebase across services and repositories, giving AI agents full context on existing types, routes, and function behaviours over MCP before they write duplicate or breaking code.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -58,11 +58,11 @@
58
58
  "zod": "^3.23.0"
59
59
  },
60
60
  "optionalDependencies": {
61
- "@carrick-tools/cli-darwin-arm64": "0.3.109",
62
- "@carrick-tools/cli-darwin-x64": "0.3.109",
63
- "@carrick-tools/cli-linux-arm64": "0.3.109",
64
- "@carrick-tools/cli-linux-x64": "0.3.109",
65
- "@carrick-tools/cli-win32-x64": "0.3.109"
61
+ "@carrick-tools/cli-darwin-arm64": "0.3.110",
62
+ "@carrick-tools/cli-darwin-x64": "0.3.110",
63
+ "@carrick-tools/cli-linux-arm64": "0.3.110",
64
+ "@carrick-tools/cli-linux-x64": "0.3.110",
65
+ "@carrick-tools/cli-win32-x64": "0.3.110"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@types/node": "^24.13.3"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "carrick",
3
3
  "description": "Claude Code plugin for Carrick, which indexes TypeScript codebases across service and repository boundaries. After each edit it adds the file's routes, calls, and cross-service type mismatches to the session, and it registers Carrick's language server. It pairs with the Carrick MCP server, which lets agents search functions by intent rather than name.",
4
- "version": "0.3.109",
4
+ "version": "0.3.110",
5
5
  "author": {
6
6
  "name": "Carrick",
7
7
  "email": "hello@carrick.tools"
@@ -50,6 +50,16 @@ export declare class TypeBundler {
50
50
  */
51
51
  private expandOrigin;
52
52
  private extractTypeDefinition;
53
+ /**
54
+ * carrick#1841: what a consumer receives when the symbol the model named for
55
+ * its response is a response table keyed by status code (`{ 200: Item;
56
+ * 404: Problem }`): the table's 2xx values, expanded like any other body.
57
+ * `unknown` where the table states no success body (no 2xx row, or only
58
+ * rows such as `204: void`). `undefined` when the symbol is not a table, so
59
+ * the caller bundles it as written. The capture's symbol anchor reads the
60
+ * same rule (`capture/anchors.ts`), so both answers agree.
61
+ */
62
+ private statusTableBodyText;
53
63
  private elementNeedsArrayParens;
54
64
  private extractTypeDefinitionBase;
55
65
  private isPathSource;
@@ -7,6 +7,7 @@ import * as path from 'node:path';
7
7
  import * as fs from 'node:fs';
8
8
  import { expandTypeStructural, } from './type-structural-expander.js';
9
9
  import { externalImportsOf } from './origin.js';
10
+ import { statusTableBody } from './capture/index.js';
10
11
  /**
11
12
  * #248: upper bound on `SymbolRequest.array_depth`. SDL list nesting is
12
13
  * realistically 1-3 levels (`[Order!]!`, at most `[[Order!]!]!`); this is a
@@ -301,6 +302,17 @@ export class TypeBundler {
301
302
  };
302
303
  }
303
304
  extractTypeDefinition(symbol) {
305
+ const tableBody = symbol.consumer_response ? this.statusTableBodyText(symbol) : undefined;
306
+ if (tableBody !== undefined) {
307
+ const alias = symbol.alias || symbol.symbol_name;
308
+ const depth = symbol.array_depth ?? 0;
309
+ if (depth > MAX_ARRAY_DEPTH)
310
+ return null;
311
+ const typeString = depth > 0 && tableBody !== 'unknown'
312
+ ? `(${tableBody})${'[]'.repeat(depth)}`
313
+ : tableBody;
314
+ return { definition: `export type ${alias} = ${typeString};`, typeString };
315
+ }
304
316
  const base = this.extractTypeDefinitionBase(symbol);
305
317
  if (!base)
306
318
  return null;
@@ -338,6 +350,36 @@ export class TypeBundler {
338
350
  const typeString = `${element}${'[]'.repeat(depth)}`;
339
351
  return { definition: `export type ${alias} = ${typeString};`, typeString };
340
352
  }
353
+ /**
354
+ * carrick#1841: what a consumer receives when the symbol the model named for
355
+ * its response is a response table keyed by status code (`{ 200: Item;
356
+ * 404: Problem }`): the table's 2xx values, expanded like any other body.
357
+ * `unknown` where the table states no success body (no 2xx row, or only
358
+ * rows such as `204: void`). `undefined` when the symbol is not a table, so
359
+ * the caller bundles it as written. The capture's symbol anchor reads the
360
+ * same rule (`capture/anchors.ts`), so both answers agree.
361
+ */
362
+ statusTableBodyText(symbol) {
363
+ const site = this.siteOf(symbol);
364
+ if (!site)
365
+ return undefined;
366
+ const declaration = site.sourceFile.getTypeAlias(site.name) ?? site.sourceFile.getInterface(site.name);
367
+ if (!declaration)
368
+ return undefined;
369
+ const table = declaration.getType();
370
+ const checker = this.project.getTypeChecker().compilerObject;
371
+ const read = statusTableBody(checker, table.compilerType, declaration.compilerNode);
372
+ if (!read)
373
+ return undefined;
374
+ if (read.kind === 'no_body')
375
+ return 'unknown';
376
+ const bodies = read.entries.map((entry) => {
377
+ const value = table.getProperty(entry.key)?.getTypeAtLocation(declaration);
378
+ return value ? expandTypeStructural(value, this.expandOrigin()) : 'unknown';
379
+ });
380
+ const distinct = [...new Set(bodies)];
381
+ return distinct.length === 1 ? distinct[0] : distinct.map((body) => `(${body})`).join(' | ');
382
+ }
341
383
  /// Whether the element type must be parenthesised before an `[]` suffix.
342
384
  /// Interfaces/classes/enums are always object/nominal (never a top-level
343
385
  /// union), so only type aliases and typed variables can carry a bare
@@ -10,6 +10,7 @@ import { printTypeForDestination, substituteUndeclaredNames, undeclaredNamesIn,
10
10
  import { typeIsOrContainsMachinery } from './machinery.js';
11
11
  import { installedPackageSpecifier } from './installed-package.js';
12
12
  import { realPath } from './service-config.js';
13
+ import { statusTableBody } from './status-table.js';
13
14
  import { unresolvedAtAnchor, unresolvedSpecifiersReachableFrom, } from './unresolved.js';
14
15
  /**
15
16
  * Repo-root-relative source file -> the specifier the surface entry imports
@@ -155,6 +156,37 @@ export function resolveAnchor(program, request, args) {
155
156
  }
156
157
  const arrayDepth = Math.max(0, request.array_depth ?? 0);
157
158
  const declared = checker.getDeclaredTypeOfSymbol(resolvedExport);
159
+ // carrick#1841: a symbol named for what a consumer receives that is a
160
+ // response table keyed by status code (`{ 200: Item; 404: Problem }`) is
161
+ // not the body. The body is its 2xx rows, indexed out of the table so the
162
+ // compiler still emits it (`import('./m').Table[200]`). A table with no
163
+ // success body abstains, decided. The v1 bundle reads the same rule
164
+ // (`TypeBundler.statusTableBodyText`).
165
+ if (request.consumer_response) {
166
+ const table = statusTableBody(checker, declared, resolvedExport.declarations?.[0] ?? sourceFile);
167
+ if (table?.kind === 'no_body') {
168
+ return {
169
+ request,
170
+ aliasText: 'unknown',
171
+ serialization: 'structural_fallback',
172
+ abstainReason: `'${request.symbol_name}' is a response table keyed by status code with no ` +
173
+ 'success row that states a body, so this consumer states no response contract',
174
+ };
175
+ }
176
+ if (table?.kind === 'body') {
177
+ const rows = [...new Set(table.entries.map((entry) => entry.key))].map((key) => /^\d+$/.test(key)
178
+ ? `import('${spec}').${request.symbol_name}[${key}]`
179
+ : `import('${spec}').${request.symbol_name}[${JSON.stringify(key)}]`);
180
+ const body = rows.join(' | ');
181
+ const unresolvedBody = unresolvedAtAnchor(program, sourceFile, declared, resolvedExport.declarations?.[0] ?? sourceFile, '<0>'.repeat(arrayDepth));
182
+ return {
183
+ request,
184
+ aliasText: arraySuffix && rows.length > 1 ? `(${body})${arraySuffix}` : `${body}${arraySuffix}`,
185
+ serialization: 'emitted',
186
+ ...(unresolvedBody ? { unresolved: unresolvedBody } : {}),
187
+ };
188
+ }
189
+ }
158
190
  const unresolved = unresolvedAtAnchor(program, sourceFile, declared, resolvedExport.declarations?.[0] ?? sourceFile, '<0>'.repeat(arrayDepth));
159
191
  return {
160
192
  request,
@@ -50,6 +50,11 @@ export interface SymbolAnchorRequest {
50
50
  * `import('./m').Sym[]`. Omitted/0 captures the symbol as-is.
51
51
  */
52
52
  array_depth?: number;
53
+ /**
54
+ * carrick#1841: the symbol names what a consumer receives, so a response
55
+ * table keyed by status code captures as its 2xx body, not as the table.
56
+ */
57
+ consumer_response?: boolean;
53
58
  }
54
59
  /**
55
60
  * Inline literal type text with no addressable symbol (the v1 inline-alias
@@ -15,6 +15,7 @@
15
15
  import type { CheckStubInput } from './api.js';
16
16
  import type { ProbePlan } from './check-probe.js';
17
17
  import { WriteGuard } from './guarded-fs.js';
18
+ import { type ResolutionEdges } from './resolution-edges.js';
18
19
  export interface AssembledStub {
19
20
  serviceName: string;
20
21
  /** Workspace package dir under packages/ (== the @carrick/<dir> suffix). */
@@ -45,6 +46,15 @@ export interface AssembledWorkspace {
45
46
  export declare function computeDedupeOverrides(stubs: {
46
47
  dependencies: Record<string, string>;
47
48
  }[]): Record<string, string>;
49
+ /**
50
+ * pnpm parent-scoped overrides (`<parent>@<version>><child>`) that install
51
+ * each edge the stubs recorded at the version the scanned repo installed
52
+ * (carrick#2091). A parent-scoped override wins over a generic one, so an
53
+ * edge's version first passes through the dedupe table: one physical copy per
54
+ * compat group still holds. An edge two stubs record at versions the dedupe
55
+ * does not reconcile is left to the resolver.
56
+ */
57
+ export declare function computeResolutionOverrides(edgeSets: ResolutionEdges[], dedupe: Record<string, string>): Record<string, string>;
48
58
  export interface AssembleOptions {
49
59
  stubs: CheckStubInput[];
50
60
  workspaceRoot?: string;
@@ -15,6 +15,7 @@
15
15
  import * as fs from 'node:fs';
16
16
  import * as path from 'node:path';
17
17
  import { WriteGuard } from './guarded-fs.js';
18
+ import { RESOLUTION_FILE } from './resolution-edges.js';
18
19
  const PROBES_PACKAGE = 'carrick-probes';
19
20
  function parseVersion(v) {
20
21
  const core = v.replace(/^[^\d]*/, '').split('-')[0].split('+')[0];
@@ -90,6 +91,57 @@ export function computeDedupeOverrides(stubs) {
90
91
  .sort()
91
92
  .map((k) => [k, overrides[k]]));
92
93
  }
94
+ /** A stub's recorded edges; a stub stored without the file records none. */
95
+ function readStubResolution(stubDir) {
96
+ let parsed;
97
+ try {
98
+ parsed = JSON.parse(fs.readFileSync(path.join(stubDir, RESOLUTION_FILE), 'utf8'));
99
+ }
100
+ catch {
101
+ return {};
102
+ }
103
+ const edges = parsed?.edges;
104
+ if (!edges || typeof edges !== 'object')
105
+ return {};
106
+ const out = {};
107
+ for (const [parent, children] of Object.entries(edges)) {
108
+ if (!children || typeof children !== 'object')
109
+ continue;
110
+ for (const [child, version] of Object.entries(children)) {
111
+ if (typeof version === 'string')
112
+ (out[parent] ??= {})[child] = version;
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+ /**
118
+ * pnpm parent-scoped overrides (`<parent>@<version>><child>`) that install
119
+ * each edge the stubs recorded at the version the scanned repo installed
120
+ * (carrick#2091). A parent-scoped override wins over a generic one, so an
121
+ * edge's version first passes through the dedupe table: one physical copy per
122
+ * compat group still holds. An edge two stubs record at versions the dedupe
123
+ * does not reconcile is left to the resolver.
124
+ */
125
+ export function computeResolutionOverrides(edgeSets, dedupe) {
126
+ const versionsOf = new Map();
127
+ for (const edges of edgeSets) {
128
+ for (const [parent, children] of Object.entries(edges)) {
129
+ for (const [child, version] of Object.entries(children)) {
130
+ const key = `${parent}>${child}`;
131
+ if (!versionsOf.has(key))
132
+ versionsOf.set(key, new Set());
133
+ versionsOf.get(key).add(dedupe[`${child}@${version}`] ?? version);
134
+ }
135
+ }
136
+ }
137
+ const overrides = {};
138
+ for (const key of [...versionsOf.keys()].sort()) {
139
+ const versions = versionsOf.get(key);
140
+ if (versions.size === 1)
141
+ overrides[key] = [...versions][0];
142
+ }
143
+ return overrides;
144
+ }
93
145
  function readStubPackageName(stubDir) {
94
146
  const pkgPath = path.join(stubDir, 'package.json');
95
147
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
@@ -116,6 +168,7 @@ export function assembleWorkspace(opts) {
116
168
  guard.writeFile(path.join(workspaceDir, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n');
117
169
  const assembled = [];
118
170
  const dependencySets = [];
171
+ const edgeSets = [];
119
172
  const svcToPkg = new Map();
120
173
  const dirToPkg = new Map();
121
174
  const dirToSvc = new Map();
@@ -126,12 +179,20 @@ export function assembleWorkspace(opts) {
126
179
  guard.copyTree(stub.stub_dir, dest, (src) => !src.split(path.sep).includes('node_modules'));
127
180
  assembled.push({ serviceName: stub.service_name, packageDir, packageName });
128
181
  dependencySets.push({ dependencies: readStubDependencies(stub.stub_dir) });
182
+ edgeSets.push(readStubResolution(stub.stub_dir));
129
183
  svcToPkg.set(stub.service_name, packageName);
130
184
  dirToPkg.set(packageDir, packageName);
131
185
  dirToSvc.set(packageDir, stub.service_name);
132
186
  }
133
- // Root manifest carries the semver-dedupe overrides.
134
- const overrides = computeDedupeOverrides(dependencySets);
187
+ // Root manifest carries the semver-dedupe overrides and the recorded edges.
188
+ const dedupe = computeDedupeOverrides(dependencySets);
189
+ const merged = {
190
+ ...dedupe,
191
+ ...computeResolutionOverrides(edgeSets, dedupe),
192
+ };
193
+ const overrides = Object.fromEntries(Object.keys(merged)
194
+ .sort()
195
+ .map((k) => [k, merged[k]]));
135
196
  guard.writeFile(path.join(workspaceDir, 'package.json'), JSON.stringify({
136
197
  name: 'carrick-check-workspace',
137
198
  version: '0.0.0',
@@ -181,12 +242,13 @@ const NPMRC = [
181
242
  'strict-peer-dependencies=false',
182
243
  // Determinism: install exactly the pinned closure, no implicit peer pull-in.
183
244
  'auto-install-peers=false',
184
- // Determinism: the scratch workspace has no committed lockfile, so the
185
- // transitive closure would otherwise be a pure function of live registry
186
- // state. prefer-offline resolves from the local store/metadata cache
245
+ // Determinism: the scratch workspace has no committed lockfile. Direct deps
246
+ // are exact-pinned by the stubs, and every transitive edge a stub recorded
247
+ // from the scanned repo's installed tree is pinned by a parent-scoped
248
+ // override in the root manifest. Only unrecorded edges resolve, and for
249
+ // those prefer-offline resolves from the local store/metadata cache
187
250
  // whenever possible (byte-stable across runs on a host), and the explicit
188
251
  // resolution-mode pins pnpm's resolver behavior across pnpm versions.
189
- // Direct deps are exact-pinned by the stubs; only transitives resolve.
190
252
  'prefer-offline=true',
191
253
  'resolution-mode=highest',
192
254
  '',
@@ -59,3 +59,4 @@ export declare function surfaceEntryFileName(): string;
59
59
  /** Same normalization intent as bundle_file_stems on the Rust side. */
60
60
  export declare function sanitizeServiceName(name: string): string;
61
61
  export declare function captureStub(opts: CaptureStubOptions): CaptureStubResult;
62
+ export { classifyStatusCodes, constituents, httpStatus, statesNoBody, statusKeyCode, statusTableBody, statusTableOf, successEntries, } from './status-table.js';
@@ -33,6 +33,7 @@ import * as path from 'node:path';
33
33
  import { entryRelativeSpecifier, resolveAnchor } from './anchors.js';
34
34
  import { findAugmentationFiles } from './augmentations.js';
35
35
  import { installedVersions, lockfileVersions } from './lockfile.js';
36
+ import { installedResolutionEdges, RESOLUTION_FILE } from './resolution-edges.js';
36
37
  import { rewriteEmittedSpecifiers } from './paths-rewrite.js';
37
38
  import { installedPackageSpecifier, typesPackageOf, withInstalledPackages } from './installed-package.js';
38
39
  import { selfCheckStub } from './self-check.js';
@@ -428,9 +429,8 @@ export function captureStub(opts) {
428
429
  // repos whose lockfiles we do not parse (yarn classic v1, binary
429
430
  // bun.lockb). The parsed lockfile (npm, pnpm, yarn-berry, text bun.lock)
430
431
  // remains the bare-checkout fallback. Both paths pin only
431
- // the directly-referenced externals; transitives resolve at check-install
432
- // (check-workspace NPMRC: "Direct deps are exact-pinned by the stubs;
433
- // only transitives resolve").
432
+ // the directly-referenced externals; what those depend on is recorded
433
+ // from the installed tree below (RESOLUTION_FILE).
434
434
  const installed = installedVersions(repoRoot, externalSpecs);
435
435
  const lockVersions = lockfileVersions(repoRoot);
436
436
  for (const name of Object.keys(deno?.pinned ?? {}))
@@ -451,6 +451,18 @@ export function captureStub(opts) {
451
451
  else if (!rewritten.pins[typesPackageOf(name)])
452
452
  unpinned.push(name);
453
453
  }
454
+ // ---- Record how the installed tree resolved what the pins depend on ----
455
+ // The check pins each recorded edge, so it installs what the repo
456
+ // installed rather than the newest version in range (carrick#2091).
457
+ const resolution = deno
458
+ ? { edges: {}, unrecorded: 0 }
459
+ : installedResolutionEdges(repoRoot, pinned);
460
+ const edgeCount = Object.values(resolution.edges).reduce((n, children) => n + Object.keys(children).length, 0);
461
+ if (edgeCount > 0) {
462
+ stubGuard.writeFile(path.join(stubDir, RESOLUTION_FILE), JSON.stringify({ edges: resolution.edges }, null, 2) + '\n');
463
+ }
464
+ console.error(`[sidecar] capture ${packageName}: ${edgeCount} dependency edge(s) recorded from the installed tree, ` +
465
+ `${resolution.unrecorded} left to the registry`);
454
466
  const dependencyRoot = deno?.config.workspaceRoot ?? repoRoot;
455
467
  const bareCheckout = !deno && !fs.existsSync(path.join(dependencyRoot, 'node_modules'));
456
468
  stubGuard.writeFile(path.join(stubDir, 'package.json'), JSON.stringify({
@@ -751,3 +763,6 @@ function resolveAnchors(opts, parsed, ctx, deno) {
751
763
  ctx.guard.unlink(ctx.entryPath);
752
764
  }
753
765
  }
766
+ // carrick#1841: the response-table reading the inferrer and the v1 bundle
767
+ // share with the capture's symbol anchor.
768
+ export { classifyStatusCodes, constituents, httpStatus, statesNoBody, statusKeyCode, statusTableBody, statusTableOf, successEntries, } from './status-table.js';
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Where an installed package sits on disk, by path alone, for capture's
3
+ * resolution walk. It mirrors `DeclarationReader.installedPackage` in
4
+ * library-claims.ts: the capture seam (pinned decision 11a) lets neither side
5
+ * import the other, so a change to what counts as installed changes both.
6
+ *
7
+ * Seam: node builtins only.
8
+ */
9
+ /**
10
+ * The installed package directory a real path lies in, by path alone: the
11
+ * directory after the path's last `node_modules` segment (two segments for a
12
+ * scope), taken relative to `root`, and the name it is installed as. A source
13
+ * file in a directory named `node_modules` above `root`, or a checkout under a
14
+ * `node_modules` ancestor, is not installed; an install hoisted above `root`
15
+ * (`../../node_modules/pkg`) and a pnpm store
16
+ * (`node_modules/.pnpm/pkg@1/node_modules/pkg`) are. The caller reads the
17
+ * package.json there: without one, nothing is installed.
18
+ */
19
+ export declare function installedPackageDirectory(root: string, realPath: string): {
20
+ directory: string;
21
+ name: string;
22
+ } | undefined;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Where an installed package sits on disk, by path alone, for capture's
3
+ * resolution walk. It mirrors `DeclarationReader.installedPackage` in
4
+ * library-claims.ts: the capture seam (pinned decision 11a) lets neither side
5
+ * import the other, so a change to what counts as installed changes both.
6
+ *
7
+ * Seam: node builtins only.
8
+ */
9
+ import * as path from 'node:path';
10
+ /**
11
+ * The installed package directory a real path lies in, by path alone: the
12
+ * directory after the path's last `node_modules` segment (two segments for a
13
+ * scope), taken relative to `root`, and the name it is installed as. A source
14
+ * file in a directory named `node_modules` above `root`, or a checkout under a
15
+ * `node_modules` ancestor, is not installed; an install hoisted above `root`
16
+ * (`../../node_modules/pkg`) and a pnpm store
17
+ * (`node_modules/.pnpm/pkg@1/node_modules/pkg`) are. The caller reads the
18
+ * package.json there: without one, nothing is installed.
19
+ */
20
+ export function installedPackageDirectory(root, realPath) {
21
+ const segments = path.relative(root, realPath).split(path.sep);
22
+ const last = segments.lastIndexOf('node_modules');
23
+ if (last < 0)
24
+ return undefined;
25
+ const width = segments[last + 1]?.startsWith('@') ? 2 : 1;
26
+ return {
27
+ directory: path.resolve(root, ...segments.slice(0, last + 1 + width)),
28
+ name: segments.slice(last + 1, last + 1 + width).join('/'),
29
+ };
30
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * How the scanned repo's installed tree resolved the packages a stub pins
3
+ * (carrick#2091).
4
+ *
5
+ * A stub pins only the externals its declarations import. The check
6
+ * workspace has no lockfile, so without this record every package those pins
7
+ * depend on resolves fresh from the registry: the check can install a version
8
+ * the repo never ran, or fail on one the registry lists but cannot serve.
9
+ *
10
+ * The walk reads the installed tree the way Node's lookup does, so one reader
11
+ * covers npm, pnpm (isolated store and hoisted), yarn's node-modules linker
12
+ * and bun. Starting at each pin installed at the pinned version, it follows
13
+ * `dependencies` and `optionalDependencies` breadth first and records
14
+ * `<parent>@<version> -> <child>: <version>` for every child installed as a
15
+ * published package of that name. Workspace members, `link:` targets and npm
16
+ * aliases are left out: the registry cannot serve them by that name. An edge
17
+ * whose parent version is installed twice with different children is left
18
+ * out, since no one answer is the repo's.
19
+ *
20
+ * The walk only reads. Seam: node builtins and this bundle only.
21
+ */
22
+ /** The stub file the check reads to pin transitive edges. */
23
+ export declare const RESOLUTION_FILE = "carrick-resolution.json";
24
+ /** `<parent>@<version>` -> child name -> child version. */
25
+ export type ResolutionEdges = Record<string, Record<string, string>>;
26
+ export interface InstalledResolution {
27
+ /** Key-sorted at both levels; byte-stable for one tree. */
28
+ edges: ResolutionEdges;
29
+ /** Dependencies of walked packages that no edge records: the check resolves these from the registry. */
30
+ unrecorded: number;
31
+ }
32
+ /**
33
+ * The edges the installed tree under `serviceRoot` resolved, from the stub's
34
+ * pins outward. A pin not installed at its pinned version starts no walk.
35
+ */
36
+ export declare function installedResolutionEdges(serviceRoot: string, pins: Record<string, string>): InstalledResolution;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * How the scanned repo's installed tree resolved the packages a stub pins
3
+ * (carrick#2091).
4
+ *
5
+ * A stub pins only the externals its declarations import. The check
6
+ * workspace has no lockfile, so without this record every package those pins
7
+ * depend on resolves fresh from the registry: the check can install a version
8
+ * the repo never ran, or fail on one the registry lists but cannot serve.
9
+ *
10
+ * The walk reads the installed tree the way Node's lookup does, so one reader
11
+ * covers npm, pnpm (isolated store and hoisted), yarn's node-modules linker
12
+ * and bun. Starting at each pin installed at the pinned version, it follows
13
+ * `dependencies` and `optionalDependencies` breadth first and records
14
+ * `<parent>@<version> -> <child>: <version>` for every child installed as a
15
+ * published package of that name. Workspace members, `link:` targets and npm
16
+ * aliases are left out: the registry cannot serve them by that name. An edge
17
+ * whose parent version is installed twice with different children is left
18
+ * out, since no one answer is the repo's.
19
+ *
20
+ * The walk only reads. Seam: node builtins and this bundle only.
21
+ */
22
+ import * as fs from 'node:fs';
23
+ import * as path from 'node:path';
24
+ import { installedPackageDirectory } from './installed-package-directory.js';
25
+ import { isPublishedSemver } from './lockfile.js';
26
+ /** The stub file the check reads to pin transitive edges. */
27
+ export const RESOLUTION_FILE = 'carrick-resolution.json';
28
+ function validName(name) {
29
+ return !name.split('/').some((s) => s === '' || s === '.' || s === '..');
30
+ }
31
+ /**
32
+ * Node's lookup of `name` from `fromDir`: `<dir>/node_modules/<name>` at each
33
+ * ancestor, skipping an ancestor that is itself a `node_modules` directory.
34
+ * That skip is how a pnpm store package finds its sibling links.
35
+ */
36
+ function lookup(fromDir, name) {
37
+ let dir = fromDir;
38
+ for (;;) {
39
+ if (path.basename(dir) !== 'node_modules') {
40
+ const candidate = path.join(dir, 'node_modules', ...name.split('/'));
41
+ if (fs.existsSync(path.join(candidate, 'package.json')))
42
+ return candidate;
43
+ }
44
+ const parent = path.dirname(dir);
45
+ if (parent === dir)
46
+ return undefined;
47
+ dir = parent;
48
+ }
49
+ }
50
+ /**
51
+ * The package `name` installed as Node would find it from `fromDir`: a
52
+ * directory `installedPackageDirectory` accepts, holding a package.json of
53
+ * that name with a published version.
54
+ */
55
+ function installedFrom(root, fromDir, name) {
56
+ if (!validName(name))
57
+ return undefined;
58
+ const found = lookup(fromDir, name);
59
+ if (found === undefined)
60
+ return undefined;
61
+ try {
62
+ const dir = fs.realpathSync(found);
63
+ const installed = installedPackageDirectory(root, dir);
64
+ if (installed === undefined || installed.directory !== dir || installed.name !== name) {
65
+ return undefined;
66
+ }
67
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
68
+ if (manifest?.name !== name || typeof manifest.version !== 'string')
69
+ return undefined;
70
+ if (!isPublishedSemver(manifest.version))
71
+ return undefined;
72
+ const names = new Set();
73
+ for (const field of [manifest.dependencies, manifest.optionalDependencies]) {
74
+ if (field && typeof field === 'object')
75
+ for (const dep of Object.keys(field))
76
+ names.add(dep);
77
+ }
78
+ return { dir, name, version: manifest.version, dependencies: [...names].sort() };
79
+ }
80
+ catch {
81
+ // Dangling link, unreadable or corrupt manifest: nothing installed here.
82
+ return undefined;
83
+ }
84
+ }
85
+ function sorted(record) {
86
+ return Object.fromEntries(Object.keys(record)
87
+ .sort()
88
+ .map((k) => [k, record[k]]));
89
+ }
90
+ /**
91
+ * The edges the installed tree under `serviceRoot` resolved, from the stub's
92
+ * pins outward. A pin not installed at its pinned version starts no walk.
93
+ */
94
+ export function installedResolutionEdges(serviceRoot, pins) {
95
+ let root;
96
+ try {
97
+ root = fs.realpathSync(path.resolve(serviceRoot));
98
+ }
99
+ catch {
100
+ return { edges: {}, unrecorded: 0 };
101
+ }
102
+ const queue = [];
103
+ const visited = new Set();
104
+ for (const name of Object.keys(pins).sort()) {
105
+ const pkg = installedFrom(root, root, name);
106
+ if (pkg === undefined || pkg.version !== pins[name] || visited.has(pkg.dir))
107
+ continue;
108
+ visited.add(pkg.dir);
109
+ queue.push(pkg);
110
+ }
111
+ // parent key -> child -> every version a copy of that parent resolved.
112
+ const seen = new Map();
113
+ // Each (parent key, child) a walked package declares, recorded or not.
114
+ const declared = new Set();
115
+ for (let i = 0; i < queue.length; i++) {
116
+ const parent = queue[i];
117
+ const key = `${parent.name}@${parent.version}`;
118
+ let children = seen.get(key);
119
+ if (children === undefined)
120
+ seen.set(key, (children = new Map()));
121
+ for (const name of parent.dependencies) {
122
+ declared.add(`${key}>${name}`);
123
+ const child = installedFrom(root, parent.dir, name);
124
+ if (child === undefined)
125
+ continue;
126
+ let versions = children.get(name);
127
+ if (versions === undefined)
128
+ children.set(name, (versions = new Set()));
129
+ versions.add(child.version);
130
+ if (!visited.has(child.dir)) {
131
+ visited.add(child.dir);
132
+ queue.push(child);
133
+ }
134
+ }
135
+ }
136
+ const edges = {};
137
+ let recorded = 0;
138
+ for (const [key, children] of seen) {
139
+ const kept = {};
140
+ for (const [name, versions] of children) {
141
+ if (versions.size === 1)
142
+ kept[name] = [...versions][0];
143
+ }
144
+ if (Object.keys(kept).length === 0)
145
+ continue;
146
+ edges[key] = sorted(kept);
147
+ recorded += Object.keys(kept).length;
148
+ }
149
+ return { edges: sorted(edges), unrecorded: declared.size - recorded };
150
+ }