@cratis/pi 7.103.6 → 7.104.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package/corpus/agents/screenplay-modeler.md +1 -0
- package/package/corpus/harnesses/copilot/instructions/profiles.instructions.md +6 -2
- package/package/corpus/harnesses/pi/agents/screenplay-modeler.md +1 -0
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/ServerBridge.ts +7 -5
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/process.ts +15 -3
- package/package/corpus/rules/profiles.md +6 -2
- package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/SKILL.md +30 -4
- package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/references/dotnet-nuget.md +136 -11
- package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/scripts/cratis-local-packages.sh +1143 -49
- package/package/corpus/skills/cratis-screenplay-captures-and-reactions/SKILL.md +2 -2
- package/package/corpus/skills/cratis-screenplay-command-surface/SKILL.md +2 -2
- package/package/corpus/skills/cratis-screenplay-event-modeling/SKILL.md +1 -1
- package/package/corpus/skills/cratis-screenplay-event-modeling/references/file-layout.md +15 -6
- package/package/corpus/skills/cratis-screenplay-event-modeling/verification.json +4 -1
- package/package/corpus/skills/cratis-screenplay-model-authoring/SKILL.md +7 -3
- package/package/corpus/skills/cratis-screenplay-model-authoring/references/mcp-loop.md +15 -4
- package/package/corpus/skills/cratis-screenplay-model-authoring/references/mcp-tools.md +49 -3
- package/package/corpus/skills/cratis-screenplay-model-authoring/references/repairs.md +27 -3
- package/package/corpus/skills/cratis-screenplay-model-authoring/verification.json +14 -0
- package/package/corpus/skills/cratis-screenplay-model-review/SKILL.md +6 -0
- package/package/corpus/skills/cratis-screenplay-model-review/references/checklist.md +1 -1
- package/package/corpus/skills/cratis-screenplay-model-review/verification.json +2 -0
- package/package/corpus/skills/cratis-screenplay-modeling-lifecycle/SKILL.md +4 -0
- package/package/corpus/skills/cratis-screenplay-projections/SKILL.md +4 -5
- package/package/corpus/skills/cratis-screenplay-read-surface/SKILL.md +4 -5
- package/package/corpus/skills/cratis-screenplay-specifications/SKILL.md +2 -2
- package/package/corpus/skills/cratis-screenplay-toolchain/SKILL.md +6 -0
- package/package/corpus/skills/cratis-screenplay-toolchain/references/cheat-sheet.md +13 -0
- package/package/corpus/skills/cratis-screenplay-toolchain/references/diagnostics.md +10 -2
- package/package/corpus/skills/cratis-screenplay-toolchain/verification.json +3 -0
- package/package/corpus/skills/cratis-screenplay-ui-composition/SKILL.md +8 -2
- package/package/corpus/skills/cratis-screenplay-ui-composition/references/ui-profile-and-theme.md +28 -0
- package/package/corpus/skills/cratis-screenplay-ui-composition/verification.json +4 -0
- package/package.json +1 -1
|
@@ -10,12 +10,13 @@ Portable local package verification for macOS/Linux (Bash 3.2+). Windows: use WS
|
|
|
10
10
|
Exit codes: 0 success, 1 findings/failed operation, 2 cannot run/invalid input.
|
|
11
11
|
|
|
12
12
|
Commands (COMMAND --help shows these instructions too):
|
|
13
|
-
stamp PRODUCER [PRODUCER...]
|
|
13
|
+
stamp [--base-version X.Y.Z] PRODUCER [PRODUCER...]
|
|
14
14
|
Next patch of the highest reachable vX.Y.Z tag, UTC minute and HEAD sha7.
|
|
15
15
|
With several producers the highest next patch wins (one stamp for a multi-hop
|
|
16
16
|
chain, so no stamped package sorts below a released pin) and sha7 is the last
|
|
17
17
|
producer's; every HEAD is printed to stderr.
|
|
18
|
-
stamp --train PRODUCER [PRODUCER...]
|
|
18
|
+
stamp --train [--base-version X.Y.Z] PRODUCER [PRODUCER...]
|
|
19
|
+
--base-version overrides the tag-derived base (strict SemVer core, no suffix).
|
|
19
20
|
Train stamp X.Y.Z-train.g<sha7>: the same next patch, no minute, so one code
|
|
20
21
|
state always yields one stamp. Accepted everywhere a local stamp is.
|
|
21
22
|
closure [--map FILE] [--train] CONSUMER=PATH PRODUCER=PATH...
|
|
@@ -52,6 +53,19 @@ Commands (COMMAND --help shows these instructions too):
|
|
|
52
53
|
verify-assets CONSUMER-PATH STAMP [--npm] [--dir REL] [--json]
|
|
53
54
|
Manual, weaker form without a plan: every package at STAMP in the feed that the
|
|
54
55
|
consumer resolves must be STAMP (none resolved: exit 1).
|
|
56
|
+
verify-pack --composition FILE [--json]
|
|
57
|
+
verify-assets CONSUMER-PATH --composition FILE [--json]
|
|
58
|
+
use-nuget CONSUMER --composition FILE [--pin-transitive]
|
|
59
|
+
Opt-in mixed-version NuGet closure: tab-separated producer NAME STAMP
|
|
60
|
+
ABSOLUTE_WORKTREE rows, then package PRODUCER PACKAGE_ID rows. Case-insensitive
|
|
61
|
+
IDs have one owner. All declared artifacts must exist; nothing is built/repacked.
|
|
62
|
+
Uses Node, unzip and the selected SDK's native NuGet.Versioning APIs; unavailable
|
|
63
|
+
validation fails closed (2). Bad artifacts/ranges/assets are findings (1).
|
|
64
|
+
Composition JSON: {"packages":[{"id":..,"version":..,"producer":..}]} (no stamp).
|
|
65
|
+
Activation publishes a journaled overlay with exclusive exact source mappings.
|
|
66
|
+
The next mutation recovers interrupted work, or refuses and preserves unknown
|
|
67
|
+
ownership/drift. Active compositions retain member artifacts. Restore before
|
|
68
|
+
changing a composition or switching between it and a legacy overlay.
|
|
55
69
|
pack-nuget PRODUCER STAMP [--property NAME=VALUE]... PROJECT-OR-SLN...
|
|
56
70
|
Release-pack each target into the NuGet feed; requires dotnet. Each --property
|
|
57
71
|
becomes one -p:NAME=VALUE argument of that command only (closure prints the
|
|
@@ -87,6 +101,8 @@ Commands (COMMAND --help shows these instructions too):
|
|
|
87
101
|
Default: list all local feed/cache/image/session subjects, without deleting.
|
|
88
102
|
--apply deletes listed subjects (docker rmi, never force). Refuses more than
|
|
89
103
|
CRATIS_LOCAL_CLEAN_MAX subjects (default 500), or unresolved npm backups.
|
|
104
|
+
Refuses selected stamps referenced by registered live consumer overlays/manifests;
|
|
105
|
+
restore those consumers first. Dry-run uses the same protection.
|
|
90
106
|
Known sessions are registered in FEED/sessions; an optional consumer also
|
|
91
107
|
discovers its per-worktree session. Docker probes have a 10-second deadline.
|
|
92
108
|
--self-test
|
|
@@ -132,10 +148,28 @@ fi
|
|
|
132
148
|
FEED=${CRATIS_LOCAL_FEED:-${XDG_CACHE_HOME:-$HOME/.cache}/cratis/local-feed}
|
|
133
149
|
NUGET_CACHE=${NUGET_PACKAGES:-$HOME/.nuget/packages}
|
|
134
150
|
case "$FEED" in /*) ;; *) FEED=$PWD/$FEED ;; esac
|
|
151
|
+
# The configured spelling (for example /var/... on macOS, or a symlinked cache) is kept
|
|
152
|
+
# so leak scans catch consumer references written through either path.
|
|
153
|
+
FEED_ALIAS=$FEED
|
|
154
|
+
# Resolve configured/system symlink parents without creating a missing feed.
|
|
155
|
+
# Subsequent checks concern helper-owned components, not /tmp, /var or HOME.
|
|
156
|
+
resolve_feed() {
|
|
157
|
+
local directory=$FEED suffix='' physical
|
|
158
|
+
while [ ! -d "$directory" ]; do
|
|
159
|
+
[ ! -e "$directory" ] && [ ! -L "$directory" ] || die "not a feed directory: $directory"
|
|
160
|
+
suffix=/${directory##*/}$suffix
|
|
161
|
+
directory=${directory%/*}; [ -n "$directory" ] || directory=/
|
|
162
|
+
done
|
|
163
|
+
physical=$(cd -P "$directory" && pwd -P) || die "cannot resolve feed: $FEED"
|
|
164
|
+
if [ -n "$suffix" ]; then FEED=${physical%/}$suffix; else FEED=$physical; fi
|
|
165
|
+
}
|
|
166
|
+
[ "$MODE" = --self-test ] || resolve_feed
|
|
135
167
|
case "$NUGET_CACHE" in /*) ;; *) NUGET_CACHE=$PWD/$NUGET_CACHE ;; esac
|
|
136
168
|
TMP=''
|
|
137
169
|
PACK_REPO=''
|
|
138
170
|
PACK_MANIFESTS=()
|
|
171
|
+
SESSION_LOCK=''
|
|
172
|
+
SESSION_RECOVERY_GUARD=''
|
|
139
173
|
cleanup() {
|
|
140
174
|
local status=$?
|
|
141
175
|
trap - EXIT
|
|
@@ -145,6 +179,14 @@ cleanup() {
|
|
|
145
179
|
status=1
|
|
146
180
|
fi
|
|
147
181
|
fi
|
|
182
|
+
if [ -n "$SESSION_LOCK" ] && [ ! -e "$SESSION_LOCK/journal.json" ] &&
|
|
183
|
+
[ -f "$SESSION_LOCK/owner" ] && [ "$(cat "$SESSION_LOCK/owner")" = "$$" ]; then
|
|
184
|
+
rm -- "$SESSION_LOCK/owner"
|
|
185
|
+
rmdir -- "$SESSION_LOCK" || status=1
|
|
186
|
+
fi
|
|
187
|
+
if [ -n "$SESSION_RECOVERY_GUARD" ]; then
|
|
188
|
+
rmdir -- "$SESSION_RECOVERY_GUARD" || status=1
|
|
189
|
+
fi
|
|
148
190
|
if [ -n "$TMP" ]; then rm -rf -- "$TMP"; fi
|
|
149
191
|
exit "$status"
|
|
150
192
|
}
|
|
@@ -187,8 +229,8 @@ validate_session() {
|
|
|
187
229
|
[ -e "$entry" ] || [ -L "$entry" ] || continue
|
|
188
230
|
[ ! -L "$entry" ] || die "symlink in session: $entry"
|
|
189
231
|
case "${entry##*/}" in
|
|
190
|
-
repo|stamp|env|npm-dir|Directory.Packages.props|nuget.config) [ -f "$entry" ] || die "unexpected session entry: $entry" ;;
|
|
191
|
-
npm-backup) [ -d "$entry" ] || die "unexpected session entry: $entry" ;;
|
|
232
|
+
repo|stamp|env|npm-dir|Directory.Packages.props|cratis-local.targets|nuget.config) [ -f "$entry" ] || die "unexpected session entry: $entry" ;;
|
|
233
|
+
npm-backup|composition) [ -d "$entry" ] || die "unexpected session entry: $entry" ;;
|
|
192
234
|
*) die "unrecognized file in session (preserved): $entry" ;;
|
|
193
235
|
esac
|
|
194
236
|
done
|
|
@@ -227,12 +269,31 @@ base_above() {
|
|
|
227
269
|
|
|
228
270
|
stamp() {
|
|
229
271
|
[ $# -ge 1 ] || die 'stamp requires a producer repository'
|
|
230
|
-
local repo base='' sha='' arg train=0 lines='' rname origin
|
|
272
|
+
local repo base='' override='' sha='' arg train=0 lines='' rname origin
|
|
273
|
+
local producers=()
|
|
231
274
|
if [ "$1" = --train ]; then train=1; shift; [ $# -ge 1 ] || die 'stamp --train requires a producer repository'; fi
|
|
275
|
+
while [ $# -gt 0 ]; do
|
|
276
|
+
arg=$1; shift
|
|
277
|
+
case "$arg" in
|
|
278
|
+
--base-version)
|
|
279
|
+
[ $# -gt 0 ] || die '--base-version requires X.Y.Z'
|
|
280
|
+
[ -z "$override" ] || die '--base-version may only be supplied once'
|
|
281
|
+
[[ "$1" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || die "invalid base version: $1 (expected SemVer core X.Y.Z)"
|
|
282
|
+
override=$1; shift ;;
|
|
283
|
+
--train) train=1 ;;
|
|
284
|
+
-*) die "unknown stamp option: $arg" ;;
|
|
285
|
+
*) producers+=("$arg") ;;
|
|
286
|
+
esac
|
|
287
|
+
done
|
|
288
|
+
[ ${#producers[@]} -gt 0 ] || die 'stamp requires a producer repository'
|
|
289
|
+
set -- "${producers[@]}"
|
|
290
|
+
base=$override
|
|
232
291
|
for arg in "$@"; do
|
|
233
292
|
repo=$(repo_root "$arg")
|
|
234
|
-
|
|
235
|
-
|
|
293
|
+
if [ -z "$override" ]; then
|
|
294
|
+
next_base "$repo"
|
|
295
|
+
if [ -z "$base" ] || base_above "$NEXT_BASE" "$base"; then base=$NEXT_BASE; fi
|
|
296
|
+
fi
|
|
236
297
|
sha=$(git -C "$repo" rev-parse HEAD) || die 'producer has no HEAD commit'
|
|
237
298
|
if [ $# -gt 1 ]; then printf 'HEAD %s %s\n' "$repo" "$sha" >&2; fi
|
|
238
299
|
# The repository name is its origin's last path part (stable across checkouts), else its folder name.
|
|
@@ -350,15 +411,6 @@ NODE
|
|
|
350
411
|
printf 'produced %s npm tarballs\n' "$count"
|
|
351
412
|
}
|
|
352
413
|
|
|
353
|
-
# Read Include/Update attributes without assuming one element per line.
|
|
354
|
-
package_ids() {
|
|
355
|
-
awk 'BEGIN { RS="<" } /^PackageVersion[[:space:]]/ {
|
|
356
|
-
gsub(sprintf("%c",39),"\"")
|
|
357
|
-
if (match($0, /(Include|Update)[[:space:]]*=[[:space:]]*"[^"]+"/)) {
|
|
358
|
-
value=substr($0,RSTART,RLENGTH); sub(/^[^"]*"/,"",value); sub(/"$/,"",value); print value
|
|
359
|
-
}
|
|
360
|
-
}' "$1"
|
|
361
|
-
}
|
|
362
414
|
source_mapping() {
|
|
363
415
|
local config=$1 output=$2 id
|
|
364
416
|
shift 2
|
|
@@ -397,9 +449,43 @@ source_mapping() {
|
|
|
397
449
|
' "$config" > "$output"
|
|
398
450
|
}
|
|
399
451
|
|
|
452
|
+
# These headers are shared by the legacy and composition writers. The session
|
|
453
|
+
# lives below the Git directory, never above a consumer project in the lookup.
|
|
454
|
+
nuget_props_start() {
|
|
455
|
+
cat <<'XML'
|
|
456
|
+
<Project>
|
|
457
|
+
<PropertyGroup><_CratisLocalPackagesProps>$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildProjectDirectory)'))</_CratisLocalPackagesProps></PropertyGroup>
|
|
458
|
+
<Import Project="$(_CratisLocalPackagesProps)" Condition="'$(_CratisLocalPackagesProps)' != '' and Exists('$(_CratisLocalPackagesProps)')" />
|
|
459
|
+
XML
|
|
460
|
+
if [ "$1" -eq 1 ]; then
|
|
461
|
+
printf ' <PropertyGroup Condition="%s"><CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled></PropertyGroup>\n' "'\$(ManagePackageVersionsCentrally)' == 'true'"
|
|
462
|
+
fi
|
|
463
|
+
printf ' <ItemGroup Condition="%s">\n' "'\$(ManagePackageVersionsCentrally)' == 'true'"
|
|
464
|
+
}
|
|
465
|
+
nuget_targets_start() {
|
|
466
|
+
cat <<'XML'
|
|
467
|
+
<Project>
|
|
468
|
+
<Import Project="$(_CratisLocalPreviousAfterMicrosoftCommonTargets)" Condition="'$(_CratisLocalPreviousAfterMicrosoftCommonTargets)' != '' and '$(_CratisLocalPreviousAfterMicrosoftCommonTargets)' != '$(MSBuildThisFileFullPath)' and Exists('$(_CratisLocalPreviousAfterMicrosoftCommonTargets)')" />
|
|
469
|
+
<ItemGroup Condition="'$(ManagePackageVersionsCentrally)' != 'true'">
|
|
470
|
+
XML
|
|
471
|
+
}
|
|
472
|
+
nuget_overlay_end() { printf ' </ItemGroup>\n</Project>\n'; }
|
|
473
|
+
nuget_env() {
|
|
474
|
+
local session=$1 targets_key=CustomAfterMicrosoftCommon"Targets"
|
|
475
|
+
printf 'export %s=%q\n' DirectoryPackages"PropsPath" "$session/Directory.Packages.props"
|
|
476
|
+
printf 'export %s=%q\n' RestoreAdditional"ProjectSources" "$FEED/nuget"
|
|
477
|
+
# Capture at source time, not activation time; sourcing twice must not recurse.
|
|
478
|
+
printf 'if [ "%s" != %q ]; then\n' "\${$targets_key:-}" "$session/cratis-local.targets"
|
|
479
|
+
printf ' export _CratisLocalPreviousAfterMicrosoftCommonTargets="%s"\nfi\n' "\${$targets_key:-}"
|
|
480
|
+
printf 'export %s=%q\n' "$targets_key" "$session/cratis-local.targets"
|
|
481
|
+
}
|
|
482
|
+
|
|
400
483
|
use_nuget() {
|
|
484
|
+
if [ "${2:-}" = --composition ]; then composition use-nuget "$@"; return; fi
|
|
485
|
+
case " $* " in *' --composition '*) die 'cannot mix a stamp or IDs with --composition' ;; esac
|
|
401
486
|
[ $# -ge 2 ] || die 'use-nuget requires consumer and stamp'
|
|
402
|
-
|
|
487
|
+
[ ! -d "$(session_path "$(repo_root "$1")")/composition" ] || die 'active composition; restore first'
|
|
488
|
+
local repo version session props id file config='' props_key sources_key targets_key pin=0 args=()
|
|
403
489
|
local ids=()
|
|
404
490
|
repo=$(repo_root "$1"); version=$2; shift 2
|
|
405
491
|
for id in "$@"; do
|
|
@@ -409,7 +495,6 @@ use_nuget() {
|
|
|
409
495
|
valid_stamp "$version"; props=$repo/Directory.Packages.props
|
|
410
496
|
[ -f "$props" ] || die 'no Directory.Packages.props (non-CPM). Manual alternative: untracked project copy, explicit PackageReference versions, dotnet restore --source FEED/nuget'
|
|
411
497
|
session=$(session_path "$repo"); scratch
|
|
412
|
-
package_ids "$props" > "$TMP/consumer-ids"
|
|
413
498
|
if [ $# -gt 0 ]; then
|
|
414
499
|
ids=("$@")
|
|
415
500
|
elif [ -d "$FEED/nuget" ]; then
|
|
@@ -443,24 +528,29 @@ use_nuget() {
|
|
|
443
528
|
fi
|
|
444
529
|
register_session "$repo" "$session" "$version"
|
|
445
530
|
{
|
|
446
|
-
|
|
447
|
-
if [ "$pin" -eq 1 ]; then
|
|
448
|
-
printf ' <PropertyGroup><CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled></PropertyGroup>\n'
|
|
449
|
-
fi
|
|
450
|
-
printf ' <ItemGroup>\n'
|
|
531
|
+
nuget_props_start "$pin"
|
|
451
532
|
for id in "${ids[@]}"; do
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
printf ' <PackageVersion %s="%s" Version="%s" />\n' "$action" "$id" "$version"
|
|
533
|
+
# Remove/include works with any nearest props, not only root IDs.
|
|
534
|
+
printf ' <PackageVersion Remove="%s" /><PackageVersion Include="%s" Version="%s" />\n' "$id" "$id" "$version"
|
|
455
535
|
done
|
|
456
|
-
|
|
536
|
+
nuget_overlay_end
|
|
457
537
|
} > "$session/Directory.Packages.props"
|
|
458
|
-
props_key=DirectoryPackages"PropsPath"; sources_key=RestoreAdditional"ProjectSources"
|
|
459
538
|
{
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
539
|
+
nuget_targets_start
|
|
540
|
+
for id in "${ids[@]}"; do
|
|
541
|
+
printf ' <PackageReference Update="%s" Version="%s" />\n' "$id" "$version"
|
|
542
|
+
done
|
|
543
|
+
nuget_overlay_end
|
|
544
|
+
} > "$session/cratis-local.targets"
|
|
545
|
+
nuget_env "$session" > "$session/env"
|
|
546
|
+
props_key=DirectoryPackages"PropsPath"; sources_key=RestoreAdditional"ProjectSources"; targets_key=CustomAfterMicrosoftCommon"Targets"
|
|
547
|
+
printf ' %q' "-p:$props_key=$session/Directory.Packages.props" "-p:$sources_key=$FEED/nuget" "-p:$targets_key=$session/cratis-local.targets"
|
|
548
|
+
if [ "${CustomAfterMicrosoftCommonTargets:-}" != "$session/cratis-local.targets" ]; then
|
|
549
|
+
printf ' %q' "-p:_CratisLocalPreviousAfterMicrosoftCommonTargets=${CustomAfterMicrosoftCommonTargets:-}"
|
|
550
|
+
else
|
|
551
|
+
printf ' %q' "-p:_CratisLocalPreviousAfterMicrosoftCommonTargets=${_CratisLocalPreviousAfterMicrosoftCommonTargets:-}"
|
|
552
|
+
fi
|
|
553
|
+
printf '\n'
|
|
464
554
|
printf 'source %q\n' "$session/env"
|
|
465
555
|
if [ -n "$config" ]; then
|
|
466
556
|
source_mapping "$config" "$session/nuget.config" "${ids[@]}"
|
|
@@ -469,6 +559,557 @@ use_nuget() {
|
|
|
469
559
|
rm -- "$session/nuget.config"
|
|
470
560
|
fi
|
|
471
561
|
printf 'overrode %s NuGet package IDs\n' "${#ids[@]}"
|
|
562
|
+
printf 'Optional per-worktree cache (set before restore and clean): export NUGET_PACKAGES=%q\n' "$(git -C "$repo" rev-parse --absolute-git-dir)/cratis-local-nuget"
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
# A shared lock covers legacy mutations too: registration rollback must never replace
|
|
566
|
+
# another invocation's registry writes. An incomplete/unknown lock is preserved.
|
|
567
|
+
acquire_session_lock() {
|
|
568
|
+
local lock=$FEED/.composition-lock
|
|
569
|
+
if [ "$DRY" -eq 1 ]; then
|
|
570
|
+
[ ! -e "$lock" ] && [ ! -L "$lock" ] || die 'session locked; recovery requires a non-dry-run mutation'
|
|
571
|
+
return 0
|
|
572
|
+
fi
|
|
573
|
+
mkdir -p "$FEED"
|
|
574
|
+
# Serialize inspection, journal recovery and replacement so a contender cannot
|
|
575
|
+
# recover a stale owner and then delete the next invocation's live lock.
|
|
576
|
+
# An abandoned guard fails closed, just like an incomplete/unknown lock.
|
|
577
|
+
mkdir "$FEED/.composition-recovery" || die 'local package session is locked'
|
|
578
|
+
SESSION_RECOVERY_GUARD=$FEED/.composition-recovery
|
|
579
|
+
if [ -e "$lock" ] || [ -L "$lock" ]; then composition recover; fi
|
|
580
|
+
mkdir "$lock" || die 'local package session is locked'
|
|
581
|
+
SESSION_LOCK=$lock
|
|
582
|
+
printf '%s\n' "$$" > "$lock/owner"
|
|
583
|
+
rmdir -- "$SESSION_RECOVERY_GUARD"
|
|
584
|
+
SESSION_RECOVERY_GUARD=''
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
# Opt-in composition uses Node orchestration and the selected SDK's native NuGet
|
|
588
|
+
# APIs. No npm dependency, persistent project, package restore or producer build.
|
|
589
|
+
composition() {
|
|
590
|
+
need node; scratch
|
|
591
|
+
local operation=$1 repo='' session=''
|
|
592
|
+
shift
|
|
593
|
+
case "$operation" in
|
|
594
|
+
use-nuget|verify-assets|restore-state)
|
|
595
|
+
[ $# -ge 1 ] || die "$operation requires a consumer"
|
|
596
|
+
repo=$(repo_root "$1"); session=$(session_path "$repo") ;;
|
|
597
|
+
esac
|
|
598
|
+
nuget_props_start 0 > "$TMP/overlay-props"
|
|
599
|
+
nuget_props_start 1 > "$TMP/overlay-props-pin"
|
|
600
|
+
nuget_targets_start > "$TMP/overlay-targets"
|
|
601
|
+
nuget_overlay_end > "$TMP/overlay-end"
|
|
602
|
+
nuget_env "$session/composition" > "$TMP/overlay-env"
|
|
603
|
+
node - "$operation" "$FEED" "$TMP" "$DRY" "$repo" "$session" "$@" <<'COMPOSITION'
|
|
604
|
+
const fs = require('node:fs');
|
|
605
|
+
const path = require('node:path');
|
|
606
|
+
const cp = require('node:child_process');
|
|
607
|
+
const crypto = require('node:crypto');
|
|
608
|
+
const [mode, feed, scratch, dry, repo, session, ...argv] = process.argv.slice(2);
|
|
609
|
+
const lock = path.join(feed, '.composition-lock');
|
|
610
|
+
const registry = path.join(feed, 'sessions');
|
|
611
|
+
const local = /-(?:local\.[0-9]{12}|train)\.g[0-9a-f]{7}(?:$|[^0-9A-Za-z])/;
|
|
612
|
+
const stamp = /^[0-9]+\.[0-9]+\.[0-9]+-(?:local\.[0-9]{12}|train)\.g[0-9a-f]{7}$/;
|
|
613
|
+
const idPattern = /^[A-Za-z0-9_.-]+$/;
|
|
614
|
+
const lower = s => s.toLowerCase();
|
|
615
|
+
const exists = p => fs.existsSync(p) || !!fs.lstatSync(path.dirname(p), {throwIfNoEntry:false}) && fs.readdirSync(path.dirname(p)).includes(path.basename(p));
|
|
616
|
+
function fail(message, code = 2) { const error = new Error(message); error.code = code; throw error; }
|
|
617
|
+
function plain(p, directory = false) {
|
|
618
|
+
const st = fs.lstatSync(p, {throwIfNoEntry:false});
|
|
619
|
+
if (!st || st.isSymbolicLink() || (directory ? !st.isDirectory() : !st.isFile())) fail('unsafe or missing path: ' + p);
|
|
620
|
+
}
|
|
621
|
+
function safeParents(p, ownedRoot = feed) {
|
|
622
|
+
if (/[\x00-\x1f]/.test(p)) fail('control character in path');
|
|
623
|
+
const root = path.resolve(ownedRoot), start = path.resolve(p);
|
|
624
|
+
if (start !== root && !start.startsWith(root + path.sep)) fail('outside helper-owned directory: ' + p);
|
|
625
|
+
for (let cur = start; ; cur = path.dirname(cur)) {
|
|
626
|
+
const st = fs.lstatSync(cur, {throwIfNoEntry:false});
|
|
627
|
+
if (st && (st.isSymbolicLink() || !st.isDirectory())) fail('unsafe directory: ' + cur);
|
|
628
|
+
if (cur === root) break;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
function bytes(p) { if (!exists(p)) return null; plain(p); return fs.readFileSync(p).toString('base64'); }
|
|
632
|
+
function write(p, value) { fs.writeFileSync(p, value, {flag:'wx', mode:0o600}); }
|
|
633
|
+
function run(command, args) {
|
|
634
|
+
const result = cp.spawnSync(command, args, {encoding:'utf8', maxBuffer:32 * 1024 * 1024});
|
|
635
|
+
if (result.error || result.status !== 0) fail(command + ' failed: ' + (result.error?.message || result.stderr || result.stdout), 2);
|
|
636
|
+
return result.stdout;
|
|
637
|
+
}
|
|
638
|
+
function manifest(file) {
|
|
639
|
+
plain(file);
|
|
640
|
+
const producers = new Map(), packages = new Map(), rows = [];
|
|
641
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
642
|
+
if (line === '' || line.startsWith('#')) continue;
|
|
643
|
+
const row = line.split('\t');
|
|
644
|
+
if (row.some(s => s === '' || /[\r\x00-\x08\x0b-\x1f]/.test(s))) fail('malformed composition row');
|
|
645
|
+
if (row[0] === 'producer' && row.length === 4) {
|
|
646
|
+
const [, name, version, worktree] = row;
|
|
647
|
+
if (!idPattern.test(name) || producers.has(lower(name)) || !stamp.test(version) || !path.isAbsolute(worktree)) fail('invalid or duplicate producer: ' + name);
|
|
648
|
+
plain(worktree, true);
|
|
649
|
+
if (run('git', ['-C', worktree, 'rev-parse', '--show-toplevel']).trim() !== worktree) fail('producer must name an absolute worktree root: ' + worktree);
|
|
650
|
+
producers.set(lower(name), {name, version, worktree});
|
|
651
|
+
} else if (row[0] === 'package' && row.length === 3) rows.push(row);
|
|
652
|
+
else fail('malformed composition row: ' + line);
|
|
653
|
+
}
|
|
654
|
+
for (const [, owner, id] of rows) {
|
|
655
|
+
const producer = producers.get(lower(owner));
|
|
656
|
+
if (!producer || !idPattern.test(id) || packages.has(lower(id))) fail('unknown producer or duplicate package ownership: ' + id);
|
|
657
|
+
packages.set(lower(id), {id, producer:producer.name, version:producer.version, worktree:producer.worktree});
|
|
658
|
+
}
|
|
659
|
+
if (!producers.size || !packages.size) fail('empty composition');
|
|
660
|
+
for (const p of producers.values()) if (![...packages.values()].some(pkg => pkg.producer === p.name)) fail('empty producer closure: ' + p.name);
|
|
661
|
+
return [...packages.values()].sort((a,b) => lower(a.id).localeCompare(lower(b.id), 'en'));
|
|
662
|
+
}
|
|
663
|
+
const normalize = packages => packages.map(p => [p.producer,p.id,p.version,p.worktree].join('\t')).join('\n') + '\n';
|
|
664
|
+
const xml = s => s.replaceAll('&','&').replaceAll('<','<').replaceAll('"','"').replaceAll("'",''');
|
|
665
|
+
const msxml = s => xml(s.replace(/[%$@;?*()]/g, c => '%' + c.charCodeAt(0).toString(16)));
|
|
666
|
+
// All version/range comparisons and nuspec XML parsing run inside the SDK.
|
|
667
|
+
function native(packages, graphs = []) {
|
|
668
|
+
const inputs = path.join(scratch, 'native-inputs.tsv');
|
|
669
|
+
const checks = path.join(scratch, 'native-graphs.tsv');
|
|
670
|
+
const result = path.join(scratch, 'native-result');
|
|
671
|
+
let input = '';
|
|
672
|
+
packages.forEach((pkg, i) => {
|
|
673
|
+
const artifact = path.join(feed, 'nuget', `${pkg.id}.${pkg.version}.nupkg`);
|
|
674
|
+
safeParents(path.dirname(artifact));
|
|
675
|
+
if (!exists(artifact)) fail('missing: ' + artifact, 1);
|
|
676
|
+
plain(artifact);
|
|
677
|
+
let names;
|
|
678
|
+
try { names = run('unzip', ['-Z1', artifact]).split('\n').filter(n => /\.nuspec$/i.test(n)); }
|
|
679
|
+
catch (e) { fail('corrupt package: ' + artifact, 1); }
|
|
680
|
+
if (names.length !== 1 || !/^[A-Za-z0-9_.-]+\.nuspec$/i.test(names[0])) fail('ambiguous nuspec: ' + artifact, 1);
|
|
681
|
+
const extracted = cp.spawnSync('unzip', ['-p', artifact, names[0]], {maxBuffer:8 * 1024 * 1024});
|
|
682
|
+
if (extracted.error || extracted.status !== 0) fail('unreadable nuspec: ' + artifact, 1);
|
|
683
|
+
const nuspec = path.join(scratch, `package-${i}.xml`);
|
|
684
|
+
fs.writeFileSync(nuspec, extracted.stdout);
|
|
685
|
+
input += [pkg.id, pkg.version, nuspec].join('\t') + '\n';
|
|
686
|
+
});
|
|
687
|
+
fs.writeFileSync(inputs, input); fs.writeFileSync(checks, graphs.join('\n'));
|
|
688
|
+
const project = path.join(scratch, 'native.proj');
|
|
689
|
+
fs.writeFileSync(project, `<Project>
|
|
690
|
+
<UsingTask TaskName="CompositionValidation" TaskFactory="RoslynCodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)/Microsoft.Build.Tasks.Core.dll">
|
|
691
|
+
<ParameterGroup><Sdk ParameterType="System.String" Required="true"/><Inputs ParameterType="System.String" Required="true"/><Graphs ParameterType="System.String" Required="true"/><Result ParameterType="System.String" Required="true"/></ParameterGroup>
|
|
692
|
+
<Task><Code Type="Fragment" Language="cs"><![CDATA[
|
|
693
|
+
var verdict = "0";
|
|
694
|
+
var advisories = new System.Text.StringBuilder();
|
|
695
|
+
try {
|
|
696
|
+
var assembly = System.Reflection.Assembly.LoadFrom(System.IO.Path.Combine(Sdk, "NuGet.Versioning.dll"));
|
|
697
|
+
var vt = assembly.GetType("NuGet.Versioning.NuGetVersion", true);
|
|
698
|
+
var rt = assembly.GetType("NuGet.Versioning.VersionRange", true);
|
|
699
|
+
var pv = vt.GetMethod("TryParse", new[] { typeof(string), vt.MakeByRefType() });
|
|
700
|
+
var pr = rt.GetMethod("TryParse", new[] { typeof(string), rt.MakeByRefType() });
|
|
701
|
+
var sat = rt.GetMethod("Satisfies", new[] { vt });
|
|
702
|
+
if (pv == null || pr == null || sat == null) throw new MissingMethodException("native NuGet validation API unavailable");
|
|
703
|
+
Func<string, object> version = text => {
|
|
704
|
+
var args = new object[] { text, null };
|
|
705
|
+
if (!(bool)pv.Invoke(null, args)) throw new FormatException("invalid version: " + text);
|
|
706
|
+
return args[1];
|
|
707
|
+
};
|
|
708
|
+
Func<string, object> range = text => {
|
|
709
|
+
var args = new object[] { text, null };
|
|
710
|
+
if (String.IsNullOrWhiteSpace(text) || !(bool)pr.Invoke(null, args)) throw new FormatException("invalid range: " + text);
|
|
711
|
+
return args[1];
|
|
712
|
+
};
|
|
713
|
+
Action<string, string, string> satisfies = (v, r, context) => {
|
|
714
|
+
if (!(bool)sat.Invoke(range(r), new[] { version(v) })) throw new FormatException("dependency range: " + context + " " + r + " excludes " + v);
|
|
715
|
+
};
|
|
716
|
+
var fa = System.Reflection.Assembly.LoadFrom(System.IO.Path.Combine(Sdk, "NuGet.Frameworks.dll"));
|
|
717
|
+
var ft = fa.GetType("NuGet.Frameworks.NuGetFramework", true);
|
|
718
|
+
var reducerType = fa.GetType("NuGet.Frameworks.FrameworkReducer", true);
|
|
719
|
+
var fp = ft.GetMethod("Parse", new[] { typeof(string) });
|
|
720
|
+
var nearest = reducerType.GetMethod("GetNearest", new[] { ft, typeof(System.Collections.Generic.IEnumerable<>).MakeGenericType(ft) });
|
|
721
|
+
if (fp == null || nearest == null) throw new MissingMethodException("native NuGet framework API unavailable");
|
|
722
|
+
var reducer = Activator.CreateInstance(reducerType);
|
|
723
|
+
Func<string, object> framework = text => fp.Invoke(null, new object[] { String.IsNullOrEmpty(text) ? "any" : text });
|
|
724
|
+
var dependencies = new System.Collections.Generic.Dictionary<string, System.Collections.Generic.Dictionary<object, System.Collections.Generic.HashSet<string>>>(StringComparer.OrdinalIgnoreCase);
|
|
725
|
+
var owned = new System.Collections.Generic.Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
726
|
+
var rows = System.IO.File.ReadAllLines(Inputs);
|
|
727
|
+
foreach (var row in rows) { var p = row.Split('\t'); version(p[1]); owned.Add(p[0], p[1]); }
|
|
728
|
+
foreach (var row in rows) {
|
|
729
|
+
var p = row.Split('\t');
|
|
730
|
+
var document = new System.Xml.XmlDocument(); document.XmlResolver = null;
|
|
731
|
+
var settings = new System.Xml.XmlReaderSettings(); settings.DtdProcessing = System.Xml.DtdProcessing.Prohibit; settings.XmlResolver = null;
|
|
732
|
+
using (var reader = System.Xml.XmlReader.Create(p[2], settings)) document.Load(reader);
|
|
733
|
+
var metadata = document.SelectNodes("/*[local-name()='package']/*[local-name()='metadata']");
|
|
734
|
+
if (metadata.Count != 1) throw new FormatException("ambiguous metadata: " + p[0]);
|
|
735
|
+
var ids = metadata[0].SelectNodes("*[local-name()='id']");
|
|
736
|
+
var versions = metadata[0].SelectNodes("*[local-name()='version']");
|
|
737
|
+
if (ids.Count != 1 || versions.Count != 1 || !String.Equals(ids[0].InnerText, p[0], StringComparison.OrdinalIgnoreCase) || versions[0].InnerText != p[1]) throw new FormatException("nuspec identity/version mismatch: " + p[0]);
|
|
738
|
+
var groups = new System.Collections.Generic.Dictionary<object, System.Collections.Generic.HashSet<string>>();
|
|
739
|
+
dependencies.Add(p[0], groups);
|
|
740
|
+
var sections = metadata[0].SelectNodes("*[local-name()='dependencies']");
|
|
741
|
+
if (sections.Count > 1) throw new FormatException("ambiguous dependency sections: " + p[0]);
|
|
742
|
+
if (sections.Count == 1) {
|
|
743
|
+
var grouped = sections[0].SelectNodes("*[local-name()='group']");
|
|
744
|
+
if (grouped.Count == 0) groups.Add(framework("any"), new System.Collections.Generic.HashSet<string>(StringComparer.OrdinalIgnoreCase));
|
|
745
|
+
else {
|
|
746
|
+
if (sections[0].SelectNodes("*[local-name()='dependency']").Count != 0) throw new FormatException("mixed dependency groups: " + p[0]);
|
|
747
|
+
foreach (System.Xml.XmlElement group in grouped) {
|
|
748
|
+
var target = framework(group.GetAttribute("targetFramework"));
|
|
749
|
+
if (groups.ContainsKey(target)) throw new FormatException("duplicate dependency group: " + p[0]);
|
|
750
|
+
groups.Add(target, new System.Collections.Generic.HashSet<string>(StringComparer.OrdinalIgnoreCase));
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
foreach (System.Xml.XmlNode dep in document.SelectNodes("//*[local-name()='dependency']")) {
|
|
755
|
+
var id = dep.Attributes["id"]; var r = dep.Attributes["version"];
|
|
756
|
+
if (id == null || String.IsNullOrWhiteSpace(id.Value) || r == null || (dep.ParentNode.LocalName != "group" && dep.ParentNode.LocalName != "dependencies")) throw new FormatException("unreadable dependency: " + p[0]);
|
|
757
|
+
var section = dep.ParentNode.LocalName == "group" ? dep.ParentNode.ParentNode : dep.ParentNode;
|
|
758
|
+
if (sections.Count != 1 || section != sections[0]) throw new FormatException("misplaced dependency: " + p[0]);
|
|
759
|
+
range(r.Value);
|
|
760
|
+
if (owned.ContainsKey(id.Value)) {
|
|
761
|
+
satisfies(owned[id.Value], r.Value, p[0] + " -> " + id.Value);
|
|
762
|
+
var target = dep.ParentNode.LocalName == "group" ? framework(((System.Xml.XmlElement)dep.ParentNode).GetAttribute("targetFramework")) : framework("any");
|
|
763
|
+
groups[target].Add(id.Value);
|
|
764
|
+
}
|
|
765
|
+
else if (System.Text.RegularExpressions.Regex.IsMatch(r.Value, @"-(local\\.[0-9]{12}|train)\\.g[0-9a-f]{7}")) throw new FormatException("undeclared local dependency: " + id.Value);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
foreach (var row in System.IO.File.ReadAllLines(Graphs)) {
|
|
769
|
+
if (row.Length == 0) continue;
|
|
770
|
+
var p = row.Split('\t');
|
|
771
|
+
if (p[0] == "~") { if (!(bool)sat.Invoke(range(p[2]), new[] { version(p[1]) })) advisories.Append("\\ndependency range outside the composition: " + p[3] + " " + p[2] + " excludes " + p[1]); continue; }
|
|
772
|
+
if (p[0] != "@") { satisfies(p[0], p[1], p[2]); continue; }
|
|
773
|
+
var groups = dependencies[p[2]];
|
|
774
|
+
if (groups.Count == 0) continue;
|
|
775
|
+
var candidates = Array.CreateInstance(ft, groups.Count); var index = 0;
|
|
776
|
+
foreach (var candidate in groups.Keys) candidates.SetValue(candidate, index++);
|
|
777
|
+
var selected = nearest.Invoke(reducer, new object[] { framework(p[1].Split('/')[0]), candidates });
|
|
778
|
+
if (selected == null) continue;
|
|
779
|
+
var consumed = new System.Collections.Generic.HashSet<string>(p[3].Split(','), StringComparer.OrdinalIgnoreCase);
|
|
780
|
+
var edges = new System.Collections.Generic.HashSet<string>(p[4].Split(','), StringComparer.OrdinalIgnoreCase);
|
|
781
|
+
foreach (var required in groups[selected]) if (!consumed.Contains(required) || !edges.Contains(required)) throw new FormatException("required local dependency not consumed: " + p[2] + " -> " + required + " " + p[1]);
|
|
782
|
+
}
|
|
783
|
+
verdict = "0" + advisories.ToString();
|
|
784
|
+
} catch (Exception e) {
|
|
785
|
+
verdict = (e is MissingMethodException || e is System.IO.FileNotFoundException || e is TypeLoadException ? "2" : "1") + "\\n" + e.Message;
|
|
786
|
+
}
|
|
787
|
+
System.IO.File.WriteAllText(Result, verdict);
|
|
788
|
+
]]></Code></Task></UsingTask>
|
|
789
|
+
<Target Name="Validate"><CompositionValidation Sdk="$(MSBuildToolsPath)" Inputs="${msxml(inputs)}" Graphs="${msxml(checks)}" Result="${msxml(result)}"/></Target></Project>`);
|
|
790
|
+
const execution = cp.spawnSync('dotnet', ['msbuild', project, '-t:Validate', '-nologo', '-v:quiet', '-m:1', '-nodeReuse:false'], {encoding:'utf8', maxBuffer:8 * 1024 * 1024});
|
|
791
|
+
if (execution.error || execution.status !== 0 || !exists(result)) fail('native SDK validator unavailable: ' + (execution.error?.message || execution.stdout || execution.stderr));
|
|
792
|
+
const [code, ...message] = fs.readFileSync(result,'utf8').split('\n');
|
|
793
|
+
if (code !== '0') fail(message.join('\n'), Number(code));
|
|
794
|
+
return message.filter(Boolean);
|
|
795
|
+
}
|
|
796
|
+
function assets(packages) {
|
|
797
|
+
const owned = new Map(packages.map(p => [lower(p.id), p]));
|
|
798
|
+
const files = [], resolved = new Map(), checks = [];
|
|
799
|
+
function walk(dir) {
|
|
800
|
+
for (const entry of fs.readdirSync(dir, {withFileTypes:true})) {
|
|
801
|
+
if (entry.isSymbolicLink() || ['.git','node_modules'].includes(entry.name)) continue;
|
|
802
|
+
const file = path.join(dir,entry.name);
|
|
803
|
+
if (entry.isDirectory()) walk(file);
|
|
804
|
+
else if (entry.name === 'project.assets.json' && path.basename(dir) === 'obj') files.push(file);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
walk(repo);
|
|
808
|
+
if (!files.length) fail('no obj/project.assets.json (restore the consumer first)');
|
|
809
|
+
const object = value => value && typeof value === 'object' && !Array.isArray(value);
|
|
810
|
+
for (const file of files) {
|
|
811
|
+
let data;
|
|
812
|
+
try { data = JSON.parse(fs.readFileSync(file,'utf8')); } catch { fail('malformed assets: ' + file,1); }
|
|
813
|
+
if (!object(data.libraries) || !object(data.targets) || !Object.keys(data.targets).length || data.logs?.some(log => log.level === 'Error')) fail('invalid or failed restore assets: ' + file,1);
|
|
814
|
+
const libraries = new Map();
|
|
815
|
+
function keyParts(key, value) {
|
|
816
|
+
const parts = key.split('/');
|
|
817
|
+
if (parts.length !== 2 || !idPattern.test(parts[0]) || !parts[1] || !object(value) || !['package','project'].includes(value.type)) fail('malformed asset entry: ' + key + ' ' + file,1);
|
|
818
|
+
return parts;
|
|
819
|
+
}
|
|
820
|
+
for (const [key, lib] of Object.entries(data.libraries)) {
|
|
821
|
+
const [id, version] = keyParts(key,lib);
|
|
822
|
+
if (lib.type !== 'package') continue;
|
|
823
|
+
// Multitarget restores can hold several versions of one ID, one per framework.
|
|
824
|
+
if (libraries.has(lower(id) + '/' + version)) fail('duplicate assets library: ' + key,1);
|
|
825
|
+
libraries.set(lower(id) + '/' + version, {id,version});
|
|
826
|
+
const pkg = owned.get(lower(id));
|
|
827
|
+
if (pkg && version !== pkg.version || !pkg && local.test(version)) fail('wrong or undeclared local library: ' + key + ' ' + file,1);
|
|
828
|
+
}
|
|
829
|
+
const used = new Set();
|
|
830
|
+
for (const [framework, target] of Object.entries(data.targets)) {
|
|
831
|
+
if (!object(target) || /[\t\r\n]/.test(framework)) fail('malformed assets target: ' + file,1);
|
|
832
|
+
const nodes = new Map();
|
|
833
|
+
for (const [key,node] of Object.entries(target)) {
|
|
834
|
+
const [id,version] = keyParts(key,node);
|
|
835
|
+
if (nodes.has(lower(id))) fail('duplicate target ID: ' + id,1);
|
|
836
|
+
nodes.set(lower(id), {id,version,node});
|
|
837
|
+
if (node.type !== 'package') continue;
|
|
838
|
+
if (!libraries.has(lower(id) + '/' + version)) fail('target/library mismatch: ' + key + ' ' + file,1);
|
|
839
|
+
used.add(lower(id) + '/' + version);
|
|
840
|
+
checks.push([version,`[${version}]`,`${file} ${framework} ${id}`].join('\t'));
|
|
841
|
+
if (owned.has(lower(id))) resolved.set(lower(id),owned.get(lower(id)));
|
|
842
|
+
}
|
|
843
|
+
const packageIds = [...nodes.values()].filter(n => n.node.type === 'package').map(n => n.id).join(',');
|
|
844
|
+
for (const {id,node} of nodes.values()) {
|
|
845
|
+
if (owned.has(lower(id)) && node.type === 'package') checks.push(['@',framework,id,packageIds,Object.keys(node.dependencies || {}).join(',')].join('\t'));
|
|
846
|
+
if (node.dependencies !== undefined && !object(node.dependencies)) fail('malformed target dependencies: ' + id,1);
|
|
847
|
+
for (const [dep,range] of Object.entries(node.dependencies || {})) {
|
|
848
|
+
if (typeof range !== 'string' || /[\t\r\n]/.test(range)) fail('malformed asset range: ' + dep,1);
|
|
849
|
+
const selected = nodes.get(lower(dep));
|
|
850
|
+
if (!selected) fail('unresolved target dependency: ' + id + ' -> ' + dep + ' ' + file,1);
|
|
851
|
+
// An edge between two IDs outside the composition is NuGet's own NU1608 case and exists
|
|
852
|
+
// without the overlay too; report it as a warning instead of refusing the composition.
|
|
853
|
+
if (selected.node.type === 'package' && (owned.has(lower(id)) || owned.has(lower(dep)))) checks.push([selected.version,range,`${file} ${framework} ${id} -> ${dep}`].join('\t'));
|
|
854
|
+
else if (selected.node.type === 'package') checks.push(['~',selected.version,range,`${file} ${framework} ${id} -> ${dep}`].join('\t'));
|
|
855
|
+
if (owned.has(lower(dep)) && selected.node.type !== 'package') fail('owned dependency resolved as project: ' + dep,1);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
for (const [key, {id}] of libraries) if (owned.has(lower(id)) && !used.has(key)) fail('owned library absent from targets: ' + id + ' ' + file,1);
|
|
860
|
+
}
|
|
861
|
+
if (!resolved.size) fail('verify-assets: no composition package consumed',1);
|
|
862
|
+
return {checks, packages:[...resolved.values()], files:files.length};
|
|
863
|
+
}
|
|
864
|
+
// Journal contains only the transaction's exact expected bytes. Recovery refuses
|
|
865
|
+
// drift, unknown files and symlinks rather than deleting another task's work.
|
|
866
|
+
function directoryBytes(dir) {
|
|
867
|
+
plain(dir,true); const result = {};
|
|
868
|
+
for (const name of fs.readdirSync(dir)) { plain(path.join(dir,name)); result[name] = bytes(path.join(dir,name)); }
|
|
869
|
+
return result;
|
|
870
|
+
}
|
|
871
|
+
function same(a,b) { return JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort()); }
|
|
872
|
+
function removeOwned(dir, expected, partial = false) {
|
|
873
|
+
if (!exists(dir)) return;
|
|
874
|
+
const actual = directoryBytes(dir);
|
|
875
|
+
if (partial ? Object.keys(actual).some(n => actual[n] !== expected[n]) : !same(actual,expected)) fail('transaction files changed; preserved: ' + dir);
|
|
876
|
+
for (const name of Object.keys(actual)) fs.unlinkSync(path.join(dir,name));
|
|
877
|
+
fs.rmdirSync(dir);
|
|
878
|
+
}
|
|
879
|
+
function journal(value) {
|
|
880
|
+
const file = path.join(lock,'journal.json');
|
|
881
|
+
fs.writeFileSync(file + '.next', JSON.stringify(value), {mode:0o600});
|
|
882
|
+
fs.renameSync(file + '.next', file);
|
|
883
|
+
}
|
|
884
|
+
function recoverState(j) {
|
|
885
|
+
if (j.schema !== 1 || !path.isAbsolute(j.repo) || !path.isAbsolute(j.session) || j.session === '/' || j.session === j.repo || !/^[a-f0-9]{24}$/.test(j.token) || !j.files || Object.keys(j.files).some(n => !['manifest.tsv','Directory.Packages.props','cratis-local.targets','nuget.config','env','transaction','state.json'].includes(n))) fail('unknown transaction journal');
|
|
886
|
+
safeParents(j.session,j.session);
|
|
887
|
+
const pub = path.join(j.session,'composition'), stage = path.join(j.session, '.composition-' + j.token);
|
|
888
|
+
if (j.operation === 'activate') {
|
|
889
|
+
if (!j.committed) {
|
|
890
|
+
const current = bytes(registry);
|
|
891
|
+
if (current !== j.registryBefore && current !== j.registryAfter) fail('registry changed; transaction preserved');
|
|
892
|
+
if (exists(pub)) removeOwned(pub,j.files);
|
|
893
|
+
if (current === j.registryAfter && current !== j.registryBefore) {
|
|
894
|
+
if (j.registryBefore === null) fs.unlinkSync(registry);
|
|
895
|
+
else { fs.writeFileSync(path.join(lock,'registry.rollback'),Buffer.from(j.registryBefore,'base64')); fs.renameSync(path.join(lock,'registry.rollback'),registry); }
|
|
896
|
+
}
|
|
897
|
+
const marker = path.join(j.session,'repo');
|
|
898
|
+
if (!j.hadRepo && exists(marker)) {
|
|
899
|
+
if (bytes(marker) !== Buffer.from(j.repo + '\n').toString('base64')) fail('session marker changed; preserved');
|
|
900
|
+
fs.unlinkSync(marker);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
removeOwned(stage,j.files,true);
|
|
904
|
+
const pending = path.join(lock,'registry.next');
|
|
905
|
+
if (exists(pending)) {
|
|
906
|
+
if (bytes(pending) !== j.registryAfter) fail('staged registry changed; preserved');
|
|
907
|
+
fs.unlinkSync(pending);
|
|
908
|
+
}
|
|
909
|
+
if (!j.committed && !j.hadSession && exists(j.session) && fs.readdirSync(j.session).length === 0) fs.rmdirSync(j.session);
|
|
910
|
+
} else if (j.operation === 'restore') {
|
|
911
|
+
if (!j.committed && exists(stage)) {
|
|
912
|
+
if (exists(pub) || !same(directoryBytes(stage),j.files)) fail('restoration state changed; preserved');
|
|
913
|
+
fs.renameSync(stage,pub);
|
|
914
|
+
} else if (j.committed) removeOwned(stage,j.files);
|
|
915
|
+
} else fail('unknown transaction operation');
|
|
916
|
+
fs.unlinkSync(path.join(lock,'journal.json'));
|
|
917
|
+
}
|
|
918
|
+
function recover() {
|
|
919
|
+
safeParents(lock); plain(lock,true); plain(path.join(lock,'owner'));
|
|
920
|
+
const owner = fs.readFileSync(path.join(lock,'owner'),'utf8').trim();
|
|
921
|
+
if (!/^[1-9][0-9]*$/.test(owner)) fail('unknown lock owner; preserved');
|
|
922
|
+
try { process.kill(Number(owner),0); fail('local package session is locked by ' + owner); }
|
|
923
|
+
catch (error) { if (error.code !== 'ESRCH') throw error; }
|
|
924
|
+
const allowed = new Set(['owner','journal.json','journal.json.next','registry.next','registry.rollback']);
|
|
925
|
+
if (fs.readdirSync(lock).some(n => !allowed.has(n))) fail('unknown lock state; preserved');
|
|
926
|
+
for (const n of fs.readdirSync(lock)) plain(path.join(lock,n));
|
|
927
|
+
if (exists(path.join(lock,'journal.json'))) recoverState(JSON.parse(fs.readFileSync(path.join(lock,'journal.json'),'utf8')));
|
|
928
|
+
else if (fs.readdirSync(lock).some(n => n !== 'owner')) fail('incomplete transaction journal; preserved');
|
|
929
|
+
for (const n of fs.readdirSync(lock)) fs.unlinkSync(path.join(lock,n));
|
|
930
|
+
fs.rmdirSync(lock);
|
|
931
|
+
}
|
|
932
|
+
// Fixture-only fault injection requires the isolated self-test capability file.
|
|
933
|
+
function boundary(name) {
|
|
934
|
+
const capability = process.env.CRATIS_LOCAL_COMPOSITION_TEST_ROOT;
|
|
935
|
+
if (!capability || !feed.startsWith(capability + path.sep) || !fs.existsSync(path.join(capability,'.composition-fixture'))) return;
|
|
936
|
+
if (process.env.CRATIS_LOCAL_COMPOSITION_FAIL === name) fail('injected failure: ' + name,1);
|
|
937
|
+
if (process.env.CRATIS_LOCAL_COMPOSITION_INTERRUPT === name) { process.kill(process.ppid,'SIGKILL'); process.kill(process.pid,'SIGKILL'); }
|
|
938
|
+
}
|
|
939
|
+
function activeFiles() {
|
|
940
|
+
const pub = path.join(session,'composition');
|
|
941
|
+
if (!exists(pub)) return null;
|
|
942
|
+
const files = directoryBytes(pub);
|
|
943
|
+
const allowed = new Set(['manifest.tsv','Directory.Packages.props','cratis-local.targets','nuget.config','env','transaction','state.json']);
|
|
944
|
+
if (Object.keys(files).some(n => !allowed.has(n)) || !files['state.json'] || !files.transaction || !/^[a-f0-9]{24}\n$/.test(Buffer.from(files.transaction,'base64').toString())) fail('unsafe composition session');
|
|
945
|
+
const expected = JSON.parse(Buffer.from(files['state.json'],'base64').toString());
|
|
946
|
+
const actual = {...files}; delete actual.transaction; delete actual['state.json'];
|
|
947
|
+
if (!same(actual,expected)) fail('composition files changed; preserved');
|
|
948
|
+
return files;
|
|
949
|
+
}
|
|
950
|
+
function activate(packages,pin) {
|
|
951
|
+
safeParents(session,session); safeParents(feed);
|
|
952
|
+
const props = path.join(repo,'Directory.Packages.props'); plain(props);
|
|
953
|
+
if (exists(session)) {
|
|
954
|
+
plain(session,true);
|
|
955
|
+
const allowed = new Set(['repo','composition']);
|
|
956
|
+
if (fs.readdirSync(session).some(n => !allowed.has(n))) fail('conflicting active overlay/session; restore first');
|
|
957
|
+
if (exists(path.join(session,'repo')) && fs.readFileSync(path.join(session,'repo'),'utf8') !== repo + '\n') fail('session belongs to another checkout');
|
|
958
|
+
}
|
|
959
|
+
const pub = path.join(session,'composition');
|
|
960
|
+
const template = name => fs.readFileSync(path.join(scratch,'overlay-' + name),'utf8');
|
|
961
|
+
const escaped = packages.map(p => `<PackageVersion Remove="${xml(p.id)}"/><PackageVersion Include="${xml(p.id)}" Version="${p.version}"/>`).join('\n');
|
|
962
|
+
const references = packages.map(p => `<PackageReference Update="${xml(p.id)}" Version="${p.version}"/>`).join('\n');
|
|
963
|
+
const files = {
|
|
964
|
+
'manifest.tsv':normalize(packages),
|
|
965
|
+
'Directory.Packages.props':template(pin ? 'props-pin' : 'props') + escaped + '\n' + template('end'),
|
|
966
|
+
'cratis-local.targets':template('targets') + references + '\n' + template('end'),
|
|
967
|
+
env:template('env')
|
|
968
|
+
};
|
|
969
|
+
// Native XML manipulation preserves namespaces/configuration and makes exact
|
|
970
|
+
// local mappings exclusive; remote wildcards remain less specific.
|
|
971
|
+
const configs = run('git',['-C',repo,'ls-files','-z']).split('\0').filter(n => ['nuget.config','NuGet.Config','NuGet.config'].includes(n));
|
|
972
|
+
if (configs.length > 1) fail('ambiguous root NuGet config');
|
|
973
|
+
if (configs.length) files['nuget.config'] = configOverlay(path.join(repo,configs[0]),packages);
|
|
974
|
+
else files['nuget.config'] = configOverlay(null,packages);
|
|
975
|
+
files['state.json'] = JSON.stringify(Object.fromEntries(Object.entries(files).map(([k,v]) => [k,Buffer.from(v).toString('base64')]))) + '\n';
|
|
976
|
+
const previous = activeFiles();
|
|
977
|
+
if (previous) {
|
|
978
|
+
const expected = Object.fromEntries(Object.entries(files).map(([k,v]) => [k,Buffer.from(v).toString('base64')]));
|
|
979
|
+
const withoutToken = {...previous}; delete withoutToken.transaction;
|
|
980
|
+
if (!same(expected,withoutToken) || !fs.readFileSync(registry,'utf8').split('\n').includes(session)) fail('different or unregistered active composition; restore first');
|
|
981
|
+
instructions(packages); return;
|
|
982
|
+
}
|
|
983
|
+
if (dry === '1') { console.log('dry-run: validated ' + packages.length + ' composition packages; no overlay written'); return; }
|
|
984
|
+
const token = crypto.randomBytes(12).toString('hex'); files.transaction = token + '\n';
|
|
985
|
+
const expected = Object.fromEntries(Object.entries(files).map(([k,v]) => [k,Buffer.from(v).toString('base64')]));
|
|
986
|
+
const before = bytes(registry);
|
|
987
|
+
const oldRegistry = before === null ? '' : Buffer.from(before,'base64').toString();
|
|
988
|
+
if (oldRegistry && !oldRegistry.endsWith('\n')) fail('malformed session registry');
|
|
989
|
+
const after = oldRegistry.split('\n').includes(session) ? oldRegistry : oldRegistry + session + '\n';
|
|
990
|
+
const j = {schema:1,operation:'activate',session,repo,token,files:expected,hadSession:exists(session),hadRepo:exists(path.join(session,'repo')),registryBefore:before,registryAfter:Buffer.from(after).toString('base64'),committed:false};
|
|
991
|
+
journal(j);
|
|
992
|
+
try {
|
|
993
|
+
boundary('journal');
|
|
994
|
+
if (!j.hadSession) { fs.mkdirSync(path.dirname(session), {recursive:true}); fs.mkdirSync(session); }
|
|
995
|
+
boundary('session');
|
|
996
|
+
const stage = path.join(session,'.composition-' + token); fs.mkdirSync(stage);
|
|
997
|
+
// A partial stage is recorded before each file write for exact recovery.
|
|
998
|
+
for (const [name,value] of Object.entries(files)) { write(path.join(stage,name),value); boundary('stage-' + name); }
|
|
999
|
+
boundary('staged');
|
|
1000
|
+
if (bytes(registry) !== before) fail('registry changed before publication');
|
|
1001
|
+
fs.renameSync(stage,pub); boundary('published');
|
|
1002
|
+
if (!j.hadRepo) write(path.join(session,'repo'),repo + '\n');
|
|
1003
|
+
boundary('repo');
|
|
1004
|
+
write(path.join(lock,'registry.next'),after); boundary('registration-staged');
|
|
1005
|
+
if (bytes(registry) !== before) fail('registry changed before registration');
|
|
1006
|
+
fs.renameSync(path.join(lock,'registry.next'),registry); boundary('registered');
|
|
1007
|
+
j.committed = true; journal(j); boundary('committed');
|
|
1008
|
+
recoverState(j);
|
|
1009
|
+
} catch (error) {
|
|
1010
|
+
if (!j.committed) recoverState(j);
|
|
1011
|
+
throw error;
|
|
1012
|
+
}
|
|
1013
|
+
instructions(packages);
|
|
1014
|
+
}
|
|
1015
|
+
function shell(s) { return "'" + s.replaceAll("'", "'\\''") + "'"; }
|
|
1016
|
+
function instructions(packages) {
|
|
1017
|
+
const pub = path.join(session,'composition');
|
|
1018
|
+
console.log('source ' + shell(path.join(pub,'env')));
|
|
1019
|
+
console.log('For restore, also pass: --configfile ' + shell(path.join(pub,'nuget.config')));
|
|
1020
|
+
console.log('overrode ' + packages.length + ' NuGet package IDs in composition');
|
|
1021
|
+
}
|
|
1022
|
+
// XML config overlay is implemented by a second SDK task in the same temporary
|
|
1023
|
+
// project, using XmlDocument rather than a regex/token-based XML rewrite.
|
|
1024
|
+
function configOverlay(config,packages) {
|
|
1025
|
+
if (config) plain(config);
|
|
1026
|
+
const output = path.join(scratch,'config.xml'), project = path.join(scratch,'config.proj');
|
|
1027
|
+
const ids = path.join(scratch,'config-ids'); fs.writeFileSync(ids,packages.map(p => p.id).join('\n'));
|
|
1028
|
+
fs.writeFileSync(project, `<Project><UsingTask TaskName="CompositionConfig" TaskFactory="RoslynCodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)/Microsoft.Build.Tasks.Core.dll"><ParameterGroup><Input ParameterType="System.String"/><Output ParameterType="System.String"/><Feed ParameterType="System.String"/><Ids ParameterType="System.String"/></ParameterGroup><Task><Code Type="Fragment" Language="cs"><![CDATA[
|
|
1029
|
+
var doc = new System.Xml.XmlDocument(); doc.XmlResolver = null;
|
|
1030
|
+
if (Input.Length == 0) doc.LoadXml("<configuration><packageSources><clear/><add key='nuget.org' value='https://api.nuget.org/v3/index.json'/></packageSources></configuration>");
|
|
1031
|
+
else { var settings = new System.Xml.XmlReaderSettings(); settings.DtdProcessing = System.Xml.DtdProcessing.Prohibit; settings.XmlResolver = null; using (var reader = System.Xml.XmlReader.Create(Input, settings)) doc.Load(reader); }
|
|
1032
|
+
var root = doc.DocumentElement;
|
|
1033
|
+
if (root.Name != "configuration") throw new FormatException("invalid NuGet config");
|
|
1034
|
+
var sources = root.SelectNodes("packageSources");
|
|
1035
|
+
if (sources.Count != 1 || root.SelectNodes("packageSourceMapping").Count > 1) throw new FormatException("ambiguous NuGet sources/mapping");
|
|
1036
|
+
foreach (System.Xml.XmlElement source in sources[0].SelectNodes("add")) {
|
|
1037
|
+
if (String.Equals(source.GetAttribute("key"),"cratis-local",StringComparison.OrdinalIgnoreCase)) throw new FormatException("source key cratis-local already exists");
|
|
1038
|
+
var value = source.GetAttribute("value"); Uri uri;
|
|
1039
|
+
if (Input.Length > 0 && !System.IO.Path.IsPathRooted(value) && !Uri.TryCreate(value, UriKind.Absolute, out uri) && !value.StartsWith("%")) source.SetAttribute("value",System.IO.Path.GetFullPath(System.IO.Path.Combine(System.IO.Path.GetDirectoryName(Input),value)));
|
|
1040
|
+
}
|
|
1041
|
+
var localSource = doc.CreateElement("add"); localSource.SetAttribute("key","cratis-local"); localSource.SetAttribute("value",Feed); sources[0].AppendChild(localSource);
|
|
1042
|
+
var mapping = root.SelectSingleNode("packageSourceMapping");
|
|
1043
|
+
if (mapping != null) foreach (System.Xml.XmlElement source in mapping.SelectNodes("packageSource")) if (String.Equals(source.GetAttribute("key"),"cratis-local",StringComparison.OrdinalIgnoreCase)) throw new FormatException("mapping key cratis-local already exists");
|
|
1044
|
+
foreach (System.Xml.XmlElement source in root.SelectNodes("disabledPackageSources/add")) if (String.Equals(source.GetAttribute("key"),"cratis-local",StringComparison.OrdinalIgnoreCase)) throw new FormatException("source key cratis-local is disabled");
|
|
1045
|
+
if (mapping == null) {
|
|
1046
|
+
mapping = doc.CreateElement("packageSourceMapping"); root.AppendChild(mapping);
|
|
1047
|
+
foreach (System.Xml.XmlElement source in sources[0].SelectNodes("add")) {
|
|
1048
|
+
if (source.GetAttribute("key") == "cratis-local") continue;
|
|
1049
|
+
var remote = doc.CreateElement("packageSource"); remote.SetAttribute("key",source.GetAttribute("key"));
|
|
1050
|
+
var wildcard = doc.CreateElement("package"); wildcard.SetAttribute("pattern","*"); remote.AppendChild(wildcard); mapping.AppendChild(remote);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
var localMapping = doc.CreateElement("packageSource"); localMapping.SetAttribute("key","cratis-local");
|
|
1054
|
+
foreach (var id in System.IO.File.ReadAllLines(Ids)) {
|
|
1055
|
+
foreach (System.Xml.XmlElement entry in mapping.SelectNodes("packageSource/package")) if (String.Equals(entry.GetAttribute("pattern"),id,StringComparison.OrdinalIgnoreCase)) entry.ParentNode.RemoveChild(entry);
|
|
1056
|
+
var entryLocal = doc.CreateElement("package"); entryLocal.SetAttribute("pattern",id); localMapping.AppendChild(entryLocal);
|
|
1057
|
+
}
|
|
1058
|
+
mapping.AppendChild(localMapping); doc.Save(Output);
|
|
1059
|
+
]]></Code></Task></UsingTask><Target Name="Overlay"><CompositionConfig Input="${msxml(config || '')}" Output="${msxml(output)}" Feed="${msxml(path.join(feed,'nuget'))}" Ids="${msxml(ids)}"/></Target></Project>`);
|
|
1060
|
+
run('dotnet',['msbuild',project,'-t:Overlay','-nologo','-v:quiet','-m:1','-nodeReuse:false']);
|
|
1061
|
+
return fs.readFileSync(output,'utf8');
|
|
1062
|
+
}
|
|
1063
|
+
function restoreState() {
|
|
1064
|
+
const files = activeFiles(); if (!files) return;
|
|
1065
|
+
if (dry === '1') { console.log('dry-run: remove composition overlay'); return; }
|
|
1066
|
+
const token = crypto.randomBytes(12).toString('hex');
|
|
1067
|
+
const j = {schema:1,operation:'restore',session,repo,token,files,committed:false};
|
|
1068
|
+
journal(j);
|
|
1069
|
+
try {
|
|
1070
|
+
boundary('restore-journal');
|
|
1071
|
+
fs.renameSync(path.join(session,'composition'),path.join(session,'.composition-' + token)); boundary('restore-renamed');
|
|
1072
|
+
j.committed = true; journal(j); boundary('restore-committed'); recoverState(j);
|
|
1073
|
+
} catch (error) { if (!j.committed) recoverState(j); throw error; }
|
|
1074
|
+
}
|
|
1075
|
+
function cleanGuard() {
|
|
1076
|
+
const knownSessions = new Set(argv[1] ? [argv[1]] : []);
|
|
1077
|
+
if (exists(registry)) { plain(registry); for (const known of fs.readFileSync(registry,'utf8').split('\n').filter(Boolean)) knownSessions.add(known); }
|
|
1078
|
+
const selected = argv[0] || '';
|
|
1079
|
+
for (const known of knownSessions) {
|
|
1080
|
+
if (!path.isAbsolute(known)) fail('invalid registered session');
|
|
1081
|
+
safeParents(known,known);
|
|
1082
|
+
const composition = path.join(known,'composition');
|
|
1083
|
+
if (!exists(composition)) continue;
|
|
1084
|
+
plain(composition,true); plain(path.join(composition,'manifest.tsv'));
|
|
1085
|
+
const versions = fs.readFileSync(path.join(composition,'manifest.tsv'),'utf8').trim().split('\n').map(row => row.split('\t')[2]);
|
|
1086
|
+
if (!selected || selected === 'all' || versions.includes(selected)) fail('active composition retains member artifacts; restore first: ' + known);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
try {
|
|
1090
|
+
safeParents(feed);
|
|
1091
|
+
if (mode === 'recover') recover();
|
|
1092
|
+
else if (mode === 'restore-state') restoreState();
|
|
1093
|
+
else if (mode === 'clean-guard') cleanGuard();
|
|
1094
|
+
else {
|
|
1095
|
+
const args = [...argv]; if (mode !== 'verify-pack') args.shift();
|
|
1096
|
+
if (args.shift() !== '--composition' || !args.length) fail('expected --composition FILE');
|
|
1097
|
+
const file = args.shift(); let json = false, pin = false;
|
|
1098
|
+
for (const arg of args) {
|
|
1099
|
+
if (arg === '--json' && mode !== 'use-nuget' && !json) json = true;
|
|
1100
|
+
else if (arg === '--pin-transitive' && mode === 'use-nuget' && !pin) pin = true;
|
|
1101
|
+
else fail('cannot mix composition with stamp, IDs or other options: ' + arg);
|
|
1102
|
+
}
|
|
1103
|
+
const packages = manifest(file);
|
|
1104
|
+
const consumed = mode === 'verify-assets' ? assets(packages) : null;
|
|
1105
|
+
const warnings = native(packages,consumed?.checks);
|
|
1106
|
+
for (const warning of warnings) console.error('WARNING: ' + warning);
|
|
1107
|
+
if (mode === 'use-nuget') activate(packages,pin);
|
|
1108
|
+
else if (json) console.log(JSON.stringify({packages:(consumed?.packages || packages).map(({id,version,producer}) => ({id,version,producer})), warnings}));
|
|
1109
|
+
else console.log(mode + ': ' + (consumed?.packages.length || packages.length) + ' composition packages' + (consumed ? ', ' + consumed.files + ' assets files' : '') + ', native NuGet validation passed');
|
|
1110
|
+
}
|
|
1111
|
+
} catch (error) { console.error('ERROR: ' + error.message); process.exitCode = Number.isInteger(error.code) ? error.code : 2; }
|
|
1112
|
+
COMPOSITION
|
|
472
1113
|
}
|
|
473
1114
|
|
|
474
1115
|
# Dependency map: tab-separated rows, '#' comments (see references/dependency-map.md).
|
|
@@ -817,6 +1458,7 @@ report_findings() {
|
|
|
817
1458
|
# Plan mode checks exactly the plan the closure printed. Name mode (manual, weaker) builds the plan rows from
|
|
818
1459
|
# the named repositories' ids rows instead of from edges.
|
|
819
1460
|
verify_pack() {
|
|
1461
|
+
case " $* " in *' --composition '*) composition verify-pack "$@"; return ;; esac
|
|
820
1462
|
[ $# -ge 2 ] || die 'verify-pack requires STAMP and either --consumer C --producer P... or repository NAMEs'
|
|
821
1463
|
local stamp=$1 json=0 out=1 ecosystem='' consumer='' name eco d found
|
|
822
1464
|
local producers=() names=()
|
|
@@ -871,6 +1513,7 @@ verify_pack() {
|
|
|
871
1513
|
# Plan mode scans for exactly the planned IDs of one ecosystem; feed-wide mode (no --consumer) scans every
|
|
872
1514
|
# package at the stamp in the feed, which is manual and weaker.
|
|
873
1515
|
verify_assets() {
|
|
1516
|
+
case " $* " in *' --composition '*) composition verify-assets "$@"; return ;; esac
|
|
874
1517
|
[ $# -ge 2 ] || die 'verify-assets requires CONSUMER-PATH, STAMP, [--npm] [--dir REL]'
|
|
875
1518
|
local path=$1 stamp=$2 npm=0 dir='' json=0 out=1 consumer='' eco=nuget d name file matched nm unit
|
|
876
1519
|
local producers=()
|
|
@@ -950,6 +1593,7 @@ use_npm() {
|
|
|
950
1593
|
dir=$repo${rel:+/$rel}
|
|
951
1594
|
valid_stamp "$version"; need node; need tar; scratch
|
|
952
1595
|
session=$(session_path "$repo"); backup=$session/npm-backup
|
|
1596
|
+
[ ! -d "$session/composition" ] || die 'active composition; restore first'
|
|
953
1597
|
[ -f "$dir/package.json" ] || die "consumer has no package.json in ${rel:-the root}; use --dir REL for a Yarn project in a subdirectory"
|
|
954
1598
|
[ ! -e "$backup" ] || die "active npm backup exists: $backup; restore first"
|
|
955
1599
|
node - "$dir" "$FEED/npm" "$version" "$TMP/package.json" <<'NODE'
|
|
@@ -994,6 +1638,11 @@ restore() {
|
|
|
994
1638
|
local repo session backup dir
|
|
995
1639
|
repo=$(repo_root "$1"); session=$(session_path "$repo"); backup=$session/npm-backup
|
|
996
1640
|
validate_session "$session"
|
|
1641
|
+
if [ -d "$session/composition" ]; then
|
|
1642
|
+
composition restore-state "$repo"
|
|
1643
|
+
if [ "$DRY" -eq 0 ]; then check "$repo"; fi
|
|
1644
|
+
return
|
|
1645
|
+
fi
|
|
997
1646
|
dir=$repo
|
|
998
1647
|
if [ -f "$session/npm-dir" ]; then dir=$repo/$(cat "$session/npm-dir"); fi
|
|
999
1648
|
if [ -f "$session/repo" ] && [ "$(cat "$session/repo")" != "$repo" ]; then die 'session belongs to another checkout'; fi
|
|
@@ -1009,7 +1658,7 @@ restore() {
|
|
|
1009
1658
|
if [ -f "$backup/yarn.lock" ]; then mutate cp -p "$backup/yarn.lock" "$dir/yarn.lock"; else mutate rm -f -- "$dir/yarn.lock"; fi
|
|
1010
1659
|
mutate rm -rf -- "$backup"
|
|
1011
1660
|
fi
|
|
1012
|
-
mutate rm -f -- "$session/Directory.Packages.props" "$session/nuget.config" "$session/env" "$session/stamp" "$session/npm-dir"
|
|
1661
|
+
mutate rm -f -- "$session/Directory.Packages.props" "$session/cratis-local.targets" "$session/nuget.config" "$session/env" "$session/stamp" "$session/npm-dir"
|
|
1013
1662
|
if [ "$DRY" -eq 0 ]; then check "$repo"; fi
|
|
1014
1663
|
}
|
|
1015
1664
|
|
|
@@ -1051,15 +1700,17 @@ check() {
|
|
|
1051
1700
|
}
|
|
1052
1701
|
scan_lines() {
|
|
1053
1702
|
local path=$1 input=$2
|
|
1054
|
-
awk -v path="$path" -v feed="$FEED" '
|
|
1703
|
+
awk -v path="$path" -v feed="$FEED" -v alias="$FEED_ALIAS" '
|
|
1055
1704
|
function hit(pattern) { print path ":" number ":" pattern }
|
|
1056
1705
|
{
|
|
1057
1706
|
number=$0; sub(/:.*/,"",number); line=$0; sub(/^[^:]*:/,"",line)
|
|
1058
1707
|
if (line ~ /-local\.[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]\.g[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]/) hit("local stamp")
|
|
1059
1708
|
if (line ~ /-train\.g[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]/) hit("train stamp")
|
|
1060
|
-
if (index(line,feed)) hit("local feed path")
|
|
1709
|
+
if (index(line,feed) || (alias != feed && index(line,alias))) hit("local feed path")
|
|
1061
1710
|
if (index(line,"DirectoryPackages" "PropsPath")) hit("CPM overlay property")
|
|
1062
1711
|
if (index(line,"RestoreAdditional" "ProjectSources")) hit("additional restore sources")
|
|
1712
|
+
if (index(line,"CustomAfterMicrosoftCommon" "Targets")) hit("local targets property")
|
|
1713
|
+
if (index(line,"_CratisLocalPreviousAfterMicrosoftCommon" "Targets")) hit("previous targets property")
|
|
1063
1714
|
if (line ~ /<PackageVersion[[:space:]]+Update[[:space:]]*=/) hit("package version override")
|
|
1064
1715
|
if (path ~ /(^|\/)(package.json|yarn.lock)$/ && line ~ /(file|portal|link):/ &&
|
|
1065
1716
|
(line ~ /\.tgz/ || line ~ /(file|portal|link):(\/|\.\.?\/|~\/|[A-Za-z]:)/)) hit("local npm reference")
|
|
@@ -1087,6 +1738,41 @@ bounded_docker() {
|
|
|
1087
1738
|
local_artifact() {
|
|
1088
1739
|
[[ "$1" =~ -(local\.[0-9]{12}|train)\.g[0-9a-f]{7}($|[^0-9A-Za-z]) ]]
|
|
1089
1740
|
}
|
|
1741
|
+
# Legacy registrations name consumers, not an artifact ownership graph. Fail closed
|
|
1742
|
+
# for the whole requested cleanup if any selected stamp is still live. Also keep a
|
|
1743
|
+
# selected session when its overlays/manifests reference another stamp.
|
|
1744
|
+
clean_live_references() {
|
|
1745
|
+
local selected=$1 session repo file found stamp status blocked=0 session_selected
|
|
1746
|
+
while IFS= read -r session; do
|
|
1747
|
+
[ -d "$session" ] && [ -f "$session/repo" ] || continue
|
|
1748
|
+
validate_session "$session"
|
|
1749
|
+
repo=$(cat "$session/repo")
|
|
1750
|
+
[ -d "$repo" ] || continue
|
|
1751
|
+
[ "$(repo_root "$repo")" = "$repo" ] || die "registered consumer no longer matches repository: $repo"
|
|
1752
|
+
session_selected=0
|
|
1753
|
+
if [ -f "$session/stamp" ] && [ "$(cat "$session/stamp")" = "$selected" ]; then session_selected=1; fi
|
|
1754
|
+
# Follow manifest symlinks too; traversal/read failures refuse cleanup.
|
|
1755
|
+
find -L "$repo" \( -name .git -o -name node_modules \) -prune -o \( -iname nuget.config -o -iname Directory.Packages.props -o -name packages.lock.json -o -name package.json -o -name yarn.lock \) \( -type f -o -type l \) -print0 > "$TMP/live-files" || die "cannot inspect registered consumer: $repo"
|
|
1756
|
+
for file in "$session/Directory.Packages.props" "$session/cratis-local.targets" "$session/nuget.config"; do
|
|
1757
|
+
if [ -e "$file" ]; then printf '%s\0' "$file" >> "$TMP/live-files"; fi
|
|
1758
|
+
done
|
|
1759
|
+
while IFS= read -r -d '' file; do
|
|
1760
|
+
[ -f "$file" ] && [ -r "$file" ] || die "cannot read live manifest: $file"
|
|
1761
|
+
status=0
|
|
1762
|
+
found=$(grep -Eio '[0-9]+\.[0-9]+\.[0-9]+-(local\.[0-9]{12}|train)\.g[0-9a-f]{7}' "$file") || status=$?
|
|
1763
|
+
[ "$status" -le 1 ] || die "cannot inspect live manifest: $file"
|
|
1764
|
+
[ -n "$found" ] || continue
|
|
1765
|
+
while IFS= read -r stamp; do
|
|
1766
|
+
stamp=$(printf '%s' "$stamp" | tr '[:upper:]' '[:lower:]')
|
|
1767
|
+
if [ -z "$selected" ] || [ "$selected" = all ] || [ "$session_selected" -eq 1 ] || [ "$stamp" = "$selected" ]; then
|
|
1768
|
+
printf 'kept stamp %s: registered live reference in %s\n' "$stamp" "$file"
|
|
1769
|
+
blocked=1
|
|
1770
|
+
fi
|
|
1771
|
+
done <<< "$found"
|
|
1772
|
+
done < "$TMP/live-files"
|
|
1773
|
+
done < "$TMP/unique-sessions"
|
|
1774
|
+
[ "$blocked" -eq 0 ] || die 'live references retained; restore registered consumers before cleaning (no subjects removed)'
|
|
1775
|
+
}
|
|
1090
1776
|
clean() {
|
|
1091
1777
|
local version='' apply=0 consumer='' arg path cache image session maximum count=0 rel=''
|
|
1092
1778
|
local subjects=() kinds=()
|
|
@@ -1101,6 +1787,19 @@ clean() {
|
|
|
1101
1787
|
*) [ -z "$consumer" ] || die 'clean takes at most one consumer'; consumer=$(repo_root "$arg") ;;
|
|
1102
1788
|
esac
|
|
1103
1789
|
done
|
|
1790
|
+
local registered
|
|
1791
|
+
if [ -n "$consumer" ]; then
|
|
1792
|
+
session=$(session_path "$consumer")
|
|
1793
|
+
if [ -e "$session/composition" ] || [ -L "$session/composition" ]; then composition clean-guard "${version:-all}" "$session"; fi
|
|
1794
|
+
fi
|
|
1795
|
+
if [ -f "$FEED/sessions" ]; then
|
|
1796
|
+
while IFS= read -r registered; do
|
|
1797
|
+
if [ -e "$registered/composition" ] || [ -L "$registered/composition" ]; then
|
|
1798
|
+
composition clean-guard "${version:-all}"
|
|
1799
|
+
break
|
|
1800
|
+
fi
|
|
1801
|
+
done < "$FEED/sessions"
|
|
1802
|
+
fi
|
|
1104
1803
|
maximum=${CRATIS_LOCAL_CLEAN_MAX:-500}
|
|
1105
1804
|
[[ "$maximum" =~ ^[0-9]+$ ]] || die 'CRATIS_LOCAL_CLEAN_MAX must be a whole number'
|
|
1106
1805
|
[ ${#maximum} -le 6 ] || die 'CRATIS_LOCAL_CLEAN_MAX is too large'
|
|
@@ -1164,11 +1863,13 @@ clean() {
|
|
|
1164
1863
|
[ ! -d "$session/npm-backup" ] || die "unresolved npm backup: $session; restore before cleaning"
|
|
1165
1864
|
subjects+=("$session"); kinds+=(session)
|
|
1166
1865
|
done < "$TMP/unique-sessions"
|
|
1866
|
+
clean_live_references "$version"
|
|
1167
1867
|
count=${#subjects[@]}
|
|
1168
1868
|
[ "$count" -le "$maximum" ] || die "clean fuse: $count subjects exceeds $maximum"
|
|
1169
1869
|
for ((arg=0; arg<count; arg++)); do printf '%s: %s\n' "${kinds[$arg]}" "${subjects[$arg]}"; done
|
|
1170
1870
|
if [ "$apply" -eq 1 ] && [ "$DRY" -eq 0 ]; then
|
|
1171
1871
|
for ((arg=0; arg<count; arg++)); do
|
|
1872
|
+
clean_live_references "$version"
|
|
1172
1873
|
path=${subjects[$arg]}
|
|
1173
1874
|
case "${kinds[$arg]}" in
|
|
1174
1875
|
image) docker rmi "$path" ;;
|
|
@@ -1177,7 +1878,7 @@ clean() {
|
|
|
1177
1878
|
session)
|
|
1178
1879
|
[ -f "$path/stamp" ] && [ ! -L "$path" ] && [ ! -e "$path/npm-backup" ] || die "session changed: $path"
|
|
1179
1880
|
validate_session "$path"
|
|
1180
|
-
rm -f -- "$path/repo" "$path/stamp" "$path/env" "$path/npm-dir" "$path/Directory.Packages.props" "$path/nuget.config"
|
|
1881
|
+
rm -f -- "$path/repo" "$path/stamp" "$path/env" "$path/npm-dir" "$path/Directory.Packages.props" "$path/cratis-local.targets" "$path/nuget.config"
|
|
1181
1882
|
rmdir -- "$path" ;;
|
|
1182
1883
|
esac
|
|
1183
1884
|
done
|
|
@@ -1186,10 +1887,30 @@ clean() {
|
|
|
1186
1887
|
}
|
|
1187
1888
|
|
|
1188
1889
|
# shellcheck disable=SC2016 # fixtures assert literal $STAMP in printed commands
|
|
1890
|
+
self_test_nuget_overlay() {
|
|
1891
|
+
local output=$1 id=$2 version=$3
|
|
1892
|
+
local cpm_on="'\$(ManagePackageVersionsCentrally)' == 'true'" cpm_off="'\$(ManagePackageVersionsCentrally)' != 'true'"
|
|
1893
|
+
grep -Fq "GetPathOfFileAbove('Directory.Packages.props', '\$(MSBuildProjectDirectory)')" "$output/Directory.Packages.props" || failed 'self-test: nearest CPM import'
|
|
1894
|
+
grep -Fq 'Import Project="$(_CratisLocalPackagesProps)" Condition="' "$output/Directory.Packages.props" || failed 'self-test: conditional nearest import'
|
|
1895
|
+
grep -Fq "ItemGroup Condition=\"$cpm_on\"" "$output/Directory.Packages.props" || failed 'self-test: CPM-only versions'
|
|
1896
|
+
grep -Fq "PropertyGroup Condition=\"$cpm_on\"" "$output/Directory.Packages.props" || failed 'self-test: CPM-only transitive pinning'
|
|
1897
|
+
grep -Fq "ItemGroup Condition=\"$cpm_off\"" "$output/cratis-local.targets" || failed 'self-test: opt-out-only references'
|
|
1898
|
+
grep -Fq "PackageReference Update=\"$id\" Version=\"$version\"" "$output/cratis-local.targets" || failed 'self-test: opt-out version override'
|
|
1899
|
+
grep -Fq 'Import Project="$(_CratisLocalPreviousAfterMicrosoftCommonTargets)"' "$output/cratis-local.targets" || failed 'self-test: chained targets import'
|
|
1900
|
+
grep -Fq "!= '\$(MSBuildThisFileFullPath)' and Exists('\$(_CratisLocalPreviousAfterMicrosoftCommonTargets)')" "$output/cratis-local.targets" || failed 'self-test: chained targets import is guarded'
|
|
1901
|
+
env -i PATH=/usr/bin:/bin CustomAfterMicrosoftCommonTargets='/previous targets/file.targets' /bin/bash -c '
|
|
1902
|
+
source "$1"; source "$1"
|
|
1903
|
+
[ "$_CratisLocalPreviousAfterMicrosoftCommonTargets" = "/previous targets/file.targets" ] &&
|
|
1904
|
+
[ "$CustomAfterMicrosoftCommonTargets" = "$2/cratis-local.targets" ]
|
|
1905
|
+
' _ "$output/env" "$output" || failed 'self-test: source twice preserves previous targets'
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1189
1908
|
self_test() {
|
|
1190
1909
|
[ $# -eq 0 ] || die '--self-test takes no arguments'
|
|
1191
1910
|
local root home repo feed version output status passed=0 skipped=0 test_key arg map
|
|
1192
|
-
|
|
1911
|
+
# Fixtures use the physical path (macOS TMPDIR is /var -> /private/var) because the helper
|
|
1912
|
+
# reports repositories by their git toplevel; symlinked feeds have their own test below.
|
|
1913
|
+
scratch; root=$(cd -P "$TMP" && pwd -P)/self-test; mkdir -p "$root/home" "$root/repo"
|
|
1193
1914
|
map=$root/map.tsv; home=$root/home; repo=$root/repo; feed=$home/.cache/cratis/local-feed
|
|
1194
1915
|
# System-only tests stay hermetic; npm tests inherit PATH to discover node.
|
|
1195
1916
|
# Both isolate HOME/config and omit the host adapter.
|
|
@@ -1201,7 +1922,10 @@ self_test() {
|
|
|
1201
1922
|
repo=$(repo_root "$repo")
|
|
1202
1923
|
git -C "$repo" config user.email test@example.invalid
|
|
1203
1924
|
git -C "$repo" config user.name 'Local package self-test'
|
|
1204
|
-
printf '<Project><ItemGroup><PackageVersion Include="Cratis.Test" Version="1.2.3" /></ItemGroup></Project>\n' > "$repo/Directory.Packages.props"
|
|
1925
|
+
printf '<Project><PropertyGroup><ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally></PropertyGroup><ItemGroup><PackageVersion Include="Cratis.Test" Version="1.2.3" /></ItemGroup></Project>\n' > "$repo/Directory.Packages.props"
|
|
1926
|
+
mkdir -p "$repo/nested"
|
|
1927
|
+
printf '<Project><PropertyGroup><ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally></PropertyGroup></Project>\n' > "$repo/nested/Directory.Packages.props"
|
|
1928
|
+
printf '<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup><ItemGroup><PackageReference Include="Cratis.Test" Version="1.2.3" /></ItemGroup></Project>\n' > "$repo/nested/OptOut.csproj"
|
|
1205
1929
|
printf '{"private":true,"dependencies":{"@cratis/test":"1.2.3"}}\n' > "$repo/package.json"
|
|
1206
1930
|
cat > "$repo/yarn.lock" <<'LOCK'
|
|
1207
1931
|
# This file is generated by running "yarn install" inside your project.
|
|
@@ -1234,6 +1958,7 @@ LOCK
|
|
|
1234
1958
|
version=$(st stamp "$repo")
|
|
1235
1959
|
[[ "$version" =~ ^1\.2\.4-local\.[0-9]{12}\.g[0-9a-f]{7}$ ]] || failed 'self-test: stamp format'
|
|
1236
1960
|
passed=$((passed + 1))
|
|
1961
|
+
self_test_base_version
|
|
1237
1962
|
mkdir -p "$feed/nuget" "$feed/npm"
|
|
1238
1963
|
: > "$feed/nuget/Cratis.Test.1.2.4-local.200001010000.g1234567.nupkg"
|
|
1239
1964
|
st --dry-run use-nuget "$repo" 1.2.4-local.200001010000.g1234567 Cratis.Test > "$root/output"
|
|
@@ -1250,12 +1975,13 @@ LOCK
|
|
|
1250
1975
|
done
|
|
1251
1976
|
st use-nuget "$repo" "$version" > "$root/output"
|
|
1252
1977
|
output=$(git -C "$repo" rev-parse --absolute-git-dir)/cratis-local
|
|
1253
|
-
grep -Fq "
|
|
1254
|
-
grep -Fq "PackageVersion Update=\"Cratis.Test\" Version=\"$version\"" "$output/Directory.Packages.props" || failed 'self-test: pinned package Update'
|
|
1978
|
+
grep -Fq "PackageVersion Include=\"Cratis.Test\" Version=\"$version\"" "$output/Directory.Packages.props" || failed 'self-test: pinned package Include'
|
|
1255
1979
|
grep -Fq "PackageVersion Include=\"Cratis.Transitive\" Version=\"$version\"" "$output/Directory.Packages.props" || failed 'self-test: transitive package Include'
|
|
1256
1980
|
if grep -Fq 'CentralPackageTransitivePinningEnabled' "$output/Directory.Packages.props"; then failed 'self-test: transitive pinning must be opt-in'; fi
|
|
1257
1981
|
st use-nuget "$repo" "$version" --pin-transitive > "$root/output"
|
|
1258
1982
|
grep -Fq 'CentralPackageTransitivePinningEnabled>true' "$output/Directory.Packages.props" || failed 'self-test: --pin-transitive enables pinning'
|
|
1983
|
+
self_test_nuget_overlay "$output" Cratis.Test "$version"
|
|
1984
|
+
passed=$((passed + 8))
|
|
1259
1985
|
grep -Fq "PackageVersion Include=\"Cratis.Transitive\" Version=\"$version\"" "$output/Directory.Packages.props" || failed 'self-test: --pin-transitive keeps the IDs'
|
|
1260
1986
|
st use-nuget "$repo" "$version" Cratis.Test --pin-transitive > "$root/output" || failed 'self-test: --pin-transitive with explicit IDs'
|
|
1261
1987
|
st use-nuget "$repo" "$version" > "$root/output"
|
|
@@ -1265,12 +1991,14 @@ LOCK
|
|
|
1265
1991
|
passed=$((passed + 5))
|
|
1266
1992
|
st check "$repo" > "$root/output" || failed 'self-test: clean repo'
|
|
1267
1993
|
passed=$((passed + 1))
|
|
1268
|
-
for test_key in stamp npm props sources override feed; do
|
|
1994
|
+
for test_key in stamp npm props sources targets previous override feed; do
|
|
1269
1995
|
case "$test_key" in
|
|
1270
1996
|
stamp) printf '%s\n' "$version" > "$repo/probe" ;;
|
|
1271
1997
|
npm) printf '{"resolutions":{"@cratis/test":"%s%s"}}\n' 'file:/tmp/' 'package.tgz' > "$repo/package.json" ;;
|
|
1272
1998
|
props) printf '%s=/tmp/overlay\n' DirectoryPackages"PropsPath" > "$repo/probe" ;;
|
|
1273
1999
|
sources) printf '%s=/tmp/feed\n' RestoreAdditional"ProjectSources" > "$repo/probe" ;;
|
|
2000
|
+
targets) printf '%s=/tmp/overlay.targets\n' CustomAfterMicrosoftCommon"Targets" > "$repo/probe" ;;
|
|
2001
|
+
previous) printf '%s=/tmp/previous.targets\n' _CratisLocalPreviousAfterMicrosoftCommon"Targets" > "$repo/probe" ;;
|
|
1274
2002
|
override) printf '<PackageVersion %s="Cratis.Test" />\n' Update > "$repo/probe" ;;
|
|
1275
2003
|
feed) printf '%s\n' "$feed" > "$repo/probe" ;;
|
|
1276
2004
|
esac
|
|
@@ -1279,6 +2007,20 @@ LOCK
|
|
|
1279
2007
|
passed=$((passed + 1))
|
|
1280
2008
|
git -C "$repo" checkout -- probe package.json
|
|
1281
2009
|
done
|
|
2010
|
+
# A feed configured through a symlink is found under both its configured and its physical spelling.
|
|
2011
|
+
mkdir -p "$feed" && ln -s "$(dirname "$feed")" "$root/feed-alias"
|
|
2012
|
+
for test_key in "$root/feed-alias/$(basename "$feed")" "$(cd -P "$feed" && pwd -P)"; do
|
|
2013
|
+
printf '%s/nuget\n' "$test_key" > "$repo/probe"
|
|
2014
|
+
status=0; env -i HOME="$home" PATH=/usr/bin:/bin:/usr/sbin:/sbin TMPDIR="$TMP" CRATIS_LOCAL_FEED="$root/feed-alias/$(basename "$feed")" /bin/bash "$SELF" check "$repo" > "$root/output" || status=$?
|
|
2015
|
+
[ "$status" -eq 1 ] || failed "self-test: did not find aliased feed leak: $test_key"
|
|
2016
|
+
passed=$((passed + 1))
|
|
2017
|
+
git -C "$repo" checkout -- probe
|
|
2018
|
+
done
|
|
2019
|
+
rm "$root/feed-alias"
|
|
2020
|
+
# A consumer's own published-version reference update is legitimate, not an overlay leak.
|
|
2021
|
+
printf '<PackageReference %s="Cratis.Test" Version="1.2.3" />\n' Update > "$repo/probe"
|
|
2022
|
+
st check "$repo" > "$root/output" || failed 'self-test: published reference update reported as leak'
|
|
2023
|
+
git -C "$repo" checkout -- probe; passed=$((passed + 1))
|
|
1282
2024
|
printf '%s\n' "$version" > "$repo/untracked"
|
|
1283
2025
|
status=0; st check "$repo" > "$root/output" || status=$?
|
|
1284
2026
|
[ "$status" -eq 1 ] || failed 'self-test: untracked leak'
|
|
@@ -1289,6 +2031,9 @@ LOCK
|
|
|
1289
2031
|
[ "$status" -eq 1 ] || failed 'self-test: staged content'
|
|
1290
2032
|
git -C "$repo" restore --staged probe; git -C "$repo" checkout -- probe
|
|
1291
2033
|
passed=$((passed + 1))
|
|
2034
|
+
st restore "$repo" > "$root/output" || failed 'self-test: restore legacy overlay'
|
|
2035
|
+
[ ! -e "$output/cratis-local.targets" ] && [ ! -e "$output/env" ] || failed 'self-test: legacy targets and env survive restore'
|
|
2036
|
+
passed=$((passed + 1))
|
|
1292
2037
|
if command -v node >/dev/null 2>&1; then
|
|
1293
2038
|
# Preserve pre-existing user edits as well as the committed contents.
|
|
1294
2039
|
st_npm use-npm "$repo" "$version"
|
|
@@ -1319,8 +2064,9 @@ LOCK
|
|
|
1319
2064
|
cp "$repo/package.json" "$root/original-package.json"
|
|
1320
2065
|
cp "$repo/yarn.lock" "$root/original-yarn.lock"
|
|
1321
2066
|
st_npm use-npm "$repo" "$version"
|
|
1322
|
-
# Model Yarn's replacement of a remote entry with a file-tarball entry.
|
|
1323
|
-
|
|
2067
|
+
# Model Yarn's replacement of a remote entry with a file-tarball entry. Yarn writes the
|
|
2068
|
+
# path the overlay put in package.json, which uses the physical feed path.
|
|
2069
|
+
node - "$repo/yarn.lock" "$(cd -P "$feed/npm" && pwd -P)/@cratis-test-$version.tgz" "$version" <<'NODE'
|
|
1324
2070
|
const fs = require('fs');
|
|
1325
2071
|
const [file, input, stamp] = process.argv.slice(2);
|
|
1326
2072
|
const tarball = require('path').normalize(input);
|
|
@@ -1450,6 +2196,7 @@ NODE
|
|
|
1450
2196
|
[ ! -e "$(git -C "$sub" rev-parse --absolute-git-dir)/cratis-local/npm-dir" ] || failed 'self-test: restore removes npm-dir'
|
|
1451
2197
|
passed=$((passed + 6))
|
|
1452
2198
|
else printf 'SKIP: use-npm --dir needs node\n'; skipped=$((skipped + 6)); fi
|
|
2199
|
+
self_test_live_clean
|
|
1453
2200
|
mkdir -p "$home/.nuget/packages/cratis.test/$version" "$home/.nuget/packages/cratis.test/1.2.3"
|
|
1454
2201
|
mkdir -p "$home/.nuget/packages/cratis.test/1.2.3-local.keep"
|
|
1455
2202
|
for arg in nuget npm; do
|
|
@@ -1476,6 +2223,92 @@ NODE
|
|
|
1476
2223
|
printf 'self-test: %s passed, %s skipped\n' "$passed" "$skipped"
|
|
1477
2224
|
}
|
|
1478
2225
|
|
|
2226
|
+
self_test_base_version() {
|
|
2227
|
+
local base mode actual status
|
|
2228
|
+
for mode in local train; do
|
|
2229
|
+
local options=()
|
|
2230
|
+
if [ "$mode" = train ]; then options=(--train); fi
|
|
2231
|
+
for base in 0.0.0 5.0.0 10.20.300; do
|
|
2232
|
+
actual=$(st stamp ${options[@]+"${options[@]}"} --base-version "$base" "$repo") || failed 'self-test: base override'
|
|
2233
|
+
case "$actual" in "$base-$mode."*) ;; *) failed "self-test: base override grammar $actual" ;; esac
|
|
2234
|
+
valid_stamp "$actual"
|
|
2235
|
+
passed=$((passed + 1))
|
|
2236
|
+
done
|
|
2237
|
+
for base in '' 01.0.0 1.01.0 1.0.01 v5.0.0 5.0 5.0.0.1 5.0.0-preview 5.0.0+build -1.0.0 '5.0.0 '; do
|
|
2238
|
+
status=0; st stamp ${options[@]+"${options[@]}"} --base-version "$base" "$repo" > "$root/output" 2>&1 || status=$?
|
|
2239
|
+
[ "$status" -eq 2 ] || failed "self-test: invalid base accepted: $base"
|
|
2240
|
+
passed=$((passed + 1))
|
|
2241
|
+
done
|
|
2242
|
+
done
|
|
2243
|
+
for mode in missing repeated producer; do
|
|
2244
|
+
status=0
|
|
2245
|
+
case "$mode" in
|
|
2246
|
+
missing) st stamp "$repo" --base-version > "$root/output" 2>&1 || status=$? ;;
|
|
2247
|
+
repeated) st stamp --base-version 5.0.0 --base-version 6.0.0 "$repo" > "$root/output" 2>&1 || status=$? ;;
|
|
2248
|
+
producer) st stamp --base-version 5.0.0 > "$root/output" 2>&1 || status=$? ;;
|
|
2249
|
+
esac
|
|
2250
|
+
[ "$status" -eq 2 ] || failed "self-test: base option $mode"
|
|
2251
|
+
passed=$((passed + 1))
|
|
2252
|
+
done
|
|
2253
|
+
actual=$(st stamp "$repo" --base-version 0.0.0 "$repo" 2> "$root/stderr")
|
|
2254
|
+
case "$actual" in 0.0.0-local.*) ;; *) failed 'self-test: multi-producer explicit base overrides all tags' ;; esac
|
|
2255
|
+
passed=$((passed + 1))
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
self_test_live_clean() {
|
|
2259
|
+
local lr=$root/live-repo lf=$root/live-feed ls file mode status
|
|
2260
|
+
local lv=5.0.0-local.202601010000.gabcdef0 uv=5.0.0-train.gabcdef1
|
|
2261
|
+
mkdir -p "$lr/nested" "$lf/nuget"
|
|
2262
|
+
git -C "$lr" init -q
|
|
2263
|
+
printf '<Project/>\n' > "$lr/Directory.Packages.props"
|
|
2264
|
+
git -C "$lr" add Directory.Packages.props
|
|
2265
|
+
git -C "$lr" -c user.email=test@example.invalid -c user.name='Live clean fixture' commit -qm initial
|
|
2266
|
+
ls=$(git -C "$lr" rev-parse --absolute-git-dir)/cratis-local
|
|
2267
|
+
lct() { env -i HOME="$home" PATH=/usr/bin:/bin:/usr/sbin:/sbin TMPDIR="$TMP" CRATIS_LOCAL_FEED="$lf" NUGET_PACKAGES="$root/live-cache" /bin/bash "$SELF" "$@"; }
|
|
2268
|
+
: > "$lf/nuget/Cratis.Live.$lv.nupkg"
|
|
2269
|
+
: > "$lf/nuget/Cratis.Unused.$uv.nupkg"
|
|
2270
|
+
mkdir -p "$root/live-cache/cratis.live/$lv" "$root/live-cache/cratis.live/5.0.0"
|
|
2271
|
+
lct use-nuget "$lr" "$lv" > "$root/output"
|
|
2272
|
+
for mode in dry apply; do
|
|
2273
|
+
local options=()
|
|
2274
|
+
if [ "$mode" = apply ]; then options=(--apply); fi
|
|
2275
|
+
status=0; lct clean --stamp "$lv" ${options[@]+"${options[@]}"} > "$root/output" 2> "$root/stderr" || status=$?
|
|
2276
|
+
{ [ "$status" -eq 2 ] && has "kept stamp $lv" "$root/output" && has "$ls/Directory.Packages.props" "$root/output"; } || failed "self-test: $mode clean must report live overlay"
|
|
2277
|
+
[ -f "$lf/nuget/Cratis.Live.$lv.nupkg" ] && [ -f "$ls/env" ] && [ -d "$root/live-cache/cratis.live/$lv" ] || failed 'self-test: live clean mutated subjects'
|
|
2278
|
+
passed=$((passed + 2))
|
|
2279
|
+
done
|
|
2280
|
+
lct restore "$lr" > "$root/output"
|
|
2281
|
+
for file in nuget.config Directory.Packages.props nested/packages.lock.json nested/package.json nested/yarn.lock; do
|
|
2282
|
+
printf '%s\n' "$lv" > "$lr/$file"
|
|
2283
|
+
for mode in dry apply; do
|
|
2284
|
+
local options=()
|
|
2285
|
+
if [ "$mode" = apply ]; then options=(--apply); fi
|
|
2286
|
+
status=0; lct clean --stamp "$lv" ${options[@]+"${options[@]}"} > "$root/output" 2> "$root/stderr" || status=$?
|
|
2287
|
+
{ [ "$status" -eq 2 ] && has "kept stamp $lv" "$root/output" && has "$lr/$file" "$root/output"; } || failed "self-test: $mode clean missed live $file"
|
|
2288
|
+
[ -f "$lf/nuget/Cratis.Live.$lv.nupkg" ] || failed 'self-test: live manifest artifact removed'
|
|
2289
|
+
passed=$((passed + 2))
|
|
2290
|
+
done
|
|
2291
|
+
if [ "$file" = Directory.Packages.props ]; then printf '<Project/>\n' > "$lr/$file"; else rm "$lr/$file"; fi
|
|
2292
|
+
done
|
|
2293
|
+
printf '%s\n' "$uv" | tr '[:lower:]' '[:upper:]' > "$lr/nested/packages.lock.json"
|
|
2294
|
+
for mode in dry apply; do
|
|
2295
|
+
local options=()
|
|
2296
|
+
if [ "$mode" = apply ]; then options=(--apply); fi
|
|
2297
|
+
status=0; lct clean --stamp "$uv" ${options[@]+"${options[@]}"} > "$root/output" 2> "$root/stderr" || status=$?
|
|
2298
|
+
{ [ "$status" -eq 2 ] && has "kept stamp $uv" "$root/output" && [ -f "$lf/nuget/Cratis.Unused.$uv.nupkg" ]; } || failed 'self-test: case-insensitive train reference protection'
|
|
2299
|
+
passed=$((passed + 1))
|
|
2300
|
+
done
|
|
2301
|
+
printf '%s\n' "$lv" > "$lr/nested/packages.lock.json"
|
|
2302
|
+
lct clean --stamp "$uv" --apply > "$root/output" || failed 'self-test: unrelated live stamp blocks unreferenced cleanup'
|
|
2303
|
+
[ ! -e "$lf/nuget/Cratis.Unused.$uv.nupkg" ] && [ -f "$lf/nuget/Cratis.Live.$lv.nupkg" ] || failed 'self-test: unreferenced stamp selection'
|
|
2304
|
+
rm "$lr/nested/packages.lock.json"
|
|
2305
|
+
lct clean --stamp "$lv" > "$root/output"
|
|
2306
|
+
has "Cratis.Live.$lv.nupkg" "$root/output" && [ -d "$root/live-cache/cratis.live/$lv" ] || failed 'self-test: unreferenced dry-run'
|
|
2307
|
+
lct clean --stamp "$lv" --apply > "$root/output"
|
|
2308
|
+
[ ! -e "$lf/nuget/Cratis.Live.$lv.nupkg" ] && [ ! -d "$root/live-cache/cratis.live/$lv" ] && [ -d "$root/live-cache/cratis.live/5.0.0" ] || failed 'self-test: unreferenced per-worktree cleanup scope'
|
|
2309
|
+
passed=$((passed + 4))
|
|
2310
|
+
}
|
|
2311
|
+
|
|
1479
2312
|
# C fixtures: the printed closure is one plan, in order, and every line is valid shell.
|
|
1480
2313
|
self_test_closure() {
|
|
1481
2314
|
local name line a b c p last first fa fl fm
|
|
@@ -1692,8 +2525,12 @@ self_test_verify() {
|
|
|
1692
2525
|
expect_status() {
|
|
1693
2526
|
local want=$1 desc=$2 got=0
|
|
1694
2527
|
shift 2
|
|
2528
|
+
printf 'self-test [%ss]: %s\n' "$SECONDS" "$desc" >&2
|
|
1695
2529
|
"$@" > "$root/output" 2> "$root/stderr" || got=$?
|
|
1696
|
-
[ "$got" -
|
|
2530
|
+
if [ "$got" -ne "$want" ]; then
|
|
2531
|
+
cat "$root/stderr" >&2
|
|
2532
|
+
failed "self-test: $desc (exit $got, wanted $want)"
|
|
2533
|
+
fi
|
|
1697
2534
|
passed=$((passed + 1))
|
|
1698
2535
|
}
|
|
1699
2536
|
tsv() { local IFS=$'\t'; printf '%s\n' "$*"; }
|
|
@@ -1728,7 +2565,7 @@ self_test_matrix() {
|
|
|
1728
2565
|
} > "$root/p1.expected"
|
|
1729
2566
|
fxm plan App Lib Mid > "$root/output" 2> "$root/stderr" || failed 'self-test: P1 plan'
|
|
1730
2567
|
cmp -s "$root/output" "$root/p1.expected" || failed 'self-test: P1 plan rows differ from the expected plan'
|
|
1731
|
-
has 'Lib -> Orl -> App' "$root/stderr" || failed 'self-test: P1 unnamed-hop warning'
|
|
2568
|
+
has 'Lib -> Orl -> App' "$root/stderr" || { cat "$root/stderr" >&2; failed 'self-test: P1 unnamed-hop warning'; }
|
|
1732
2569
|
if has 'WARNING' "$root/output"; then failed 'self-test: P1 warning must stay on stderr'; fi
|
|
1733
2570
|
# P2/P3: the real map. An NuGet-only edge plans only the NuGet ecosystem of a dual-ecosystem producer;
|
|
1734
2571
|
# npm-only ids on a NuGet edge cannot be planned. P3 must move to the fixture map if Screenplay ships NuGet ids.
|
|
@@ -1763,6 +2600,263 @@ self_test_matrix() {
|
|
|
1763
2600
|
passed=$((passed + 3))
|
|
1764
2601
|
self_test_closure
|
|
1765
2602
|
self_test_verify
|
|
2603
|
+
self_test_symlink_feed
|
|
2604
|
+
self_test_composition
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
# Legacy mutations must work without Node and with system/configured symlink parents.
|
|
2608
|
+
self_test_symlink_feed() {
|
|
2609
|
+
local physical=$root/feed-physical-parent alias=$root/feed-symlink-parent
|
|
2610
|
+
local sr=$root/feed-symlink-repo sv=1.2.3-local.202610240544.gabcdef0
|
|
2611
|
+
mkdir -p "$physical" "$sr"
|
|
2612
|
+
ln -s "$physical" "$alias"
|
|
2613
|
+
git -C "$sr" init -q
|
|
2614
|
+
printf '.ai-work/\n' > "$sr/.gitignore"
|
|
2615
|
+
printf '<Project><ItemGroup><PackageVersion Include="Cratis.Symlink" Version="1.2.3"/></ItemGroup></Project>\n' > "$sr/Directory.Packages.props"
|
|
2616
|
+
git -C "$sr" add .gitignore Directory.Packages.props
|
|
2617
|
+
git -C "$sr" -c user.email=test@example.invalid -c user.name='Symlink feed fixture' commit -qm initial
|
|
2618
|
+
slf() { env -i HOME="$home" PATH=/usr/bin:/bin:/usr/sbin:/sbin TMPDIR="$TMP" CRATIS_LOCAL_FEED="$alias/missing/feed" CRATIS_LOCAL_SESSION_DIR="$sr/.ai-work/local-packages" /bin/bash "$SELF" "$@"; }
|
|
2619
|
+
expect_status 0 'symlink feed resolution does not create a missing feed' slf check "$sr"
|
|
2620
|
+
[ ! -e "$physical/missing" ] || failed 'symlink feed resolution created directories'
|
|
2621
|
+
mkdir -p "$physical/missing/feed/nuget"
|
|
2622
|
+
: > "$physical/missing/feed/nuget/Cratis.Symlink.$sv.nupkg"
|
|
2623
|
+
expect_status 0 'legacy use-nuget under symlinked feed parent' slf use-nuget "$sr" "$sv" Cratis.Symlink
|
|
2624
|
+
has "$physical/missing/feed/nuget" "$sr/.ai-work/local-packages/env" || failed 'legacy feed path was not resolved physically'
|
|
2625
|
+
expect_status 0 'legacy restore under symlinked feed parent' slf restore "$sr"
|
|
2626
|
+
has '0 leaks' "$root/output" || failed 'symlink feed restore leaked a local pin'
|
|
2627
|
+
expect_status 0 'legacy clean under symlinked feed parent' slf clean --stamp "$sv" --apply
|
|
2628
|
+
[ ! -e "$physical/missing/feed/nuget/Cratis.Symlink.$sv.nupkg" ] || failed 'symlink feed clean left the artifact'
|
|
2629
|
+
passed=$((passed + 4))
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
# Composition fixtures are isolated, SDK-native and never restore/build a producer.
|
|
2633
|
+
self_test_composition() {
|
|
2634
|
+
local cf=$root/composition-parent/feed cr=$root/composition-repo cm=$root/composition.tsv
|
|
2635
|
+
local a=22.138.1-local.202610240544.gd1ad215 b=0.2.2-local.202610240544.g91916a3 c=0.1.18-local.202610240544.g524bdce
|
|
2636
|
+
local boundary kind base=$root/composition-prior cpkg=$root/composition-package
|
|
2637
|
+
if ! command -v node >/dev/null 2>&1 || ! command -v dotnet >/dev/null 2>&1 || ! command -v zip >/dev/null 2>&1 || ! command -v unzip >/dev/null 2>&1; then
|
|
2638
|
+
printf 'SKIP: composition fixtures require node, dotnet SDK, zip and unzip\n'; skipped=$((skipped + 1)); return 0
|
|
2639
|
+
fi
|
|
2640
|
+
mkdir -p "$root/composition-physical-parent"
|
|
2641
|
+
ln -s "$root/composition-physical-parent" "$root/composition-parent"
|
|
2642
|
+
mkdir -p "$cf/nuget" "$cr" "$cpkg"
|
|
2643
|
+
: > "$root/.composition-fixture"
|
|
2644
|
+
git -C "$cr" init -q
|
|
2645
|
+
printf '.ai-work/\nobj/\n' > "$cr/.gitignore"
|
|
2646
|
+
printf '<Project><PropertyGroup><ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally></PropertyGroup><ItemGroup><PackageVersion Include="Cratis.Keep" Version="1.2.3"/></ItemGroup></Project>\n' > "$cr/Directory.Packages.props"
|
|
2647
|
+
mkdir -p "$cr/nested"
|
|
2648
|
+
printf '<Project><PropertyGroup><ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally></PropertyGroup></Project>\n' > "$cr/nested/Directory.Packages.props"
|
|
2649
|
+
printf '<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup><ItemGroup><PackageReference Include="Cratis.Stage" Version="0.1.0" /></ItemGroup></Project>\n' > "$cr/nested/OptOut.csproj"
|
|
2650
|
+
printf '<configuration><packageSources><clear/><add key="remote" value="https://example.invalid/v3/index.json"/></packageSources><packageSourceMapping><packageSource key="remote"><package pattern="*"/><package pattern="Cratis.*"/><package pattern="Cratis.Screenplay*"/><package pattern="Cratis.Screenplay"/></packageSource></packageSourceMapping></configuration>\n' > "$cr/nuget.config"
|
|
2651
|
+
git -C "$cr" add .gitignore Directory.Packages.props nuget.config nested
|
|
2652
|
+
git -C "$cr" -c user.email=test@example.invalid -c user.name='Composition fixture' commit -qm initial
|
|
2653
|
+
cp "$cr/Directory.Packages.props" "$root/composition-original.props"
|
|
2654
|
+
printf 'producer\tChronicle\t%s\t%s\nproducer\tScreenplay\t%s\t%s\nproducer\tStage\t%s\t%s\npackage\tChronicle\tCratis.Chronicle.InProcess\npackage\tScreenplay\tCratis.Screenplay\npackage\tStage\tCratis.Stage\n' "$a" "$cr" "$b" "$cr" "$c" "$cr" > "$cm"
|
|
2655
|
+
cst() { env -i HOME="$home" PATH="$PATH" TMPDIR="$TMP" DOTNET_CLI_HOME="$home" DOTNET_NOLOGO=1 DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 PI_PHASE_ORIGINAL_PATH="${PI_PHASE_ORIGINAL_PATH:-}" PI_PHASE_SHIM_DIR="${PI_PHASE_SHIM_DIR:-}" PI_PHASE_MSBUILD_MAX_CPU="${PI_PHASE_MSBUILD_MAX_CPU:-1}" CRATIS_LOCAL_FEED="$cf" CRATIS_LOCAL_SESSION_DIR="$cr/.ai-work/local-packages" CRATIS_LOCAL_COMPOSITION_TEST_ROOT="$root" CRATIS_LOCAL_COMPOSITION_FAIL="${composition_fail:-}" CRATIS_LOCAL_COMPOSITION_INTERRUPT="${composition_interrupt:-}" /bin/bash "$SELF" "$@"; }
|
|
2656
|
+
cmpkg() {
|
|
2657
|
+
local id=$1 ver=$2 dependencies=${3:-} xmlfile=$cpkg/$1.nuspec
|
|
2658
|
+
printf '<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd"><metadata><id>%s</id><version>%s</version><dependencies>%s</dependencies></metadata></package>' "$id" "$ver" "$dependencies" > "$xmlfile"
|
|
2659
|
+
rm -f -- "$cf/nuget/$id.$ver.nupkg"
|
|
2660
|
+
(cd "$cpkg" && zip -q "$cf/nuget/$id.$ver.nupkg" "$id.nuspec")
|
|
2661
|
+
}
|
|
2662
|
+
cmpkg Cratis.Chronicle.InProcess "$a"
|
|
2663
|
+
cmpkg Cratis.Screenplay "$b" '<group targetFramework="net10.0"><dependency id="Cratis.Chronicle.InProcess" version="[22.136.4,23.0.0)"/></group>'
|
|
2664
|
+
cmpkg Cratis.Stage "$c" "<dependency id=\"Cratis.Screenplay\" version=\"[$b]\"/>"
|
|
2665
|
+
expect_status 0 'composition native mixed-version pack' cst verify-pack --composition "$cm" --json
|
|
2666
|
+
node - "$root/output" "$a" "$b" "$c" <<'JSON'
|
|
2667
|
+
const fs = require('fs'); const [file,...versions] = process.argv.slice(2), data = JSON.parse(fs.readFileSync(file));
|
|
2668
|
+
if (data.packages.length !== 3 || !versions.every(v => data.packages.some(p => p.version === v && p.producer))) process.exit(1);
|
|
2669
|
+
JSON
|
|
2670
|
+
passed=$((passed + 1))
|
|
2671
|
+
mkdir -p "$root/no-sdk"
|
|
2672
|
+
printf '#!/bin/sh\nexit 127\n' > "$root/no-sdk/dotnet"; chmod +x "$root/no-sdk/dotnet"
|
|
2673
|
+
expect_status 2 'composition unavailable native SDK fails closed' env -i HOME="$home" PATH="$root/no-sdk:$PATH" TMPDIR="$TMP" CRATIS_LOCAL_FEED="$cf" /bin/bash "$SELF" verify-pack --composition "$cm"
|
|
2674
|
+
mkdir -p "$root/composition-foreign-nuget" "$root/composition-foreign-lock"
|
|
2675
|
+
printf 'keep foreign lock state\n' > "$root/composition-foreign-lock/sentinel"
|
|
2676
|
+
mv "$cf/nuget" "$root/composition-original-nuget"
|
|
2677
|
+
ln -s "$root/composition-foreign-nuget" "$cf/nuget"
|
|
2678
|
+
expect_status 2 'composition rejects helper-owned nuget symlink' cst verify-pack --composition "$cm"
|
|
2679
|
+
[ -d "$root/composition-foreign-nuget" ] || failed 'composition removed foreign nuget directory'
|
|
2680
|
+
rm "$cf/nuget"; mv "$root/composition-original-nuget" "$cf/nuget"
|
|
2681
|
+
ln -s "$root/composition-foreign-lock" "$cf/.composition-lock"
|
|
2682
|
+
expect_status 2 'composition rejects helper-owned lock symlink' cst use-nuget "$cr" --composition "$cm"
|
|
2683
|
+
has 'keep foreign lock state' "$root/composition-foreign-lock/sentinel" || failed 'composition removed foreign lock state'
|
|
2684
|
+
rm "$cf/.composition-lock"
|
|
2685
|
+
passed=$((passed + 2))
|
|
2686
|
+
cp "$cm" "$root/composition-good.tsv"
|
|
2687
|
+
printf 'package\tStage\tcratis.screenplay\n' >> "$cm"
|
|
2688
|
+
expect_status 2 'composition duplicate case-insensitive owner' cst verify-pack --composition "$cm"
|
|
2689
|
+
cp "$root/composition-good.tsv" "$cm"
|
|
2690
|
+
expect_status 2 'composition flags cannot mix legacy stamp' cst verify-pack "$a" --composition "$cm"
|
|
2691
|
+
expect_status 2 'composition activation cannot mix legacy IDs' cst use-nuget "$cr" --composition "$cm" Cratis.Stage
|
|
2692
|
+
printf 'producer\tBad\t1.2.3\t%s\npackage\tBad\tBad\n' "$cr" > "$root/composition-bad.tsv"
|
|
2693
|
+
expect_status 2 'composition rejects nonlocal producer stamp' cst verify-pack --composition "$root/composition-bad.tsv"
|
|
2694
|
+
cmpkg Cratis.Screenplay "$b" '<group><dependency id="Cratis.Chronicle.InProcess" version="[22.0.0,22.1.0)"/></group>'
|
|
2695
|
+
expect_status 1 'composition incompatible native group range' cst verify-pack --composition "$cm"
|
|
2696
|
+
cmpkg Cratis.Screenplay "$b" '<dependency id="Cratis.Chronicle.InProcess" version="[broken"/>'
|
|
2697
|
+
expect_status 1 'composition malformed native range' cst verify-pack --composition "$cm"
|
|
2698
|
+
cmpkg Cratis.Screenplay "$b" "<dependency id=\"Cratis.Undeclared\" version=\"[$a]\"/>"
|
|
2699
|
+
expect_status 1 'composition undeclared local nuspec edge' cst verify-pack --composition "$cm"
|
|
2700
|
+
cmpkg Cratis.Screenplay "$b" '<group><dependency id="Cratis.Chronicle.InProcess" version="[22.136.4,23.0.0)"/></group>'
|
|
2701
|
+
cp "$cf/nuget/Cratis.Stage.$c.nupkg" "$root/stage.nupkg"
|
|
2702
|
+
cmpkg Cratis.Stage "$c"; printf '<package><metadata>' > "$cpkg/Cratis.Stage.nuspec"
|
|
2703
|
+
(cd "$cpkg" && zip -q "$cf/nuget/Cratis.Stage.$c.nupkg" Cratis.Stage.nuspec)
|
|
2704
|
+
expect_status 1 'composition malformed XML' cst verify-pack --composition "$cm"
|
|
2705
|
+
cp "$root/stage.nupkg" "$cf/nuget/Cratis.Stage.$c.nupkg"
|
|
2706
|
+
mv "$cf/nuget/Cratis.Stage.$c.nupkg" "$root/hidden-stage.nupkg"
|
|
2707
|
+
expect_status 1 'composition missing package' cst verify-pack --composition "$cm"
|
|
2708
|
+
mv "$root/hidden-stage.nupkg" "$cf/nuget/Cratis.Stage.$c.nupkg"
|
|
2709
|
+
mkdir -p "$cr/obj"
|
|
2710
|
+
cat > "$cr/obj/project.assets.json" <<JSON
|
|
2711
|
+
{"libraries":{"Cratis.Chronicle.InProcess/$a":{"type":"package"},"Cratis.Screenplay/$b":{"type":"package"},"Cratis.Stage/$c":{"type":"package"}},"targets":{"net10.0":{"Cratis.Chronicle.InProcess/$a":{"type":"package"},"Cratis.Screenplay/$b":{"type":"package","dependencies":{"Cratis.Chronicle.InProcess":"[22.136.4,23.0.0)"}},"Cratis.Stage/$c":{"type":"package","dependencies":{"Cratis.Screenplay":"[$b]"}}}}}
|
|
2712
|
+
JSON
|
|
2713
|
+
cp "$cr/obj/project.assets.json" "$root/composition-assets.json"
|
|
2714
|
+
expect_status 0 'composition exact assets graph' cst verify-assets "$cr" --composition "$cm" --json
|
|
2715
|
+
node - "$root/composition-assets.json" "$cr/obj/project.assets.json" <<'JSON'
|
|
2716
|
+
const fs=require('fs'), [input,output]=process.argv.slice(2), data=JSON.parse(fs.readFileSync(input));
|
|
2717
|
+
const target=data.targets['net10.0']; delete target[Object.keys(target).find(k=>k.startsWith('Cratis.Chronicle'))];
|
|
2718
|
+
fs.writeFileSync(output,JSON.stringify(data));
|
|
2719
|
+
JSON
|
|
2720
|
+
expect_status 1 'composition missing consumed local dependency' cst verify-assets "$cr" --composition "$cm"
|
|
2721
|
+
node - "$root/composition-assets.json" "$cr/obj/project.assets.json" <<'JSON'
|
|
2722
|
+
const fs=require('fs'), [input,output]=process.argv.slice(2), data=JSON.parse(fs.readFileSync(input));
|
|
2723
|
+
for (const graph of [data.libraries,data.targets['net10.0']]) delete graph[Object.keys(graph).find(k=>k.startsWith('Cratis.Chronicle'))];
|
|
2724
|
+
const target=data.targets['net10.0']; target[Object.keys(target).find(k=>k.startsWith('Cratis.Screenplay'))].dependencies={};
|
|
2725
|
+
fs.writeFileSync(output,JSON.stringify(data));
|
|
2726
|
+
JSON
|
|
2727
|
+
expect_status 1 'composition nuspec local dependency absent from graph' cst verify-assets "$cr" --composition "$cm"
|
|
2728
|
+
cmpkg Cratis.Screenplay "$b" '<group targetFramework="net8.0"><dependency id="Cratis.Chronicle.InProcess" version="[22.136.4,23.0.0)"/></group><group targetFramework="net10.0"/>'
|
|
2729
|
+
expect_status 0 'composition native nearest group allows unused owned ID' cst verify-assets "$cr" --composition "$cm"
|
|
2730
|
+
cmpkg Cratis.Screenplay "$b" '<group><dependency id="Cratis.Chronicle.InProcess" version="[22.136.4,23.0.0)"/></group>'
|
|
2731
|
+
node - "$root/composition-assets.json" "$cr/obj/project.assets.json" "$a" <<'JSON'
|
|
2732
|
+
const fs=require('fs'), [input,output,a]=process.argv.slice(2), data=JSON.parse(fs.readFileSync(input));
|
|
2733
|
+
data.targets.net9=data.targets['net10.0']; data.targets.net9['Cratis.Chronicle.InProcess/'+a].dependencies={'Cratis.Stage':'[1.0.0,2.0.0)'};
|
|
2734
|
+
fs.writeFileSync(output,JSON.stringify(data));
|
|
2735
|
+
JSON
|
|
2736
|
+
expect_status 1 'composition bad edge in another target' cst verify-assets "$cr" --composition "$cm"
|
|
2737
|
+
node - "$root/composition-assets.json" "$cr/obj/project.assets.json" <<'JSON'
|
|
2738
|
+
const fs=require('fs'), [input,output]=process.argv.slice(2), data=JSON.parse(fs.readFileSync(input)), target=data.targets['net10.0'];
|
|
2739
|
+
Object.assign(data.libraries,{'Other.A/1.0.0':{type:'package'},'Other.B/2.0.0':{type:'package'}});
|
|
2740
|
+
Object.assign(target,{'Other.A/1.0.0':{type:'package',dependencies:{'Other.B':'[1.0.0]'}},'Other.B/2.0.0':{type:'package'}});
|
|
2741
|
+
fs.writeFileSync(output,JSON.stringify(data));
|
|
2742
|
+
JSON
|
|
2743
|
+
expect_status 0 'composition range conflict outside the composition is a warning' cst verify-assets "$cr" --composition "$cm" --json
|
|
2744
|
+
has 'WARNING: dependency range outside the composition' "$root/stderr" || failed 'composition outside-range warning on stderr'
|
|
2745
|
+
node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1]));if(d.warnings.length!==1||!/Other\.A -> Other\.B/.test(d.warnings[0]))process.exit(1)' "$root/output" || failed 'composition outside-range warning in JSON'
|
|
2746
|
+
node - "$root/composition-assets.json" "$cr/obj/project.assets.json" "$c" <<'JSON'
|
|
2747
|
+
const fs=require('fs'), [input,output,c]=process.argv.slice(2), data=JSON.parse(fs.readFileSync(input)), target=data.targets['net10.0'];
|
|
2748
|
+
data.libraries['Other.B/2.0.0']={type:'package'}; target['Other.B/2.0.0']={type:'package'};
|
|
2749
|
+
target['Cratis.Stage/'+c].dependencies['Other.B']='[1.0.0]';
|
|
2750
|
+
fs.writeFileSync(output,JSON.stringify(data));
|
|
2751
|
+
JSON
|
|
2752
|
+
expect_status 1 'composition range conflict on a composed edge is refused' cst verify-assets "$cr" --composition "$cm"
|
|
2753
|
+
node - "$root/composition-assets.json" "$cr/obj/project.assets.json" <<'JSON'
|
|
2754
|
+
const fs=require('fs'), [input,output]=process.argv.slice(2), data=JSON.parse(fs.readFileSync(input));
|
|
2755
|
+
data.targets.net9=JSON.parse(JSON.stringify(data.targets['net10.0']));
|
|
2756
|
+
Object.assign(data.libraries,{'Other.C/1.0.0':{type:'package'},'Other.C/2.0.0':{type:'package'}});
|
|
2757
|
+
data.targets.net9['Other.C/1.0.0']={type:'package'}; data.targets['net10.0']['Other.C/2.0.0']={type:'package'};
|
|
2758
|
+
fs.writeFileSync(output,JSON.stringify(data));
|
|
2759
|
+
JSON
|
|
2760
|
+
expect_status 0 'composition multitarget assets with one released ID at two versions' cst verify-assets "$cr" --composition "$cm"
|
|
2761
|
+
passed=$((passed + 3))
|
|
2762
|
+
sed "s/$a/22.136.4/g" "$root/composition-assets.json" > "$cr/obj/project.assets.json"
|
|
2763
|
+
expect_status 1 'composition published fallback version' cst verify-assets "$cr" --composition "$cm"
|
|
2764
|
+
printf '{"libraries":{},"targets":{"net10.0":{}}}\n' > "$cr/obj/project.assets.json"
|
|
2765
|
+
expect_status 1 'composition zero consumption' cst verify-assets "$cr" --composition "$cm"
|
|
2766
|
+
printf '{broken\n' > "$cr/obj/project.assets.json"
|
|
2767
|
+
expect_status 1 'composition malformed assets' cst verify-assets "$cr" --composition "$cm"
|
|
2768
|
+
cp "$root/composition-assets.json" "$cr/obj/project.assets.json"
|
|
2769
|
+
expect_status 0 'composition activation with transitive pinning' cst use-nuget "$cr" --composition "$cm" --pin-transitive
|
|
2770
|
+
output=$cr/.ai-work/local-packages/composition
|
|
2771
|
+
grep -Fq 'CentralPackageTransitivePinningEnabled>true' "$output/Directory.Packages.props" || failed 'composition pin property'
|
|
2772
|
+
self_test_nuget_overlay "$output" Cratis.Stage "$c"
|
|
2773
|
+
passed=$((passed + 8))
|
|
2774
|
+
# Only exact owned IDs are removed remotely; broader patterns remain remote.
|
|
2775
|
+
[ "$(grep -c 'pattern="Cratis.Screenplay"' "$output/nuget.config")" -eq 1 ] || failed 'composition exclusive exact mapping'
|
|
2776
|
+
has 'pattern="*"' "$output/nuget.config" || failed 'composition removed remote wildcard'
|
|
2777
|
+
has 'pattern="Cratis.*"' "$output/nuget.config" || failed 'composition removed broader remote prefix'
|
|
2778
|
+
has 'pattern="Cratis.Screenplay*"' "$output/nuget.config" || failed 'composition removed overlapping remote prefix'
|
|
2779
|
+
has "$root/composition-physical-parent/feed/nuget" "$output/env" || failed 'composition feed path was not resolved physically'
|
|
2780
|
+
passed=$((passed + 4))
|
|
2781
|
+
cp "$output/Directory.Packages.props" "$root/composition-overlay.props"
|
|
2782
|
+
expect_status 0 'composition identical activation is a no-op' cst use-nuget "$cr" --composition "$cm" --pin-transitive
|
|
2783
|
+
cmp -s "$output/Directory.Packages.props" "$root/composition-overlay.props" || failed 'composition repeated activation drift'
|
|
2784
|
+
expect_status 2 'composition changed pinning refuses reactivation' cst use-nuget "$cr" --composition "$cm"
|
|
2785
|
+
expect_status 2 'composition rejects legacy activation while active' cst use-nuget "$cr" "$a" Cratis.Chronicle.InProcess
|
|
2786
|
+
expect_status 2 'composition active artifacts retained by clean' cst clean --stamp "$a" --apply
|
|
2787
|
+
expect_status 2 'composition refuses legacy npm activation' cst use-npm "$cr" "$b"
|
|
2788
|
+
cp "$output/env" "$root/composition-env"
|
|
2789
|
+
printf '# unrelated session edit\n' >> "$output/env"
|
|
2790
|
+
expect_status 2 'composition edited overlay retained on restore' cst restore "$cr"
|
|
2791
|
+
grep -Fq 'unrelated session edit' "$output/env" || failed 'composition deleted edited overlay'
|
|
2792
|
+
cp "$root/composition-env" "$output/env"
|
|
2793
|
+
cp "$output/cratis-local.targets" "$root/composition-targets"
|
|
2794
|
+
printf '<!-- unrelated targets edit -->\n' >> "$output/cratis-local.targets"
|
|
2795
|
+
expect_status 2 'composition edited targets retained on restore' cst restore "$cr"
|
|
2796
|
+
has 'unrelated targets edit' "$output/cratis-local.targets" || failed 'composition deleted edited targets'
|
|
2797
|
+
cp "$root/composition-targets" "$output/cratis-local.targets"
|
|
2798
|
+
printf '<!-- consumer edit -->\n' >> "$cr/Directory.Packages.props"
|
|
2799
|
+
cp "$cr/Directory.Packages.props" "$root/composition-original.props"
|
|
2800
|
+
expect_status 0 'composition restores preserving consumer edits' cst restore "$cr"
|
|
2801
|
+
has '0 leaks' "$root/output" || failed 'composition restore did not run check'
|
|
2802
|
+
expect_status 0 'composition repeated restore' cst restore "$cr"
|
|
2803
|
+
[ ! -e "$output" ] || failed 'composition overlay survives restore'
|
|
2804
|
+
cmp -s "$cr/Directory.Packages.props" "$root/composition-original.props" || failed 'composition consumer props mutated'
|
|
2805
|
+
# Every live mutation boundary is exercised both as an ordinary failure and
|
|
2806
|
+
# an uncatchable parent/child interruption, then recovered by the next call.
|
|
2807
|
+
for kind in fail interrupt; do
|
|
2808
|
+
for boundary in journal session stage-manifest.tsv stage-Directory.Packages.props stage-cratis-local.targets stage-nuget.config stage-env stage-state.json stage-transaction staged published repo registration-staged registered committed; do
|
|
2809
|
+
printf '%s\n' "$root/another-session" > "$cf/sessions"; cp "$cf/sessions" "$base"
|
|
2810
|
+
composition_fail=''; composition_interrupt=''
|
|
2811
|
+
if [ "$kind" = fail ]; then composition_fail=$boundary; else composition_interrupt=$boundary; fi
|
|
2812
|
+
status=0; cst use-nuget "$cr" --composition "$cm" > "$root/output" 2> "$root/error" || status=$?
|
|
2813
|
+
[ "$status" -ne 0 ] || failed "composition $kind $boundary returned success"
|
|
2814
|
+
[ ! -s "$root/output" ] || failed "composition $kind $boundary printed activation instructions"
|
|
2815
|
+
composition_fail=''; composition_interrupt=''
|
|
2816
|
+
# A recovery-triggering restore must roll back incomplete activation,
|
|
2817
|
+
# or finish a committed activation and then restore it.
|
|
2818
|
+
expect_status 0 "composition recovery $kind $boundary" cst restore "$cr"
|
|
2819
|
+
[ ! -e "$output" ] || failed "composition recovery left publication $boundary"
|
|
2820
|
+
if [ "$boundary" != committed ]; then cmp -s "$cf/sessions" "$base" || failed "composition registry rollback $boundary"; fi
|
|
2821
|
+
cmp -s "$cr/Directory.Packages.props" "$root/composition-original.props" || failed "composition consumer drift $boundary"
|
|
2822
|
+
passed=$((passed + 3))
|
|
2823
|
+
done
|
|
2824
|
+
done
|
|
2825
|
+
expect_status 0 'composition activate before interrupted restoration' cst use-nuget "$cr" --composition "$cm"
|
|
2826
|
+
for boundary in restore-journal restore-renamed restore-committed; do
|
|
2827
|
+
composition_interrupt=$boundary
|
|
2828
|
+
status=0; cst restore "$cr" > "$root/output" 2> "$root/error" || status=$?
|
|
2829
|
+
[ "$status" -ne 0 ] || failed "composition interrupted restoration $boundary"
|
|
2830
|
+
composition_interrupt=''
|
|
2831
|
+
expect_status 0 "composition recover interrupted restoration $boundary" cst restore "$cr"
|
|
2832
|
+
[ ! -e "$output" ] || failed 'composition incomplete restoration recovery'
|
|
2833
|
+
expect_status 0 'composition reactivation after restoration recovery' cst use-nuget "$cr" --composition "$cm"
|
|
2834
|
+
done
|
|
2835
|
+
expect_status 0 'composition final restore' cst restore "$cr"
|
|
2836
|
+
# Another recoverer holds the transition guard after observing this dead owner.
|
|
2837
|
+
# A contender must not touch the stale lock (or its journal) until that transition ends.
|
|
2838
|
+
local dead_owner
|
|
2839
|
+
dead_owner=$(node -p process.pid)
|
|
2840
|
+
kill -0 "$dead_owner" 2>/dev/null && failed 'composition fixture owner is still alive'
|
|
2841
|
+
mkdir "$cf/.composition-lock" "$cf/.composition-recovery"
|
|
2842
|
+
printf '%s\n' "$dead_owner" > "$cf/.composition-lock/owner"
|
|
2843
|
+
expect_status 2 'composition competing stale-lock recoverer is excluded' cst restore "$cr"
|
|
2844
|
+
[ "$(cat "$cf/.composition-lock/owner")" = "$dead_owner" ] || failed 'composition contender removed stale lock'
|
|
2845
|
+
[ -d "$cf/.composition-recovery" ] || failed 'composition contender removed recovery guard'
|
|
2846
|
+
rmdir "$cf/.composition-recovery"
|
|
2847
|
+
expect_status 0 'composition stale-lock takeover after recovery guard release' cst restore "$cr"
|
|
2848
|
+
[ ! -e "$cf/.composition-lock" ] || failed 'composition stale-lock takeover left lock'
|
|
2849
|
+
mkdir -p "$cf/.composition-lock"; printf 'unknown owner\n' > "$cf/.composition-lock/owner"
|
|
2850
|
+
expect_status 2 'composition unknown ownership fails closed' cst use-nuget "$cr" --composition "$cm"
|
|
2851
|
+
grep -Fq 'unknown owner' "$cf/.composition-lock/owner" || failed 'composition deleted unknown owner'
|
|
2852
|
+
rm "$cf/.composition-lock/owner"; rmdir "$cf/.composition-lock"
|
|
2853
|
+
mkdir -p "$cf/.composition-lock"; printf '%s\n' "$$" > "$cf/.composition-lock/owner"
|
|
2854
|
+
expect_status 2 'composition concurrent activation lock' cst use-nuget "$cr" --composition "$cm"
|
|
2855
|
+
rm "$cf/.composition-lock/owner"; rmdir "$cf/.composition-lock"
|
|
2856
|
+
mkdir -p "$cr/.ai-work/local-packages/npm-backup"; printf 'unrelated npm backup\n' > "$cr/.ai-work/local-packages/npm-backup/package.json"
|
|
2857
|
+
expect_status 2 'composition preserves incompatible npm backup' cst use-nuget "$cr" --composition "$cm"
|
|
2858
|
+
grep -Fq 'unrelated npm backup' "$cr/.ai-work/local-packages/npm-backup/package.json" || failed 'composition npm backup drift'
|
|
2859
|
+
passed=$((passed + 1))
|
|
1766
2860
|
}
|
|
1767
2861
|
|
|
1768
2862
|
case "$MODE" in
|
|
@@ -1773,11 +2867,11 @@ case "$MODE" in
|
|
|
1773
2867
|
verify-assets) verify_assets "$@" ;;
|
|
1774
2868
|
pack-nuget) pack_nuget "$@" ;;
|
|
1775
2869
|
pack-npm) pack_npm "$@" ;;
|
|
1776
|
-
use-nuget) use_nuget "$@" ;;
|
|
1777
|
-
use-npm) use_npm "$@" ;;
|
|
1778
|
-
restore) restore "$@" ;;
|
|
2870
|
+
use-nuget) acquire_session_lock; use_nuget "$@" ;;
|
|
2871
|
+
use-npm) acquire_session_lock; use_npm "$@" ;;
|
|
2872
|
+
restore) acquire_session_lock; restore "$@" ;;
|
|
1779
2873
|
check) check "$@" ;;
|
|
1780
|
-
clean) clean "$@" ;;
|
|
2874
|
+
clean) acquire_session_lock; clean "$@" ;;
|
|
1781
2875
|
--self-test) self_test "$@" ;;
|
|
1782
2876
|
*) die "unknown command: $MODE (use --help)" ;;
|
|
1783
2877
|
esac
|