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 +6 -6
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/sidecar/dist/src/bundler.d.ts +10 -0
- package/sidecar/dist/src/bundler.js +42 -0
- package/sidecar/dist/src/capture/anchors.js +32 -0
- package/sidecar/dist/src/capture/api.d.ts +5 -0
- package/sidecar/dist/src/capture/check-workspace.d.ts +10 -0
- package/sidecar/dist/src/capture/check-workspace.js +68 -6
- package/sidecar/dist/src/capture/index.d.ts +1 -0
- package/sidecar/dist/src/capture/index.js +18 -3
- package/sidecar/dist/src/capture/installed-package-directory.d.ts +22 -0
- package/sidecar/dist/src/capture/installed-package-directory.js +30 -0
- package/sidecar/dist/src/capture/resolution-edges.d.ts +36 -0
- package/sidecar/dist/src/capture/resolution-edges.js +150 -0
- package/sidecar/dist/src/capture/status-table.d.ts +62 -0
- package/sidecar/dist/src/capture/status-table.js +104 -0
- package/sidecar/dist/src/failure-path.d.ts +42 -2
- package/sidecar/dist/src/failure-path.js +38 -13
- package/sidecar/dist/src/library-claims.js +2 -0
- package/sidecar/dist/src/line-index.d.ts +9 -0
- package/sidecar/dist/src/line-index.js +24 -0
- package/sidecar/dist/src/response-modes.d.ts +77 -0
- package/sidecar/dist/src/response-modes.js +459 -0
- package/sidecar/dist/src/type-inferrer.d.ts +42 -0
- package/sidecar/dist/src/type-inferrer.js +237 -76
- package/sidecar/dist/src/types.d.ts +16 -0
- package/sidecar/dist/src/validators.d.ts +39 -16
- package/sidecar/dist/src/validators.js +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "carrick",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
62
|
-
"@carrick-tools/cli-darwin-x64": "0.3.
|
|
63
|
-
"@carrick-tools/cli-linux-arm64": "0.3.
|
|
64
|
-
"@carrick-tools/cli-linux-x64": "0.3.
|
|
65
|
-
"@carrick-tools/cli-win32-x64": "0.3.
|
|
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.
|
|
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
|
|
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
|
|
185
|
-
//
|
|
186
|
-
//
|
|
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;
|
|
432
|
-
//
|
|
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
|
+
}
|