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.
- package/bin/carrick.mjs +3 -0
- package/dist/contract.d.ts +6 -0
- package/dist/contract.js +5 -0
- package/dist/contract.js.map +1 -1
- package/dist/hook/post-edit.js +11 -2
- package/dist/hook/post-edit.js.map +1 -1
- package/dist/hook/post-read.d.ts +2 -0
- package/dist/hook/post-read.js +89 -0
- package/dist/hook/post-read.js.map +1 -0
- package/dist/hook/reuse.d.ts +24 -0
- package/dist/hook/reuse.js +58 -2
- package/dist/hook/reuse.js.map +1 -1
- package/dist/init/settings.js +6 -0
- package/dist/init/settings.js.map +1 -1
- package/dist/render.d.ts +22 -2
- package/dist/render.js +53 -2
- package/dist/render.js.map +1 -1
- package/dist/update.js +3 -0
- package/dist/update.js.map +1 -1
- package/package.json +62 -7
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/hooks/hooks.json +10 -0
- package/scripts/install-scanned-deps.sh +539 -0
- package/sidecar/dist/src/capture/api.d.ts +22 -1
- package/sidecar/dist/src/capture/check-probe.d.ts +8 -0
- package/sidecar/dist/src/capture/check-probe.js +9 -1
- package/sidecar/dist/src/capture/check.d.ts +32 -0
- package/sidecar/dist/src/capture/check.js +74 -5
- package/sidecar/dist/src/capture/checkout.d.ts +38 -0
- package/sidecar/dist/src/capture/checkout.js +139 -0
- package/sidecar/dist/src/capture/index.d.ts +1 -0
- package/sidecar/dist/src/capture/index.js +22 -7
- package/sidecar/dist/src/capture/machinery.js +1 -30
- package/sidecar/dist/src/capture/project-references.js +2 -1
- package/sidecar/dist/src/capture/self-check.d.ts +20 -1
- package/sidecar/dist/src/capture/self-check.js +30 -11
- package/sidecar/dist/src/origin.js +1 -29
- package/sidecar/dist/src/project-loader.d.ts +6 -0
- package/sidecar/dist/src/project-loader.js +64 -1
- package/sidecar/dist/src/type-inferrer.d.ts +235 -1
- 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],
|
|
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.
|