carrick 0.3.53
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/LICENSE.md +99 -0
- package/README.md +79 -0
- package/bin/carrick.mjs +168 -0
- package/dist/channel.d.ts +16 -0
- package/dist/channel.js +37 -0
- package/dist/channel.js.map +1 -0
- package/dist/cli.d.ts +29 -0
- package/dist/cli.js +60 -0
- package/dist/cli.js.map +1 -0
- package/dist/contract.d.ts +154 -0
- package/dist/contract.js +141 -0
- package/dist/contract.js.map +1 -0
- package/dist/diagnostics.d.ts +54 -0
- package/dist/diagnostics.js +199 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/hook/post-edit.d.ts +13 -0
- package/dist/hook/post-edit.js +76 -0
- package/dist/hook/post-edit.js.map +1 -0
- package/dist/hook/session-start.d.ts +2 -0
- package/dist/hook/session-start.js +44 -0
- package/dist/hook/session-start.js.map +1 -0
- package/dist/init/identity.d.ts +20 -0
- package/dist/init/identity.js +60 -0
- package/dist/init/identity.js.map +1 -0
- package/dist/init/repos.d.ts +19 -0
- package/dist/init/repos.js +46 -0
- package/dist/init/repos.js.map +1 -0
- package/dist/init/run.d.ts +11 -0
- package/dist/init/run.js +228 -0
- package/dist/init/run.js.map +1 -0
- package/dist/init/settings.d.ts +50 -0
- package/dist/init/settings.js +138 -0
- package/dist/init/settings.js.map +1 -0
- package/dist/log.d.ts +2 -0
- package/dist/log.js +35 -0
- package/dist/log.js.map +1 -0
- package/dist/native.d.ts +48 -0
- package/dist/native.js +127 -0
- package/dist/native.js.map +1 -0
- package/dist/render.d.ts +65 -0
- package/dist/render.js +334 -0
- package/dist/render.js.map +1 -0
- package/dist/root.d.ts +30 -0
- package/dist/root.js +82 -0
- package/dist/root.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +255 -0
- package/dist/server.js.map +1 -0
- package/dist/templates.d.ts +18 -0
- package/dist/templates.js +66 -0
- package/dist/templates.js.map +1 -0
- package/package.json +72 -0
- package/plugin/.claude-plugin/plugin.json +6 -0
- package/plugin/.lsp.json +14 -0
- package/plugin/hooks/hooks.json +27 -0
- package/sidecar/dist/src/bundler.d.ts +141 -0
- package/sidecar/dist/src/bundler.js +680 -0
- package/sidecar/dist/src/capture/anchors.d.ts +61 -0
- package/sidecar/dist/src/capture/anchors.js +1132 -0
- package/sidecar/dist/src/capture/api.d.ts +378 -0
- package/sidecar/dist/src/capture/api.js +10 -0
- package/sidecar/dist/src/capture/augmentations.d.ts +20 -0
- package/sidecar/dist/src/capture/augmentations.js +60 -0
- package/sidecar/dist/src/capture/check-classify.d.ts +58 -0
- package/sidecar/dist/src/capture/check-classify.js +189 -0
- package/sidecar/dist/src/capture/check-deep.d.ts +32 -0
- package/sidecar/dist/src/capture/check-deep.js +91 -0
- package/sidecar/dist/src/capture/check-poison.d.ts +40 -0
- package/sidecar/dist/src/capture/check-poison.js +155 -0
- package/sidecar/dist/src/capture/check-probe.d.ts +70 -0
- package/sidecar/dist/src/capture/check-probe.js +147 -0
- package/sidecar/dist/src/capture/check-scrub.d.ts +31 -0
- package/sidecar/dist/src/capture/check-scrub.js +71 -0
- package/sidecar/dist/src/capture/check-workspace.d.ts +52 -0
- package/sidecar/dist/src/capture/check-workspace.js +218 -0
- package/sidecar/dist/src/capture/check.d.ts +39 -0
- package/sidecar/dist/src/capture/check.js +450 -0
- package/sidecar/dist/src/capture/deep-walk.d.ts +62 -0
- package/sidecar/dist/src/capture/deep-walk.js +243 -0
- package/sidecar/dist/src/capture/index.d.ts +36 -0
- package/sidecar/dist/src/capture/index.js +477 -0
- package/sidecar/dist/src/capture/lockfile.d.ts +48 -0
- package/sidecar/dist/src/capture/lockfile.js +490 -0
- package/sidecar/dist/src/capture/machinery.d.ts +59 -0
- package/sidecar/dist/src/capture/machinery.js +160 -0
- package/sidecar/dist/src/capture/node-builder.d.ts +37 -0
- package/sidecar/dist/src/capture/node-builder.js +123 -0
- package/sidecar/dist/src/capture/paths-rewrite.d.ts +34 -0
- package/sidecar/dist/src/capture/paths-rewrite.js +100 -0
- package/sidecar/dist/src/capture/self-check.d.ts +38 -0
- package/sidecar/dist/src/capture/self-check.js +317 -0
- package/sidecar/dist/src/capture/specifiers.d.ts +39 -0
- package/sidecar/dist/src/capture/specifiers.js +56 -0
- package/sidecar/dist/src/definition-resolver.d.ts +57 -0
- package/sidecar/dist/src/definition-resolver.js +153 -0
- package/sidecar/dist/src/index.d.ts +14 -0
- package/sidecar/dist/src/index.js +564 -0
- package/sidecar/dist/src/monorepo-builder.d.ts +129 -0
- package/sidecar/dist/src/monorepo-builder.js +584 -0
- package/sidecar/dist/src/project-loader.d.ts +130 -0
- package/sidecar/dist/src/project-loader.js +399 -0
- package/sidecar/dist/src/type-inferrer.d.ts +940 -0
- package/sidecar/dist/src/type-inferrer.js +3540 -0
- package/sidecar/dist/src/type-structural-expander.d.ts +61 -0
- package/sidecar/dist/src/type-structural-expander.js +283 -0
- package/sidecar/dist/src/type-text-canonicalizer.d.ts +40 -0
- package/sidecar/dist/src/type-text-canonicalizer.js +297 -0
- package/sidecar/dist/src/types.d.ts +669 -0
- package/sidecar/dist/src/types.js +5 -0
- package/sidecar/dist/src/validators.d.ts +2214 -0
- package/sidecar/dist/src/validators.js +336 -0
- package/sidecar/package.json +6 -0
- package/templates/carrick.json +7 -0
- package/templates/carrick.yml +45 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-compat v2 capture core: "tsc as the serializer"
|
|
3
|
+
* (docs/reference/type-compat-synthetic-monorepo.md, Capture phase).
|
|
4
|
+
*
|
|
5
|
+
* Produces a types-only stub package for one service:
|
|
6
|
+
*
|
|
7
|
+
* @carrick/<service>/
|
|
8
|
+
* |- package.json name, types entry, pinned deps (exact versions)
|
|
9
|
+
* |- tsconfig.snapshot.json
|
|
10
|
+
* |- carrick-manifest.json per-alias records + fidelity metric
|
|
11
|
+
* `- types/
|
|
12
|
+
* |- surface.d.ts entry: export type <alias> = ...
|
|
13
|
+
* `- nested .d.ts tree compiler-emitted declaration closure
|
|
14
|
+
*
|
|
15
|
+
* Two-phase flow:
|
|
16
|
+
* Phase A (analysis): a placeholder surface entry + the anchors' source
|
|
17
|
+
* files form a program; addressable anchors run their guards, anonymous
|
|
18
|
+
* anchors are located and printed via the SymbolTracker-backed node
|
|
19
|
+
* builder (anchored at their placeholder -- the destination file).
|
|
20
|
+
* Phase B (emit): the final entry runs `tsc --noCheck --declaration
|
|
21
|
+
* --emitDeclarationOnly` with the repo's own parsed options, plus every
|
|
22
|
+
* detected augmentation file as an extra root; the tree is relocated
|
|
23
|
+
* into the stub, specifiers are rewritten, deps pinned, and the
|
|
24
|
+
* per-alias self-check classifies the result.
|
|
25
|
+
*
|
|
26
|
+
* Seam note: this directory is the whole v2 capture bundle. It imports only
|
|
27
|
+
* node builtins and `typescript`; the rest of the sidecar reaches it only
|
|
28
|
+
* through ./api.js types and this file's `captureStub`.
|
|
29
|
+
*/
|
|
30
|
+
import ts from 'typescript';
|
|
31
|
+
import * as fs from 'node:fs';
|
|
32
|
+
import * as path from 'node:path';
|
|
33
|
+
import * as os from 'node:os';
|
|
34
|
+
import { entryRelativeSpecifier, resolveAnchor } from './anchors.js';
|
|
35
|
+
import { findAugmentationFiles } from './augmentations.js';
|
|
36
|
+
import { installedVersions, lockfileVersions } from './lockfile.js';
|
|
37
|
+
import { rewriteEmittedSpecifiers } from './paths-rewrite.js';
|
|
38
|
+
import { selfCheckStub } from './self-check.js';
|
|
39
|
+
import { collectSpecifiers, isRelative, packageNameOf } from './specifiers.js';
|
|
40
|
+
// v2 check core ("tsc as the judge"). Same bundle, same seam: the sidecar
|
|
41
|
+
// reaches it only through this door (index.js).
|
|
42
|
+
export { runCheck } from './check.js';
|
|
43
|
+
const SURFACE_ENTRY_BASENAME = '__carrick_surface__';
|
|
44
|
+
/** Same normalization intent as bundle_file_stems on the Rust side. */
|
|
45
|
+
export function sanitizeServiceName(name) {
|
|
46
|
+
return name.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
47
|
+
}
|
|
48
|
+
function fail(stubDir, packageName, errors) {
|
|
49
|
+
return {
|
|
50
|
+
success: false,
|
|
51
|
+
stub_dir: stubDir,
|
|
52
|
+
package_name: packageName,
|
|
53
|
+
emitted_files: [],
|
|
54
|
+
pinned_dependencies: {},
|
|
55
|
+
unpinned_externals: [],
|
|
56
|
+
aliases: [],
|
|
57
|
+
fidelity: emptyFidelity(),
|
|
58
|
+
augmentation_files: [],
|
|
59
|
+
specifier_rewrites: 0,
|
|
60
|
+
bare_checkout: false,
|
|
61
|
+
ts_version: ts.version,
|
|
62
|
+
errors,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function emptyFidelity() {
|
|
66
|
+
return {
|
|
67
|
+
total_aliases: 0,
|
|
68
|
+
by_serialization: { emitted: 0, node_builder: 0, structural_fallback: 0 },
|
|
69
|
+
by_self_check: { ok: 0, allowlisted_external: 0, decayed_internal: 0 },
|
|
70
|
+
by_anchor_origin: { 'llm-symbol': 0, 'deterministic-infer': 0, 'anchor-backfill': 0 },
|
|
71
|
+
usable_rate: 0,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function computeFidelity(records) {
|
|
75
|
+
const fidelity = emptyFidelity();
|
|
76
|
+
fidelity.total_aliases = records.length;
|
|
77
|
+
for (const record of records) {
|
|
78
|
+
fidelity.by_serialization[record.serialization]++;
|
|
79
|
+
fidelity.by_self_check[record.self_check]++;
|
|
80
|
+
fidelity.by_anchor_origin[record.anchor_origin]++;
|
|
81
|
+
}
|
|
82
|
+
const usable = fidelity.by_self_check.ok + fidelity.by_self_check.allowlisted_external;
|
|
83
|
+
fidelity.usable_rate =
|
|
84
|
+
records.length === 0 ? 0 : Math.round((usable / records.length) * 1000) / 1000;
|
|
85
|
+
return fidelity;
|
|
86
|
+
}
|
|
87
|
+
export function captureStub(opts) {
|
|
88
|
+
const repoRoot = path.resolve(opts.repoRoot);
|
|
89
|
+
const packageName = `@carrick/${sanitizeServiceName(opts.serviceName)}`;
|
|
90
|
+
const stubDir = path.resolve(opts.outDir);
|
|
91
|
+
const errors = [];
|
|
92
|
+
const configPath = opts.tsconfigPath
|
|
93
|
+
? path.resolve(opts.tsconfigPath)
|
|
94
|
+
: path.join(repoRoot, 'tsconfig.json');
|
|
95
|
+
let parsed;
|
|
96
|
+
if (!fs.existsSync(configPath)) {
|
|
97
|
+
if (opts.tsconfigPath) {
|
|
98
|
+
// An explicitly named tsconfig that does not exist is a caller bug.
|
|
99
|
+
return fail(stubDir, packageName, [`tsconfig not found at ${configPath}`]);
|
|
100
|
+
}
|
|
101
|
+
// No tsconfig in the repo: synthesize defaults (parity with the v1
|
|
102
|
+
// project loader's DEFAULT_COMPILER_OPTIONS) so tsconfig-less repos
|
|
103
|
+
// still capture instead of shipping no surface at all.
|
|
104
|
+
parsed = ts.parseJsonConfigFileContent({
|
|
105
|
+
compilerOptions: {
|
|
106
|
+
target: 'ESNext',
|
|
107
|
+
module: 'ESNext',
|
|
108
|
+
moduleResolution: 'Bundler',
|
|
109
|
+
strict: true,
|
|
110
|
+
esModuleInterop: true,
|
|
111
|
+
skipLibCheck: true,
|
|
112
|
+
allowJs: true,
|
|
113
|
+
checkJs: false,
|
|
114
|
+
resolveJsonModule: true,
|
|
115
|
+
},
|
|
116
|
+
}, ts.sys, repoRoot);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const configHost = {
|
|
120
|
+
...ts.sys,
|
|
121
|
+
onUnRecoverableConfigFileDiagnostic: (d) => {
|
|
122
|
+
throw new Error(ts.flattenDiagnosticMessageText(d.messageText, '\n'));
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
try {
|
|
126
|
+
parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, configHost) ?? undefined;
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
return fail(stubDir, packageName, [err instanceof Error ? err.message : String(err)]);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!parsed) {
|
|
133
|
+
return fail(stubDir, packageName, [`failed to parse ${configPath}`]);
|
|
134
|
+
}
|
|
135
|
+
// The surface entry must live inside the effective rootDir (design doc
|
|
136
|
+
// Capture step 1: an entry at repo root with rootDir "src" fails TS6059).
|
|
137
|
+
const entryDir = parsed.options.rootDir
|
|
138
|
+
? path.resolve(path.dirname(configPath), parsed.options.rootDir)
|
|
139
|
+
: repoRoot;
|
|
140
|
+
const entryPath = path.join(entryDir, `${SURFACE_ENTRY_BASENAME}.ts`);
|
|
141
|
+
// ---- Phase A: analysis program over placeholder entry + anchor sources ----
|
|
142
|
+
let resolved;
|
|
143
|
+
try {
|
|
144
|
+
resolved = resolveAnchors(opts, parsed, { repoRoot, entryDir, entryPath });
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
return fail(stubDir, packageName, [err instanceof Error ? err.message : String(err)]);
|
|
148
|
+
}
|
|
149
|
+
// ---- Augmentation detection over the tsconfig's full file list ----
|
|
150
|
+
const augmentationSources = findAugmentationFiles(parsed.fileNames.filter((f) => !f.includes(`${path.sep}node_modules${path.sep}`)));
|
|
151
|
+
// ---- Phase B: declaration emit of the final entry ----
|
|
152
|
+
const entryLines = ['// Generated by Carrick capture v2. Deleted after emit.'];
|
|
153
|
+
for (const anchor of resolved) {
|
|
154
|
+
const comment = anchor.failureReason
|
|
155
|
+
? ` // capture-degraded: ${anchor.failureReason.replace(/\n/g, ' ')}`
|
|
156
|
+
: '';
|
|
157
|
+
entryLines.push(`export type ${anchor.request.alias} = ${anchor.aliasText};${comment}`);
|
|
158
|
+
}
|
|
159
|
+
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'carrick-capture-v2-'));
|
|
160
|
+
const emitted = new Map();
|
|
161
|
+
// Input .d.ts files (ambient stubs, augmentation declarations, local
|
|
162
|
+
// hand-written declarations in the import closure) are never re-emitted by
|
|
163
|
+
// tsc; they must ship verbatim or the tree's references to them dangle.
|
|
164
|
+
const declarationSources = new Map();
|
|
165
|
+
let emitPartial = false;
|
|
166
|
+
try {
|
|
167
|
+
fs.writeFileSync(entryPath, entryLines.join('\n') + '\n');
|
|
168
|
+
const emitOptions = {
|
|
169
|
+
...parsed.options,
|
|
170
|
+
// The load-bearing trio: emit declarations without checking, so
|
|
171
|
+
// type-error-laden and bare (no node_modules) checkouts still emit.
|
|
172
|
+
noCheck: true,
|
|
173
|
+
declaration: true,
|
|
174
|
+
emitDeclarationOnly: true,
|
|
175
|
+
noEmit: false,
|
|
176
|
+
declarationMap: false,
|
|
177
|
+
composite: false,
|
|
178
|
+
incremental: false,
|
|
179
|
+
outDir: staging,
|
|
180
|
+
rootDir: entryDir,
|
|
181
|
+
};
|
|
182
|
+
const program = ts.createProgram([entryPath, ...augmentationSources], emitOptions);
|
|
183
|
+
const emitResult = program.emit(undefined, (fileName, text) => emitted.set(fileName, text), undefined,
|
|
184
|
+
/* emitOnlyDtsFiles */ true);
|
|
185
|
+
// emitSkipped is PER-PROGRAM even when only one file's declaration emit
|
|
186
|
+
// failed (e.g. TS4023 from a hand-rolled ambient stub shadowing a real
|
|
187
|
+
// package): every other file's .d.ts was still written to the callback.
|
|
188
|
+
// Fail wholesale only when nothing at all emitted; otherwise keep the
|
|
189
|
+
// emitted subset and demote exactly the aliases it cannot support.
|
|
190
|
+
if (emitResult.emitSkipped && emitted.size === 0) {
|
|
191
|
+
return fail(stubDir, packageName, ['declaration emit was skipped']);
|
|
192
|
+
}
|
|
193
|
+
emitPartial = emitResult.emitSkipped;
|
|
194
|
+
for (const d of emitResult.diagnostics) {
|
|
195
|
+
errors.push(ts.flattenDiagnosticMessageText(d.messageText, '\n'));
|
|
196
|
+
}
|
|
197
|
+
for (const sourceFile of program.getSourceFiles()) {
|
|
198
|
+
if (!sourceFile.isDeclarationFile)
|
|
199
|
+
continue;
|
|
200
|
+
const abs = path.resolve(sourceFile.fileName);
|
|
201
|
+
const rel = path.relative(entryDir, abs).split(path.sep).join('/');
|
|
202
|
+
if (rel.startsWith('..') || rel.includes('node_modules/'))
|
|
203
|
+
continue;
|
|
204
|
+
declarationSources.set(rel, sourceFile.getFullText());
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
return fail(stubDir, packageName, [err instanceof Error ? err.message : String(err)]);
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
if (fs.existsSync(entryPath))
|
|
212
|
+
fs.unlinkSync(entryPath);
|
|
213
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
214
|
+
}
|
|
215
|
+
// ---- Partial-emit recovery ----
|
|
216
|
+
// The corpus-2 notifications-svc shape: one file's declaration emit was
|
|
217
|
+
// skipped but the rest of the tree emitted fine. Keep the tree; demote any
|
|
218
|
+
// alias whose surface reference would dangle (its module produced no .d.ts)
|
|
219
|
+
// and rewrite its surface line to `unknown` so the kept tree carries no
|
|
220
|
+
// dangling specifiers that would smear the healthy aliases at self-check or
|
|
221
|
+
// poison the whole service at check time. Fail-closed: a demoted alias is
|
|
222
|
+
// `unknown` at the surface, so the check phase's IsUnknown probe gate (or
|
|
223
|
+
// the poison rule, for unemitted modules still referenced by kept files)
|
|
224
|
+
// decays it to unverifiable — it can never read compatible.
|
|
225
|
+
if (emitPartial) {
|
|
226
|
+
errors.push(`declaration emit was partial: kept ${emitted.size} emitted file(s); ` +
|
|
227
|
+
'aliases referencing unemitted modules are demoted to structural_fallback');
|
|
228
|
+
resolved = demoteDanglingAliases({ resolved, emitted, declarationSources, staging });
|
|
229
|
+
}
|
|
230
|
+
// ---- Relocate the emitted tree into the stub package ----
|
|
231
|
+
const typesDir = path.join(stubDir, 'types');
|
|
232
|
+
fs.rmSync(stubDir, { recursive: true, force: true });
|
|
233
|
+
fs.mkdirSync(typesDir, { recursive: true });
|
|
234
|
+
const emittedFiles = [];
|
|
235
|
+
let surfaceAbsPath = '';
|
|
236
|
+
for (const [fileName, text] of emitted) {
|
|
237
|
+
let rel = path.relative(staging, fileName).split(path.sep).join('/');
|
|
238
|
+
if (rel === `${SURFACE_ENTRY_BASENAME}.d.ts`)
|
|
239
|
+
rel = 'surface.d.ts';
|
|
240
|
+
const dest = path.join(typesDir, rel);
|
|
241
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
242
|
+
fs.writeFileSync(dest, text);
|
|
243
|
+
emittedFiles.push(rel);
|
|
244
|
+
if (rel === 'surface.d.ts')
|
|
245
|
+
surfaceAbsPath = dest;
|
|
246
|
+
}
|
|
247
|
+
if (!surfaceAbsPath) {
|
|
248
|
+
return fail(stubDir, packageName, ['no surface.d.ts produced by emit']);
|
|
249
|
+
}
|
|
250
|
+
// Verbatim copies of in-repo declaration sources (see declarationSources).
|
|
251
|
+
for (const [rel, text] of declarationSources) {
|
|
252
|
+
if (emittedFiles.includes(rel))
|
|
253
|
+
continue;
|
|
254
|
+
const dest = path.join(typesDir, rel);
|
|
255
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
256
|
+
fs.writeFileSync(dest, text);
|
|
257
|
+
emittedFiles.push(rel);
|
|
258
|
+
}
|
|
259
|
+
// Tree-relative names of augmentation files that made it into the tree
|
|
260
|
+
// (.ts augmentations arrive via declaration emit, .d.ts ones verbatim).
|
|
261
|
+
const augmentationFiles = augmentationSources
|
|
262
|
+
.map((abs) => {
|
|
263
|
+
const noDts = abs.replace(/\.d\.ts$/, '');
|
|
264
|
+
const noExt = noDts === abs ? abs.replace(/\.(ts|tsx|mts|cts)$/, '') : noDts;
|
|
265
|
+
const rel = path.relative(entryDir, noExt).split(path.sep).join('/');
|
|
266
|
+
return `${rel}.d.ts`;
|
|
267
|
+
})
|
|
268
|
+
.filter((rel) => emittedFiles.includes(rel))
|
|
269
|
+
.map((rel) => `types/${rel}`);
|
|
270
|
+
// ---- Post-emit specifier rewrite (paths mappings + absolute internals) ----
|
|
271
|
+
const specifierRewrites = rewriteEmittedSpecifiers({
|
|
272
|
+
typesDir,
|
|
273
|
+
files: emittedFiles,
|
|
274
|
+
options: parsed.options,
|
|
275
|
+
configPath,
|
|
276
|
+
entryDir,
|
|
277
|
+
});
|
|
278
|
+
// ---- Pin external deps: installed node_modules first, lockfile fallback ----
|
|
279
|
+
// Externals are collected AFTER the rewrite pass: a rewritten paths
|
|
280
|
+
// specifier is internal, not a dependency.
|
|
281
|
+
const externalSpecs = new Set();
|
|
282
|
+
for (const rel of emittedFiles) {
|
|
283
|
+
const text = fs.readFileSync(path.join(typesDir, rel), 'utf8');
|
|
284
|
+
for (const spec of collectSpecifiers(text)) {
|
|
285
|
+
if (!isRelative(spec) && !spec.startsWith('node:')) {
|
|
286
|
+
externalSpecs.add(packageNameOf(spec));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// Precedence: an installed checkout's node_modules is what the repo
|
|
291
|
+
// actually resolves against, so it wins over the lockfile — and it pins
|
|
292
|
+
// repos whose lockfiles we do not parse (yarn classic v1, binary
|
|
293
|
+
// bun.lockb). The parsed lockfile (npm, pnpm, yarn-berry, text bun.lock)
|
|
294
|
+
// remains the bare-checkout fallback. Both paths pin only
|
|
295
|
+
// the directly-referenced externals; transitives resolve at check-install
|
|
296
|
+
// (check-workspace NPMRC: "Direct deps are exact-pinned by the stubs;
|
|
297
|
+
// only transitives resolve").
|
|
298
|
+
const installed = installedVersions(repoRoot, externalSpecs);
|
|
299
|
+
const lockVersions = lockfileVersions(repoRoot);
|
|
300
|
+
const pinned = {};
|
|
301
|
+
const unpinned = [];
|
|
302
|
+
for (const name of [...externalSpecs].sort()) {
|
|
303
|
+
const version = installed.get(name) ?? lockVersions.get(name);
|
|
304
|
+
if (version)
|
|
305
|
+
pinned[name] = version;
|
|
306
|
+
else
|
|
307
|
+
unpinned.push(name);
|
|
308
|
+
}
|
|
309
|
+
const bareCheckout = !fs.existsSync(path.join(repoRoot, 'node_modules'));
|
|
310
|
+
fs.writeFileSync(path.join(stubDir, 'package.json'), JSON.stringify({
|
|
311
|
+
name: packageName,
|
|
312
|
+
version: '0.0.0-carrick',
|
|
313
|
+
private: true,
|
|
314
|
+
types: './types/surface.d.ts',
|
|
315
|
+
dependencies: pinned,
|
|
316
|
+
}, null, 2) + '\n');
|
|
317
|
+
fs.writeFileSync(path.join(stubDir, 'tsconfig.snapshot.json'), JSON.stringify({
|
|
318
|
+
ts_version: ts.version,
|
|
319
|
+
strict: parsed.options.strict ?? false,
|
|
320
|
+
strictNullChecks: parsed.options.strictNullChecks ?? parsed.options.strict ?? false,
|
|
321
|
+
exactOptionalPropertyTypes: parsed.options.exactOptionalPropertyTypes ?? false,
|
|
322
|
+
module: parsed.options.module !== undefined ? ts.ModuleKind[parsed.options.module] : undefined,
|
|
323
|
+
target: parsed.options.target !== undefined ? ts.ScriptTarget[parsed.options.target] : undefined,
|
|
324
|
+
}, null, 2) + '\n');
|
|
325
|
+
// ---- Capture-time self-check (per-alias closure attribution) ----
|
|
326
|
+
const aliases = selfCheckStub({
|
|
327
|
+
stubDir,
|
|
328
|
+
surfaceAbsPath,
|
|
329
|
+
resolved,
|
|
330
|
+
pinned,
|
|
331
|
+
bareCheckout,
|
|
332
|
+
repoRoot,
|
|
333
|
+
});
|
|
334
|
+
const fidelity = computeFidelity(aliases);
|
|
335
|
+
fs.writeFileSync(path.join(stubDir, 'carrick-manifest.json'), JSON.stringify({
|
|
336
|
+
package_name: packageName,
|
|
337
|
+
ts_version: ts.version,
|
|
338
|
+
bare_checkout: bareCheckout,
|
|
339
|
+
aliases,
|
|
340
|
+
fidelity,
|
|
341
|
+
}, null, 2) + '\n');
|
|
342
|
+
return {
|
|
343
|
+
success: true,
|
|
344
|
+
stub_dir: stubDir,
|
|
345
|
+
package_name: packageName,
|
|
346
|
+
emitted_files: emittedFiles.map((rel) => `types/${rel}`).sort(),
|
|
347
|
+
pinned_dependencies: pinned,
|
|
348
|
+
unpinned_externals: unpinned,
|
|
349
|
+
aliases,
|
|
350
|
+
fidelity,
|
|
351
|
+
augmentation_files: augmentationFiles.sort(),
|
|
352
|
+
specifier_rewrites: specifierRewrites,
|
|
353
|
+
bare_checkout: bareCheckout,
|
|
354
|
+
ts_version: ts.version,
|
|
355
|
+
errors,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Partial-emit demotion: with the set of modules that DID reach the tree
|
|
360
|
+
* (emitted .d.ts plus verbatim declaration sources), demote every anchor
|
|
361
|
+
* whose alias text references a relative module absent from that set, and
|
|
362
|
+
* rewrite the demoted aliases' lines in the emitted surface to `unknown`.
|
|
363
|
+
* Anchors already demoted stay as they are; anchors whose text is
|
|
364
|
+
* self-contained (node-builder structural prints, literal object text) are
|
|
365
|
+
* untouched even when their source file failed to emit — their surface line
|
|
366
|
+
* references nothing that can dangle.
|
|
367
|
+
*/
|
|
368
|
+
function demoteDanglingAliases(args) {
|
|
369
|
+
// Extensionless, entryDir-relative POSIX module ids present in the tree.
|
|
370
|
+
const treeModules = new Set();
|
|
371
|
+
let surfaceKey;
|
|
372
|
+
for (const fileName of args.emitted.keys()) {
|
|
373
|
+
const rel = path.relative(args.staging, fileName).split(path.sep).join('/');
|
|
374
|
+
if (rel === `${SURFACE_ENTRY_BASENAME}.d.ts`)
|
|
375
|
+
surfaceKey = fileName;
|
|
376
|
+
if (rel.endsWith('.d.ts'))
|
|
377
|
+
treeModules.add(rel.slice(0, -'.d.ts'.length));
|
|
378
|
+
}
|
|
379
|
+
for (const rel of args.declarationSources.keys()) {
|
|
380
|
+
if (rel.endsWith('.d.ts'))
|
|
381
|
+
treeModules.add(rel.slice(0, -'.d.ts'.length));
|
|
382
|
+
}
|
|
383
|
+
// The surface sits at the tree root, so its relative specifiers resolve
|
|
384
|
+
// against the root; anything escaping the root cannot be in the tree.
|
|
385
|
+
const moduleInTree = (spec) => {
|
|
386
|
+
const id = path.posix.normalize(spec);
|
|
387
|
+
if (id.startsWith('..'))
|
|
388
|
+
return false;
|
|
389
|
+
return treeModules.has(id) || treeModules.has(`${id}/index`);
|
|
390
|
+
};
|
|
391
|
+
const demoted = new Set();
|
|
392
|
+
const next = args.resolved.map((anchor) => {
|
|
393
|
+
if (anchor.failureReason !== undefined)
|
|
394
|
+
return anchor;
|
|
395
|
+
const dangling = [...collectSpecifiers(anchor.aliasText)].find((spec) => isRelative(spec) && !moduleInTree(spec));
|
|
396
|
+
if (dangling === undefined)
|
|
397
|
+
return anchor;
|
|
398
|
+
demoted.add(anchor.request.alias);
|
|
399
|
+
return {
|
|
400
|
+
request: anchor.request,
|
|
401
|
+
aliasText: 'unknown',
|
|
402
|
+
serialization: 'structural_fallback',
|
|
403
|
+
failureReason: `declaration emit was skipped for module '${dangling}'; ` +
|
|
404
|
+
'alias demoted to keep the partially emitted tree usable',
|
|
405
|
+
};
|
|
406
|
+
});
|
|
407
|
+
if (surfaceKey !== undefined && demoted.size > 0) {
|
|
408
|
+
args.emitted.set(surfaceKey, rewriteSurfaceAliasesToUnknown(args.emitted.get(surfaceKey), demoted));
|
|
409
|
+
}
|
|
410
|
+
return next;
|
|
411
|
+
}
|
|
412
|
+
/** Replace `export type <name> = ...;` with `= unknown` for each demoted
|
|
413
|
+
* alias in the emitted surface text (span-accurate, statement-level). */
|
|
414
|
+
function rewriteSurfaceAliasesToUnknown(text, demoted) {
|
|
415
|
+
const sf = ts.createSourceFile('surface.d.ts', text, ts.ScriptTarget.Latest, true);
|
|
416
|
+
const spans = [];
|
|
417
|
+
for (const stmt of sf.statements) {
|
|
418
|
+
if (ts.isTypeAliasDeclaration(stmt) && demoted.has(stmt.name.text)) {
|
|
419
|
+
spans.push({ start: stmt.getStart(sf), end: stmt.getEnd(), name: stmt.name.text });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
let out = text;
|
|
423
|
+
for (const span of spans.reverse()) {
|
|
424
|
+
out =
|
|
425
|
+
out.slice(0, span.start) +
|
|
426
|
+
`export type ${span.name} = unknown;` +
|
|
427
|
+
out.slice(span.end);
|
|
428
|
+
}
|
|
429
|
+
return out;
|
|
430
|
+
}
|
|
431
|
+
/** Phase A: build the placeholder entry, then resolve every anchor. */
|
|
432
|
+
function resolveAnchors(opts, parsed, ctx) {
|
|
433
|
+
const placeholderLines = ['// Carrick capture v2 analysis placeholder.'];
|
|
434
|
+
for (const anchor of opts.anchors) {
|
|
435
|
+
placeholderLines.push(`export type ${anchor.alias} = unknown;`);
|
|
436
|
+
}
|
|
437
|
+
fs.writeFileSync(ctx.entryPath, placeholderLines.join('\n') + '\n');
|
|
438
|
+
try {
|
|
439
|
+
const anchorSources = [
|
|
440
|
+
...new Set(opts.anchors
|
|
441
|
+
.filter((a) => a.kind !== 'literal')
|
|
442
|
+
.map((a) => path.join(ctx.repoRoot, a.source_file))),
|
|
443
|
+
].filter((f) => fs.existsSync(f));
|
|
444
|
+
const program = ts.createProgram([ctx.entryPath, ...anchorSources], {
|
|
445
|
+
...parsed.options,
|
|
446
|
+
noEmit: true,
|
|
447
|
+
});
|
|
448
|
+
const entrySource = program.getSourceFile(ctx.entryPath);
|
|
449
|
+
const placeholders = new Map();
|
|
450
|
+
if (entrySource) {
|
|
451
|
+
for (const stmt of entrySource.statements) {
|
|
452
|
+
if (ts.isTypeAliasDeclaration(stmt))
|
|
453
|
+
placeholders.set(stmt.name.text, stmt);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
// A literal anchor whose text is a bare identifier resolves through a
|
|
457
|
+
// sibling symbol anchor's module when one names the same symbol.
|
|
458
|
+
const siblingSymbolSpecs = new Map();
|
|
459
|
+
for (const anchor of opts.anchors) {
|
|
460
|
+
if (anchor.kind !== 'symbol')
|
|
461
|
+
continue;
|
|
462
|
+
if (!siblingSymbolSpecs.has(anchor.symbol_name)) {
|
|
463
|
+
siblingSymbolSpecs.set(anchor.symbol_name, entryRelativeSpecifier(ctx.entryDir, ctx.repoRoot, anchor.source_file));
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return opts.anchors.map((request) => resolveAnchor(program, request, {
|
|
467
|
+
repoRoot: ctx.repoRoot,
|
|
468
|
+
entryDir: ctx.entryDir,
|
|
469
|
+
placeholder: placeholders.get(request.alias),
|
|
470
|
+
siblingSymbolSpecs,
|
|
471
|
+
}));
|
|
472
|
+
}
|
|
473
|
+
finally {
|
|
474
|
+
if (fs.existsSync(ctx.entryPath))
|
|
475
|
+
fs.unlinkSync(ctx.entryPath);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact-version dependency pins for the stub package, from two sources:
|
|
3
|
+
*
|
|
4
|
+
* 1. `installedVersions` — the repo's own node_modules, when installed.
|
|
5
|
+
* This is the version the repo actually resolves against, so it wins
|
|
6
|
+
* over the lockfile, and it covers repos whose lockfiles we do not
|
|
7
|
+
* parse at all (yarn classic v1, binary bun.lockb).
|
|
8
|
+
* 2. `lockfileVersions` — parsed lockfile fallback for bare checkouts
|
|
9
|
+
* (npm v1/v2/v3, pnpm v6/v9, yarn-berry v2+, and bun's text bun.lock
|
|
10
|
+
* in scope).
|
|
11
|
+
*
|
|
12
|
+
* Pin selection prefers the DIRECT (top-level) dependency version: a package
|
|
13
|
+
* present at multiple versions must pin to the version the emitted tree
|
|
14
|
+
* resolves against — the hoisted/direct install — never to whichever nested
|
|
15
|
+
* copy happens to appear first in the lockfile (npm sorts
|
|
16
|
+
* `node_modules/a/node_modules/b` before `node_modules/b`, so first-match
|
|
17
|
+
* picks a nested version the tree never sees). When a lockfile offers
|
|
18
|
+
* multiple versions and no direct install can be identified, the package
|
|
19
|
+
* stays UNPINNED (fail-closed abstain): a wrong pin fails or corrupts the
|
|
20
|
+
* synthetic-workspace typecheck, no pin merely abstains.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Resolve exact versions from the repo's installed node_modules by reading
|
|
24
|
+
* `node_modules/<pkg>/package.json` for each referenced external. On an
|
|
25
|
+
* installed checkout this is ground truth — the versions the repo actually
|
|
26
|
+
* runs against — and it needs no lockfile parser, which is what covers the
|
|
27
|
+
* formats without one (yarn classic v1, binary bun.lockb).
|
|
28
|
+
*
|
|
29
|
+
* Workspace-member guard (fail-closed): package managers link workspace
|
|
30
|
+
* siblings into node_modules as symlinks (`node_modules/@ws/member ->
|
|
31
|
+
* ../../packages/member`). Those carry unpublished versions; pinning one
|
|
32
|
+
* would make the whole synthetic-workspace install fail at check time. So a
|
|
33
|
+
* pin is taken only when the entry's realpath still lives under a
|
|
34
|
+
* node_modules directory inside the repo. pnpm's own
|
|
35
|
+
* `node_modules/<pkg> -> node_modules/.pnpm/<pkg>@<v>/node_modules/<pkg>`
|
|
36
|
+
* links pass that test and stay pinnable; realpaths inside the repo but
|
|
37
|
+
* outside every node_modules (workspace members) and realpaths outside the
|
|
38
|
+
* repo entirely (npm/yarn link to a dev checkout) are skipped — the
|
|
39
|
+
* external stays unpinned unless the lockfile supplies a version.
|
|
40
|
+
*/
|
|
41
|
+
export declare function installedVersions(repoRoot: string, names: Iterable<string>): Map<string, string>;
|
|
42
|
+
/**
|
|
43
|
+
* Resolve exact versions for the repo. Walks up from repoRoot so monorepo
|
|
44
|
+
* members with a hoisted root lockfile still pin (nearest lockfile wins;
|
|
45
|
+
* package-lock.json > pnpm-lock.yaml > yarn.lock > bun.lock at the same
|
|
46
|
+
* level).
|
|
47
|
+
*/
|
|
48
|
+
export declare function lockfileVersions(repoRoot: string): Map<string, string>;
|