@cratis/pi 7.103.7 → 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.
@@ -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
- next_base "$repo"
235
- if [ -z "$base" ] || base_above "$NEXT_BASE" "$base"; then base=$NEXT_BASE; fi
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
- local repo version session props id file config='' props_key sources_key action pin=0 args=()
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
- printf '<Project>\n <Import Project="%s" />\n' "$(xml_escape "$props")"
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
- action=Include
453
- if grep -Fxiq -- "$id" "$TMP/consumer-ids"; then action=Update; fi
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
- printf ' </ItemGroup>\n</Project>\n'
536
+ nuget_overlay_end
457
537
  } > "$session/Directory.Packages.props"
458
- props_key=DirectoryPackages"PropsPath"; sources_key=RestoreAdditional"ProjectSources"
459
538
  {
460
- printf 'export %s=%q\n' "$props_key" "$session/Directory.Packages.props"
461
- printf 'export %s=%q\n' "$sources_key" "$FEED/nuget"
462
- } > "$session/env"
463
- printf ' %q' "-p:$props_key=$session/Directory.Packages.props" "-p:$sources_key=$FEED/nuget"; printf '\n'
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('&','&amp;').replaceAll('<','&lt;').replaceAll('"','&quot;').replaceAll("'",'&apos;');
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
- scratch; root=$TMP/self-test; mkdir -p "$root/home" "$root/repo"
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 "Import Project=\"$repo/Directory.Packages.props\"" "$output/Directory.Packages.props" || failed 'self-test: overlay import'
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
- node - "$repo/yarn.lock" "$feed/npm/@cratis-test-$version.tgz" "$version" <<'NODE'
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" -eq "$want" ] || failed "self-test: $desc (exit $got, wanted $want)"
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