carrick 0.3.111 → 0.3.113

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/bin/carrick.mjs +3 -0
  2. package/dist/contract.d.ts +6 -0
  3. package/dist/contract.js +5 -0
  4. package/dist/contract.js.map +1 -1
  5. package/dist/hook/post-edit.js +11 -2
  6. package/dist/hook/post-edit.js.map +1 -1
  7. package/dist/hook/post-read.d.ts +2 -0
  8. package/dist/hook/post-read.js +89 -0
  9. package/dist/hook/post-read.js.map +1 -0
  10. package/dist/hook/reuse.d.ts +24 -0
  11. package/dist/hook/reuse.js +58 -2
  12. package/dist/hook/reuse.js.map +1 -1
  13. package/dist/init/settings.js +6 -0
  14. package/dist/init/settings.js.map +1 -1
  15. package/dist/render.d.ts +22 -2
  16. package/dist/render.js +53 -2
  17. package/dist/render.js.map +1 -1
  18. package/dist/update.js +3 -0
  19. package/dist/update.js.map +1 -1
  20. package/package.json +62 -7
  21. package/plugin/.claude-plugin/plugin.json +1 -1
  22. package/plugin/hooks/hooks.json +10 -0
  23. package/scripts/install-scanned-deps.sh +539 -0
  24. package/sidecar/dist/src/capture/api.d.ts +22 -1
  25. package/sidecar/dist/src/capture/check-probe.d.ts +8 -0
  26. package/sidecar/dist/src/capture/check-probe.js +9 -1
  27. package/sidecar/dist/src/capture/check.d.ts +32 -0
  28. package/sidecar/dist/src/capture/check.js +74 -5
  29. package/sidecar/dist/src/capture/checkout.d.ts +38 -0
  30. package/sidecar/dist/src/capture/checkout.js +139 -0
  31. package/sidecar/dist/src/capture/index.d.ts +1 -0
  32. package/sidecar/dist/src/capture/index.js +22 -7
  33. package/sidecar/dist/src/capture/machinery.js +1 -30
  34. package/sidecar/dist/src/capture/project-references.js +2 -1
  35. package/sidecar/dist/src/capture/self-check.d.ts +20 -1
  36. package/sidecar/dist/src/capture/self-check.js +30 -11
  37. package/sidecar/dist/src/origin.js +1 -29
  38. package/sidecar/dist/src/project-loader.d.ts +6 -0
  39. package/sidecar/dist/src/project-loader.js +64 -1
  40. package/sidecar/dist/src/type-inferrer.d.ts +235 -1
  41. package/sidecar/dist/src/type-inferrer.js +901 -111
@@ -0,0 +1,539 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Install the scanned repository's dependencies so the type layer sees real
4
+ # types instead of `error`.
5
+ #
6
+ # On a bare checkout every import from a package resolves to the compiler's
7
+ # `error` type and the printed endpoint type is `any`, so the index records a
8
+ # route it cannot describe. `action.yml` calls this before "Run analysis".
9
+ #
10
+ # Posture (carrick#706):
11
+ # - Node installs are lockfile-gated; Deno uses its declared configuration
12
+ # in frozen mode, including projects that disable the lockfile;
13
+ # - lifecycle scripts disabled everywhere, so nothing in the scanned repo
14
+ # executes during a scan;
15
+ # - time-boxed: an install that fails or runs long prints a `::warning::`
16
+ # and the scan continues, where the pre-flight then refuses that service
17
+ # rather than indexing `any` through it (src/preflight.rs).
18
+ #
19
+ # Every service, not the scan root alone (carrick#1312). The pre-flight
20
+ # refuses PER SERVICE — a lockfile reachable from a service root with no
21
+ # `node_modules` under it — so an installer that prepares the root alone
22
+ # leaves a repo whose services carry their own lockfiles refused for every
23
+ # nested one. The set installed here is the set that check walks:
24
+ #
25
+ # - the services come from `carrick derive`, which resolves them with the same
26
+ # `service_derivation::resolve` the scan hands to the pre-flight (src/main.rs), so
27
+ # `carrick.json` services and inferred workspace members are both covered
28
+ # without a second derivation living in this file;
29
+ # - the scan root is always in the set, so a checkout whose services cannot
30
+ # be derived is still prepared exactly as it was before;
31
+ # - each service root walks UP to the nearest lockfile, which is where the
32
+ # install runs. Hoisting falls out of that walk: every member of a pnpm
33
+ # workspace reaches the one lockfile at the workspace root, so the set
34
+ # dedupes to a single install.
35
+ #
36
+ # Same-level precedence mirrors the sidecar's `lockfileVersions`
37
+ # (src/sidecar/src/capture/lockfile.ts) so the install and the type capture
38
+ # agree on which manager a directory uses.
39
+ #
40
+ # Usage:
41
+ # install-scanned-deps.sh detect <scan-root> # key=value lines
42
+ # install-scanned-deps.sh install <scan-root> # always exits 0
43
+ # install-scanned-deps.sh install-one <root> <manager> # always exits 0
44
+ #
45
+ # CARRICK_INSTALL_UNTRUSTED=1 is for a machine that scans repositories it does
46
+ # not own (the hosted runner). Lifecycle scripts being off is not enough there:
47
+ # a pnpm `.pnpmfile.cjs`, a Yarn `yarnPath` / `yarn-path` release and Yarn
48
+ # Berry `plugins` are all files from the scanned tree that run during an
49
+ # install, and so is any program a project `.npmrc` names (carrick#2246). In
50
+ # this mode none of them can:
51
+ # - npm runs with every setting that names a program (`git`, `node-options`,
52
+ # `script-shell`, `shell`, `node-gyp`) set in the environment, which
53
+ # outranks the tree's `.npmrc` (see `npm_trusted_tools`);
54
+ # - pnpm installs with `--ignore-pnpmfile`, which also stops the pnpmfile a
55
+ # `configDependencies` package ships (pnpm 10 then fails the frozen
56
+ # install on `pnpmfileChecksum` and warns, carrick#2251); no pnpm setting
57
+ # in `.npmrc` or `pnpm-workspace.yaml` starts a program from the tree
58
+ # during a frozen install without scripts (surveyed on pnpm 9 and 10,
59
+ # carrick#2246);
60
+ # - Yarn runs with YARN_IGNORE_PATH=1, so the launcher never hands the
61
+ # install to a committed release, and Berry with YARN_ENABLE_SCRIPTS=0;
62
+ # - a Yarn root where any `.yarnrc.yml` from it up to the clone root lists
63
+ # `plugins`, or names a `yarnPath` with no `packageManager` pin (the
64
+ # version cannot be known without running the repo's file), or whose
65
+ # `yarn.lock` resolves a package from a git repository (Berry prepares it
66
+ # with a package manager that reads the clone's own config, carrick#2251;
67
+ # classic runs its `prepare`, carrick#2258) or with Berry's `exec:`
68
+ # protocol (carrick#2257), is not installed: it warns and the pre-flight
69
+ # refuses it. The scanner reads those files as YAML (`$CARRICK_CLI
70
+ # __untrusted-yarn-refusal`), so this mode needs CARRICK_CLI for Yarn
71
+ # roots and refuses them without it;
72
+ # - corepack never writes `packageManager` into the repo's package.json
73
+ # (COREPACK_ENABLE_AUTO_PIN=0), and classic Yarn and bun install frozen, so
74
+ # no tracked file differs from the commit afterwards.
75
+ # Unset, the script behaves as it always has: on a developer's own CI the
76
+ # tree is theirs. `action.yml` leaves it unset.
77
+ #
78
+ # CARRICK_CLI names the Carrick to ask for the services. `action.yml` writes a
79
+ # wrapper for whichever copy the run resolved, npm package or release asset.
80
+ # Deliberately NOT `CARRICK_BIN`: that variable names a scanner BINARY to the
81
+ # npm package, which would resolve the wrapper as its own binary and re-enter
82
+ # it forever.
83
+ set -uo pipefail
84
+
85
+ # How long an install may take before it is killed and the scan continues bare.
86
+ INSTALL_TIMEOUT_SECONDS="${CARRICK_INSTALL_TIMEOUT:-300}"
87
+
88
+ # Set by a machine that scans repositories it does not own; see the header.
89
+ UNTRUSTED="${CARRICK_INSTALL_UNTRUSTED:-}"
90
+
91
+ # The delimiter for the one multi-line step output this script writes. A
92
+ # workflow reads `cache_dirs` as the `path:` of actions/cache, which takes one
93
+ # directory per line, and a plain `key=value` line cannot carry that.
94
+ CACHE_DIRS_DELIMITER="CARRICK_CACHE_DIRS"
95
+
96
+ # On stderr, not stdout: `detect` writes step outputs to stdout and the runner
97
+ # fails a step for any GITHUB_OUTPUT line that is not `key=value`. Workflow
98
+ # commands are honoured on either stream.
99
+ warn() { echo "::warning::$*" >&2; }
100
+
101
+ absolute() { (cd "$1" 2>/dev/null && pwd -P); }
102
+
103
+ # The manager named by the lockfile in ONE directory, in the sidecar's
104
+ # precedence order. Prints nothing when that directory carries no lockfile.
105
+ detect_manager() {
106
+ local root="$1"
107
+ if [ -f "$root/package-lock.json" ]; then echo "npm:$root/package-lock.json"
108
+ elif [ -f "$root/pnpm-lock.yaml" ]; then echo "pnpm:$root/pnpm-lock.yaml"
109
+ elif [ -f "$root/yarn.lock" ]; then echo "yarn:$root/yarn.lock"
110
+ elif [ -f "$root/bun.lock" ]; then echo "bun:$root/bun.lock"
111
+ elif [ -f "$root/bun.lockb" ]; then echo "bun:$root/bun.lockb"
112
+ elif [ -f "$root/deno.json" ] || [ -f "$root/deno.jsonc" ]; then
113
+ if [ -f "$root/deno.lock" ]; then echo "deno:$root/deno.lock"
114
+ elif [ -f "$root/deno.json" ]; then echo "deno:$root/deno.json"
115
+ else echo "deno:$root/deno.jsonc"; fi
116
+ fi
117
+ }
118
+
119
+ # The Deno configuration a directory carries, in Deno's own order.
120
+ deno_config() {
121
+ if [ -f "$1/deno.json" ]; then echo "$1/deno.json"
122
+ elif [ -f "$1/deno.jsonc" ]; then echo "$1/deno.jsonc"
123
+ fi
124
+ }
125
+
126
+ # Every service root the scan will visit, absolute, one per line.
127
+ #
128
+ # The scan root leads, and is emitted whatever the scanner answers: a checkout
129
+ # the derivation cannot read is still installed the way it was before this
130
+ # walked services at all.
131
+ service_roots() {
132
+ local scan_root="$1" derived parsed
133
+ printf '%s\n' "$scan_root"
134
+ if [ -z "${CARRICK_CLI:-}" ]; then
135
+ return 0
136
+ fi
137
+ derived=$("$CARRICK_CLI" derive --workspace "$scan_root" 2>/dev/null)
138
+ if [ -z "$derived" ]; then
139
+ warn "Carrick could not derive this repository's services, so only $scan_root is prepared; a service with its own lockfile will be refused as uninstalled."
140
+ return 0
141
+ fi
142
+ # `carrick.derive/0`: one entry per repo, each service's `directory`
143
+ # relative to that repo's path. A service that names no directory is the
144
+ # repo itself.
145
+ parsed=$(printf '%s' "$derived" | node -e '
146
+ const path = require("path");
147
+ let text = "";
148
+ process.stdin.on("data", (chunk) => (text += chunk));
149
+ process.stdin.on("end", () => {
150
+ try {
151
+ const derived = JSON.parse(text);
152
+ for (const repo of derived.repos || []) {
153
+ for (const service of repo.services || []) {
154
+ console.log(path.resolve(repo.path, service.directory || "."));
155
+ }
156
+ }
157
+ } catch {}
158
+ });
159
+ ' 2>/dev/null)
160
+ # Node reads the answer, and `action.yml` sets Node up before this runs. A
161
+ # runner without it, or a document this does not understand, must say so
162
+ # rather than look like a repository with one service.
163
+ if [ -z "$parsed" ]; then
164
+ warn "Carrick named this repository's services but none could be read here, so only $scan_root is prepared; a service with its own lockfile will be refused as uninstalled."
165
+ return 0
166
+ fi
167
+ printf '%s\n' "$parsed"
168
+ }
169
+
170
+ # Where the install for ONE service root runs: the nearest directory at or
171
+ # above it that carries a lockfile, stopping at the scan root.
172
+ #
173
+ # The same rule `preflight::dependencies_unprepared` applies, including its
174
+ # stop: a monorepo root with a lockfile of its own is not an install of a
175
+ # nested workspace that has one too, so the walk takes the first answer.
176
+ # Prints `manager<TAB>install-root<TAB>lockfile`, or nothing.
177
+ #
178
+ # Every directory is checked to sit inside the scan root BEFORE its lockfile
179
+ # is read, so a service root outside the checkout (carrick#2245) is never
180
+ # looked at, let alone installed.
181
+ nearest_lockfile() {
182
+ local service_root="$1" scan_root="$2" dir found
183
+ dir="$service_root"
184
+ while :; do
185
+ case "$dir" in "$scan_root" | "$scan_root"/*) ;; *) return 1 ;; esac
186
+ found=$(detect_manager "$dir")
187
+ if [ -n "$found" ]; then
188
+ printf '%s\t%s\t%s\n' "${found%%:*}" "$dir" "${found#*:}"
189
+ return 0
190
+ fi
191
+ [ "$dir" = "$scan_root" ] && return 1
192
+ dir=$(dirname "$dir")
193
+ done
194
+ }
195
+
196
+ # Whether an install already covers this service: `node_modules` anywhere from
197
+ # the service root up to the install root, which is what the pre-flight reads.
198
+ # A hoisted member has none of its own and is prepared by the root's.
199
+ already_installed() {
200
+ local service_root="$1" install_root="$2" dir="$1"
201
+ while :; do
202
+ [ -d "$dir/node_modules" ] && return 0
203
+ [ "$dir" = "$install_root" ] && return 1
204
+ case "$dir" in "$install_root"/*) ;; *) return 1 ;; esac
205
+ dir=$(dirname "$dir")
206
+ done
207
+ }
208
+
209
+ # The installs this checkout needs, deduped, one `manager<TAB>root<TAB>lockfile`
210
+ # line each, sorted so a cache key over them is stable.
211
+ install_plan() {
212
+ local scan_root="$1" service_root row manager install_root
213
+ while IFS= read -r service_root; do
214
+ [ -n "$service_root" ] && [ -d "$service_root" ] || continue
215
+ service_root=$(absolute "$service_root") || continue
216
+ [ -n "$service_root" ] || continue
217
+ row=$(nearest_lockfile "$service_root" "$scan_root") || continue
218
+ IFS=$'\t' read -r manager install_root _ <<<"$row"
219
+ # Deno caches outside the tree, so `node_modules` is no evidence that its
220
+ # dependencies are there: a Deno root is always prepared, and a Node
221
+ # install already on disk does not prove the SEPARATE Deno cache is warm
222
+ # either, so a mixed root that has one is still prepared for Deno.
223
+ if [ "$manager" = "deno" ]; then
224
+ printf '%s\n' "$row"
225
+ continue
226
+ fi
227
+ if [ -d "$install_root/node_modules" ] &&
228
+ { [ -f "$install_root/deno.json" ] || [ -f "$install_root/deno.jsonc" ]; }; then
229
+ printf 'deno\t%s\t%s\n' "$install_root" "$(deno_config "$install_root")"
230
+ continue
231
+ fi
232
+ already_installed "$service_root" "$install_root" && continue
233
+ printf '%s\n' "$row"
234
+ done < <(service_roots "$scan_root" | sort -u) | sort -u
235
+ }
236
+
237
+ # The manager's own download store, which is what gets cached between runs.
238
+ # `node_modules` is deliberately not cached: `npm ci` deletes it before it
239
+ # installs, and a cache saved from a failed install would poison the next run,
240
+ # whereas a partial store is integrity-checked by the manager and harmless.
241
+ # An empty answer means "cache nothing"; it is never an error.
242
+ cache_dir_for() {
243
+ local manager="$1" dir=""
244
+ case "$manager" in
245
+ deno)
246
+ dir=$(deno info --json 2>/dev/null | node -e 'let text="";process.stdin.on("data",c=>text+=c);process.stdin.on("end",()=>{try{console.log(JSON.parse(text).denoDir||"")}catch{}})' || true)
247
+ ;;
248
+ npm)
249
+ command -v npm >/dev/null 2>&1 && dir=$(npm config get cache 2>/dev/null || true)
250
+ ;;
251
+ pnpm)
252
+ dir=$(corepack pnpm store path --silent 2>/dev/null | tail -n 1 || true)
253
+ ;;
254
+ yarn)
255
+ if command -v yarn >/dev/null 2>&1; then
256
+ dir=$(yarn config get cacheFolder 2>/dev/null | tail -n 1 || true)
257
+ case "$dir" in /*) ;; *) dir=$(yarn cache dir 2>/dev/null | tail -n 1 || true) ;; esac
258
+ fi
259
+ ;;
260
+ bun)
261
+ command -v bun >/dev/null 2>&1 && dir="${HOME}/.bun/install/cache"
262
+ ;;
263
+ esac
264
+ # Managers print `undefined`, `null` or an error line when they cannot
265
+ # answer. Only an absolute path is a cache directory.
266
+ case "$dir" in /*) echo "$dir" ;; esac
267
+ }
268
+
269
+ sha256_of() {
270
+ if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1
271
+ elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1
272
+ fi
273
+ }
274
+
275
+ sha256_of_stdin() {
276
+ if command -v sha256sum >/dev/null 2>&1; then sha256sum | cut -d' ' -f1
277
+ elif command -v shasum >/dev/null 2>&1; then shasum -a 256 | cut -d' ' -f1
278
+ fi
279
+ }
280
+
281
+ cmd_detect() {
282
+ local root="$1" plan managers lockfiles sums manager install_root relative
283
+
284
+ root=$(absolute "$root")
285
+ if [ -z "$root" ]; then
286
+ echo "should_install=false"
287
+ echo "reason=scan root $1 does not exist"
288
+ return 0
289
+ fi
290
+
291
+ plan=$(install_plan "$root")
292
+ if [ -z "$plan" ]; then
293
+ echo "should_install=false"
294
+ echo "reason=no service of this repository has a lockfile that is not installed"
295
+ return 0
296
+ fi
297
+
298
+ # Hyphen-joined, never comma-joined: `actions/cache` rejects a key holding
299
+ # a comma outright, and a mixed npm+deno repo would fail the cache step.
300
+ managers=$(printf '%s\n' "$plan" | cut -f1 | sort -u | paste -sd- -)
301
+ lockfiles=$(printf '%s\n' "$plan" | cut -f3)
302
+ # The key covers EVERY lockfile the run installs from: a repo whose nested
303
+ # service changed its dependencies must not restore a store keyed on the
304
+ # root's unchanged lockfile alone.
305
+ sums=$(while IFS= read -r lockfile; do sha256_of "$lockfile"; done <<<"$lockfiles" | sort | tr -d '\n')
306
+
307
+ # A log line, not a step output. The one thing a user debugging a pre-flight
308
+ # refusal needs is WHICH roots this prepared, and a count does not say.
309
+ while IFS=$'\t' read -r manager install_root _; do
310
+ [ -n "$manager" ] || continue
311
+ relative="${install_root#"$root"}"
312
+ relative="${relative#/}"
313
+ echo "Carrick prepares ${relative:-the repository root} with $manager." >&2
314
+ done <<<"$plan"
315
+
316
+ echo "should_install=true"
317
+ echo "roots=$(printf '%s\n' "$plan" | wc -l | tr -d ' ')"
318
+ echo "managers=$managers"
319
+ echo "lockfiles_sha256=$(printf '%s' "$sums" | sha256_of_stdin)"
320
+ # The one multi-line output: `actions/cache` reads it as `path:`, which takes
321
+ # a directory per line, and GitHub carries that only in the heredoc form.
322
+ echo "cache_dirs<<$CACHE_DIRS_DELIMITER"
323
+ printf '%s\n' "$plan" | cut -f1 | sort -u | while IFS= read -r manager; do
324
+ cache_dir_for "$manager"
325
+ done | sort -u
326
+ echo "$CACHE_DIRS_DELIMITER"
327
+ }
328
+
329
+ # Run one install command with scripts disabled, time-boxed, output to a log
330
+ # file rather than a pipe: a pipeline reports the last command's status, so a
331
+ # failed install piped to `tail` would count as success.
332
+ run_install() {
333
+ local root="$1" log="$2"
334
+ shift 2
335
+ # `timeout` is GNU coreutils: present on the runners, absent on a stock
336
+ # macOS dev box. Without it the install still runs, just unbounded.
337
+ if command -v timeout >/dev/null 2>&1; then
338
+ ( cd "$root" && timeout -k 10 "$INSTALL_TIMEOUT_SECONDS" "$@" >"$log" 2>&1 )
339
+ else
340
+ ( cd "$root" && "$@" >"$log" 2>&1 )
341
+ fi
342
+ }
343
+
344
+ # npm settings that name a program npm starts, each pinned to the machine's
345
+ # own answer for an untrusted install (carrick#2246). npm reads them from the
346
+ # tree's `.npmrc`, and the environment outranks that file, in this npm and in
347
+ # every npm it starts: a git dependency is cloned with `git`, and one whose
348
+ # manifest declares a build script is prepared by a child `npm install` that
349
+ # runs whatever `node-options` says, `--ignore-scripts` or not (pacote's
350
+ # `git.js`). `script-shell`, `shell` and `node-gyp` are the other settings that
351
+ # name a program; nothing `npm ci --ignore-scripts` does reaches them today.
352
+ # Sets NPM_TRUSTED_TOOLS to `env` assignments.
353
+ npm_trusted_tools() {
354
+ local gyp
355
+ NPM_TRUSTED_TOOLS=(
356
+ "npm_config_git=$(command -v git || echo git)"
357
+ # Never empty: npm skips an empty `npm_config_*` variable and falls back
358
+ # to the tree's file (@npmcli/config `loadEnv`). A space is no option.
359
+ "npm_config_node_options=${NODE_OPTIONS:- }"
360
+ "npm_config_script_shell=/bin/sh"
361
+ "npm_config_shell=/bin/sh"
362
+ )
363
+ # npm's own node-gyp, read where no project config applies.
364
+ gyp=$(cd / && npm config get node-gyp 2>/dev/null)
365
+ case "$gyp" in /*) NPM_TRUSTED_TOOLS+=("npm_config_node_gyp=$gyp") ;; esac
366
+ }
367
+
368
+ # Whether a Yarn root cannot be installed without running a file from the tree.
369
+ # Prints the reason when it cannot. The scanner reads every `.yarnrc.yml` Yarn
370
+ # would read for the root, as YAML, executing nothing (src/untrusted_yarn.rs).
371
+ # A root it cannot answer for is refused, never installed unread.
372
+ yarn_untrusted_refusal() {
373
+ local answer problem status
374
+ if [ -z "${CARRICK_CLI:-}" ]; then
375
+ echo "no Carrick was named to read its .yarnrc.yml files (CARRICK_CLI is unset)"
376
+ return 0
377
+ fi
378
+ # stdout is the answer; stderr is only read when there is none, since a
379
+ # wrapper may print notices there.
380
+ problem=$(mktemp "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/carrick-yarnrc.XXXXXX") || {
381
+ echo "its .yarnrc.yml files could not be read"
382
+ return 0
383
+ }
384
+ answer=$("$CARRICK_CLI" __untrusted-yarn-refusal "$1" 2>"$problem")
385
+ status=$?
386
+ if [ $status -ne 0 ]; then
387
+ echo "its .yarnrc.yml files could not be read (exit $status): $(tail -n 1 "$problem")"
388
+ rm -f "$problem"
389
+ return 0
390
+ fi
391
+ rm -f "$problem"
392
+ [ -n "$answer" ] || return 1
393
+ echo "$answer"
394
+ }
395
+
396
+ # Yarn Berry's lockfile opens with a `__metadata` block; classic's does not.
397
+ yarn_is_berry() {
398
+ [ -f "$1/.yarnrc.yml" ] || grep -q '^__metadata:' "$1/yarn.lock" 2>/dev/null
399
+ }
400
+
401
+ cmd_install_one() {
402
+ local root="$1" manager="$2" log status refusal
403
+ if [ "$manager" != "deno" ] && { [ -f "$root/deno.json" ] || [ -f "$root/deno.jsonc" ]; }; then
404
+ # Mixed roots need the global Deno cache AND the Node compiler's local
405
+ # install. Neither preparation executes authorized project lifecycle code.
406
+ cmd_install_one "$root" deno
407
+ fi
408
+ log=$(mktemp "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/carrick-dependency-install.XXXXXX") || return 1
409
+
410
+ # corepack ships with the Node the Action set up; it is how pnpm and yarn
411
+ # reach the version the repo pins in `packageManager`.
412
+ corepack enable >/dev/null 2>&1 || true
413
+
414
+ case "$manager" in
415
+ deno)
416
+ # A project can authorize lifecycle scripts through allowScripts in its
417
+ # config. Deno has no --ignore-scripts: disabling node_modules is what
418
+ # prevents those scripts from running, even for an authorizing config.
419
+ # Frozen mode also prevents rewriting the project's lockfile.
420
+ run_install "$root" "$log" deno install --frozen --node-modules-dir=none
421
+ status=$?
422
+ ;;
423
+ npm)
424
+ if [ -n "$UNTRUSTED" ]; then
425
+ npm_trusted_tools
426
+ run_install "$root" "$log" env "${NPM_TRUSTED_TOOLS[@]}" npm ci --ignore-scripts
427
+ else
428
+ run_install "$root" "$log" npm ci --ignore-scripts
429
+ fi
430
+ status=$?
431
+ ;;
432
+ pnpm)
433
+ if [ -n "$UNTRUSTED" ]; then
434
+ # pnpm loads the tree's `.pnpmfile.cjs` on install unless told not to.
435
+ run_install "$root" "$log" corepack pnpm install --frozen-lockfile --ignore-scripts --ignore-pnpmfile
436
+ else
437
+ run_install "$root" "$log" corepack pnpm install --frozen-lockfile --ignore-scripts
438
+ fi
439
+ status=$?
440
+ ;;
441
+ yarn)
442
+ if [ -n "$UNTRUSTED" ] && refusal=$(yarn_untrusted_refusal "$root"); then
443
+ [ -f "$log" ] && rm -f "$log"
444
+ warn "Carrick did not install $root with yarn: $refusal. The scan will refuse that service rather than run the repository's code or index \`any\` through its packages."
445
+ return 0
446
+ fi
447
+ # Classic Yarn accepts `--mode=skip-build` without skipping the build,
448
+ # so the two lines are told apart by what the tree says they are,
449
+ # never by which command happens to exit 0.
450
+ if yarn_is_berry "$root"; then
451
+ run_install "$root" "$log" yarn install --mode=skip-build
452
+ elif [ -n "$UNTRUSTED" ]; then
453
+ # Frozen, or classic rewrites yarn.lock.
454
+ run_install "$root" "$log" yarn install --frozen-lockfile --ignore-scripts
455
+ else
456
+ run_install "$root" "$log" yarn install --ignore-scripts
457
+ fi
458
+ status=$?
459
+ ;;
460
+ bun)
461
+ if ! command -v bun >/dev/null 2>&1; then
462
+ warn "Carrick found a bun lockfile but bun is not installed on this runner; add oven-sh/setup-bun before the Carrick step, or types through dependencies stay \`any\`."
463
+ return 0
464
+ fi
465
+ if [ -n "$UNTRUSTED" ]; then
466
+ run_install "$root" "$log" bun install --ignore-scripts --frozen-lockfile
467
+ else
468
+ run_install "$root" "$log" bun install --ignore-scripts
469
+ fi
470
+ status=$?
471
+ ;;
472
+ *)
473
+ warn "Carrick does not know how to install with '$manager'; continuing on the bare checkout."
474
+ return 0
475
+ ;;
476
+ esac
477
+
478
+ if [ $status -eq 0 ]; then
479
+ echo "Installed $root dependencies with $manager (lifecycle scripts disabled)."
480
+ [ -f "$log" ] && tail -n 3 "$log"
481
+ return 0
482
+ fi
483
+
484
+ [ -f "$log" ] && tail -n 20 "$log"
485
+ if [ $status -eq 124 ] || [ $status -eq 137 ]; then
486
+ warn "Carrick's dependency install ($manager) in $root hit the ${INSTALL_TIMEOUT_SECONDS}s limit; the scan will refuse that service rather than index \`any\` through its packages."
487
+ else
488
+ warn "Carrick's dependency install ($manager) in $root failed (exit $status); the scan will refuse that service rather than index \`any\` through its packages."
489
+ fi
490
+ return 0
491
+ }
492
+
493
+ # Every root the pre-flight will ask about, in one step. One root's failure
494
+ # does not stop the others: a repo whose thirteenth service cannot install
495
+ # still gets the twelve that can.
496
+ cmd_install() {
497
+ local root row manager install_root
498
+ root=$(absolute "$1")
499
+ if [ -z "$root" ]; then
500
+ warn "Carrick's dependency install found no directory at $1; continuing on the bare checkout."
501
+ return 0
502
+ fi
503
+ while IFS= read -r row; do
504
+ [ -n "$row" ] || continue
505
+ IFS=$'\t' read -r manager install_root _ <<<"$row"
506
+ cmd_install_one "$install_root" "$manager"
507
+ done < <(install_plan "$root")
508
+ }
509
+
510
+ main() {
511
+ local action="${1:-}"
512
+ if [ -n "$UNTRUSTED" ]; then
513
+ # Inherited by every install this script starts. Yarn: never hand the
514
+ # install to a release file committed in the tree, never run build
515
+ # scripts, never rewrite the lockfile. corepack: never write
516
+ # `packageManager` into the repo's package.json.
517
+ export YARN_IGNORE_PATH=1 YARN_ENABLE_SCRIPTS=0 YARN_ENABLE_IMMUTABLE_INSTALLS=1 COREPACK_ENABLE_AUTO_PIN=0
518
+ fi
519
+ case "$action" in
520
+ detect)
521
+ [ $# -eq 2 ] || { echo "usage: $0 detect <scan-root>" >&2; exit 2; }
522
+ cmd_detect "$2"
523
+ ;;
524
+ install)
525
+ [ $# -eq 2 ] || { echo "usage: $0 install <scan-root>" >&2; exit 2; }
526
+ cmd_install "$2"
527
+ ;;
528
+ install-one)
529
+ [ $# -eq 3 ] || { echo "usage: $0 install-one <root> <manager>" >&2; exit 2; }
530
+ cmd_install_one "$2" "$3"
531
+ ;;
532
+ *)
533
+ echo "usage: $0 {detect|install|install-one} ..." >&2
534
+ exit 2
535
+ ;;
536
+ esac
537
+ }
538
+
539
+ main "$@"
@@ -208,10 +208,14 @@ export type SelfCheckOutcome = 'ok' | 'allowlisted_external' | 'decayed_internal
208
208
  * - `projected_value_only`: every read of a call's result takes a member out
209
209
  * of it and none reads the value itself, so the site states a part of a
210
210
  * payload rather than the payload a caller receives (carrick#1375).
211
+ * - `serialised_body_unread`: a handler returns a status and a body string it
212
+ * serialised itself, and at least one return builds that string in a way
213
+ * the inferrer cannot trace to a typed value, so it publishes no part of
214
+ * the contract (carrick#2200).
211
215
  * - `not_recorded`: the position carries a top type and this layer has no
212
216
  * cause for it.
213
217
  */
214
- export type TypeProvenanceReason = 'declared' | 'unresolved_import' | 'budget_exhausted' | 'no_payload_evidence' | 'machinery_envelope' | 'coerced_input' | 'no_success_payload' | 'no_request_body' | 'projected_value_only' | 'not_recorded';
218
+ export type TypeProvenanceReason = 'declared' | 'unresolved_import' | 'budget_exhausted' | 'no_payload_evidence' | 'machinery_envelope' | 'coerced_input' | 'no_success_payload' | 'no_request_body' | 'projected_value_only' | 'serialised_body_unread' | 'not_recorded';
215
219
  /**
216
220
  * One `any`/`unknown` finding inside a captured or inferred type, with its
217
221
  * position and its cause. Sorted by `path` wherever a list is emitted, so the
@@ -518,6 +522,23 @@ export interface CheckResult {
518
522
  verdicts: CheckVerdict[];
519
523
  degraded_services: DegradedService[];
520
524
  errors: string[];
525
+ /** How the compiler ended, present only when it ended abnormally (a
526
+ * signal, or a non-zero exit with no parseable diagnostics): every pair is then
527
+ * unverifiable, and this says whether it died of memory (carrick#2209). */
528
+ compiler_end?: CompilerEnd;
529
+ }
530
+ /** How the check's compiler process ended. */
531
+ export interface CompilerEnd {
532
+ /** Its exit code, or null when a signal ended it. */
533
+ exit_code: number | null;
534
+ /** The signal that ended it (`SIGABRT` when V8 aborts on a full heap,
535
+ * `SIGKILL` from the OS), or null. */
536
+ signal: string | null;
537
+ /** Whether its stderr carried V8's heap-exhaustion line. */
538
+ heap_limit: boolean;
539
+ /** The heap cap it was started with, in MB, or null when it ran at V8's
540
+ * default. */
541
+ heap_cap_mb: number | null;
521
542
  }
522
543
  export interface CheckOptions {
523
544
  stubs: CheckStubInput[];
@@ -99,6 +99,14 @@ export interface ProbePlan {
99
99
  * `strictNullChecks` (the retype runs under the consumer's own options) both
100
100
  * are assignable to every type, so the `toJSON` test held for them and
101
101
  * `infer R` came back `unknown` (carrick#1514).
102
+ *
103
+ * The depth counts `toJSON()` hops only, never nesting (carrick#2058). A body
104
+ * is mapped at every depth it has, a list's element at the list's own depth,
105
+ * so a `Date` three lists down travels as a string like one at the top. The
106
+ * mapped type is deferred, so a recursive type is expanded only as far as the
107
+ * compiler's own comparison walks it. What the count bounds is a `toJSON()`
108
+ * that returns a value with another `toJSON()`, which a type can do forever
109
+ * (one that returns its own type), and which no depth of nesting reaches.
102
110
  */
103
111
  export declare function jsonWireDeclarations(prefix: string): string[];
104
112
  export declare function decisiveAssignmentLine(plan: ProbePlan): number;
@@ -88,6 +88,14 @@ export function pairId(spec) {
88
88
  * `strictNullChecks` (the retype runs under the consumer's own options) both
89
89
  * are assignable to every type, so the `toJSON` test held for them and
90
90
  * `infer R` came back `unknown` (carrick#1514).
91
+ *
92
+ * The depth counts `toJSON()` hops only, never nesting (carrick#2058). A body
93
+ * is mapped at every depth it has, a list's element at the list's own depth,
94
+ * so a `Date` three lists down travels as a string like one at the top. The
95
+ * mapped type is deferred, so a recursive type is expanded only as far as the
96
+ * compiler's own comparison walks it. What the count bounds is a `toJSON()`
97
+ * that returns a value with another `toJSON()`, which a type can do forever
98
+ * (one that returns its own type), and which no depth of nesting reaches.
91
99
  */
92
100
  export function jsonWireDeclarations(prefix) {
93
101
  const depth = `${prefix}JsonWireDepth`;
@@ -95,7 +103,7 @@ export function jsonWireDeclarations(prefix) {
95
103
  const same = `${prefix}JsonWireSame`;
96
104
  return [
97
105
  `type ${depth} = [never, 0, 1, 2, 3, 4, 5, 6];`,
98
- `type ${wire}<T, D extends number = 6> = [D] extends [never] ? T : T extends null | undefined ? T : T extends { toJSON: (...args: any[]) => infer R } ? ${wire}<R, ${depth}[D]> : T extends (...args: any[]) => any ? T : T extends object ? { [K in keyof T]: ${wire}<T[K], ${depth}[D]> } : T;`,
106
+ `type ${wire}<T, D extends number = 6> = [D] extends [never] ? T : T extends null | undefined ? T : T extends { toJSON: (...args: any[]) => infer R } ? ${wire}<R, ${depth}[D]> : T extends (...args: any[]) => any ? T : T extends object ? { [K in keyof T]: ${wire}<T[K], D> } : T;`,
99
107
  `type ${same}<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;`,
100
108
  ];
101
109
  }
@@ -16,6 +16,38 @@
16
16
  */
17
17
  import type { CheckOptions, CheckProgressPhase, CheckResult } from './api.js';
18
18
  export type CheckProgress = (phase: CheckProgressPhase, message: string) => void;
19
+ /**
20
+ * Whether a process's stderr carries V8's fatal heap-exhaustion line
21
+ * (`Reached heap limit`, `Ineffective mark-compacts near heap limit`, and the
22
+ * other forms all end in `JavaScript heap out of memory`), and not one of the
23
+ * hard size limits that end the same way (carrick#2209).
24
+ *
25
+ * The scanner applies the same rule to the sidecar's own stderr
26
+ * (carrick#2208); the two must stay in step.
27
+ */
28
+ export declare function carriesHeapLimitLine(stderr: string): boolean;
29
+ /** What a Node child of the sidecar is started with. */
30
+ export interface ChildHeap {
31
+ /** `NODE_OPTIONS` for the child. */
32
+ nodeOptions: string | undefined;
33
+ /** The heap cap passed to the child, in MB, or null when it runs at V8's
34
+ * default. */
35
+ heapCapMb: number | null;
36
+ }
37
+ /**
38
+ * The heap a Node child of the sidecar (the check's compiler) is given
39
+ * (carrick#2209).
40
+ *
41
+ * The scanner sizes the sidecar's cap to the machine and passes it as a
42
+ * command-line flag, which a child does not inherit, so the child ran at V8's
43
+ * default. Handing it the same cap would overcommit the box: the sidecar's
44
+ * cap is already 3/4 of memory, and two such caps turn a heap error into an
45
+ * OS kill, which the sizing exists to avoid. So the child gets what is left of
46
+ * the sidecar's cap after what the sidecar holds now (`rssMb`), and only when
47
+ * that is more than V8's default; otherwise `NODE_OPTIONS` is left as it is
48
+ * and the child runs at the default, as before.
49
+ */
50
+ export declare function childHeap(execArgv: readonly string[], existing: string | undefined, rssMb: number): ChildHeap;
19
51
  /**
20
52
  * Locate a vendored executable the way node locates a package: walk up from
21
53
  * the bundle and take the first `node_modules/.bin/<name>` that exists.