@cratis/pi 7.103.7 → 7.105.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,25 +53,47 @@ 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).
55
- pack-nuget PRODUCER STAMP [--property NAME=VALUE]... PROJECT-OR-SLN...
56
+ verify-pack --composition FILE [--json]
57
+ verify-assets CONSUMER-PATH --composition FILE [--json]
58
+ use-nuget CONSUMER --composition FILE [--task ID] [--pin-transitive] [--private-cache]
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.
69
+ list [--json] [--stamp STAMP]
70
+ Read-only inventory of feed artifacts and stamp records, including legacy stamps.
71
+ Shows producer, task state, live/total consumers and age. Requires node; npm
72
+ package names are read from tarballs with tar. Takes no session lock.
73
+ finish --task ID [--apply]
74
+ Print records naming ID; --apply marks that task finished. No match is success.
75
+ Finishing never restores consumers or removes packages. Requires node.
76
+ pack-nuget PRODUCER STAMP [--task ID] [--property NAME=VALUE]... PROJECT-OR-SLN...
56
77
  Release-pack each target into the NuGet feed; requires dotnet. Each --property
57
78
  becomes one -p:NAME=VALUE argument of that command only (closure prints the
58
79
  properties of each map pack row); no environment variable carries them.
59
- pack-npm PRODUCER STAMP
80
+ pack-npm PRODUCER STAMP [--task ID]
60
81
  Requires node, npm and Yarn 4, and a completed repository build. Temporarily
61
82
  version non-private workspaces, pack each with npm pack --ignore-scripts (the
62
83
  publish workflows' tool, so the tarball matches the published one) as
63
84
  <scope>-<name>-<version>.tgz, and restore tracked manifests even on failure.
64
85
  This helper does not build or run lifecycle scripts.
65
- use-nuget CONSUMER STAMP [--pin-transitive] [PACKAGE-ID...]
86
+ use-nuget CONSUMER STAMP [--task ID] [--pin-transitive] [--private-cache] [PACKAGE-ID...]
66
87
  Create a CPM overlay importing the repository's own Directory.Packages.props.
67
88
  Transitive pinning stays as the consumer sets it; --pin-transitive forces it on so
68
89
  stamped IDs the consumer does not pin still take effect (can raise NU1109 floors).
69
90
  Defaults to every stamped package ID in the feed. Prints properties and sourceable
70
91
  env. With source mapping, also prints the required restore --configfile.
92
+ --private-cache (or CRATIS_LOCAL_PRIVATE_CACHE=1) exports NUGET_PACKAGES in env
93
+ and registers a marked cache at <absolute-git-dir>/cratis-local-nuget.
71
94
  Non-CPM: manually use an untracked project copy with explicit PackageReference
72
95
  versions and dotnet restore --source FEED/nuget; do not edit shipped versions.
73
- use-npm CONSUMER STAMP [--dir REL]
96
+ use-npm CONSUMER STAMP [--task ID] [--dir REL]
74
97
  --dir REL names a Yarn project in a subdirectory (restore and clean follow it).
75
98
  Requires node and tar. Back up package.json, yarn.lock (including existing edits),
76
99
  and git diff; add file-tarball resolutions for every stamped feed package.
@@ -79,16 +102,37 @@ Commands (COMMAND --help shows these instructions too):
79
102
  Restore npm backups when only overlay resolutions and their local lockfile
80
103
  entries changed (including yarn install). Refuse unrelated changes and retain
81
104
  backups for review. Remove CPM/env overlays
82
- and run check. No git checkout is used on consumer files.
105
+ and run check. Keep the private NuGet cache. No git checkout is used on consumer files.
83
106
  check REPO [--staged]
84
107
  Scan added/changed tracked lines (staged-only if requested), plus all contents
85
108
  of untracked, non-ignored files. Reports path:line:pattern and file counts.
86
- clean [CONSUMER] [--stamp STAMP | --all] [--apply] [--dir REL]
109
+ clean [CONSUMER] [--stamp STAMP | --all | --expired [--online]] [--apply] [--dir REL] [--private-cache]
87
110
  Default: list all local feed/cache/image/session subjects, without deleting.
88
111
  --apply deletes listed subjects (docker rmi, never force). Refuses more than
89
112
  CRATIS_LOCAL_CLEAN_MAX subjects (default 500), or unresolved npm backups.
113
+ Refuses selected stamps referenced by registered live consumer overlays/manifests;
114
+ restore those consumers first. Dry-run uses the same protection.
90
115
  Known sessions are registered in FEED/sessions; an optional consumer also
91
- discovers its per-worktree session. Docker probes have a 10-second deadline.
116
+ discovers its per-worktree session. Registered private caches are also scanned;
117
+ missing/mismatched markers or unsafe cache paths refuse the entire clean (2).
118
+ clean CONSUMER --private-cache lists the whole marked cache for removal; --apply
119
+ removes it and its registration only after restore and with no live stamp.
120
+ Removes selected FEED/stamps/STAMP records with the artifacts on --apply.
121
+ --expired keeps unrecorded stamps, compositions and live references; expires
122
+ finished producing tasks, gone consumers or stamps superseded by a release.
123
+ Release evidence is offline NuGet caches unless --online opts into 10-second
124
+ NuGet/npm checks. Kept stamps succeed (0). --apply also prunes gone consumers
125
+ and missing sessions. Uses the same fuse, lock and dry-run default.
126
+ Docker probes have a 10-second deadline.
127
+
128
+ Pack/use commands (including --composition) accept --task ID, overriding
129
+ CRATIS_LOCAL_TASK, else record null. Convention: owner/repo#number. IDs match
130
+ ^[A-Za-z0-9][A-Za-z0-9._#:/-]{0,127}$ and are JSON values, never path components.
131
+ Registry mutations require node. Successful packs record producer HEAD and branch;
132
+ use records consumers after publication; restore retains their history.
133
+ Invalid records: pack/use and selected clean subjects refuse with 2. Restore reads only
134
+ session stamps after restoring overlays; finish skips invalid records with WARNINGs.
135
+ Restore/finish exit 1 for invalid records; list shows invalid rows and exits 1.
92
136
  --self-test
93
137
  Isolated scratch HOME tests; npm uses inherited PATH to discover node.
94
138
  Other tests use system PATH. Reports passed and skipped counts.
@@ -98,6 +142,8 @@ if present (trusted shell code), then use these defaults for unset variables:
98
142
  CRATIS_LOCAL_FEED=${XDG_CACHE_HOME:-$HOME/.cache}/cratis/local-feed
99
143
  CRATIS_LOCAL_SESSION_DIR=<absolute-git-dir>/cratis-local (per checkout)
100
144
  NUGET_PACKAGES=$HOME/.nuget/packages
145
+ CRATIS_LOCAL_PRIVATE_CACHE=0 (1 opts use-nuget into a private worktree cache)
146
+ CRATIS_LOCAL_TASK=optional task ID (overridden by --task ID)
101
147
  CRATIS_LOCAL_CLEAN_MAX=500
102
148
  CRATIS_LOCAL_RUN=optional executable wrapper, invoked for each heavy step as:
103
149
  "$CRATIS_LOCAL_RUN" build LABEL -- COMMAND ARGUMENTS...
@@ -132,10 +178,28 @@ fi
132
178
  FEED=${CRATIS_LOCAL_FEED:-${XDG_CACHE_HOME:-$HOME/.cache}/cratis/local-feed}
133
179
  NUGET_CACHE=${NUGET_PACKAGES:-$HOME/.nuget/packages}
134
180
  case "$FEED" in /*) ;; *) FEED=$PWD/$FEED ;; esac
181
+ # The configured spelling (for example /var/... on macOS, or a symlinked cache) is kept
182
+ # so leak scans catch consumer references written through either path.
183
+ FEED_ALIAS=$FEED
184
+ # Resolve configured/system symlink parents without creating a missing feed.
185
+ # Subsequent checks concern helper-owned components, not /tmp, /var or HOME.
186
+ resolve_feed() {
187
+ local directory=$FEED suffix='' physical
188
+ while [ ! -d "$directory" ]; do
189
+ [ ! -e "$directory" ] && [ ! -L "$directory" ] || die "not a feed directory: $directory"
190
+ suffix=/${directory##*/}$suffix
191
+ directory=${directory%/*}; [ -n "$directory" ] || directory=/
192
+ done
193
+ physical=$(cd -P "$directory" && pwd -P) || die "cannot resolve feed: $FEED"
194
+ if [ -n "$suffix" ]; then FEED=${physical%/}$suffix; else FEED=$physical; fi
195
+ }
196
+ [ "$MODE" = --self-test ] || resolve_feed
135
197
  case "$NUGET_CACHE" in /*) ;; *) NUGET_CACHE=$PWD/$NUGET_CACHE ;; esac
136
198
  TMP=''
137
199
  PACK_REPO=''
138
200
  PACK_MANIFESTS=()
201
+ SESSION_LOCK=''
202
+ SESSION_RECOVERY_GUARD=''
139
203
  cleanup() {
140
204
  local status=$?
141
205
  trap - EXIT
@@ -145,6 +209,14 @@ cleanup() {
145
209
  status=1
146
210
  fi
147
211
  fi
212
+ if [ -n "$SESSION_LOCK" ] && [ ! -e "$SESSION_LOCK/journal.json" ] &&
213
+ [ -f "$SESSION_LOCK/owner" ] && [ "$(cat "$SESSION_LOCK/owner")" = "$$" ]; then
214
+ rm -- "$SESSION_LOCK/owner"
215
+ rmdir -- "$SESSION_LOCK" || status=1
216
+ fi
217
+ if [ -n "$SESSION_RECOVERY_GUARD" ]; then
218
+ rmdir -- "$SESSION_RECOVERY_GUARD" || status=1
219
+ fi
148
220
  if [ -n "$TMP" ]; then rm -rf -- "$TMP"; fi
149
221
  exit "$status"
150
222
  }
@@ -187,8 +259,8 @@ validate_session() {
187
259
  [ -e "$entry" ] || [ -L "$entry" ] || continue
188
260
  [ ! -L "$entry" ] || die "symlink in session: $entry"
189
261
  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" ;;
262
+ repo|stamp|env|npm-dir|nuget-cache|Directory.Packages.props|cratis-local.targets|nuget.config) [ -f "$entry" ] || die "unexpected session entry: $entry" ;;
263
+ npm-backup|composition) [ -d "$entry" ] || die "unexpected session entry: $entry" ;;
192
264
  *) die "unrecognized file in session (preserved): $entry" ;;
193
265
  esac
194
266
  done
@@ -204,6 +276,50 @@ register_session() {
204
276
  printf '%s\n' "$session" >> "$FEED/sessions"
205
277
  fi
206
278
  }
279
+ # Validate every helper-owned path component before trusting a cache registration.
280
+ validate_private_cache() {
281
+ local repo=$1 cache=$2 git_dir parent relative
282
+ git_dir=$(git -C "$repo" rev-parse --absolute-git-dir) || die "cannot find cache repository: $repo"
283
+ case "$cache" in "$git_dir/"*) relative=${cache#"$git_dir/"} ;; *) die "private cache outside repository git directory: $cache" ;; esac
284
+ case "/$relative/" in */../*|*/./*|*//*) die "unsafe private cache path: $cache" ;; esac
285
+ parent=$cache
286
+ while [ "$parent" != "$git_dir" ]; do
287
+ [ -d "$parent" ] && [ ! -L "$parent" ] || die "unsafe private cache directory: $parent"
288
+ parent=${parent%/*}
289
+ done
290
+ [ -f "$cache/.cratis-local-cache" ] && [ ! -L "$cache/.cratis-local-cache" ] || die "private cache marker missing: $cache"
291
+ [ "$(cat "$cache/.cratis-local-cache")" = "$repo" ] || die "private cache marker mismatch: $cache"
292
+ }
293
+ registered_private_cache() {
294
+ local session=$1 repo cache
295
+ validate_session "$session"
296
+ [ -f "$session/repo" ] || die "private cache session has no repository: $session"
297
+ repo=$(cat "$session/repo"); cache=$(cat "$session/nuget-cache")
298
+ [ "$(repo_root "$repo")" = "$repo" ] && [ "$(session_path "$repo")" = "$session" ] || die "private cache session no longer matches repository: $session"
299
+ validate_private_cache "$repo" "$cache"
300
+ printf '%s\n' "$cache"
301
+ }
302
+ prepare_private_cache() {
303
+ local repo=$1 cache=$2 session=$3
304
+ validate_session "$session"
305
+ if [ -e "$cache" ] || [ -L "$cache" ]; then validate_private_cache "$repo" "$cache"; fi
306
+ if [ -f "$session/nuget-cache" ]; then
307
+ [ "$(registered_private_cache "$session")" = "$cache" ] || die "different private cache already registered: $session"
308
+ fi
309
+ }
310
+ register_private_cache() {
311
+ local repo=$1 cache=$2 session=$3
312
+ prepare_private_cache "$repo" "$cache" "$session"
313
+ mkdir -p "$cache"
314
+ printf '%s\n' "$repo" > "$cache/.cratis-local-cache"
315
+ printf '%s\n' "$cache" > "$session/nuget-cache"
316
+ }
317
+ private_cache_restored() {
318
+ local session=$1 entry
319
+ for entry in stamp env Directory.Packages.props cratis-local.targets nuget.config npm-backup composition; do
320
+ [ ! -e "$session/$entry" ] && [ ! -L "$session/$entry" ] || die "private cache still referenced by $session/$entry; restore first"
321
+ done
322
+ }
207
323
  xml_escape() { printf '%s' "$1" | sed -e 's/\&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g' -e 's/"/\&quot;/g' -e "s/'/\&apos;/g"; }
208
324
 
209
325
  # Next patch of the highest reachable vX.Y.Z tag; sets NEXT_BASE.
@@ -227,12 +343,31 @@ base_above() {
227
343
 
228
344
  stamp() {
229
345
  [ $# -ge 1 ] || die 'stamp requires a producer repository'
230
- local repo base='' sha='' arg train=0 lines='' rname origin
346
+ local repo base='' override='' sha='' arg train=0 lines='' rname origin
347
+ local producers=()
231
348
  if [ "$1" = --train ]; then train=1; shift; [ $# -ge 1 ] || die 'stamp --train requires a producer repository'; fi
349
+ while [ $# -gt 0 ]; do
350
+ arg=$1; shift
351
+ case "$arg" in
352
+ --base-version)
353
+ [ $# -gt 0 ] || die '--base-version requires X.Y.Z'
354
+ [ -z "$override" ] || die '--base-version may only be supplied once'
355
+ [[ "$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)"
356
+ override=$1; shift ;;
357
+ --train) train=1 ;;
358
+ -*) die "unknown stamp option: $arg" ;;
359
+ *) producers+=("$arg") ;;
360
+ esac
361
+ done
362
+ [ ${#producers[@]} -gt 0 ] || die 'stamp requires a producer repository'
363
+ set -- "${producers[@]}"
364
+ base=$override
232
365
  for arg in "$@"; do
233
366
  repo=$(repo_root "$arg")
234
- next_base "$repo"
235
- if [ -z "$base" ] || base_above "$NEXT_BASE" "$base"; then base=$NEXT_BASE; fi
367
+ if [ -z "$override" ]; then
368
+ next_base "$repo"
369
+ if [ -z "$base" ] || base_above "$NEXT_BASE" "$base"; then base=$NEXT_BASE; fi
370
+ fi
236
371
  sha=$(git -C "$repo" rev-parse HEAD) || die 'producer has no HEAD commit'
237
372
  if [ $# -gt 1 ]; then printf 'HEAD %s %s\n' "$repo" "$sha" >&2; fi
238
373
  # The repository name is its origin's last path part (stable across checkouts), else its folder name.
@@ -252,9 +387,326 @@ stamp() {
252
387
  printf '%s-local.%s.g%.7s\n' "$base" "$(date -u +%Y%m%d%H%M)" "$sha"
253
388
  }
254
389
 
390
+ # All stamp-record mutations use this path, after builds or overlay publication.
391
+ registry_update() {
392
+ [ "$DRY" -eq 0 ] || return 0
393
+ [ -n "$SESSION_LOCK" ] || acquire_session_lock
394
+ registry "$@"
395
+ }
396
+ registry_consumer() {
397
+ if ! (registry_update use "$@"); then
398
+ printf 'WARNING: stamp registry update failed; overlay kept; restore before cleaning\n' >&2
399
+ exit 1
400
+ fi
401
+ }
402
+ registry() {
403
+ need node
404
+ case "$1" in list|expiry) expiry_context "$@" ;; esac
405
+ node - "$FEED" "$TASK" "$TMP" "$@" <<'REGISTRY'
406
+ const fs = require('node:fs'), path = require('node:path'), cp = require('node:child_process');
407
+ const [feed, taskValue, scratch, operation, ...args] = process.argv.slice(2);
408
+ const task = taskValue || null, root = path.join(feed, 'stamps');
409
+ const stampPattern = /^([0-9]+\.[0-9]+\.[0-9]+)-(local\.[0-9]{12}|train)\.g[0-9a-f]{7}$/;
410
+ const taskPattern = /^[A-Za-z0-9][A-Za-z0-9._#:/-]{0,127}$/;
411
+ const now = new Date().toISOString();
412
+ function fail(message) { throw new Error(message); }
413
+ function safe(file, directory = false) {
414
+ const relative = path.relative(feed, file);
415
+ if (relative.startsWith('..') || path.isAbsolute(relative)) fail('outside stamp registry: ' + file);
416
+ let current = feed;
417
+ for (const part of ['', ...relative.split(path.sep).filter(Boolean)]) {
418
+ current = path.join(current, part);
419
+ const stat = fs.lstatSync(current, {throwIfNoEntry:false});
420
+ if (stat && (stat.isSymbolicLink() || (current !== file || directory ? !stat.isDirectory() : !stat.isFile()))) fail('unsafe registry path: ' + current);
421
+ }
422
+ }
423
+ function recordPath(stamp) {
424
+ if (!stampPattern.test(stamp)) fail('invalid registry stamp: ' + stamp);
425
+ const file = path.join(root, stamp, 'record.json'); safe(file); return file;
426
+ }
427
+ function read(stamp) {
428
+ const file = recordPath(stamp);
429
+ if (!fs.existsSync(file)) return null;
430
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'));
431
+ const match = stamp.match(stampPattern);
432
+ if (value.schema !== 1 || value.stamp !== stamp || value.base !== match[1] || value.kind !== (match[2] === 'train' ? 'train' : 'local') ||
433
+ !Array.isArray(value.packs) || !Array.isArray(value.consumers) || !value.tasks || typeof value.tasks !== 'object' || Array.isArray(value.tasks) ||
434
+ !Number.isFinite(Date.parse(value.created)) || !Number.isFinite(Date.parse(value.updated))) fail('invalid stamp record: ' + file);
435
+ for (const pack of value.packs) {
436
+ if (!['nuget','npm'].includes(pack.ecosystem) || !Array.isArray(pack.ids) || pack.ids.some(id => typeof id !== 'string') ||
437
+ !pack.producer || ['repo','worktree','branch','head'].some(k => typeof pack.producer[k] !== 'string') ||
438
+ !(pack.task === null || typeof pack.task === 'string' && taskPattern.test(pack.task)) || !Number.isFinite(Date.parse(pack.time))) fail('invalid pack record: ' + file);
439
+ }
440
+ for (const consumer of value.consumers) {
441
+ if (['worktree','session','ecosystem','time'].some(k => typeof consumer[k] !== 'string') || !['nuget','npm'].includes(consumer.ecosystem) ||
442
+ !(consumer.task === null || typeof consumer.task === 'string' && taskPattern.test(consumer.task)) ||
443
+ !Number.isFinite(Date.parse(consumer.time)) || consumer.restored && !Number.isFinite(Date.parse(consumer.restored))) fail('invalid consumer record: ' + file);
444
+ }
445
+ for (const [id, state] of Object.entries(value.tasks)) {
446
+ if (!taskPattern.test(id) || !state || !(state.finished === null || typeof state.finished === 'string' && Number.isFinite(Date.parse(state.finished)))) fail('invalid task record: ' + file);
447
+ }
448
+ return value;
449
+ }
450
+ function warning(stamp, error) {
451
+ console.error('WARNING: stamp registry: ' + path.join(root, stamp, 'record.json') + ': ' + error.message);
452
+ process.exitCode = 1;
453
+ }
454
+ function sessionStamps(session) {
455
+ const files = ['stamp','Directory.Packages.props','cratis-local.targets','nuget.config','env','npm-backup/expected-package.json','composition/manifest.tsv'];
456
+ const found = new Set();
457
+ for (const name of files) {
458
+ const file = path.join(session, name);
459
+ if (!fs.existsSync(file)) continue;
460
+ for (const stamp of fs.readFileSync(file, 'utf8').match(/[0-9]+\.[0-9]+\.[0-9]+-(?:local\.[0-9]{12}|train)\.g[0-9a-f]{7}/g) || []) found.add(stamp);
461
+ }
462
+ for (const stamp of found) console.log(stamp);
463
+ }
464
+ function stamps() {
465
+ safe(root, true);
466
+ if (!fs.existsSync(root)) return [];
467
+ return fs.readdirSync(root).filter(s => stampPattern.test(s)).sort();
468
+ }
469
+ function write(value) {
470
+ const file = recordPath(value.stamp), next = file + '.next'; safe(next);
471
+ fs.mkdirSync(path.dirname(file), {recursive:true, mode:0o700});
472
+ value.updated = now;
473
+ fs.writeFileSync(next, JSON.stringify(value, null, 2) + '\n', {mode:0o600});
474
+ fs.chmodSync(next, 0o600); fs.renameSync(next, file);
475
+ }
476
+ function consumerState(consumer) {
477
+ if (consumer.restored) return 'restored';
478
+ if (!fs.existsSync(consumer.worktree)) return 'gone';
479
+ const git = cp.spawnSync('git', ['-C', consumer.worktree, 'rev-parse', '--show-toplevel'], {encoding:'utf8'});
480
+ if (git.status !== 0 || git.stdout.trim() !== consumer.worktree) return 'gone';
481
+ return ['env','Directory.Packages.props','cratis-local.targets','nuget.config','npm-backup','composition'].some(n => fs.existsSync(path.join(consumer.session,n))) ? 'live' : 'restored';
482
+ }
483
+ function packages() {
484
+ const found = new Map();
485
+ for (const ecosystem of ['nuget','npm']) {
486
+ const directory = path.join(feed,ecosystem); safe(directory,true);
487
+ if (!fs.existsSync(directory)) continue;
488
+ for (const name of fs.readdirSync(directory)) {
489
+ const match = name.match(/^(.*?)[.-]([0-9]+\.[0-9]+\.[0-9]+-(?:local\.[0-9]{12}|train)\.g[0-9a-f]{7})\.(nupkg|tgz)$/);
490
+ if (!match || (ecosystem === 'nuget' ? match[3] !== 'nupkg' : match[3] !== 'tgz')) continue;
491
+ const file = path.join(directory,name); safe(file);
492
+ let id = match[1];
493
+ if (ecosystem === 'npm') id = JSON.parse(cp.execFileSync('tar',['-xOf',file,'package/package.json'],{encoding:'utf8'})).name;
494
+ if (typeof id !== 'string' || !id) fail('invalid package ID: ' + file);
495
+ const entries = found.get(match[2]) || [];
496
+ entries.push({id,version:match[2],ecosystem,file}); found.set(match[2],entries);
497
+ }
498
+ }
499
+ return found;
500
+ }
501
+ function worktreeGone(consumer) {
502
+ if (!fs.statSync(consumer.worktree,{throwIfNoEntry:false})?.isDirectory()) return true;
503
+ const git = cp.spawnSync('git',['-C',consumer.worktree,'rev-parse','--show-toplevel'],{encoding:'utf8'});
504
+ return git.status !== 0 || git.stdout.trim() !== consumer.worktree;
505
+ }
506
+ function gone(consumer) {
507
+ return !fs.statSync(consumer.session,{throwIfNoEntry:false})?.isDirectory() || worktreeGone(consumer);
508
+ }
509
+ function expiryInputs() {
510
+ const selectedIndex = args.indexOf('--stamp'), selected = selectedIndex < 0 ? null : args[selectedIndex + 1];
511
+ const sessions = path.join(feed,'sessions'); safe(sessions);
512
+ if (fs.existsSync(sessions)) {
513
+ for (const session of fs.readFileSync(sessions,'utf8').split('\n').filter(Boolean)) {
514
+ const repo = path.join(session,'repo');
515
+ if (fs.existsSync(repo)) console.log(fs.readFileSync(repo,'utf8').trim() + '\t' + session);
516
+ else console.log('\t' + session);
517
+ }
518
+ }
519
+ for (const stamp of selected ? [selected] : stamps()) {
520
+ let value;
521
+ try { value = read(stamp); } catch (error) { if (args[0] === 'list') continue; throw error; }
522
+ for (const c of value?.consumers || []) console.log(c.worktree + '\t' + c.session);
523
+ }
524
+ }
525
+ function contextLines(name) { return fs.readFileSync(path.join(scratch,'expiry-' + name),'utf8').split('\n').filter(Boolean); }
526
+ function atLeast(version, base) {
527
+ if (!/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(version)) return false;
528
+ const a = version.split('.').map(BigInt), b = base.split('.').map(BigInt);
529
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
530
+ return true;
531
+ }
532
+ function offlineRelease(value) {
533
+ for (const pack of value.packs.filter(p => p.ecosystem === 'nuget')) for (const id of pack.ids) {
534
+ if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(id)) fail('invalid NuGet release ID: ' + id);
535
+ for (const cache of contextLines('caches')) {
536
+ const directory = path.join(cache,id.toLowerCase());
537
+ // A missing cache is no evidence; actual scan/read errors fail closed.
538
+ if (!fs.statSync(directory,{throwIfNoEntry:false})) continue;
539
+ for (const version of fs.readdirSync(directory).sort()) {
540
+ if (atLeast(version,value.base) && fs.statSync(path.join(directory,version)).isDirectory() &&
541
+ fs.statSync(path.join(directory,version,'.nupkg.metadata'),{throwIfNoEntry:false})?.isFile()) return 'released:' + id + '@' + version;
542
+ }
543
+ }
544
+ }
545
+ return null;
546
+ }
547
+ async function onlineRelease(value) {
548
+ for (const pack of value.packs) for (const id of pack.ids) {
549
+ // Test-only override: hermetic unreachable-host fixtures, never a feed setting.
550
+ const override = process.env.CRATIS_LOCAL_RELEASE_URL_BASE;
551
+ const url = pack.ecosystem === 'nuget' ? (override || 'https://api.nuget.org/v3-flatcontainer') + '/' + encodeURIComponent(id.toLowerCase()) + '/index.json' :
552
+ (override || 'https://registry.npmjs.org') + '/' + encodeURIComponent(id);
553
+ try {
554
+ const response = await fetch(url,{signal:AbortSignal.timeout(10000)});
555
+ if (!response.ok) continue;
556
+ const data = await response.json(), versions = pack.ecosystem === 'nuget' ? data.versions : Object.keys(data.versions || {});
557
+ if (!Array.isArray(versions)) continue;
558
+ const version = versions.find(v => typeof v === 'string' && atLeast(v,value.base));
559
+ if (version) return 'released:' + id + '@' + version;
560
+ } catch { /* Network failures are not release evidence and never fail cleanup. */ }
561
+ }
562
+ return null;
563
+ }
564
+ async function expiry(stamp, value, online = false) {
565
+ const kept = reason => ({expired:false,reasons:[],kept:[reason]});
566
+ if (!value) return kept('unrecorded; use clean --stamp ' + stamp);
567
+ if (contextLines('compositions').includes(stamp)) return kept('active composition member');
568
+ const reference = contextLines('live').find(line => line.split('\t')[0] === stamp);
569
+ if (reference) return kept('live reference in ' + reference.slice(reference.indexOf('\t') + 1));
570
+ const tasks = value.packs.map(p => p.task).filter(t => t !== null);
571
+ let reason = tasks.length && tasks.every(t => value.tasks[t]?.finished) ? 'task-finished' : null;
572
+ if (!reason && value.consumers.length && value.consumers.every(gone)) reason = 'consumers-gone';
573
+ if (!reason) reason = offlineRelease(value);
574
+ if (!reason && online) reason = await onlineRelease(value);
575
+ return reason ? {expired:true,reasons:[reason],kept:[]} : kept('active');
576
+ }
577
+ async function expired() {
578
+ const names = [...new Set([...packages().keys(),...stamps()])].sort();
579
+ // Validate every subject before reporting decisions or allowing any deletion.
580
+ const records = names.map(stamp => [stamp,read(stamp)]);
581
+ for (const [stamp,value] of records) {
582
+ const decision = await expiry(stamp,value,args.includes('--online'));
583
+ console.log([decision.expired ? 'expired' : 'kept',stamp,(decision.expired ? decision.reasons : decision.kept).join('; ')].join('\t'));
584
+ }
585
+ }
586
+ function prune() {
587
+ const values = stamps().map(read), sessions = path.join(feed,'sessions'); safe(sessions);
588
+ const before = fs.existsSync(sessions) ? fs.readFileSync(sessions,'utf8') : null;
589
+ for (const value of values.filter(Boolean)) {
590
+ // Keep a consumer while its worktree exists: its manifests are still scanned for live references.
591
+ const consumers = value.consumers.filter(c => !worktreeGone(c));
592
+ if (consumers.length !== value.consumers.length) { value.consumers = consumers; write(value); }
593
+ }
594
+ if (before !== null) {
595
+ const after = before.split('\n').filter(s => s && fs.statSync(s,{throwIfNoEntry:false})?.isDirectory()).join('\n');
596
+ const next = sessions + '.next'; safe(next);
597
+ fs.writeFileSync(next,after ? after + '\n' : '',{mode:0o600}); fs.renameSync(next,sessions);
598
+ }
599
+ }
600
+ async function list() {
601
+ const json = args.includes('--json'), index = args.indexOf('--stamp');
602
+ const selected = index < 0 ? null : args[index + 1];
603
+ const remaining = args.filter((_,i) => i !== index && i !== index + 1 || index < 0);
604
+ if (remaining.some(a => a !== '--json') || index >= 0 && !stampPattern.test(selected || '')) fail('list accepts --json and --stamp STAMP');
605
+ const artifacts = packages(), names = [...new Set([...artifacts.keys(), ...stamps()])].sort();
606
+ const rows = [];
607
+ for (const stamp of names.filter(s => !selected || s === selected)) {
608
+ let value = null, error;
609
+ try { value = read(stamp); } catch (failure) { error = failure.message; warning(stamp, failure); }
610
+ const match = stamp.match(stampPattern), created = value?.created || null;
611
+ rows.push({stamp,base:match[1],kind:match[2] === 'train' ? 'train' : 'local',recorded:error ? 'invalid' : !!value,...(error ? {error} : {}),packages:artifacts.get(stamp) || [],
612
+ producers:value?.packs.map(p => p.producer) || [],tasks:Object.entries(value?.tasks || {}).map(([id,s]) => ({id,finished:s.finished})),
613
+ consumers:value?.consumers.map(c => ({worktree:c.worktree,task:c.task,state:consumerState(c)})) || [],created,
614
+ ageSeconds:created ? Math.max(0,Math.floor((Date.now()-Date.parse(created))/1000)) : null,
615
+ expiry:error ? {expired:false,reasons:[],kept:['invalid record']} : await expiry(stamp,value)});
616
+ }
617
+ if (json) console.log(JSON.stringify({schema:1,feed,stamps:rows}));
618
+ else {
619
+ console.log('STAMP\tECOSYS\tPACKAGES(n)\tOWNER(repo@branch)\tTASK(state)\tCONSUMERS(live/total)\tAGE\tRECORDED');
620
+ for (const r of rows) console.log([r.stamp,[...new Set(r.packages.map(p => p.ecosystem))].join(',') || '-',r.packages.length,
621
+ r.producers.map(p => p.repo+'@'+p.branch).join(',') || '-',r.tasks.map(t => t.id+'('+ (t.finished ? 'finished' : 'active') +')').join(',') || '-',
622
+ r.consumers.filter(c => c.state === 'live').length+'/'+r.consumers.length,r.ageSeconds === null ? '-' : r.ageSeconds+'s',r.recorded].join('\t'));
623
+ }
624
+ }
625
+ function update() {
626
+ const [stamp, ecosystem, repo, session, cache, ...ids] = args;
627
+ let value = read(stamp);
628
+ if (!value) {
629
+ const match = stamp.match(stampPattern);
630
+ value = {schema:1,stamp,base:match[1],kind:match[2] === 'train' ? 'train' : 'local',created:now,updated:now,packs:[],consumers:[],tasks:{}};
631
+ }
632
+ if (task && !Object.hasOwn(value.tasks,task)) Object.defineProperty(value.tasks,task,{value:{finished:null},enumerable:true,writable:true,configurable:true});
633
+ if (operation === 'pack') {
634
+ const git = (...argv) => cp.execFileSync('git',['-C',repo,...argv],{encoding:'utf8'}).trim();
635
+ const producer = {repo:path.basename(repo),worktree:repo,branch:git('branch','--show-current'),head:git('rev-parse','HEAD')};
636
+ const entry = {ecosystem,ids,producer,task,time:now};
637
+ const index = value.packs.findIndex(p => p.ecosystem === ecosystem && p.producer.worktree === repo && p.task === task);
638
+ if (index < 0) value.packs.push(entry); else value.packs[index] = {...entry,ids:[...new Set([...value.packs[index].ids,...ids])]};
639
+ } else {
640
+ const entry = {worktree:repo,session,task,ecosystem,time:now,...(cache ? {cache} : {})};
641
+ const index = value.consumers.findIndex(c => c.worktree === repo && c.session === session && c.ecosystem === ecosystem && c.task === task);
642
+ if (index < 0) value.consumers.push(entry); else value.consumers[index] = entry;
643
+ }
644
+ write(value);
645
+ }
646
+ async function main() {
647
+ if (operation === 'list') await list();
648
+ else if (operation === 'expiry-inputs') expiryInputs();
649
+ else if (operation === 'expiry') await expired();
650
+ else if (operation === 'prune') prune();
651
+ else if (operation === 'pack' || operation === 'use') update();
652
+ else if (operation === 'restore-stamps') sessionStamps(args[0]);
653
+ else if (operation === 'remove') {
654
+ const file = recordPath(args[0]); read(args[0]);
655
+ if (fs.readdirSync(path.dirname(file)).some(n => n !== 'record.json')) fail('unexpected stamp directory entry');
656
+ fs.unlinkSync(file); fs.rmdirSync(path.dirname(file));
657
+ } else {
658
+ const selected = operation === 'restore' ? [...new Set(args.slice(1))] :
659
+ operation === 'clean' && args[0] && args[0] !== 'all' ? [args[0]] : stamps();
660
+ let count = 0;
661
+ for (const stamp of selected) {
662
+ let value;
663
+ try { value = read(stamp); } catch (error) {
664
+ if (operation !== 'finish' && operation !== 'restore') throw error;
665
+ warning(stamp, error); continue;
666
+ }
667
+ if (!value) continue;
668
+ if (operation === 'finish' && Object.hasOwn(value.tasks,task)) {
669
+ console.log((args.includes('--apply') ? 'finish: ' : 'would finish: ') + stamp + ' ' + task); count++;
670
+ if (args.includes('--apply')) { value.tasks[task].finished = now; write(value); }
671
+ } else if (operation === 'restore' && value.consumers.some(c => c.session === args[0] && !c.restored)) {
672
+ for (const c of value.consumers) if (c.session === args[0] && !c.restored) c.restored = now;
673
+ write(value);
674
+ } else if (operation === 'clean' && (!args[0] || args[0] === 'all' || stamp === args[0])) {
675
+ const directory = path.dirname(recordPath(stamp));
676
+ if (fs.readdirSync(directory).some(n => n !== 'record.json')) fail('unexpected stamp directory entry: ' + directory);
677
+ console.log(directory);
678
+ }
679
+ }
680
+ if (operation === 'finish' && !count) console.log('no records name task ' + task);
681
+ }
682
+ }
683
+ main().catch(error => { console.error('ERROR: stamp registry: ' + error.message); process.exitCode = 2; });
684
+ REGISTRY
685
+ }
686
+
687
+ # Compare file identities/times so a multi-producer stamp does not attribute earlier
688
+ # producers' packages to the current pack. Repacking the same ID is still recorded.
689
+ nuget_pack_files() {
690
+ node - "$FEED/nuget" "$@" <<'NODE'
691
+ const fs = require('node:fs'), path = require('node:path');
692
+ const [directory, stamp, before] = process.argv.slice(2);
693
+ const files = {};
694
+ for (const name of fs.existsSync(directory) ? fs.readdirSync(directory) : []) {
695
+ if (!name.endsWith('.' + stamp + '.nupkg')) continue;
696
+ const file = path.join(directory,name), st = fs.statSync(file, {bigint:true});
697
+ files[file] = [st.ino,st.size,st.mtimeNs,st.ctimeNs].map(String).join(':');
698
+ }
699
+ if (!before) console.log(JSON.stringify(files));
700
+ else {
701
+ const previous = JSON.parse(fs.readFileSync(before,'utf8'));
702
+ for (const [file,identity] of Object.entries(files)) if (previous[file] !== identity) process.stdout.write(file + '\0');
703
+ }
704
+ NODE
705
+ }
706
+
255
707
  pack_nuget() {
256
708
  [ $# -ge 3 ] || die 'pack-nuget requires producer, stamp and targets'
257
- local repo version target path count=0 property
709
+ local repo version target path count=0 property ids=()
258
710
  local properties=()
259
711
  repo=$(repo_root "$1"); version=$2; shift 2
260
712
  valid_stamp "$version"; need dotnet
@@ -267,16 +719,23 @@ pack_nuget() {
267
719
  properties+=("-p:$property")
268
720
  done
269
721
  [ $# -ge 1 ] || die 'pack-nuget requires at least one target after the properties'
722
+ if [ "$DRY" -eq 0 ]; then
723
+ need node; scratch
724
+ nuget_pack_files "$version" > "$TMP/pack-before"
725
+ fi
270
726
  mutate mkdir -p "$FEED/nuget"
271
727
  for target in "$@"; do
272
728
  (cd "$repo" && heavy build "pack NuGet $target" dotnet pack "$target" -c Release "-p:PackageVersion=$version" "-p:Version=$version" ${properties[@]+"${properties[@]}"} -o "$FEED/nuget")
273
729
  done
274
730
  [ "$DRY" -eq 0 ] || return 0
731
+ nuget_pack_files "$version" "$TMP/pack-before" > "$TMP/pack-produced"
275
732
  while IFS= read -r -d '' path; do
276
733
  printf '%s\n' "$path"; count=$((count + 1))
277
- done < <(find "$FEED/nuget" -type f -name "*.$version.nupkg" -print0)
734
+ target=${path##*/}; ids+=("${target%."$version".nupkg}")
735
+ done < "$TMP/pack-produced"
278
736
  [ "$count" -gt 0 ] || failed 'pack produced no matching nupkgs'
279
737
  printf 'produced %s nupkgs\n' "$count"
738
+ registry_update pack "$version" nuget "$repo" '' '' "${ids[@]}"
280
739
  }
281
740
 
282
741
  # Pack one workspace with npm pack (what publish does) into the feed as <scope>-<name>-<version>.tgz.
@@ -297,7 +756,7 @@ pack_npm_workspace() {
297
756
 
298
757
  pack_npm() {
299
758
  [ $# -eq 2 ] || die 'pack-npm requires producer and stamp'
300
- local repo version path count=0
759
+ local repo version path count=0 ids=()
301
760
  repo=$(repo_root "$1"); version=$2
302
761
  valid_stamp "$version"; need node; need npm; need yarn; scratch
303
762
  git -C "$repo" diff --name-only HEAD -- 'package.json' '*/package.json' > "$TMP/modified"
@@ -348,17 +807,12 @@ NODE
348
807
  done < <(find "$FEED/npm" -type f -name "*-$version.tgz" -print0)
349
808
  [ "$count" -gt 0 ] || failed 'pack produced no matching npm tarballs'
350
809
  printf 'produced %s npm tarballs\n' "$count"
810
+ while IFS= read -r -d '' path; do
811
+ ids+=("$(node -p 'require(process.argv[1]).name' "$repo/$path")")
812
+ done < "$TMP/manifests"
813
+ registry_update pack "$version" npm "$repo" '' '' "${ids[@]}"
351
814
  }
352
815
 
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
816
  source_mapping() {
363
817
  local config=$1 output=$2 id
364
818
  shift 2
@@ -397,9 +851,68 @@ source_mapping() {
397
851
  ' "$config" > "$output"
398
852
  }
399
853
 
854
+ # These headers are shared by the legacy and composition writers. The session
855
+ # lives below the Git directory, never above a consumer project in the lookup.
856
+ nuget_props_start() {
857
+ cat <<'XML'
858
+ <Project>
859
+ <PropertyGroup><_CratisLocalPackagesProps>$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildProjectDirectory)'))</_CratisLocalPackagesProps></PropertyGroup>
860
+ <Import Project="$(_CratisLocalPackagesProps)" Condition="'$(_CratisLocalPackagesProps)' != '' and Exists('$(_CratisLocalPackagesProps)')" />
861
+ XML
862
+ if [ "$1" -eq 1 ]; then
863
+ printf ' <PropertyGroup Condition="%s"><CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled></PropertyGroup>\n' "'\$(ManagePackageVersionsCentrally)' == 'true'"
864
+ fi
865
+ printf ' <ItemGroup Condition="%s">\n' "'\$(ManagePackageVersionsCentrally)' == 'true'"
866
+ }
867
+ nuget_targets_start() {
868
+ cat <<'XML'
869
+ <Project>
870
+ <Import Project="$(_CratisLocalPreviousAfterMicrosoftCommonTargets)" Condition="'$(_CratisLocalPreviousAfterMicrosoftCommonTargets)' != '' and '$(_CratisLocalPreviousAfterMicrosoftCommonTargets)' != '$(MSBuildThisFileFullPath)' and Exists('$(_CratisLocalPreviousAfterMicrosoftCommonTargets)')" />
871
+ <ItemGroup Condition="'$(ManagePackageVersionsCentrally)' != 'true'">
872
+ XML
873
+ }
874
+ nuget_overlay_end() { printf ' </ItemGroup>\n</Project>\n'; }
875
+ nuget_env() {
876
+ local session=$1 targets_key=CustomAfterMicrosoftCommon"Targets"
877
+ printf 'export %s=%q\n' DirectoryPackages"PropsPath" "$session/Directory.Packages.props"
878
+ printf 'export %s=%q\n' RestoreAdditional"ProjectSources" "$FEED/nuget"
879
+ # Capture at source time, not activation time; sourcing twice must not recurse.
880
+ printf 'if [ "%s" != %q ]; then\n' "\${$targets_key:-}" "$session/cratis-local.targets"
881
+ printf ' export _CratisLocalPreviousAfterMicrosoftCommonTargets="%s"\nfi\n' "\${$targets_key:-}"
882
+ printf 'export %s=%q\n' "$targets_key" "$session/cratis-local.targets"
883
+ if [ -n "${PRIVATE_NUGET_CACHE:-}" ]; then printf 'export NUGET_PACKAGES=%q\n' "$PRIVATE_NUGET_CACHE"; fi
884
+ }
885
+
400
886
  use_nuget() {
887
+ local private=${CRATIS_LOCAL_PRIVATE_CACHE:-0} PRIVATE_NUGET_CACHE='' option filtered=() private_repo private_session
888
+ for option in "$@"; do
889
+ if [ "$option" = --private-cache ]; then private=1; else filtered+=("$option"); fi
890
+ done
891
+ set -- ${filtered[@]+"${filtered[@]}"}
892
+ if [ "$private" = 1 ]; then
893
+ [ $# -ge 2 ] || die 'use-nuget requires consumer and stamp or composition'
894
+ private_repo=$(repo_root "$1"); private_session=$(session_path "$private_repo")
895
+ PRIVATE_NUGET_CACHE=$(git -C "$private_repo" rev-parse --absolute-git-dir)/cratis-local-nuget
896
+ prepare_private_cache "$private_repo" "$PRIVATE_NUGET_CACHE" "$private_session"
897
+ fi
898
+ if [ "${2:-}" = --composition ]; then
899
+ composition use-nuget "$@"
900
+ if [ "$private" = 1 ] && [ "$DRY" -eq 0 ]; then register_private_cache "$private_repo" "$PRIVATE_NUGET_CACHE" "$private_session"; fi
901
+ if [ "$DRY" -eq 0 ]; then
902
+ local composition_repo composition_session composition_stamp
903
+ composition_repo=$(repo_root "$1"); composition_session=$(session_path "$composition_repo")
904
+ scratch
905
+ cut -f3 "$composition_session/composition/manifest.tsv" | sort -u > "$TMP/registry-stamps"
906
+ while IFS= read -r composition_stamp; do
907
+ registry_consumer "$composition_stamp" nuget "$composition_repo" "$composition_session" "$PRIVATE_NUGET_CACHE"
908
+ done < "$TMP/registry-stamps"
909
+ fi
910
+ return
911
+ fi
912
+ case " $* " in *' --composition '*) die 'cannot mix a stamp or IDs with --composition' ;; esac
401
913
  [ $# -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=()
914
+ [ ! -d "$(session_path "$(repo_root "$1")")/composition" ] || die 'active composition; restore first'
915
+ local repo version session props id file config='' props_key sources_key targets_key pin=0 args=()
403
916
  local ids=()
404
917
  repo=$(repo_root "$1"); version=$2; shift 2
405
918
  for id in "$@"; do
@@ -409,7 +922,6 @@ use_nuget() {
409
922
  valid_stamp "$version"; props=$repo/Directory.Packages.props
410
923
  [ -f "$props" ] || die 'no Directory.Packages.props (non-CPM). Manual alternative: untracked project copy, explicit PackageReference versions, dotnet restore --source FEED/nuget'
411
924
  session=$(session_path "$repo"); scratch
412
- package_ids "$props" > "$TMP/consumer-ids"
413
925
  if [ $# -gt 0 ]; then
414
926
  ids=("$@")
415
927
  elif [ -d "$FEED/nuget" ]; then
@@ -442,25 +954,31 @@ use_nuget() {
442
954
  return 0
443
955
  fi
444
956
  register_session "$repo" "$session" "$version"
957
+ if [ "$private" = 1 ]; then register_private_cache "$repo" "$PRIVATE_NUGET_CACHE" "$session"; fi
445
958
  {
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'
959
+ nuget_props_start "$pin"
451
960
  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"
961
+ # Remove/include works with any nearest props, not only root IDs.
962
+ printf ' <PackageVersion Remove="%s" /><PackageVersion Include="%s" Version="%s" />\n' "$id" "$id" "$version"
455
963
  done
456
- printf ' </ItemGroup>\n</Project>\n'
964
+ nuget_overlay_end
457
965
  } > "$session/Directory.Packages.props"
458
- props_key=DirectoryPackages"PropsPath"; sources_key=RestoreAdditional"ProjectSources"
459
966
  {
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'
967
+ nuget_targets_start
968
+ for id in "${ids[@]}"; do
969
+ printf ' <PackageReference Update="%s" Version="%s" />\n' "$id" "$version"
970
+ done
971
+ nuget_overlay_end
972
+ } > "$session/cratis-local.targets"
973
+ nuget_env "$session" > "$session/env"
974
+ props_key=DirectoryPackages"PropsPath"; sources_key=RestoreAdditional"ProjectSources"; targets_key=CustomAfterMicrosoftCommon"Targets"
975
+ printf ' %q' "-p:$props_key=$session/Directory.Packages.props" "-p:$sources_key=$FEED/nuget" "-p:$targets_key=$session/cratis-local.targets"
976
+ if [ "${CustomAfterMicrosoftCommonTargets:-}" != "$session/cratis-local.targets" ]; then
977
+ printf ' %q' "-p:_CratisLocalPreviousAfterMicrosoftCommonTargets=${CustomAfterMicrosoftCommonTargets:-}"
978
+ else
979
+ printf ' %q' "-p:_CratisLocalPreviousAfterMicrosoftCommonTargets=${_CratisLocalPreviousAfterMicrosoftCommonTargets:-}"
980
+ fi
981
+ printf '\n'
464
982
  printf 'source %q\n' "$session/env"
465
983
  if [ -n "$config" ]; then
466
984
  source_mapping "$config" "$session/nuget.config" "${ids[@]}"
@@ -468,7 +986,562 @@ use_nuget() {
468
986
  elif [ -f "$session/nuget.config" ]; then
469
987
  rm -- "$session/nuget.config"
470
988
  fi
989
+ registry_consumer "$version" nuget "$repo" "$session" "$PRIVATE_NUGET_CACHE"
471
990
  printf 'overrode %s NuGet package IDs\n' "${#ids[@]}"
991
+ if [ "$private" != 1 ]; then
992
+ 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"
993
+ fi
994
+ }
995
+
996
+ # A shared lock covers legacy mutations too: registration rollback must never replace
997
+ # another invocation's registry writes. An incomplete/unknown lock is preserved.
998
+ acquire_session_lock() {
999
+ local lock=$FEED/.composition-lock
1000
+ if [ "$DRY" -eq 1 ]; then
1001
+ [ ! -e "$lock" ] && [ ! -L "$lock" ] || die 'session locked; recovery requires a non-dry-run mutation'
1002
+ return 0
1003
+ fi
1004
+ mkdir -p "$FEED"
1005
+ # Serialize inspection, journal recovery and replacement so a contender cannot
1006
+ # recover a stale owner and then delete the next invocation's live lock.
1007
+ # An abandoned guard fails closed, just like an incomplete/unknown lock.
1008
+ mkdir "$FEED/.composition-recovery" || die 'local package session is locked'
1009
+ SESSION_RECOVERY_GUARD=$FEED/.composition-recovery
1010
+ if [ -e "$lock" ] || [ -L "$lock" ]; then composition recover; fi
1011
+ mkdir "$lock" || die 'local package session is locked'
1012
+ SESSION_LOCK=$lock
1013
+ printf '%s\n' "$$" > "$lock/owner"
1014
+ rmdir -- "$SESSION_RECOVERY_GUARD"
1015
+ SESSION_RECOVERY_GUARD=''
1016
+ }
1017
+
1018
+ # Opt-in composition uses Node orchestration and the selected SDK's native NuGet
1019
+ # APIs. No npm dependency, persistent project, package restore or producer build.
1020
+ composition() {
1021
+ need node; scratch
1022
+ local operation=$1 repo='' session=''
1023
+ shift
1024
+ case "$operation" in
1025
+ use-nuget|verify-assets|restore-state)
1026
+ [ $# -ge 1 ] || die "$operation requires a consumer"
1027
+ repo=$(repo_root "$1"); session=$(session_path "$repo") ;;
1028
+ esac
1029
+ nuget_props_start 0 > "$TMP/overlay-props"
1030
+ nuget_props_start 1 > "$TMP/overlay-props-pin"
1031
+ nuget_targets_start > "$TMP/overlay-targets"
1032
+ nuget_overlay_end > "$TMP/overlay-end"
1033
+ nuget_env "$session/composition" > "$TMP/overlay-env"
1034
+ node - "$operation" "$FEED" "$TMP" "$DRY" "$repo" "$session" "$@" <<'COMPOSITION'
1035
+ const fs = require('node:fs');
1036
+ const path = require('node:path');
1037
+ const cp = require('node:child_process');
1038
+ const crypto = require('node:crypto');
1039
+ const [mode, feed, scratch, dry, repo, session, ...argv] = process.argv.slice(2);
1040
+ const lock = path.join(feed, '.composition-lock');
1041
+ const registry = path.join(feed, 'sessions');
1042
+ const local = /-(?:local\.[0-9]{12}|train)\.g[0-9a-f]{7}(?:$|[^0-9A-Za-z])/;
1043
+ const stamp = /^[0-9]+\.[0-9]+\.[0-9]+-(?:local\.[0-9]{12}|train)\.g[0-9a-f]{7}$/;
1044
+ const idPattern = /^[A-Za-z0-9_.-]+$/;
1045
+ const lower = s => s.toLowerCase();
1046
+ const exists = p => fs.existsSync(p) || !!fs.lstatSync(path.dirname(p), {throwIfNoEntry:false}) && fs.readdirSync(path.dirname(p)).includes(path.basename(p));
1047
+ function fail(message, code = 2) { const error = new Error(message); error.code = code; throw error; }
1048
+ function plain(p, directory = false) {
1049
+ const st = fs.lstatSync(p, {throwIfNoEntry:false});
1050
+ if (!st || st.isSymbolicLink() || (directory ? !st.isDirectory() : !st.isFile())) fail('unsafe or missing path: ' + p);
1051
+ }
1052
+ function safeParents(p, ownedRoot = feed) {
1053
+ if (/[\x00-\x1f]/.test(p)) fail('control character in path');
1054
+ const root = path.resolve(ownedRoot), start = path.resolve(p);
1055
+ if (start !== root && !start.startsWith(root + path.sep)) fail('outside helper-owned directory: ' + p);
1056
+ for (let cur = start; ; cur = path.dirname(cur)) {
1057
+ const st = fs.lstatSync(cur, {throwIfNoEntry:false});
1058
+ if (st && (st.isSymbolicLink() || !st.isDirectory())) fail('unsafe directory: ' + cur);
1059
+ if (cur === root) break;
1060
+ }
1061
+ }
1062
+ function bytes(p) { if (!exists(p)) return null; plain(p); return fs.readFileSync(p).toString('base64'); }
1063
+ function write(p, value) { fs.writeFileSync(p, value, {flag:'wx', mode:0o600}); }
1064
+ function run(command, args) {
1065
+ const result = cp.spawnSync(command, args, {encoding:'utf8', maxBuffer:32 * 1024 * 1024});
1066
+ if (result.error || result.status !== 0) fail(command + ' failed: ' + (result.error?.message || result.stderr || result.stdout), 2);
1067
+ return result.stdout;
1068
+ }
1069
+ function manifest(file) {
1070
+ plain(file);
1071
+ const producers = new Map(), packages = new Map(), rows = [];
1072
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
1073
+ if (line === '' || line.startsWith('#')) continue;
1074
+ const row = line.split('\t');
1075
+ if (row.some(s => s === '' || /[\r\x00-\x08\x0b-\x1f]/.test(s))) fail('malformed composition row');
1076
+ if (row[0] === 'producer' && row.length === 4) {
1077
+ const [, name, version, worktree] = row;
1078
+ if (!idPattern.test(name) || producers.has(lower(name)) || !stamp.test(version) || !path.isAbsolute(worktree)) fail('invalid or duplicate producer: ' + name);
1079
+ plain(worktree, true);
1080
+ if (run('git', ['-C', worktree, 'rev-parse', '--show-toplevel']).trim() !== worktree) fail('producer must name an absolute worktree root: ' + worktree);
1081
+ producers.set(lower(name), {name, version, worktree});
1082
+ } else if (row[0] === 'package' && row.length === 3) rows.push(row);
1083
+ else fail('malformed composition row: ' + line);
1084
+ }
1085
+ for (const [, owner, id] of rows) {
1086
+ const producer = producers.get(lower(owner));
1087
+ if (!producer || !idPattern.test(id) || packages.has(lower(id))) fail('unknown producer or duplicate package ownership: ' + id);
1088
+ packages.set(lower(id), {id, producer:producer.name, version:producer.version, worktree:producer.worktree});
1089
+ }
1090
+ if (!producers.size || !packages.size) fail('empty composition');
1091
+ for (const p of producers.values()) if (![...packages.values()].some(pkg => pkg.producer === p.name)) fail('empty producer closure: ' + p.name);
1092
+ return [...packages.values()].sort((a,b) => lower(a.id).localeCompare(lower(b.id), 'en'));
1093
+ }
1094
+ const normalize = packages => packages.map(p => [p.producer,p.id,p.version,p.worktree].join('\t')).join('\n') + '\n';
1095
+ const xml = s => s.replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('"','&quot;').replaceAll("'",'&apos;');
1096
+ const msxml = s => xml(s.replace(/[%$@;?*()]/g, c => '%' + c.charCodeAt(0).toString(16)));
1097
+ // All version/range comparisons and nuspec XML parsing run inside the SDK.
1098
+ function native(packages, graphs = []) {
1099
+ const inputs = path.join(scratch, 'native-inputs.tsv');
1100
+ const checks = path.join(scratch, 'native-graphs.tsv');
1101
+ const result = path.join(scratch, 'native-result');
1102
+ let input = '';
1103
+ packages.forEach((pkg, i) => {
1104
+ const artifact = path.join(feed, 'nuget', `${pkg.id}.${pkg.version}.nupkg`);
1105
+ safeParents(path.dirname(artifact));
1106
+ if (!exists(artifact)) fail('missing: ' + artifact, 1);
1107
+ plain(artifact);
1108
+ let names;
1109
+ try { names = run('unzip', ['-Z1', artifact]).split('\n').filter(n => /\.nuspec$/i.test(n)); }
1110
+ catch (e) { fail('corrupt package: ' + artifact, 1); }
1111
+ if (names.length !== 1 || !/^[A-Za-z0-9_.-]+\.nuspec$/i.test(names[0])) fail('ambiguous nuspec: ' + artifact, 1);
1112
+ const extracted = cp.spawnSync('unzip', ['-p', artifact, names[0]], {maxBuffer:8 * 1024 * 1024});
1113
+ if (extracted.error || extracted.status !== 0) fail('unreadable nuspec: ' + artifact, 1);
1114
+ const nuspec = path.join(scratch, `package-${i}.xml`);
1115
+ fs.writeFileSync(nuspec, extracted.stdout);
1116
+ input += [pkg.id, pkg.version, nuspec].join('\t') + '\n';
1117
+ });
1118
+ fs.writeFileSync(inputs, input); fs.writeFileSync(checks, graphs.join('\n'));
1119
+ const project = path.join(scratch, 'native.proj');
1120
+ fs.writeFileSync(project, `<Project>
1121
+ <UsingTask TaskName="CompositionValidation" TaskFactory="RoslynCodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)/Microsoft.Build.Tasks.Core.dll">
1122
+ <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>
1123
+ <Task><Code Type="Fragment" Language="cs"><![CDATA[
1124
+ var verdict = "0";
1125
+ var advisories = new System.Text.StringBuilder();
1126
+ try {
1127
+ var assembly = System.Reflection.Assembly.LoadFrom(System.IO.Path.Combine(Sdk, "NuGet.Versioning.dll"));
1128
+ var vt = assembly.GetType("NuGet.Versioning.NuGetVersion", true);
1129
+ var rt = assembly.GetType("NuGet.Versioning.VersionRange", true);
1130
+ var pv = vt.GetMethod("TryParse", new[] { typeof(string), vt.MakeByRefType() });
1131
+ var pr = rt.GetMethod("TryParse", new[] { typeof(string), rt.MakeByRefType() });
1132
+ var sat = rt.GetMethod("Satisfies", new[] { vt });
1133
+ if (pv == null || pr == null || sat == null) throw new MissingMethodException("native NuGet validation API unavailable");
1134
+ Func<string, object> version = text => {
1135
+ var args = new object[] { text, null };
1136
+ if (!(bool)pv.Invoke(null, args)) throw new FormatException("invalid version: " + text);
1137
+ return args[1];
1138
+ };
1139
+ Func<string, object> range = text => {
1140
+ var args = new object[] { text, null };
1141
+ if (String.IsNullOrWhiteSpace(text) || !(bool)pr.Invoke(null, args)) throw new FormatException("invalid range: " + text);
1142
+ return args[1];
1143
+ };
1144
+ Action<string, string, string> satisfies = (v, r, context) => {
1145
+ if (!(bool)sat.Invoke(range(r), new[] { version(v) })) throw new FormatException("dependency range: " + context + " " + r + " excludes " + v);
1146
+ };
1147
+ var fa = System.Reflection.Assembly.LoadFrom(System.IO.Path.Combine(Sdk, "NuGet.Frameworks.dll"));
1148
+ var ft = fa.GetType("NuGet.Frameworks.NuGetFramework", true);
1149
+ var reducerType = fa.GetType("NuGet.Frameworks.FrameworkReducer", true);
1150
+ var fp = ft.GetMethod("Parse", new[] { typeof(string) });
1151
+ var nearest = reducerType.GetMethod("GetNearest", new[] { ft, typeof(System.Collections.Generic.IEnumerable<>).MakeGenericType(ft) });
1152
+ if (fp == null || nearest == null) throw new MissingMethodException("native NuGet framework API unavailable");
1153
+ var reducer = Activator.CreateInstance(reducerType);
1154
+ Func<string, object> framework = text => fp.Invoke(null, new object[] { String.IsNullOrEmpty(text) ? "any" : text });
1155
+ var dependencies = new System.Collections.Generic.Dictionary<string, System.Collections.Generic.Dictionary<object, System.Collections.Generic.HashSet<string>>>(StringComparer.OrdinalIgnoreCase);
1156
+ var owned = new System.Collections.Generic.Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
1157
+ var rows = System.IO.File.ReadAllLines(Inputs);
1158
+ foreach (var row in rows) { var p = row.Split('\t'); version(p[1]); owned.Add(p[0], p[1]); }
1159
+ foreach (var row in rows) {
1160
+ var p = row.Split('\t');
1161
+ var document = new System.Xml.XmlDocument(); document.XmlResolver = null;
1162
+ var settings = new System.Xml.XmlReaderSettings(); settings.DtdProcessing = System.Xml.DtdProcessing.Prohibit; settings.XmlResolver = null;
1163
+ using (var reader = System.Xml.XmlReader.Create(p[2], settings)) document.Load(reader);
1164
+ var metadata = document.SelectNodes("/*[local-name()='package']/*[local-name()='metadata']");
1165
+ if (metadata.Count != 1) throw new FormatException("ambiguous metadata: " + p[0]);
1166
+ var ids = metadata[0].SelectNodes("*[local-name()='id']");
1167
+ var versions = metadata[0].SelectNodes("*[local-name()='version']");
1168
+ 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]);
1169
+ var groups = new System.Collections.Generic.Dictionary<object, System.Collections.Generic.HashSet<string>>();
1170
+ dependencies.Add(p[0], groups);
1171
+ var sections = metadata[0].SelectNodes("*[local-name()='dependencies']");
1172
+ if (sections.Count > 1) throw new FormatException("ambiguous dependency sections: " + p[0]);
1173
+ if (sections.Count == 1) {
1174
+ var grouped = sections[0].SelectNodes("*[local-name()='group']");
1175
+ if (grouped.Count == 0) groups.Add(framework("any"), new System.Collections.Generic.HashSet<string>(StringComparer.OrdinalIgnoreCase));
1176
+ else {
1177
+ if (sections[0].SelectNodes("*[local-name()='dependency']").Count != 0) throw new FormatException("mixed dependency groups: " + p[0]);
1178
+ foreach (System.Xml.XmlElement group in grouped) {
1179
+ var target = framework(group.GetAttribute("targetFramework"));
1180
+ if (groups.ContainsKey(target)) throw new FormatException("duplicate dependency group: " + p[0]);
1181
+ groups.Add(target, new System.Collections.Generic.HashSet<string>(StringComparer.OrdinalIgnoreCase));
1182
+ }
1183
+ }
1184
+ }
1185
+ foreach (System.Xml.XmlNode dep in document.SelectNodes("//*[local-name()='dependency']")) {
1186
+ var id = dep.Attributes["id"]; var r = dep.Attributes["version"];
1187
+ if (id == null || String.IsNullOrWhiteSpace(id.Value) || r == null || (dep.ParentNode.LocalName != "group" && dep.ParentNode.LocalName != "dependencies")) throw new FormatException("unreadable dependency: " + p[0]);
1188
+ var section = dep.ParentNode.LocalName == "group" ? dep.ParentNode.ParentNode : dep.ParentNode;
1189
+ if (sections.Count != 1 || section != sections[0]) throw new FormatException("misplaced dependency: " + p[0]);
1190
+ range(r.Value);
1191
+ if (owned.ContainsKey(id.Value)) {
1192
+ satisfies(owned[id.Value], r.Value, p[0] + " -> " + id.Value);
1193
+ var target = dep.ParentNode.LocalName == "group" ? framework(((System.Xml.XmlElement)dep.ParentNode).GetAttribute("targetFramework")) : framework("any");
1194
+ groups[target].Add(id.Value);
1195
+ }
1196
+ 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);
1197
+ }
1198
+ }
1199
+ foreach (var row in System.IO.File.ReadAllLines(Graphs)) {
1200
+ if (row.Length == 0) continue;
1201
+ var p = row.Split('\t');
1202
+ 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; }
1203
+ if (p[0] != "@") { satisfies(p[0], p[1], p[2]); continue; }
1204
+ var groups = dependencies[p[2]];
1205
+ if (groups.Count == 0) continue;
1206
+ var candidates = Array.CreateInstance(ft, groups.Count); var index = 0;
1207
+ foreach (var candidate in groups.Keys) candidates.SetValue(candidate, index++);
1208
+ var selected = nearest.Invoke(reducer, new object[] { framework(p[1].Split('/')[0]), candidates });
1209
+ if (selected == null) continue;
1210
+ var consumed = new System.Collections.Generic.HashSet<string>(p[3].Split(','), StringComparer.OrdinalIgnoreCase);
1211
+ var edges = new System.Collections.Generic.HashSet<string>(p[4].Split(','), StringComparer.OrdinalIgnoreCase);
1212
+ 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]);
1213
+ }
1214
+ verdict = "0" + advisories.ToString();
1215
+ } catch (Exception e) {
1216
+ verdict = (e is MissingMethodException || e is System.IO.FileNotFoundException || e is TypeLoadException ? "2" : "1") + "\\n" + e.Message;
1217
+ }
1218
+ System.IO.File.WriteAllText(Result, verdict);
1219
+ ]]></Code></Task></UsingTask>
1220
+ <Target Name="Validate"><CompositionValidation Sdk="$(MSBuildToolsPath)" Inputs="${msxml(inputs)}" Graphs="${msxml(checks)}" Result="${msxml(result)}"/></Target></Project>`);
1221
+ const execution = cp.spawnSync('dotnet', ['msbuild', project, '-t:Validate', '-nologo', '-v:quiet', '-m:1', '-nodeReuse:false'], {encoding:'utf8', maxBuffer:8 * 1024 * 1024});
1222
+ if (execution.error || execution.status !== 0 || !exists(result)) fail('native SDK validator unavailable: ' + (execution.error?.message || execution.stdout || execution.stderr));
1223
+ const [code, ...message] = fs.readFileSync(result,'utf8').split('\n');
1224
+ if (code !== '0') fail(message.join('\n'), Number(code));
1225
+ return message.filter(Boolean);
1226
+ }
1227
+ function assets(packages) {
1228
+ const owned = new Map(packages.map(p => [lower(p.id), p]));
1229
+ const files = [], resolved = new Map(), checks = [];
1230
+ function walk(dir) {
1231
+ for (const entry of fs.readdirSync(dir, {withFileTypes:true})) {
1232
+ if (entry.isSymbolicLink() || ['.git','node_modules'].includes(entry.name)) continue;
1233
+ const file = path.join(dir,entry.name);
1234
+ if (entry.isDirectory()) walk(file);
1235
+ else if (entry.name === 'project.assets.json' && path.basename(dir) === 'obj') files.push(file);
1236
+ }
1237
+ }
1238
+ walk(repo);
1239
+ if (!files.length) fail('no obj/project.assets.json (restore the consumer first)');
1240
+ const object = value => value && typeof value === 'object' && !Array.isArray(value);
1241
+ for (const file of files) {
1242
+ let data;
1243
+ try { data = JSON.parse(fs.readFileSync(file,'utf8')); } catch { fail('malformed assets: ' + file,1); }
1244
+ 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);
1245
+ const libraries = new Map();
1246
+ function keyParts(key, value) {
1247
+ const parts = key.split('/');
1248
+ if (parts.length !== 2 || !idPattern.test(parts[0]) || !parts[1] || !object(value) || !['package','project'].includes(value.type)) fail('malformed asset entry: ' + key + ' ' + file,1);
1249
+ return parts;
1250
+ }
1251
+ for (const [key, lib] of Object.entries(data.libraries)) {
1252
+ const [id, version] = keyParts(key,lib);
1253
+ if (lib.type !== 'package') continue;
1254
+ // Multitarget restores can hold several versions of one ID, one per framework.
1255
+ if (libraries.has(lower(id) + '/' + version)) fail('duplicate assets library: ' + key,1);
1256
+ libraries.set(lower(id) + '/' + version, {id,version});
1257
+ const pkg = owned.get(lower(id));
1258
+ if (pkg && version !== pkg.version || !pkg && local.test(version)) fail('wrong or undeclared local library: ' + key + ' ' + file,1);
1259
+ }
1260
+ const used = new Set();
1261
+ for (const [framework, target] of Object.entries(data.targets)) {
1262
+ if (!object(target) || /[\t\r\n]/.test(framework)) fail('malformed assets target: ' + file,1);
1263
+ const nodes = new Map();
1264
+ for (const [key,node] of Object.entries(target)) {
1265
+ const [id,version] = keyParts(key,node);
1266
+ if (nodes.has(lower(id))) fail('duplicate target ID: ' + id,1);
1267
+ nodes.set(lower(id), {id,version,node});
1268
+ if (node.type !== 'package') continue;
1269
+ if (!libraries.has(lower(id) + '/' + version)) fail('target/library mismatch: ' + key + ' ' + file,1);
1270
+ used.add(lower(id) + '/' + version);
1271
+ checks.push([version,`[${version}]`,`${file} ${framework} ${id}`].join('\t'));
1272
+ if (owned.has(lower(id))) resolved.set(lower(id),owned.get(lower(id)));
1273
+ }
1274
+ const packageIds = [...nodes.values()].filter(n => n.node.type === 'package').map(n => n.id).join(',');
1275
+ for (const {id,node} of nodes.values()) {
1276
+ if (owned.has(lower(id)) && node.type === 'package') checks.push(['@',framework,id,packageIds,Object.keys(node.dependencies || {}).join(',')].join('\t'));
1277
+ if (node.dependencies !== undefined && !object(node.dependencies)) fail('malformed target dependencies: ' + id,1);
1278
+ for (const [dep,range] of Object.entries(node.dependencies || {})) {
1279
+ if (typeof range !== 'string' || /[\t\r\n]/.test(range)) fail('malformed asset range: ' + dep,1);
1280
+ const selected = nodes.get(lower(dep));
1281
+ if (!selected) fail('unresolved target dependency: ' + id + ' -> ' + dep + ' ' + file,1);
1282
+ // An edge between two IDs outside the composition is NuGet's own NU1608 case and exists
1283
+ // without the overlay too; report it as a warning instead of refusing the composition.
1284
+ if (selected.node.type === 'package' && (owned.has(lower(id)) || owned.has(lower(dep)))) checks.push([selected.version,range,`${file} ${framework} ${id} -> ${dep}`].join('\t'));
1285
+ else if (selected.node.type === 'package') checks.push(['~',selected.version,range,`${file} ${framework} ${id} -> ${dep}`].join('\t'));
1286
+ if (owned.has(lower(dep)) && selected.node.type !== 'package') fail('owned dependency resolved as project: ' + dep,1);
1287
+ }
1288
+ }
1289
+ }
1290
+ for (const [key, {id}] of libraries) if (owned.has(lower(id)) && !used.has(key)) fail('owned library absent from targets: ' + id + ' ' + file,1);
1291
+ }
1292
+ if (!resolved.size) fail('verify-assets: no composition package consumed',1);
1293
+ return {checks, packages:[...resolved.values()], files:files.length};
1294
+ }
1295
+ // Journal contains only the transaction's exact expected bytes. Recovery refuses
1296
+ // drift, unknown files and symlinks rather than deleting another task's work.
1297
+ function directoryBytes(dir) {
1298
+ plain(dir,true); const result = {};
1299
+ for (const name of fs.readdirSync(dir)) { plain(path.join(dir,name)); result[name] = bytes(path.join(dir,name)); }
1300
+ return result;
1301
+ }
1302
+ function same(a,b) { return JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort()); }
1303
+ function removeOwned(dir, expected, partial = false) {
1304
+ if (!exists(dir)) return;
1305
+ const actual = directoryBytes(dir);
1306
+ if (partial ? Object.keys(actual).some(n => actual[n] !== expected[n]) : !same(actual,expected)) fail('transaction files changed; preserved: ' + dir);
1307
+ for (const name of Object.keys(actual)) fs.unlinkSync(path.join(dir,name));
1308
+ fs.rmdirSync(dir);
1309
+ }
1310
+ function journal(value) {
1311
+ const file = path.join(lock,'journal.json');
1312
+ fs.writeFileSync(file + '.next', JSON.stringify(value), {mode:0o600});
1313
+ fs.renameSync(file + '.next', file);
1314
+ }
1315
+ function recoverState(j) {
1316
+ 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');
1317
+ safeParents(j.session,j.session);
1318
+ const pub = path.join(j.session,'composition'), stage = path.join(j.session, '.composition-' + j.token);
1319
+ if (j.operation === 'activate') {
1320
+ if (!j.committed) {
1321
+ const current = bytes(registry);
1322
+ if (current !== j.registryBefore && current !== j.registryAfter) fail('registry changed; transaction preserved');
1323
+ if (exists(pub)) removeOwned(pub,j.files);
1324
+ if (current === j.registryAfter && current !== j.registryBefore) {
1325
+ if (j.registryBefore === null) fs.unlinkSync(registry);
1326
+ else { fs.writeFileSync(path.join(lock,'registry.rollback'),Buffer.from(j.registryBefore,'base64')); fs.renameSync(path.join(lock,'registry.rollback'),registry); }
1327
+ }
1328
+ const marker = path.join(j.session,'repo');
1329
+ if (!j.hadRepo && exists(marker)) {
1330
+ if (bytes(marker) !== Buffer.from(j.repo + '\n').toString('base64')) fail('session marker changed; preserved');
1331
+ fs.unlinkSync(marker);
1332
+ }
1333
+ }
1334
+ removeOwned(stage,j.files,true);
1335
+ const pending = path.join(lock,'registry.next');
1336
+ if (exists(pending)) {
1337
+ if (bytes(pending) !== j.registryAfter) fail('staged registry changed; preserved');
1338
+ fs.unlinkSync(pending);
1339
+ }
1340
+ if (!j.committed && !j.hadSession && exists(j.session) && fs.readdirSync(j.session).length === 0) fs.rmdirSync(j.session);
1341
+ } else if (j.operation === 'restore') {
1342
+ if (!j.committed && exists(stage)) {
1343
+ if (exists(pub) || !same(directoryBytes(stage),j.files)) fail('restoration state changed; preserved');
1344
+ fs.renameSync(stage,pub);
1345
+ } else if (j.committed) removeOwned(stage,j.files);
1346
+ } else fail('unknown transaction operation');
1347
+ fs.unlinkSync(path.join(lock,'journal.json'));
1348
+ }
1349
+ function recover() {
1350
+ safeParents(lock); plain(lock,true); plain(path.join(lock,'owner'));
1351
+ const owner = fs.readFileSync(path.join(lock,'owner'),'utf8').trim();
1352
+ if (!/^[1-9][0-9]*$/.test(owner)) fail('unknown lock owner; preserved');
1353
+ try { process.kill(Number(owner),0); fail('local package session is locked by ' + owner); }
1354
+ catch (error) { if (error.code !== 'ESRCH') throw error; }
1355
+ const allowed = new Set(['owner','journal.json','journal.json.next','registry.next','registry.rollback']);
1356
+ if (fs.readdirSync(lock).some(n => !allowed.has(n))) fail('unknown lock state; preserved');
1357
+ for (const n of fs.readdirSync(lock)) plain(path.join(lock,n));
1358
+ if (exists(path.join(lock,'journal.json'))) recoverState(JSON.parse(fs.readFileSync(path.join(lock,'journal.json'),'utf8')));
1359
+ else if (fs.readdirSync(lock).some(n => n !== 'owner')) fail('incomplete transaction journal; preserved');
1360
+ for (const n of fs.readdirSync(lock)) fs.unlinkSync(path.join(lock,n));
1361
+ fs.rmdirSync(lock);
1362
+ }
1363
+ // Fixture-only fault injection requires the isolated self-test capability file.
1364
+ function boundary(name) {
1365
+ const capability = process.env.CRATIS_LOCAL_COMPOSITION_TEST_ROOT;
1366
+ if (!capability || !feed.startsWith(capability + path.sep) || !fs.existsSync(path.join(capability,'.composition-fixture'))) return;
1367
+ if (process.env.CRATIS_LOCAL_COMPOSITION_FAIL === name) fail('injected failure: ' + name,1);
1368
+ if (process.env.CRATIS_LOCAL_COMPOSITION_INTERRUPT === name) { process.kill(process.ppid,'SIGKILL'); process.kill(process.pid,'SIGKILL'); }
1369
+ }
1370
+ function activeFiles() {
1371
+ const pub = path.join(session,'composition');
1372
+ if (!exists(pub)) return null;
1373
+ const files = directoryBytes(pub);
1374
+ const allowed = new Set(['manifest.tsv','Directory.Packages.props','cratis-local.targets','nuget.config','env','transaction','state.json']);
1375
+ 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');
1376
+ const expected = JSON.parse(Buffer.from(files['state.json'],'base64').toString());
1377
+ const actual = {...files}; delete actual.transaction; delete actual['state.json'];
1378
+ if (!same(actual,expected)) fail('composition files changed; preserved');
1379
+ return files;
1380
+ }
1381
+ function activate(packages,pin) {
1382
+ safeParents(session,session); safeParents(feed);
1383
+ const props = path.join(repo,'Directory.Packages.props'); plain(props);
1384
+ if (exists(session)) {
1385
+ plain(session,true);
1386
+ const allowed = new Set(['repo','composition','nuget-cache']);
1387
+ if (fs.readdirSync(session).some(n => !allowed.has(n))) fail('conflicting active overlay/session; restore first');
1388
+ if (exists(path.join(session,'repo')) && fs.readFileSync(path.join(session,'repo'),'utf8') !== repo + '\n') fail('session belongs to another checkout');
1389
+ }
1390
+ const pub = path.join(session,'composition');
1391
+ const template = name => fs.readFileSync(path.join(scratch,'overlay-' + name),'utf8');
1392
+ const escaped = packages.map(p => `<PackageVersion Remove="${xml(p.id)}"/><PackageVersion Include="${xml(p.id)}" Version="${p.version}"/>`).join('\n');
1393
+ const references = packages.map(p => `<PackageReference Update="${xml(p.id)}" Version="${p.version}"/>`).join('\n');
1394
+ const files = {
1395
+ 'manifest.tsv':normalize(packages),
1396
+ 'Directory.Packages.props':template(pin ? 'props-pin' : 'props') + escaped + '\n' + template('end'),
1397
+ 'cratis-local.targets':template('targets') + references + '\n' + template('end'),
1398
+ env:template('env')
1399
+ };
1400
+ // Native XML manipulation preserves namespaces/configuration and makes exact
1401
+ // local mappings exclusive; remote wildcards remain less specific.
1402
+ const configs = run('git',['-C',repo,'ls-files','-z']).split('\0').filter(n => ['nuget.config','NuGet.Config','NuGet.config'].includes(n));
1403
+ if (configs.length > 1) fail('ambiguous root NuGet config');
1404
+ if (configs.length) files['nuget.config'] = configOverlay(path.join(repo,configs[0]),packages);
1405
+ else files['nuget.config'] = configOverlay(null,packages);
1406
+ files['state.json'] = JSON.stringify(Object.fromEntries(Object.entries(files).map(([k,v]) => [k,Buffer.from(v).toString('base64')]))) + '\n';
1407
+ const previous = activeFiles();
1408
+ if (previous) {
1409
+ const expected = Object.fromEntries(Object.entries(files).map(([k,v]) => [k,Buffer.from(v).toString('base64')]));
1410
+ const withoutToken = {...previous}; delete withoutToken.transaction;
1411
+ if (!same(expected,withoutToken) || !fs.readFileSync(registry,'utf8').split('\n').includes(session)) fail('different or unregistered active composition; restore first');
1412
+ instructions(packages); return;
1413
+ }
1414
+ if (dry === '1') { console.log('dry-run: validated ' + packages.length + ' composition packages; no overlay written'); return; }
1415
+ const token = crypto.randomBytes(12).toString('hex'); files.transaction = token + '\n';
1416
+ const expected = Object.fromEntries(Object.entries(files).map(([k,v]) => [k,Buffer.from(v).toString('base64')]));
1417
+ const before = bytes(registry);
1418
+ const oldRegistry = before === null ? '' : Buffer.from(before,'base64').toString();
1419
+ if (oldRegistry && !oldRegistry.endsWith('\n')) fail('malformed session registry');
1420
+ const after = oldRegistry.split('\n').includes(session) ? oldRegistry : oldRegistry + session + '\n';
1421
+ 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};
1422
+ journal(j);
1423
+ try {
1424
+ boundary('journal');
1425
+ if (!j.hadSession) { fs.mkdirSync(path.dirname(session), {recursive:true}); fs.mkdirSync(session); }
1426
+ boundary('session');
1427
+ const stage = path.join(session,'.composition-' + token); fs.mkdirSync(stage);
1428
+ // A partial stage is recorded before each file write for exact recovery.
1429
+ for (const [name,value] of Object.entries(files)) { write(path.join(stage,name),value); boundary('stage-' + name); }
1430
+ boundary('staged');
1431
+ if (bytes(registry) !== before) fail('registry changed before publication');
1432
+ fs.renameSync(stage,pub); boundary('published');
1433
+ if (!j.hadRepo) write(path.join(session,'repo'),repo + '\n');
1434
+ boundary('repo');
1435
+ write(path.join(lock,'registry.next'),after); boundary('registration-staged');
1436
+ if (bytes(registry) !== before) fail('registry changed before registration');
1437
+ fs.renameSync(path.join(lock,'registry.next'),registry); boundary('registered');
1438
+ j.committed = true; journal(j); boundary('committed');
1439
+ recoverState(j);
1440
+ } catch (error) {
1441
+ if (!j.committed) recoverState(j);
1442
+ throw error;
1443
+ }
1444
+ instructions(packages);
1445
+ }
1446
+ function shell(s) { return "'" + s.replaceAll("'", "'\\''") + "'"; }
1447
+ function instructions(packages) {
1448
+ const pub = path.join(session,'composition');
1449
+ console.log('source ' + shell(path.join(pub,'env')));
1450
+ console.log('For restore, also pass: --configfile ' + shell(path.join(pub,'nuget.config')));
1451
+ console.log('overrode ' + packages.length + ' NuGet package IDs in composition');
1452
+ }
1453
+ // XML config overlay is implemented by a second SDK task in the same temporary
1454
+ // project, using XmlDocument rather than a regex/token-based XML rewrite.
1455
+ function configOverlay(config,packages) {
1456
+ if (config) plain(config);
1457
+ const output = path.join(scratch,'config.xml'), project = path.join(scratch,'config.proj');
1458
+ const ids = path.join(scratch,'config-ids'); fs.writeFileSync(ids,packages.map(p => p.id).join('\n'));
1459
+ 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[
1460
+ var doc = new System.Xml.XmlDocument(); doc.XmlResolver = null;
1461
+ if (Input.Length == 0) doc.LoadXml("<configuration><packageSources><clear/><add key='nuget.org' value='https://api.nuget.org/v3/index.json'/></packageSources></configuration>");
1462
+ 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); }
1463
+ var root = doc.DocumentElement;
1464
+ if (root.Name != "configuration") throw new FormatException("invalid NuGet config");
1465
+ var sources = root.SelectNodes("packageSources");
1466
+ if (sources.Count != 1 || root.SelectNodes("packageSourceMapping").Count > 1) throw new FormatException("ambiguous NuGet sources/mapping");
1467
+ foreach (System.Xml.XmlElement source in sources[0].SelectNodes("add")) {
1468
+ if (String.Equals(source.GetAttribute("key"),"cratis-local",StringComparison.OrdinalIgnoreCase)) throw new FormatException("source key cratis-local already exists");
1469
+ var value = source.GetAttribute("value"); Uri uri;
1470
+ 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)));
1471
+ }
1472
+ var localSource = doc.CreateElement("add"); localSource.SetAttribute("key","cratis-local"); localSource.SetAttribute("value",Feed); sources[0].AppendChild(localSource);
1473
+ var mapping = root.SelectSingleNode("packageSourceMapping");
1474
+ 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");
1475
+ 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");
1476
+ if (mapping == null) {
1477
+ mapping = doc.CreateElement("packageSourceMapping"); root.AppendChild(mapping);
1478
+ foreach (System.Xml.XmlElement source in sources[0].SelectNodes("add")) {
1479
+ if (source.GetAttribute("key") == "cratis-local") continue;
1480
+ var remote = doc.CreateElement("packageSource"); remote.SetAttribute("key",source.GetAttribute("key"));
1481
+ var wildcard = doc.CreateElement("package"); wildcard.SetAttribute("pattern","*"); remote.AppendChild(wildcard); mapping.AppendChild(remote);
1482
+ }
1483
+ }
1484
+ var localMapping = doc.CreateElement("packageSource"); localMapping.SetAttribute("key","cratis-local");
1485
+ foreach (var id in System.IO.File.ReadAllLines(Ids)) {
1486
+ foreach (System.Xml.XmlElement entry in mapping.SelectNodes("packageSource/package")) if (String.Equals(entry.GetAttribute("pattern"),id,StringComparison.OrdinalIgnoreCase)) entry.ParentNode.RemoveChild(entry);
1487
+ var entryLocal = doc.CreateElement("package"); entryLocal.SetAttribute("pattern",id); localMapping.AppendChild(entryLocal);
1488
+ }
1489
+ mapping.AppendChild(localMapping); doc.Save(Output);
1490
+ ]]></Code></Task></UsingTask><Target Name="Overlay"><CompositionConfig Input="${msxml(config || '')}" Output="${msxml(output)}" Feed="${msxml(path.join(feed,'nuget'))}" Ids="${msxml(ids)}"/></Target></Project>`);
1491
+ run('dotnet',['msbuild',project,'-t:Overlay','-nologo','-v:quiet','-m:1','-nodeReuse:false']);
1492
+ return fs.readFileSync(output,'utf8');
1493
+ }
1494
+ function restoreState() {
1495
+ const files = activeFiles(); if (!files) return;
1496
+ if (dry === '1') { console.log('dry-run: remove composition overlay'); return; }
1497
+ const token = crypto.randomBytes(12).toString('hex');
1498
+ const j = {schema:1,operation:'restore',session,repo,token,files,committed:false};
1499
+ journal(j);
1500
+ try {
1501
+ boundary('restore-journal');
1502
+ fs.renameSync(path.join(session,'composition'),path.join(session,'.composition-' + token)); boundary('restore-renamed');
1503
+ j.committed = true; journal(j); boundary('restore-committed'); recoverState(j);
1504
+ } catch (error) { if (!j.committed) recoverState(j); throw error; }
1505
+ }
1506
+ function cleanGuard() {
1507
+ const knownSessions = new Set(argv[1] ? [argv[1]] : []);
1508
+ if (exists(registry)) { plain(registry); for (const known of fs.readFileSync(registry,'utf8').split('\n').filter(Boolean)) knownSessions.add(known); }
1509
+ const selected = argv[0] || '';
1510
+ for (const known of knownSessions) {
1511
+ if (!path.isAbsolute(known)) fail('invalid registered session');
1512
+ safeParents(known,known);
1513
+ const composition = path.join(known,'composition');
1514
+ if (!exists(composition)) continue;
1515
+ plain(composition,true); plain(path.join(composition,'manifest.tsv'));
1516
+ const versions = fs.readFileSync(path.join(composition,'manifest.tsv'),'utf8').trim().split('\n').map(row => row.split('\t')[2]);
1517
+ if (mode === 'clean-members') { for (const version of versions) console.log(version); }
1518
+ else if (!selected || selected === 'all' || versions.includes(selected)) fail('active composition retains member artifacts; restore first: ' + known);
1519
+ }
1520
+ }
1521
+ try {
1522
+ safeParents(feed);
1523
+ if (mode === 'recover') recover();
1524
+ else if (mode === 'restore-state') restoreState();
1525
+ else if (mode === 'clean-guard' || mode === 'clean-members') cleanGuard();
1526
+ else {
1527
+ const args = [...argv]; if (mode !== 'verify-pack') args.shift();
1528
+ if (args.shift() !== '--composition' || !args.length) fail('expected --composition FILE');
1529
+ const file = args.shift(); let json = false, pin = false;
1530
+ for (const arg of args) {
1531
+ if (arg === '--json' && mode !== 'use-nuget' && !json) json = true;
1532
+ else if (arg === '--pin-transitive' && mode === 'use-nuget' && !pin) pin = true;
1533
+ else fail('cannot mix composition with stamp, IDs or other options: ' + arg);
1534
+ }
1535
+ const packages = manifest(file);
1536
+ const consumed = mode === 'verify-assets' ? assets(packages) : null;
1537
+ const warnings = native(packages,consumed?.checks);
1538
+ for (const warning of warnings) console.error('WARNING: ' + warning);
1539
+ if (mode === 'use-nuget') activate(packages,pin);
1540
+ else if (json) console.log(JSON.stringify({packages:(consumed?.packages || packages).map(({id,version,producer}) => ({id,version,producer})), warnings}));
1541
+ else console.log(mode + ': ' + (consumed?.packages.length || packages.length) + ' composition packages' + (consumed ? ', ' + consumed.files + ' assets files' : '') + ', native NuGet validation passed');
1542
+ }
1543
+ } catch (error) { console.error('ERROR: ' + error.message); process.exitCode = Number.isInteger(error.code) ? error.code : 2; }
1544
+ COMPOSITION
472
1545
  }
473
1546
 
474
1547
  # Dependency map: tab-separated rows, '#' comments (see references/dependency-map.md).
@@ -817,6 +1890,7 @@ report_findings() {
817
1890
  # Plan mode checks exactly the plan the closure printed. Name mode (manual, weaker) builds the plan rows from
818
1891
  # the named repositories' ids rows instead of from edges.
819
1892
  verify_pack() {
1893
+ case " $* " in *' --composition '*) composition verify-pack "$@"; return ;; esac
820
1894
  [ $# -ge 2 ] || die 'verify-pack requires STAMP and either --consumer C --producer P... or repository NAMEs'
821
1895
  local stamp=$1 json=0 out=1 ecosystem='' consumer='' name eco d found
822
1896
  local producers=() names=()
@@ -871,6 +1945,7 @@ verify_pack() {
871
1945
  # Plan mode scans for exactly the planned IDs of one ecosystem; feed-wide mode (no --consumer) scans every
872
1946
  # package at the stamp in the feed, which is manual and weaker.
873
1947
  verify_assets() {
1948
+ case " $* " in *' --composition '*) composition verify-assets "$@"; return ;; esac
874
1949
  [ $# -ge 2 ] || die 'verify-assets requires CONSUMER-PATH, STAMP, [--npm] [--dir REL]'
875
1950
  local path=$1 stamp=$2 npm=0 dir='' json=0 out=1 consumer='' eco=nuget d name file matched nm unit
876
1951
  local producers=()
@@ -950,6 +2025,7 @@ use_npm() {
950
2025
  dir=$repo${rel:+/$rel}
951
2026
  valid_stamp "$version"; need node; need tar; scratch
952
2027
  session=$(session_path "$repo"); backup=$session/npm-backup
2028
+ [ ! -d "$session/composition" ] || die 'active composition; restore first'
953
2029
  [ -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
2030
  [ ! -e "$backup" ] || die "active npm backup exists: $backup; restore first"
955
2031
  node - "$dir" "$FEED/npm" "$version" "$TMP/package.json" <<'NODE'
@@ -985,15 +2061,33 @@ NODE
985
2061
  git -C "$dir" diff HEAD -- package.json yarn.lock > "$backup/before.diff"
986
2062
  cp "$TMP/package.json" "$backup/expected-package.json"
987
2063
  cp "$TMP/package.json" "$dir/package.json"
2064
+ registry_consumer "$version" npm "$repo" "$session" ''
988
2065
  printf 'Next: cd %q && yarn install\n' "$dir"
989
2066
  printf 'Vite: clear stale prebundles with rm -rf node_modules/.vite\n'
990
2067
  }
991
2068
 
992
2069
  restore() {
993
2070
  [ $# -eq 1 ] || die 'restore requires a consumer'
994
- local repo session backup dir
2071
+ local repo session backup dir stamp
2072
+ local restore_stamps=()
995
2073
  repo=$(repo_root "$1"); session=$(session_path "$repo"); backup=$session/npm-backup
996
2074
  validate_session "$session"
2075
+ scratch
2076
+ # Restoring the overlay never depends on node; only the registry bookkeeping does.
2077
+ local registry_node=0
2078
+ if [ "$DRY" -eq 0 ] && command -v node > /dev/null 2>&1; then
2079
+ registry_node=1
2080
+ registry restore-stamps "$session" > "$TMP/restore-stamps"
2081
+ while IFS= read -r stamp; do restore_stamps+=("$stamp"); done < "$TMP/restore-stamps"
2082
+ elif [ "$DRY" -eq 0 ]; then
2083
+ printf 'WARNING: node not found; restoring without updating the stamp registry\n' >&2
2084
+ fi
2085
+ if [ -d "$session/composition" ]; then
2086
+ composition restore-state "$repo"
2087
+ if [ "$registry_node" -eq 1 ]; then registry_update restore "$session" ${restore_stamps[@]+"${restore_stamps[@]}"}; fi
2088
+ if [ "$DRY" -eq 0 ]; then check "$repo"; fi
2089
+ return
2090
+ fi
997
2091
  dir=$repo
998
2092
  if [ -f "$session/npm-dir" ]; then dir=$repo/$(cat "$session/npm-dir"); fi
999
2093
  if [ -f "$session/repo" ] && [ "$(cat "$session/repo")" != "$repo" ]; then die 'session belongs to another checkout'; fi
@@ -1009,7 +2103,8 @@ restore() {
1009
2103
  if [ -f "$backup/yarn.lock" ]; then mutate cp -p "$backup/yarn.lock" "$dir/yarn.lock"; else mutate rm -f -- "$dir/yarn.lock"; fi
1010
2104
  mutate rm -rf -- "$backup"
1011
2105
  fi
1012
- mutate rm -f -- "$session/Directory.Packages.props" "$session/nuget.config" "$session/env" "$session/stamp" "$session/npm-dir"
2106
+ mutate rm -f -- "$session/Directory.Packages.props" "$session/cratis-local.targets" "$session/nuget.config" "$session/env" "$session/stamp" "$session/npm-dir"
2107
+ if [ "$registry_node" -eq 1 ]; then registry_update restore "$session" ${restore_stamps[@]+"${restore_stamps[@]}"}; fi
1013
2108
  if [ "$DRY" -eq 0 ]; then check "$repo"; fi
1014
2109
  }
1015
2110
 
@@ -1051,15 +2146,17 @@ check() {
1051
2146
  }
1052
2147
  scan_lines() {
1053
2148
  local path=$1 input=$2
1054
- awk -v path="$path" -v feed="$FEED" '
2149
+ awk -v path="$path" -v feed="$FEED" -v alias="$FEED_ALIAS" '
1055
2150
  function hit(pattern) { print path ":" number ":" pattern }
1056
2151
  {
1057
2152
  number=$0; sub(/:.*/,"",number); line=$0; sub(/^[^:]*:/,"",line)
1058
2153
  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
2154
  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")
2155
+ if (index(line,feed) || (alias != feed && index(line,alias))) hit("local feed path")
1061
2156
  if (index(line,"DirectoryPackages" "PropsPath")) hit("CPM overlay property")
1062
2157
  if (index(line,"RestoreAdditional" "ProjectSources")) hit("additional restore sources")
2158
+ if (index(line,"CustomAfterMicrosoftCommon" "Targets")) hit("local targets property")
2159
+ if (index(line,"_CratisLocalPreviousAfterMicrosoftCommon" "Targets")) hit("previous targets property")
1063
2160
  if (line ~ /<PackageVersion[[:space:]]+Update[[:space:]]*=/) hit("package version override")
1064
2161
  if (path ~ /(^|\/)(package.json|yarn.lock)$/ && line ~ /(file|portal|link):/ &&
1065
2162
  (line ~ /\.tgz/ || line ~ /(file|portal|link):(\/|\.\.?\/|~\/|[A-Za-z]:)/)) hit("local npm reference")
@@ -1087,25 +2184,156 @@ bounded_docker() {
1087
2184
  local_artifact() {
1088
2185
  [[ "$1" =~ -(local\.[0-9]{12}|train)\.g[0-9a-f]{7}($|[^0-9A-Za-z]) ]]
1089
2186
  }
2187
+ # Print stamp<TAB>file. Follow manifest symlinks; traversal/read errors refuse cleanup.
2188
+ live_stamps() {
2189
+ local repo session file found stamp status
2190
+ for repo in "$@"; do
2191
+ session=${LIVE_SESSION:-$(session_path "$repo")}
2192
+ 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"
2193
+ for file in "$session/Directory.Packages.props" "$session/cratis-local.targets" "$session/nuget.config"; do
2194
+ if [ -e "$file" ]; then printf '%s\0' "$file" >> "$TMP/live-files"; fi
2195
+ done
2196
+ while IFS= read -r -d '' file; do
2197
+ [ -f "$file" ] && [ -r "$file" ] || die "cannot read live manifest: $file"
2198
+ status=0
2199
+ found=$(grep -Eio '[0-9]+\.[0-9]+\.[0-9]+-(local\.[0-9]{12}|train)\.g[0-9a-f]{7}' "$file") || status=$?
2200
+ [ "$status" -le 1 ] || die "cannot inspect live manifest: $file"
2201
+ [ -n "$found" ] || continue
2202
+ while IFS= read -r stamp; do
2203
+ stamp=$(printf '%s' "$stamp" | tr '[:upper:]' '[:lower:]')
2204
+ printf '%s\t%s\n' "$stamp" "$file"
2205
+ done <<< "$found"
2206
+ done < "$TMP/live-files"
2207
+ done
2208
+ }
2209
+ # Legacy clean still refuses the whole request, with its original messages.
2210
+ clean_live_references() {
2211
+ local selected=$1 session repo file stamp blocked=0 session_selected
2212
+ while IFS= read -r session; do
2213
+ [ -d "$session" ] && [ -f "$session/repo" ] || continue
2214
+ validate_session "$session"
2215
+ repo=$(cat "$session/repo")
2216
+ [ -d "$repo" ] || continue
2217
+ [ "$(repo_root "$repo")" = "$repo" ] || die "registered consumer no longer matches repository: $repo"
2218
+ session_selected=0
2219
+ if [ -f "$session/stamp" ] && [ "$(cat "$session/stamp")" = "$selected" ]; then session_selected=1; fi
2220
+ LIVE_SESSION=$session live_stamps "$repo" > "$TMP/legacy-live"
2221
+ while IFS=$'\t' read -r stamp file; do
2222
+ if [ -z "$selected" ] || [ "$selected" = all ] || [ "$session_selected" -eq 1 ] || [ "$stamp" = "$selected" ]; then
2223
+ printf 'kept stamp %s: registered live reference in %s\n' "$stamp" "$file"
2224
+ blocked=1
2225
+ fi
2226
+ done < "$TMP/legacy-live"
2227
+ done < "$TMP/unique-sessions"
2228
+ [ "$blocked" -eq 0 ] || die 'live references retained; restore registered consumers before cleaning (no subjects removed)'
2229
+ }
2230
+ # The registry supplies recorded consumers as well as registered sessions. Both list
2231
+ # and expired clean use exactly this offline context and the same decision function.
2232
+ expiry_context() {
2233
+ local repo session cache actual
2234
+ scratch
2235
+ registry expiry-inputs "$@" > "$TMP/expiry-inputs"
2236
+ if [ -n "${EXPIRY_CONSUMER:-}" ]; then printf '%s\t%s\n' "$EXPIRY_CONSUMER" "$(session_path "$EXPIRY_CONSUMER")" >> "$TMP/expiry-inputs"; fi
2237
+ : > "$TMP/expiry-live"; : > "$TMP/expiry-compositions"
2238
+ printf '%s\n' "$NUGET_CACHE" "$HOME/.nuget/packages" > "$TMP/expiry-caches"
2239
+ while IFS= read -r actual; do
2240
+ session=${actual#*$'\t'}; repo=${actual%%$'\t'*}
2241
+ validate_session "$session"
2242
+ if [ -e "$session/nuget-cache" ] || [ -L "$session/nuget-cache" ]; then
2243
+ cache=$(registered_private_cache "$session")
2244
+ printf '%s\n' "$cache" >> "$TMP/expiry-caches"
2245
+ fi
2246
+ if [ -e "$session/composition" ] || [ -L "$session/composition" ]; then
2247
+ composition clean-members all "$session" >> "$TMP/expiry-compositions"
2248
+ fi
2249
+ [ -n "$repo" ] && [ -d "$repo" ] || continue
2250
+ actual=$(git -C "$repo" rev-parse --show-toplevel 2>/dev/null) || continue
2251
+ [ "$actual" = "$repo" ] || continue
2252
+ LIVE_SESSION=$session live_stamps "$repo" >> "$TMP/expiry-live"
2253
+ done < "$TMP/expiry-inputs"
2254
+ }
2255
+ expired_guard() {
2256
+ local stamp=$1 found file
2257
+ expiry_context expiry
2258
+ if grep -Fxq -- "$stamp" "$TMP/expiry-compositions"; then die "expired stamp changed: active composition member: $stamp"; fi
2259
+ while IFS=$'\t' read -r found file; do
2260
+ [ "$found" != "$stamp" ] || die "expired stamp changed: live reference in $file"
2261
+ done < "$TMP/expiry-live"
2262
+ }
2263
+ subject_stamp() {
2264
+ local name=${1##*/}
2265
+ if [[ "$name" =~ ([0-9]+\.[0-9]+\.[0-9]+-(local\.[0-9]{12}|train)\.g[0-9a-f]{7}) ]]; then printf '%s\n' "${BASH_REMATCH[1]}"; fi
2266
+ }
1090
2267
  clean() {
1091
- local version='' apply=0 consumer='' arg path cache image session maximum count=0 rel=''
1092
- local subjects=() kinds=()
2268
+ local version='' apply=0 consumer='' arg path cache image session maximum count=0 rel='' private=0 whole_cache='' whole_session=''
2269
+ local expired=0 online=0 selected_stamp decision reason EXPIRY_CONSUMER=''
2270
+ local subjects=() kinds=() caches=("$NUGET_CACHE") cache_sessions=() filtered_subjects=() filtered_kinds=()
1093
2271
  while [ $# -gt 0 ]; do
1094
2272
  arg=$1; shift
1095
2273
  case "$arg" in
1096
2274
  --stamp) [ $# -gt 0 ] || die '--stamp requires a stamp'; [ -z "$version" ] || die 'choose --stamp or --all'; version=$1; shift; valid_stamp "$version" ;;
1097
2275
  --all) [ -z "$version" ] || die 'choose --stamp or --all'; version=all ;;
2276
+ --expired) expired=1 ;;
2277
+ --online) online=1 ;;
1098
2278
  --apply) apply=1 ;;
2279
+ --private-cache) private=1 ;;
1099
2280
  --dir) [ $# -gt 0 ] || die '--dir requires a relative directory'; rel=${1%/}; shift ;;
1100
2281
  -*) die "unknown clean option: $arg" ;;
1101
2282
  *) [ -z "$consumer" ] || die 'clean takes at most one consumer'; consumer=$(repo_root "$arg") ;;
1102
2283
  esac
1103
2284
  done
2285
+ [ "$expired" -eq 0 ] || [ -z "$version" ] || die '--expired cannot be combined with --stamp or --all'
2286
+ [ "$online" -eq 0 ] || [ "$expired" -eq 1 ] || die '--online requires --expired'
2287
+ [ "$expired" -eq 0 ] || [ "$private" -eq 0 ] || die '--expired cannot remove a whole private cache'
2288
+ local registered
2289
+ if [ "$expired" -eq 0 ] && [ -n "$consumer" ]; then
2290
+ session=$(session_path "$consumer")
2291
+ if [ -e "$session/composition" ] || [ -L "$session/composition" ]; then composition clean-guard "${version:-all}" "$session"; fi
2292
+ fi
2293
+ if [ "$expired" -eq 0 ] && [ -f "$FEED/sessions" ]; then
2294
+ while IFS= read -r registered; do
2295
+ if [ -e "$registered/composition" ] || [ -L "$registered/composition" ]; then
2296
+ composition clean-guard "${version:-all}"
2297
+ break
2298
+ fi
2299
+ done < "$FEED/sessions"
2300
+ fi
1104
2301
  maximum=${CRATIS_LOCAL_CLEAN_MAX:-500}
1105
2302
  [[ "$maximum" =~ ^[0-9]+$ ]] || die 'CRATIS_LOCAL_CLEAN_MAX must be a whole number'
1106
2303
  [ ${#maximum} -le 6 ] || die 'CRATIS_LOCAL_CLEAN_MAX is too large'
1107
2304
  maximum=$((10#$maximum))
1108
2305
  scratch
2306
+ if [ -f "$FEED/sessions" ]; then cat "$FEED/sessions" > "$TMP/sessions"; else : > "$TMP/sessions"; fi
2307
+ if [ -n "$consumer" ]; then session_path "$consumer" >> "$TMP/sessions"; fi
2308
+ sort -u "$TMP/sessions" > "$TMP/unique-sessions"
2309
+ if [ "$expired" -eq 1 ]; then
2310
+ EXPIRY_CONSUMER=$consumer
2311
+ if [ "$online" -eq 1 ]; then registry expiry --online > "$TMP/expiry-decisions"; else registry expiry > "$TMP/expiry-decisions"; fi
2312
+ : > "$TMP/expired-stamps"
2313
+ while IFS=$'\t' read -r decision selected_stamp reason; do
2314
+ printf '%s %s: %s\n' "$decision" "$selected_stamp" "$reason"
2315
+ if [ "$decision" = expired ]; then printf '%s\n' "$selected_stamp" >> "$TMP/expired-stamps"; fi
2316
+ done < "$TMP/expiry-decisions"
2317
+ while IFS= read -r path; do printf '%s\n' "${path#*$'\t'}" >> "$TMP/sessions"; done < "$TMP/expiry-inputs"
2318
+ sort -u "$TMP/sessions" > "$TMP/unique-sessions"
2319
+ caches+=("$HOME/.nuget/packages")
2320
+ fi
2321
+ while IFS= read -r session; do
2322
+ if [ -e "$session/nuget-cache" ] || [ -L "$session/nuget-cache" ]; then
2323
+ cache=$(registered_private_cache "$session")
2324
+ cache_sessions+=("$session"); caches+=("$cache")
2325
+ fi
2326
+ done < "$TMP/unique-sessions"
2327
+ if [ "$private" -eq 1 ]; then
2328
+ [ -n "$consumer" ] || die 'clean --private-cache requires a consumer'
2329
+ whole_session=$(session_path "$consumer")
2330
+ [ -f "$whole_session/nuget-cache" ] || die "no registered private cache: $consumer"
2331
+ whole_cache=$(registered_private_cache "$whole_session")
2332
+ private_cache_restored "$whole_session"
2333
+ clean_live_references ''
2334
+ subjects+=("$whole_cache"); kinds+=(private-cache)
2335
+ fi
2336
+ printf '%s\n' "${caches[@]}" | sort -u > "$TMP/nuget-caches"
1109
2337
  for cache in "$FEED/nuget" "$FEED/npm"; do
1110
2338
  [ -d "$cache" ] || continue
1111
2339
  while IFS= read -r -d '' path; do
@@ -1114,8 +2342,10 @@ clean() {
1114
2342
  subjects+=("$path"); kinds+=(file)
1115
2343
  done < <(find "$cache" -type f \( -name '*.nupkg' -o -name '*.snupkg' -o -name '*.tgz' \) -print0)
1116
2344
  done
1117
- if [ -d "$NUGET_CACHE" ]; then
1118
- for cache in "$NUGET_CACHE"/*; do
2345
+ while IFS= read -r path; do
2346
+ [ "$path" != "$whole_cache" ] || continue
2347
+ [ -d "$path" ] || continue
2348
+ for cache in "$path"/*; do
1119
2349
  [ -d "$cache" ] && [ ! -L "$cache" ] || continue
1120
2350
  for path in "$cache"/*-local.* "$cache"/*-train.*; do
1121
2351
  [ -d "$path" ] && [ ! -L "$path" ] || continue
@@ -1124,7 +2354,7 @@ clean() {
1124
2354
  subjects+=("$path"); kinds+=(directory)
1125
2355
  done
1126
2356
  done
1127
- fi
2357
+ done < "$TMP/nuget-caches"
1128
2358
  if [ -n "$consumer" ] && [ ! -f "$consumer${rel:+/$rel}/package.json" ]; then
1129
2359
  printf 'SKIP: no package.json in %s; pass --dir REL for a Yarn project in a subdirectory\n' "$consumer${rel:+/$rel}" >&2
1130
2360
  elif command -v yarn >/dev/null 2>&1 && [ -n "$consumer" ]; then
@@ -1150,12 +2380,10 @@ clean() {
1150
2380
  printf 'SKIP: Docker unavailable or did not respond within 10 seconds\n' >&2
1151
2381
  fi
1152
2382
  else printf 'SKIP: docker is not on PATH\n' >&2; fi
1153
- if [ -f "$FEED/sessions" ]; then cat "$FEED/sessions" > "$TMP/sessions"; else : > "$TMP/sessions"; fi
1154
- if [ -n "$consumer" ]; then session_path "$consumer" >> "$TMP/sessions"; fi
1155
- sort -u "$TMP/sessions" > "$TMP/unique-sessions"
1156
2383
  while IFS= read -r session; do
1157
2384
  [ -d "$session" ] && [ -f "$session/repo" ] && [ -f "$session/stamp" ] || continue
1158
2385
  local_artifact "$(cat "$session/stamp")" || continue
2386
+ if [ "$expired" -eq 1 ] && ! grep -Fxq -- "$(cat "$session/stamp")" "$TMP/expired-stamps"; then continue; fi
1159
2387
  if [ -n "$version" ] && [ "$version" != all ] && [ "$(cat "$session/stamp")" != "$version" ]; then continue; fi
1160
2388
  [ ! -L "$session" ] && [ "$session" != / ] || die "unsafe session: $session"
1161
2389
  validate_session "$session"
@@ -1164,44 +2392,102 @@ clean() {
1164
2392
  [ ! -d "$session/npm-backup" ] || die "unresolved npm backup: $session; restore before cleaning"
1165
2393
  subjects+=("$session"); kinds+=(session)
1166
2394
  done < "$TMP/unique-sessions"
2395
+ # Stamp records exist only after a registry-aware pack or use; without any, clean needs no node.
2396
+ : > "$TMP/stamp-records"
2397
+ if [ -e "$FEED/stamps" ] || [ -L "$FEED/stamps" ]; then registry clean "$version" > "$TMP/stamp-records"; fi
2398
+ while IFS= read -r path; do subjects+=("$path"); kinds+=(stamp-record); done < "$TMP/stamp-records"
2399
+ if [ "$expired" -eq 1 ]; then
2400
+ for ((arg=0; arg<${#subjects[@]}; arg++)); do
2401
+ if [ "${kinds[$arg]}" = session ]; then selected_stamp=$(cat "${subjects[$arg]}/stamp"); else selected_stamp=$(subject_stamp "${subjects[$arg]}"); fi
2402
+ if grep -Fxq -- "$selected_stamp" "$TMP/expired-stamps"; then
2403
+ filtered_subjects+=("${subjects[$arg]}"); filtered_kinds+=("${kinds[$arg]}")
2404
+ fi
2405
+ done
2406
+ subjects=(${filtered_subjects[@]+"${filtered_subjects[@]}"}); kinds=(${filtered_kinds[@]+"${filtered_kinds[@]}"})
2407
+ else clean_live_references "$version"; fi
1167
2408
  count=${#subjects[@]}
1168
2409
  [ "$count" -le "$maximum" ] || die "clean fuse: $count subjects exceeds $maximum"
1169
2410
  for ((arg=0; arg<count; arg++)); do printf '%s: %s\n' "${kinds[$arg]}" "${subjects[$arg]}"; done
1170
2411
  if [ "$apply" -eq 1 ] && [ "$DRY" -eq 0 ]; then
1171
2412
  for ((arg=0; arg<count; arg++)); do
2413
+ if [ "$expired" -eq 1 ]; then
2414
+ if [ "${kinds[$arg]}" = session ]; then selected_stamp=$(cat "${subjects[$arg]}/stamp"); else selected_stamp=$(subject_stamp "${subjects[$arg]}"); fi
2415
+ expired_guard "$selected_stamp"
2416
+ else clean_live_references "$version"; fi
2417
+ for session in ${cache_sessions[@]+"${cache_sessions[@]}"}; do registered_private_cache "$session" > /dev/null; done
1172
2418
  path=${subjects[$arg]}
1173
2419
  case "${kinds[$arg]}" in
2420
+ private-cache)
2421
+ [ "$(registered_private_cache "$whole_session")" = "$path" ] || die "private cache changed: $path"
2422
+ private_cache_restored "$whole_session"
2423
+ clean_live_references ''
2424
+ rm -rf -- "$path"
2425
+ rm -- "$whole_session/nuget-cache"
2426
+ # Later subjects still validate all remaining registrations.
2427
+ cache_sessions=()
2428
+ while IFS= read -r session; do
2429
+ if [ -f "$session/nuget-cache" ]; then cache_sessions+=("$session"); fi
2430
+ done < "$TMP/unique-sessions"
2431
+ ;;
2432
+ stamp-record) registry_update remove "${path##*/}" ;;
1174
2433
  image) docker rmi "$path" ;;
1175
2434
  file) [ -f "$path" ] && [ ! -L "$path" ] || die "clean subject changed: $path"; rm -- "$path" ;;
1176
2435
  directory) [ -d "$path" ] && [ ! -L "$path" ] || die "clean subject changed: $path"; rm -rf -- "$path" ;;
1177
2436
  session)
1178
2437
  [ -f "$path/stamp" ] && [ ! -L "$path" ] && [ ! -e "$path/npm-backup" ] || die "session changed: $path"
1179
2438
  validate_session "$path"
1180
- rm -f -- "$path/repo" "$path/stamp" "$path/env" "$path/npm-dir" "$path/Directory.Packages.props" "$path/nuget.config"
1181
- rmdir -- "$path" ;;
2439
+ rm -f -- "$path/stamp" "$path/env" "$path/npm-dir" "$path/Directory.Packages.props" "$path/cratis-local.targets" "$path/nuget.config"
2440
+ if [ ! -f "$path/nuget-cache" ]; then rm -f -- "$path/repo"; rmdir -- "$path"; fi ;;
1182
2441
  esac
1183
2442
  done
2443
+ if [ "$expired" -eq 1 ]; then registry_update prune; fi
1184
2444
  printf 'removed %s subjects\n' "$count"
1185
2445
  else printf 'dry-run: %s subjects (use --apply to remove)\n' "$count"; fi
1186
2446
  }
1187
2447
 
1188
2448
  # shellcheck disable=SC2016 # fixtures assert literal $STAMP in printed commands
2449
+ self_test_nuget_overlay() {
2450
+ local output=$1 id=$2 version=$3
2451
+ local cpm_on="'\$(ManagePackageVersionsCentrally)' == 'true'" cpm_off="'\$(ManagePackageVersionsCentrally)' != 'true'"
2452
+ grep -Fq "GetPathOfFileAbove('Directory.Packages.props', '\$(MSBuildProjectDirectory)')" "$output/Directory.Packages.props" || failed 'self-test: nearest CPM import'
2453
+ grep -Fq 'Import Project="$(_CratisLocalPackagesProps)" Condition="' "$output/Directory.Packages.props" || failed 'self-test: conditional nearest import'
2454
+ grep -Fq "ItemGroup Condition=\"$cpm_on\"" "$output/Directory.Packages.props" || failed 'self-test: CPM-only versions'
2455
+ grep -Fq "PropertyGroup Condition=\"$cpm_on\"" "$output/Directory.Packages.props" || failed 'self-test: CPM-only transitive pinning'
2456
+ grep -Fq "ItemGroup Condition=\"$cpm_off\"" "$output/cratis-local.targets" || failed 'self-test: opt-out-only references'
2457
+ grep -Fq "PackageReference Update=\"$id\" Version=\"$version\"" "$output/cratis-local.targets" || failed 'self-test: opt-out version override'
2458
+ grep -Fq 'Import Project="$(_CratisLocalPreviousAfterMicrosoftCommonTargets)"' "$output/cratis-local.targets" || failed 'self-test: chained targets import'
2459
+ grep -Fq "!= '\$(MSBuildThisFileFullPath)' and Exists('\$(_CratisLocalPreviousAfterMicrosoftCommonTargets)')" "$output/cratis-local.targets" || failed 'self-test: chained targets import is guarded'
2460
+ env -i PATH=/usr/bin:/bin CustomAfterMicrosoftCommonTargets='/previous targets/file.targets' /bin/bash -c '
2461
+ source "$1"; source "$1"
2462
+ [ "$_CratisLocalPreviousAfterMicrosoftCommonTargets" = "/previous targets/file.targets" ] &&
2463
+ [ "$CustomAfterMicrosoftCommonTargets" = "$2/cratis-local.targets" ]
2464
+ ' _ "$output/env" "$output" || failed 'self-test: source twice preserves previous targets'
2465
+ }
2466
+
1189
2467
  self_test() {
1190
2468
  [ $# -eq 0 ] || die '--self-test takes no arguments'
1191
- 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"
2469
+ local root home repo feed version output status passed=0 skipped=0 test_key arg map system_path
2470
+ # Fixtures use the physical path (macOS TMPDIR is /var -> /private/var) because the helper
2471
+ # reports repositories by their git toplevel; symlinked feeds have their own test below.
2472
+ scratch; root=$(cd -P "$TMP" && pwd -P)/self-test; mkdir -p "$root/home" "$root/repo"
1193
2473
  map=$root/map.tsv; home=$root/home; repo=$root/repo; feed=$home/.cache/cratis/local-feed
1194
- # System-only tests stay hermetic; npm tests inherit PATH to discover node.
1195
- # Both isolate HOME/config and omit the host adapter.
1196
- st() { env -i HOME="$home" PATH=/usr/bin:/bin:/usr/sbin:/sbin TMPDIR="$TMP" /bin/bash "$SELF" "$@"; }
1197
- stm() { env -i HOME="$home" PATH=/usr/bin:/bin:/usr/sbin:/sbin TMPDIR="$TMP" CRATIS_LOCAL_MAP="$map" /bin/bash "$SELF" "$@"; }
2474
+ # Isolate tool discovery, adding only node for the stamp registry. npm tests
2475
+ # inherit PATH. Both isolate HOME/config and omit the host adapter.
2476
+ need node; mkdir -p "$root/registry-bin"
2477
+ ln -s "$(command -v node)" "$root/registry-bin/node"
2478
+ system_path=$root/registry-bin:/usr/bin:/bin:/usr/sbin:/sbin
2479
+ st() { env -i HOME="$home" PATH="$system_path" TMPDIR="$TMP" /bin/bash "$SELF" "$@"; }
2480
+ stm() { env -i HOME="$home" PATH="$system_path" TMPDIR="$TMP" CRATIS_LOCAL_MAP="$map" /bin/bash "$SELF" "$@"; }
1198
2481
  has() { grep -Fq -- "$1" "$2"; }
1199
2482
  st_npm() { env -i HOME="$home" PATH="$PATH" TMPDIR="$TMP" /bin/bash "$SELF" "$@"; }
1200
2483
  git -C "$repo" init -q
1201
2484
  repo=$(repo_root "$repo")
1202
2485
  git -C "$repo" config user.email test@example.invalid
1203
2486
  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"
2487
+ printf '<Project><PropertyGroup><ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally></PropertyGroup><ItemGroup><PackageVersion Include="Cratis.Test" Version="1.2.3" /></ItemGroup></Project>\n' > "$repo/Directory.Packages.props"
2488
+ mkdir -p "$repo/nested"
2489
+ printf '<Project><PropertyGroup><ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally></PropertyGroup></Project>\n' > "$repo/nested/Directory.Packages.props"
2490
+ 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
2491
  printf '{"private":true,"dependencies":{"@cratis/test":"1.2.3"}}\n' > "$repo/package.json"
1206
2492
  cat > "$repo/yarn.lock" <<'LOCK'
1207
2493
  # This file is generated by running "yarn install" inside your project.
@@ -1234,6 +2520,7 @@ LOCK
1234
2520
  version=$(st stamp "$repo")
1235
2521
  [[ "$version" =~ ^1\.2\.4-local\.[0-9]{12}\.g[0-9a-f]{7}$ ]] || failed 'self-test: stamp format'
1236
2522
  passed=$((passed + 1))
2523
+ self_test_base_version
1237
2524
  mkdir -p "$feed/nuget" "$feed/npm"
1238
2525
  : > "$feed/nuget/Cratis.Test.1.2.4-local.200001010000.g1234567.nupkg"
1239
2526
  st --dry-run use-nuget "$repo" 1.2.4-local.200001010000.g1234567 Cratis.Test > "$root/output"
@@ -1250,12 +2537,13 @@ LOCK
1250
2537
  done
1251
2538
  st use-nuget "$repo" "$version" > "$root/output"
1252
2539
  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'
2540
+ grep -Fq "PackageVersion Include=\"Cratis.Test\" Version=\"$version\"" "$output/Directory.Packages.props" || failed 'self-test: pinned package Include'
1255
2541
  grep -Fq "PackageVersion Include=\"Cratis.Transitive\" Version=\"$version\"" "$output/Directory.Packages.props" || failed 'self-test: transitive package Include'
1256
2542
  if grep -Fq 'CentralPackageTransitivePinningEnabled' "$output/Directory.Packages.props"; then failed 'self-test: transitive pinning must be opt-in'; fi
1257
2543
  st use-nuget "$repo" "$version" --pin-transitive > "$root/output"
1258
2544
  grep -Fq 'CentralPackageTransitivePinningEnabled>true' "$output/Directory.Packages.props" || failed 'self-test: --pin-transitive enables pinning'
2545
+ self_test_nuget_overlay "$output" Cratis.Test "$version"
2546
+ passed=$((passed + 8))
1259
2547
  grep -Fq "PackageVersion Include=\"Cratis.Transitive\" Version=\"$version\"" "$output/Directory.Packages.props" || failed 'self-test: --pin-transitive keeps the IDs'
1260
2548
  st use-nuget "$repo" "$version" Cratis.Test --pin-transitive > "$root/output" || failed 'self-test: --pin-transitive with explicit IDs'
1261
2549
  st use-nuget "$repo" "$version" > "$root/output"
@@ -1265,12 +2553,22 @@ LOCK
1265
2553
  passed=$((passed + 5))
1266
2554
  st check "$repo" > "$root/output" || failed 'self-test: clean repo'
1267
2555
  passed=$((passed + 1))
1268
- for test_key in stamp npm props sources override feed; do
2556
+ # Legacy clean of a feed without stamp records must not need node.
2557
+ mkdir -p "$root/no-registry-feed/nuget"
2558
+ if PATH=/usr/bin:/bin:/usr/sbin:/sbin command -v node > /dev/null 2>&1; then
2559
+ printf 'SKIP: clean without node needs a system PATH without node\n' >&2; skipped=$((skipped + 1))
2560
+ else
2561
+ expect_status 0 'legacy clean without node or stamp records' env -i HOME="$home" PATH=/usr/bin:/bin:/usr/sbin:/sbin TMPDIR="$TMP" CRATIS_LOCAL_FEED="$root/no-registry-feed" /bin/bash "$SELF" clean
2562
+ passed=$((passed + 1))
2563
+ fi
2564
+ for test_key in stamp npm props sources targets previous override feed; do
1269
2565
  case "$test_key" in
1270
2566
  stamp) printf '%s\n' "$version" > "$repo/probe" ;;
1271
2567
  npm) printf '{"resolutions":{"@cratis/test":"%s%s"}}\n' 'file:/tmp/' 'package.tgz' > "$repo/package.json" ;;
1272
2568
  props) printf '%s=/tmp/overlay\n' DirectoryPackages"PropsPath" > "$repo/probe" ;;
1273
2569
  sources) printf '%s=/tmp/feed\n' RestoreAdditional"ProjectSources" > "$repo/probe" ;;
2570
+ targets) printf '%s=/tmp/overlay.targets\n' CustomAfterMicrosoftCommon"Targets" > "$repo/probe" ;;
2571
+ previous) printf '%s=/tmp/previous.targets\n' _CratisLocalPreviousAfterMicrosoftCommon"Targets" > "$repo/probe" ;;
1274
2572
  override) printf '<PackageVersion %s="Cratis.Test" />\n' Update > "$repo/probe" ;;
1275
2573
  feed) printf '%s\n' "$feed" > "$repo/probe" ;;
1276
2574
  esac
@@ -1279,6 +2577,20 @@ LOCK
1279
2577
  passed=$((passed + 1))
1280
2578
  git -C "$repo" checkout -- probe package.json
1281
2579
  done
2580
+ # A feed configured through a symlink is found under both its configured and its physical spelling.
2581
+ mkdir -p "$feed" && ln -s "$(dirname "$feed")" "$root/feed-alias"
2582
+ for test_key in "$root/feed-alias/$(basename "$feed")" "$(cd -P "$feed" && pwd -P)"; do
2583
+ printf '%s/nuget\n' "$test_key" > "$repo/probe"
2584
+ 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=$?
2585
+ [ "$status" -eq 1 ] || failed "self-test: did not find aliased feed leak: $test_key"
2586
+ passed=$((passed + 1))
2587
+ git -C "$repo" checkout -- probe
2588
+ done
2589
+ rm "$root/feed-alias"
2590
+ # A consumer's own published-version reference update is legitimate, not an overlay leak.
2591
+ printf '<PackageReference %s="Cratis.Test" Version="1.2.3" />\n' Update > "$repo/probe"
2592
+ st check "$repo" > "$root/output" || failed 'self-test: published reference update reported as leak'
2593
+ git -C "$repo" checkout -- probe; passed=$((passed + 1))
1282
2594
  printf '%s\n' "$version" > "$repo/untracked"
1283
2595
  status=0; st check "$repo" > "$root/output" || status=$?
1284
2596
  [ "$status" -eq 1 ] || failed 'self-test: untracked leak'
@@ -1289,6 +2601,9 @@ LOCK
1289
2601
  [ "$status" -eq 1 ] || failed 'self-test: staged content'
1290
2602
  git -C "$repo" restore --staged probe; git -C "$repo" checkout -- probe
1291
2603
  passed=$((passed + 1))
2604
+ st restore "$repo" > "$root/output" || failed 'self-test: restore legacy overlay'
2605
+ [ ! -e "$output/cratis-local.targets" ] && [ ! -e "$output/env" ] || failed 'self-test: legacy targets and env survive restore'
2606
+ passed=$((passed + 1))
1292
2607
  if command -v node >/dev/null 2>&1; then
1293
2608
  # Preserve pre-existing user edits as well as the committed contents.
1294
2609
  st_npm use-npm "$repo" "$version"
@@ -1319,8 +2634,9 @@ LOCK
1319
2634
  cp "$repo/package.json" "$root/original-package.json"
1320
2635
  cp "$repo/yarn.lock" "$root/original-yarn.lock"
1321
2636
  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'
2637
+ # Model Yarn's replacement of a remote entry with a file-tarball entry. Yarn writes the
2638
+ # path the overlay put in package.json, which uses the physical feed path.
2639
+ node - "$repo/yarn.lock" "$(cd -P "$feed/npm" && pwd -P)/@cratis-test-$version.tgz" "$version" <<'NODE'
1324
2640
  const fs = require('fs');
1325
2641
  const [file, input, stamp] = process.argv.slice(2);
1326
2642
  const tarball = require('path').normalize(input);
@@ -1450,6 +2766,9 @@ NODE
1450
2766
  [ ! -e "$(git -C "$sub" rev-parse --absolute-git-dir)/cratis-local/npm-dir" ] || failed 'self-test: restore removes npm-dir'
1451
2767
  passed=$((passed + 6))
1452
2768
  else printf 'SKIP: use-npm --dir needs node\n'; skipped=$((skipped + 6)); fi
2769
+ self_test_live_clean
2770
+ self_test_private_cache
2771
+ self_test_registry
1453
2772
  mkdir -p "$home/.nuget/packages/cratis.test/$version" "$home/.nuget/packages/cratis.test/1.2.3"
1454
2773
  mkdir -p "$home/.nuget/packages/cratis.test/1.2.3-local.keep"
1455
2774
  for arg in nuget npm; do
@@ -1472,10 +2791,437 @@ NODE
1472
2791
  [ -d "$home/.nuget/packages/cratis.test/1.2.3" ] || failed 'self-test: clean removed real version'
1473
2792
  passed=$((passed + 5))
1474
2793
  printf 'SKIP: real dotnet/Yarn/Docker packing is not a hermetic self-test\n'; skipped=$((skipped + 3))
2794
+ self_test_expired
1475
2795
  self_test_matrix
1476
2796
  printf 'self-test: %s passed, %s skipped\n' "$passed" "$skipped"
1477
2797
  }
1478
2798
 
2799
+ self_test_expired() {
2800
+ local fixture ef er es ec record result expected stamp=9.8.7-train.gabcdef0
2801
+ local fixtures=('task finished' 'consumers gone' 'released offline cache' 'live reference kept' 'composition kept' 'unrecorded kept' 'dry-run no mutation' 'with stamp' 'fuse' 'online unreachable kept' 'prunes sessions' 'list json expiry' 'invalid record' 'record consumer reference kept' 'private release cache' 'prerelease cache kept' 'older release kept' 'without task kept' 'with all')
2802
+ ext() { env -i HOME="$ef/home" PATH="$system_path" TMPDIR="$TMP" CRATIS_LOCAL_FEED="$ef/feed" NUGET_PACKAGES="$ec" CRATIS_LOCAL_CLEAN_MAX="${limit:-500}" CRATIS_LOCAL_RELEASE_URL_BASE=http://127.0.0.1:1 /bin/bash "$SELF" "$@"; }
2803
+ for fixture in "${fixtures[@]}"; do
2804
+ ef=$root/expired-${fixture// /-}; er=$ef/repo; ec=$ef/cache
2805
+ mkdir -p "$er" "$ef/feed/nuget" "$ec/cratis.expiry/$stamp" "$ef/home"
2806
+ git -C "$er" init -q
2807
+ es=$(git -C "$er" rev-parse --absolute-git-dir)/cratis-local
2808
+ record=$ef/feed/stamps/$stamp/record.json
2809
+ mkdir -p "${record%/*}"
2810
+ printf 'fixture\n' > "$ef/feed/nuget/Cratis.Expiry.$stamp.nupkg"
2811
+ node - "$record" "$stamp" "$er" "$es" "$fixture" <<'NODE'
2812
+ const fs = require('node:fs'); const [file,stamp,repo,session,fixture] = process.argv.slice(2), now = new Date().toISOString();
2813
+ const finished = ['task finished','live reference kept','composition kept','dry-run no mutation','fuse','prunes sessions','list json expiry','record consumer reference kept'].includes(fixture);
2814
+ const r = {schema:1,stamp,base:'9.8.7',kind:'train',created:now,updated:now,
2815
+ packs:[{ecosystem:'nuget',ids:['Cratis.Expiry'],producer:{repo:'Fixture',worktree:repo,branch:'main',head:'a'.repeat(40)},task:'Fixture#1',time:now}],
2816
+ consumers:[],tasks:{'Fixture#1':{finished:finished ? now : null}}};
2817
+ if (fixture === 'consumers gone') r.consumers.push({worktree:repo + '/gone',session:session + '-gone',ecosystem:'nuget',task:null,time:now});
2818
+ if (fixture === 'record consumer reference kept') r.consumers.push({worktree:repo,session,ecosystem:'nuget',task:null,time:now});
2819
+ if (fixture === 'without task kept') r.packs[0].task = null;
2820
+ fs.writeFileSync(file,JSON.stringify(r) + '\n');
2821
+ NODE
2822
+ expected=0; result=removed
2823
+ case "$fixture" in
2824
+ 'released offline cache') mkdir -p "$ec/cratis.expiry/9.8.8"; : > "$ec/cratis.expiry/9.8.8/.nupkg.metadata" ;;
2825
+ 'private release cache')
2826
+ mkdir -p "$es" "$er/.git/cratis-local-nuget/cratis.expiry/9.8.7"
2827
+ printf '%s\n' "$er" > "$es/repo"
2828
+ printf '%s\n' "$es" > "$ef/feed/sessions"
2829
+ printf '%s\n' "$er/.git/cratis-local-nuget" > "$es/nuget-cache"
2830
+ printf '%s\n' "$er" > "$er/.git/cratis-local-nuget/.cratis-local-cache"
2831
+ : > "$er/.git/cratis-local-nuget/cratis.expiry/9.8.7/.nupkg.metadata" ;;
2832
+ 'live reference kept'|'record consumer reference kept')
2833
+ mkdir -p "$es"; printf '%s\n' "$er" > "$es/repo"
2834
+ if [ "$fixture" = 'live reference kept' ]; then printf '%s\n' "$es" > "$ef/feed/sessions"; fi
2835
+ printf '{"dependencies":{"fixture":"%s"}}\n' "$stamp" > "$er/package.json"
2836
+ result=kept ;;
2837
+ 'composition kept')
2838
+ mkdir -p "$es/composition"; printf '%s\n' "$er" > "$es/repo"
2839
+ printf '%s\n' "$es" > "$ef/feed/sessions"
2840
+ printf 'Fixture\tCratis.Expiry\t%s\t%s\n' "$stamp" "$er" > "$es/composition/manifest.tsv"
2841
+ result=kept ;;
2842
+ 'unrecorded kept') rm -- "$record"; rmdir "${record%/*}"; result=kept ;;
2843
+ 'dry-run no mutation') cp "$record" "$ef/before"; result=kept ;;
2844
+ 'with stamp'|'with all'|'fuse'|'invalid record')
2845
+ expected=2; result=kept
2846
+ if [ "$fixture" = 'invalid record' ]; then printf '{broken\n' > "$record"; fi ;;
2847
+ 'prerelease cache kept') mkdir -p "$ec/cratis.expiry/9.8.9-preview"; : > "$ec/cratis.expiry/9.8.9-preview/.nupkg.metadata"; result=kept ;;
2848
+ 'older release kept') mkdir -p "$ec/cratis.expiry/9.8.6"; : > "$ec/cratis.expiry/9.8.6/.nupkg.metadata"; result=kept ;;
2849
+ 'online unreachable kept'|'without task kept') result=kept ;;
2850
+ 'list json expiry') result=kept ;;
2851
+ 'prunes sessions')
2852
+ mkdir -p "$es"; printf '%s\n' "$er" > "$es/repo"; : > "$es/env"
2853
+ printf '%s\n%s\n' "$ef/missing-session" "$es" > "$ef/feed/sessions"
2854
+ node - "$record" "$es" <<'NODE'
2855
+ const fs = require('node:fs'); const [file,session] = process.argv.slice(2), r = JSON.parse(fs.readFileSync(file));
2856
+ r.stamp = '9.8.7-train.g1234567'; r.packs[0].task = null;
2857
+ r.consumers = [{worktree:r.packs[0].producer.worktree,session,ecosystem:'nuget',task:null,time:r.created},
2858
+ {worktree:'/nonexistent/cratis-expiry-fixture',session:session + '-gone',ecosystem:'nuget',task:null,time:r.created},
2859
+ {worktree:r.packs[0].producer.worktree,session:session + '-missing',ecosystem:'nuget',task:null,time:r.created}];
2860
+ const target = file.replace('gabcdef0','g1234567'); fs.mkdirSync(require('node:path').dirname(target)); fs.writeFileSync(target,JSON.stringify(r));
2861
+ NODE
2862
+ ;;
2863
+ esac
2864
+ case "$fixture" in
2865
+ 'with stamp') expect_status "$expected" 'expired with stamp' ext clean --expired --stamp "$stamp" --apply ;;
2866
+ 'with all') expect_status "$expected" 'expired with all' ext clean --all --expired --apply ;;
2867
+ 'fuse') limit=0 expect_status "$expected" 'expired fuse' ext clean --expired --apply ;;
2868
+ 'online unreachable kept') expect_status 0 'expired online unreachable kept' ext clean --expired --online --apply ;;
2869
+ 'dry-run no mutation') expect_status 0 'expired dry-run no mutation' ext clean --expired ;;
2870
+ 'list json expiry')
2871
+ expect_status 0 'list json expiry' ext list --json
2872
+ node - "$root/output" <<'NODE' || failed 'list json expiry decision'
2873
+ const r = JSON.parse(require('node:fs').readFileSync(process.argv[2])).stamps;
2874
+ if (r.length !== 1 || !r[0].expiry.expired || r[0].expiry.reasons.join() !== 'task-finished' || r[0].expiry.kept.length) process.exit(1);
2875
+ NODE
2876
+ ;;
2877
+ *) expect_status "$expected" "expired $fixture" ext clean --expired --apply ;;
2878
+ esac
2879
+ if [ "$result" = removed ]; then
2880
+ [ ! -e "$record" ] && [ ! -e "$ef/feed/nuget/Cratis.Expiry.$stamp.nupkg" ] && [ ! -e "$ec/cratis.expiry/$stamp" ] || failed "expired $fixture left subjects"
2881
+ else
2882
+ [ -f "$ef/feed/nuget/Cratis.Expiry.$stamp.nupkg" ] && [ -d "$ec/cratis.expiry/$stamp" ] || failed "expired $fixture removed subjects"
2883
+ fi
2884
+ case "$fixture" in
2885
+ 'task finished'|'consumers gone') grep -Fq "expired $stamp: ${fixture// /-}" "$root/output" || failed "expired $fixture reason" ;;
2886
+ 'released offline cache') grep -Fq 'released:Cratis.Expiry@9.8.8' "$root/output" || failed 'offline release reason' ;;
2887
+ 'live reference kept'|'record consumer reference kept') grep -Fq "kept $stamp: live reference in $er/package.json" "$root/output" || failed 'live override reason' ;;
2888
+ 'composition kept') grep -Fq "kept $stamp: active composition member" "$root/output" || failed 'composition override reason' ;;
2889
+ 'unrecorded kept') grep -Fq "unrecorded; use clean --stamp $stamp" "$root/output" || failed 'unrecorded reason' ;;
2890
+ 'online unreachable kept') grep -Fq "kept $stamp: active" "$root/output" || failed 'network failure removed active stamp' ;;
2891
+ 'dry-run no mutation') cmp -s "$record" "$ef/before" || failed 'expired dry-run rewrote record' ;;
2892
+ 'prunes sessions')
2893
+ [ "$(cat "$ef/feed/sessions")" = "$es" ] || failed 'expired prunes sessions'
2894
+ node - "$ef/feed/stamps/9.8.7-train.g1234567/record.json" <<'NODE' || failed 'expired prunes consumers'
2895
+ // The consumer with a missing session but an existing worktree stays, so its manifests remain protected.
2896
+ const r = JSON.parse(require('node:fs').readFileSync(process.argv[2])); if (r.consumers.length !== 2 || r.consumers.some(c => c.worktree.endsWith('fixture')) || !r.consumers.some(c => c.session.endsWith('-missing'))) process.exit(1);
2897
+ NODE
2898
+ ;;
2899
+ esac
2900
+ done
2901
+ }
2902
+
2903
+ self_test_registry() {
2904
+ local rr=$root/'registry repo' rf=$root/registry-feed rb=$root/registry-tools rs record
2905
+ local rt=Cratis/Fixture#1 legacy=1.2.5-train.gabcdef0
2906
+ mkdir -p "$rr" "$rf/nuget" "$rb"
2907
+ git -C "$rr" init -q
2908
+ cp "$repo/Directory.Packages.props" "$rr/Directory.Packages.props"
2909
+ printf '{"name":"@cratis/registry","version":"1.0.0"}\n' > "$rr/package.json"
2910
+ git -C "$rr" add .
2911
+ git -C "$rr" -c user.email=test@example.invalid -c user.name='Registry fixture' commit -qm initial
2912
+ rs=$(git -C "$rr" rev-parse --absolute-git-dir)/cratis-local
2913
+ record=$rf/stamps/$version/record.json
2914
+ # Pack-tool doubles exercise successful/failed pack boundaries without building.
2915
+ cat > "$rb/dotnet" <<'SH'
2916
+ #!/bin/bash
2917
+ set -eu
2918
+ [ ! -e "$CRATIS_LOCAL_FEED/fail-pack" ] || exit 1
2919
+ version=''; output=''
2920
+ for arg in "$@"; do
2921
+ case "$arg" in -p:PackageVersion=*) version=${arg#*=} ;; esac
2922
+ if [ "$output" = next ]; then output=$arg; fi
2923
+ if [ "$arg" = -o ]; then output=next; fi
2924
+ done
2925
+ printf 'packed\n' > "$output/Cratis.Registry.$version.nupkg"
2926
+ SH
2927
+ printf '#!/bin/sh\nprintf '\''{"location":"."}\\n'\''\n' > "$rb/yarn"
2928
+ cat > "$rb/npm" <<'SH'
2929
+ #!/bin/bash
2930
+ set -eu
2931
+ version=$(node -p 'require("./package.json").version')
2932
+ mkdir -p "$TMPDIR/registry-tar/package"
2933
+ cp package.json "$TMPDIR/registry-tar/package/package.json"
2934
+ tar -czf "$CRATIS_LOCAL_FEED/npm/cratis-registry-$version.tgz" -C "$TMPDIR/registry-tar" package
2935
+ SH
2936
+ chmod +x "$rb/dotnet" "$rb/npm" "$rb/yarn"
2937
+ rgt() { env -i HOME="$home" PATH="$rb:$system_path" TMPDIR="$TMP" CRATIS_LOCAL_FEED="$rf" NUGET_PACKAGES="$root/registry-cache" CRATIS_LOCAL_TASK="$rt" /bin/bash "$SELF" "$@"; }
2938
+ rg_assert() { node - "$record" "$@" <<'NODE'
2939
+ const fs = require('node:fs'); const [file,kind,version,repo,session] = process.argv.slice(2);
2940
+ const r = JSON.parse(fs.readFileSync(file,'utf8'));
2941
+ if (r.schema !== 1 || r.stamp !== version || !r.created.endsWith('Z') || !r.updated.endsWith('Z')) process.exit(1);
2942
+ if (kind === 'pack' && (r.packs.length !== 2 || !r.packs.some(p => p.ecosystem === 'nuget' && p.ids.join() === 'Cratis.Registry') || !r.packs.some(p => p.ids.includes('@cratis/registry')) || r.packs.some(p => p.producer.worktree !== repo || !/^[0-9a-f]{40}$/.test(p.producer.head)) || (fs.statSync(file).mode & 0o777) !== 0o600)) process.exit(1);
2943
+ if (kind === 'use' && (r.consumers.length !== 2 || r.consumers.some(c => c.worktree !== repo || c.session !== session || c.task !== 'Cratis/Fixture#2'))) process.exit(1);
2944
+ if (kind === 'restored' && r.consumers.some(c => !c.restored?.endsWith('Z'))) process.exit(1);
2945
+ if (kind === 'finished' && !r.tasks['Cratis/Fixture#1'].finished?.endsWith('Z')) process.exit(1);
2946
+ NODE
2947
+ }
2948
+ : > "$rf/nuget/Cratis.Previous.$version.nupkg"
2949
+ rgt pack-nuget "$rr" "$version" Fixture.csproj > "$root/output"
2950
+ rgt pack-npm "$rr" "$version" > "$root/output"
2951
+ expect_status 0 'registry pack records' rg_assert pack "$version" "$rr" "$rs"
2952
+ rgt use-nuget "$rr" "$version" --task Cratis/Fixture#2 > "$root/output"
2953
+ rgt use-npm "$rr" "$version" --task Cratis/Fixture#2 > "$root/output"
2954
+ expect_status 0 'registry use consumer' rg_assert use "$version" "$rr" "$rs"
2955
+ expect_status 0 'registry restore consumer' rgt restore "$rr"
2956
+ rg_assert restored "$version" "$rr" "$rs" || failed 'registry restore history'
2957
+ cp "$record" "$root/registry-before"
2958
+ expect_status 0 'registry dry-run no write' rgt --dry-run pack-nuget "$rr" "$legacy" Fixture.csproj
2959
+ [ ! -e "$rf/stamps/$legacy" ] || failed 'registry dry-run created record'
2960
+ rgt --dry-run use-nuget "$rr" "$version" > "$root/output"
2961
+ cmp -s "$record" "$root/registry-before" || failed 'registry dry-run use rewrote record'
2962
+ expect_status 0 'finish task dry-run' rgt finish --task "$rt"
2963
+ cmp -s "$record" "$root/registry-before" || failed 'finish dry-run rewrote record'
2964
+ expect_status 0 'finish task apply' rgt finish --task "$rt" --apply
2965
+ rg_assert finished "$version" "$rr" "$rs" || failed 'finish did not mark task'
2966
+ expect_status 0 'finish unknown task' rgt finish --task Cratis/Unknown#2 --apply
2967
+ grep -Fq 'no records name task' "$root/output" || failed 'finish unknown task message'
2968
+ : > "$rf/nuget/Cratis.Legacy.$legacy.nupkg"
2969
+ expect_status 0 'list unrecorded legacy' rgt list --stamp "$legacy" --json
2970
+ node -e 'const r=JSON.parse(require("fs").readFileSync(process.argv[1])).stamps;if(r.length!==1||r[0].recorded||r[0].packages[0].id!=="Cratis.Legacy")process.exit(1)' "$root/output" || failed 'list legacy missing'
2971
+ rgt list --json > "$root/registry-list"
2972
+ rg_json() { node - "$root/registry-list" "$rf" <<'NODE'
2973
+ const fs = require('node:fs'); const [file,feed] = process.argv.slice(2), data = JSON.parse(fs.readFileSync(file));
2974
+ if (data.schema !== 1 || data.feed !== feed || data.stamps.length !== 2) process.exit(1);
2975
+ const keys = ['stamp','base','kind','recorded','packages','producers','tasks','consumers','created','ageSeconds','expiry'].sort().join();
2976
+ for (const r of data.stamps) {
2977
+ if (Object.keys(r).sort().join() !== keys || typeof r.expiry.expired !== 'boolean' || !Array.isArray(r.expiry.reasons) || !Array.isArray(r.expiry.kept)) process.exit(1);
2978
+ for (const p of r.packages) if (Object.keys(p).sort().join() !== 'ecosystem,file,id,version') process.exit(1);
2979
+ for (const c of r.consumers) if (c.state !== 'restored') process.exit(1);
2980
+ }
2981
+ NODE
2982
+ }
2983
+ expect_status 0 'list json schema' rg_json
2984
+ cp "$record" "$root/registry-good"
2985
+ printf '{"schema":2}\n' > "$record"
2986
+ cp "$record" "$root/registry-bad"
2987
+ expect_status 2 'registry rejects bad schema' rgt clean --stamp "$version" --apply
2988
+ cmp -s "$record" "$root/registry-bad" || failed 'registry rewrote bad schema'
2989
+ printf '{broken\n' > "$record"
2990
+ expect_status 2 'registry rejects malformed JSON' rgt clean --stamp "$version" --apply
2991
+ cp "$root/registry-good" "$record"
2992
+ mv "$rf/stamps" "$rf/real-stamps"; ln -s "$rf/real-stamps" "$rf/stamps"
2993
+ expect_status 2 'registry symlink refused' rgt finish --task "$rt" --apply
2994
+ rm "$rf/stamps"; mv "$rf/real-stamps" "$rf/stamps"
2995
+ ln -s "$root/registry-good" "$record.next"
2996
+ expect_status 2 'registry next symlink refused' rgt finish --task "$rt" --apply
2997
+ rm "$record.next"
2998
+ expect_status 2 'registry bad task id' rgt pack-nuget "$rr" "$version" --task 'bad task' Fixture.csproj
2999
+ : > "$rf/fail-pack"
3000
+ expect_status 1 'registry failed pack no write' rgt pack-nuget "$rr" "$legacy" Fixture.csproj
3001
+ [ ! -e "$rf/stamps/$legacy" ] || failed 'failed pack wrote record'
3002
+ rm "$rf/fail-pack"
3003
+ # Post-publication failures preserve overlays and report a warning/findings.
3004
+ printf '{"schema":2}\n' > "$record"
3005
+ expect_status 1 'registry use failure keeps overlay' rgt use-nuget "$rr" "$version"
3006
+ { [ -f "$rs/env" ] && grep -Fq WARNING "$root/stderr"; } || failed 'registry failed use lost overlay/warning'
3007
+ cp "$root/registry-good" "$record"
3008
+ rgt restore "$rr" > "$root/output"
3009
+ expect_status 0 'clean removes stamp record' rgt clean --stamp "$version" --apply
3010
+ [ ! -e "$rf/stamps/$version" ] && [ ! -e "$rf/nuget/Cratis.Registry.$version.nupkg" ] || failed 'clean retained stamp record/artifacts'
3011
+ [ -e "$rf/nuget/Cratis.Legacy.$legacy.nupkg" ] || failed 'registry clean removed another stamp'
3012
+ self_test_registry_invalid
3013
+ }
3014
+
3015
+ self_test_registry_invalid() {
3016
+ local fixture rf bad_record other=1.2.3-train.gabcdef0
3017
+ for fixture in clean-scoped restore-other restore-no-node restore-own finish list; do
3018
+ rf=$root/registry-invalid-$fixture
3019
+ bad_record=$rf/stamps/$other/record.json
3020
+ mkdir -p "$rf/nuget" "${bad_record%/*}"
3021
+ printf '{broken\n' > "$bad_record"
3022
+ : > "$rf/nuget/Cratis.Test.$version.nupkg"
3023
+ rgt use-nuget "$rr" "$version" > "$root/output"
3024
+ if [ "$fixture" = restore-own ]; then
3025
+ bad_record=$rf/stamps/$version/record.json
3026
+ printf '{broken\n' > "$bad_record"
3027
+ fi
3028
+ cp "$bad_record" "$root/invalid-before"
3029
+ case "$fixture" in
3030
+ clean-scoped)
3031
+ rgt restore "$rr" > "$root/output"
3032
+ expect_status 0 'registry invalid other record clean scoped' rgt clean --stamp "$version" --apply
3033
+ [ ! -e "$rf/stamps/$version" ] && [ ! -e "$rf/nuget/Cratis.Test.$version.nupkg" ] || failed 'invalid other record blocked scoped clean'
3034
+ expect_status 2 'registry invalid record clean all' rgt clean --all --apply
3035
+ expect_status 2 'registry invalid record clean default' rgt clean --apply
3036
+ ;;
3037
+ restore-other)
3038
+ expect_status 0 'registry invalid other record restore' rgt restore "$rr"
3039
+ ;;
3040
+ restore-no-node)
3041
+ if PATH=/usr/bin:/bin:/usr/sbin:/sbin command -v node > /dev/null 2>&1; then
3042
+ printf 'SKIP: restore without node needs a system PATH without node\n' >&2; skipped=$((skipped + 1))
3043
+ rgt restore "$rr" > "$root/output"
3044
+ else
3045
+ expect_status 0 'registry restore without node restores the overlay' env -i HOME="$home" PATH=/usr/bin:/bin:/usr/sbin:/sbin TMPDIR="$TMP" CRATIS_LOCAL_FEED="$rf" NUGET_PACKAGES="$root/registry-cache" /bin/bash "$SELF" restore "$rr"
3046
+ { [ ! -e "$rs/env" ] && grep -Fq 'WARNING: node not found' "$root/stderr"; } || failed 'restore without node kept overlay or missed warning'
3047
+ fi
3048
+ ;;
3049
+ restore-own)
3050
+ expect_status 1 'registry invalid own record restore' rgt restore "$rr"
3051
+ { grep -Fq 'WARNING:' "$root/stderr" && grep -Fq "$bad_record" "$root/stderr"; } || failed 'restore missing invalid record warning'
3052
+ ;;
3053
+ finish)
3054
+ expect_status 1 'registry finish skips invalid' rgt finish --task "$rt" --apply
3055
+ record=$rf/stamps/$version/record.json rg_assert finished "$version" "$rr" "$rs" || failed 'finish skipped valid record'
3056
+ { grep -Fq 'WARNING:' "$root/stderr" && grep -Fq "$bad_record" "$root/stderr"; } || failed 'finish missing invalid record warning'
3057
+ rgt restore "$rr" > "$root/output"
3058
+ ;;
3059
+ list)
3060
+ expect_status 1 'list invalid record' rgt list --json
3061
+ node - "$root/output" "$version" "$other" <<'NODE' || failed 'list missing valid/invalid rows'
3062
+ const fs = require('node:fs'); const [file, valid, invalid] = process.argv.slice(2);
3063
+ const rows = JSON.parse(fs.readFileSync(file, 'utf8')).stamps;
3064
+ if (rows.length !== 2 || rows.find(r => r.stamp === valid)?.recorded !== true ||
3065
+ rows.find(r => r.stamp === invalid)?.recorded !== 'invalid' || typeof rows.find(r => r.stamp === invalid)?.error !== 'string') process.exit(1);
3066
+ NODE
3067
+ expect_status 1 'list invalid record text' rgt list
3068
+ { grep -Fq invalid "$root/output" && grep -Fq "$version" "$root/output"; } || failed 'text list missing valid/invalid rows'
3069
+ rgt restore "$rr" > "$root/output"
3070
+ ;;
3071
+ esac
3072
+ [ ! -e "$rs/env" ] && [ ! -e "$rs/Directory.Packages.props" ] || failed 'invalid record prevented overlay restore'
3073
+ cmp -s "$bad_record" "$root/invalid-before" || failed 'invalid record rewritten'
3074
+ done
3075
+ }
3076
+
3077
+ self_test_private_cache() {
3078
+ local pc_repo=$root/'private cache repo' pc_feed=$root/private-feed pc_shared=$root/private-shared
3079
+ local pc_cache pc_session pc_opt=0 other=1.2.5-train.g1234567
3080
+ mkdir -p "$pc_repo" "$pc_feed/nuget"
3081
+ git -C "$pc_repo" init -q
3082
+ cp "$repo/Directory.Packages.props" "$pc_repo/Directory.Packages.props"
3083
+ git -C "$pc_repo" add Directory.Packages.props
3084
+ git -C "$pc_repo" -c user.email=test@example.invalid -c user.name='Private cache fixture' commit -qm initial
3085
+ pc_cache=$(git -C "$pc_repo" rev-parse --absolute-git-dir)/cratis-local-nuget
3086
+ pc_session=$(git -C "$pc_repo" rev-parse --absolute-git-dir)/cratis-local
3087
+ pct() { env -i HOME="$home" PATH="$system_path" TMPDIR="$TMP" CRATIS_LOCAL_FEED="$pc_feed" NUGET_PACKAGES="$pc_shared" CRATIS_LOCAL_PRIVATE_CACHE="$pc_opt" /bin/bash "$SELF" "$@"; }
3088
+ : > "$pc_feed/nuget/Cratis.Test.$version.nupkg"
3089
+ expect_status 0 'private-cache default unchanged' pct use-nuget "$pc_repo" "$version"
3090
+ [ ! -e "$pc_cache" ] && [ ! -e "$pc_session/nuget-cache" ] || failed 'private-cache default created a cache'
3091
+ has 'Optional per-worktree cache' "$root/output" || failed 'private-cache default hint changed'
3092
+ pct restore "$pc_repo" > "$root/output"
3093
+ expect_status 0 'private-cache dry-run' pct --dry-run use-nuget "$pc_repo" "$version" --private-cache
3094
+ [ ! -e "$pc_cache" ] && [ ! -e "$pc_session/nuget-cache" ] || failed 'private-cache dry-run wrote a cache'
3095
+ expect_status 0 'private-cache env export' pct use-nuget "$pc_repo" "$version" --private-cache
3096
+ env -i PATH=/usr/bin:/bin /bin/bash -c "source \"\$1\"; [ \"\$NUGET_PACKAGES\" = \"\$2\" ]" _ "$pc_session/env" "$pc_cache" || failed 'private-cache env does not export gitdir cache'
3097
+ if has 'Optional per-worktree cache' "$root/output"; then failed 'private-cache still prints manual hint'; fi
3098
+ pc_marker() { [ "$(cat "$pc_cache/.cratis-local-cache")" = "$pc_repo" ] && [ "$(cat "$pc_session/nuget-cache")" = "$pc_cache" ]; }
3099
+ expect_status 0 'private-cache marker' pc_marker
3100
+ expect_status 2 'private-cache live kept' pct clean "$pc_repo" --private-cache --apply
3101
+ [ -d "$pc_cache" ] || failed 'private-cache live cache removed'
3102
+ pct restore "$pc_repo" > "$root/output"
3103
+ [ -f "$pc_session/nuget-cache" ] && [ -d "$pc_cache" ] || failed 'private-cache restore removed cache'
3104
+ mkdir -p "$pc_cache/cratis.test/$version" "$pc_cache/cratis.test/$other" "$pc_cache/cratis.test/1.2.3" "$pc_cache/cratis.test/1.2.3-local.keep"
3105
+ mkdir -p "$pc_shared/cratis.test/$version" "$pc_shared/cratis.test/$other" "$pc_shared/cratis.test/1.2.3"
3106
+ expect_status 0 'private-cache clean scoped' pct clean --stamp "$version" --apply
3107
+ [ ! -e "$pc_cache/cratis.test/$version" ] && [ -d "$pc_cache/cratis.test/$other" ] && [ -d "$pc_cache/cratis.test/1.2.3" ] && [ -d "$pc_cache/cratis.test/1.2.3-local.keep" ] || failed 'private-cache stamp scope violated'
3108
+ [ ! -e "$pc_shared/cratis.test/$version" ] || failed 'private-cache shared local version retained'
3109
+ expect_status 0 'private-cache shared cache only local' pct clean --all --apply
3110
+ [ ! -e "$pc_shared/cratis.test/$other" ] && [ -d "$pc_shared/cratis.test/1.2.3" ] || failed 'private-cache shared non-local folder removed'
3111
+ rm -- "$pc_cache/.cratis-local-cache"
3112
+ mkdir -p "$pc_shared/cratis.test/$version"
3113
+ expect_status 2 'private-cache unmarked refused' pct clean --stamp "$version" --apply
3114
+ [ -d "$pc_shared/cratis.test/$version" ] || failed 'private-cache unmarked refusal mutated shared cache'
3115
+ printf '%s\n' "$root/other-repo" > "$pc_cache/.cratis-local-cache"
3116
+ expect_status 2 'private-cache mismatched marker refused' pct clean --all --apply
3117
+ printf '%s\n' "$pc_repo" > "$pc_cache/.cratis-local-cache"
3118
+ mv "$pc_cache" "$pc_cache.saved"; ln -s "$pc_cache.saved" "$pc_cache"
3119
+ expect_status 2 'private-cache symlink refused' pct clean --apply
3120
+ rm -- "$pc_cache"; mv "$pc_cache.saved" "$pc_cache"
3121
+ printf '%s\n' "$pc_shared" > "$pc_session/nuget-cache"
3122
+ expect_status 2 'private-cache outside gitdir refused' pct clean --apply
3123
+ printf '%s\n' "$pc_cache" > "$pc_session/nuget-cache"
3124
+ printf '<Project><!-- %s --></Project>\n' "$other" > "$pc_repo/Directory.Packages.props"
3125
+ expect_status 2 'private-cache restored manifest live kept' pct clean "$pc_repo" --private-cache --stamp "$version" --apply
3126
+ cp "$repo/Directory.Packages.props" "$pc_repo/Directory.Packages.props"
3127
+ expect_status 0 'private-cache whole dry-run' pct clean "$pc_repo" --private-cache
3128
+ [ -d "$pc_cache" ] && [ -f "$pc_session/nuget-cache" ] || failed 'private-cache whole dry-run mutated cache'
3129
+ expect_status 0 'private-cache whole removal' pct clean "$pc_repo" --private-cache --apply
3130
+ [ ! -e "$pc_cache" ] && [ ! -e "$pc_session/nuget-cache" ] && [ -d "$pc_shared/cratis.test/1.2.3" ] || failed 'private-cache whole removal scope violated'
3131
+ : > "$pc_feed/nuget/Cratis.Test.$version.nupkg"
3132
+ pc_opt=1
3133
+ expect_status 0 'private-cache environment opt-in' pct use-nuget "$pc_repo" "$version"
3134
+ pc_marker || failed 'private-cache environment opt-in missing marker'
3135
+ pct restore "$pc_repo" > "$root/output"
3136
+ pct clean "$pc_repo" --private-cache --apply > "$root/output"
3137
+ }
3138
+
3139
+ self_test_base_version() {
3140
+ local base mode actual status
3141
+ for mode in local train; do
3142
+ local options=()
3143
+ if [ "$mode" = train ]; then options=(--train); fi
3144
+ for base in 0.0.0 5.0.0 10.20.300; do
3145
+ actual=$(st stamp ${options[@]+"${options[@]}"} --base-version "$base" "$repo") || failed 'self-test: base override'
3146
+ case "$actual" in "$base-$mode."*) ;; *) failed "self-test: base override grammar $actual" ;; esac
3147
+ valid_stamp "$actual"
3148
+ passed=$((passed + 1))
3149
+ done
3150
+ 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
3151
+ status=0; st stamp ${options[@]+"${options[@]}"} --base-version "$base" "$repo" > "$root/output" 2>&1 || status=$?
3152
+ [ "$status" -eq 2 ] || failed "self-test: invalid base accepted: $base"
3153
+ passed=$((passed + 1))
3154
+ done
3155
+ done
3156
+ for mode in missing repeated producer; do
3157
+ status=0
3158
+ case "$mode" in
3159
+ missing) st stamp "$repo" --base-version > "$root/output" 2>&1 || status=$? ;;
3160
+ repeated) st stamp --base-version 5.0.0 --base-version 6.0.0 "$repo" > "$root/output" 2>&1 || status=$? ;;
3161
+ producer) st stamp --base-version 5.0.0 > "$root/output" 2>&1 || status=$? ;;
3162
+ esac
3163
+ [ "$status" -eq 2 ] || failed "self-test: base option $mode"
3164
+ passed=$((passed + 1))
3165
+ done
3166
+ actual=$(st stamp "$repo" --base-version 0.0.0 "$repo" 2> "$root/stderr")
3167
+ case "$actual" in 0.0.0-local.*) ;; *) failed 'self-test: multi-producer explicit base overrides all tags' ;; esac
3168
+ passed=$((passed + 1))
3169
+ }
3170
+
3171
+ self_test_live_clean() {
3172
+ local lr=$root/live-repo lf=$root/live-feed ls file mode status
3173
+ local lv=5.0.0-local.202601010000.gabcdef0 uv=5.0.0-train.gabcdef1
3174
+ mkdir -p "$lr/nested" "$lf/nuget"
3175
+ git -C "$lr" init -q
3176
+ printf '<Project/>\n' > "$lr/Directory.Packages.props"
3177
+ git -C "$lr" add Directory.Packages.props
3178
+ git -C "$lr" -c user.email=test@example.invalid -c user.name='Live clean fixture' commit -qm initial
3179
+ ls=$(git -C "$lr" rev-parse --absolute-git-dir)/cratis-local
3180
+ lct() { env -i HOME="$home" PATH="$system_path" TMPDIR="$TMP" CRATIS_LOCAL_FEED="$lf" NUGET_PACKAGES="$root/live-cache" /bin/bash "$SELF" "$@"; }
3181
+ : > "$lf/nuget/Cratis.Live.$lv.nupkg"
3182
+ : > "$lf/nuget/Cratis.Unused.$uv.nupkg"
3183
+ mkdir -p "$root/live-cache/cratis.live/$lv" "$root/live-cache/cratis.live/5.0.0"
3184
+ lct use-nuget "$lr" "$lv" > "$root/output"
3185
+ for mode in dry apply; do
3186
+ local options=()
3187
+ if [ "$mode" = apply ]; then options=(--apply); fi
3188
+ status=0; lct clean --stamp "$lv" ${options[@]+"${options[@]}"} > "$root/output" 2> "$root/stderr" || status=$?
3189
+ { [ "$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"
3190
+ [ -f "$lf/nuget/Cratis.Live.$lv.nupkg" ] && [ -f "$ls/env" ] && [ -d "$root/live-cache/cratis.live/$lv" ] || failed 'self-test: live clean mutated subjects'
3191
+ passed=$((passed + 2))
3192
+ done
3193
+ lct restore "$lr" > "$root/output"
3194
+ for file in nuget.config Directory.Packages.props nested/packages.lock.json nested/package.json nested/yarn.lock; do
3195
+ printf '%s\n' "$lv" > "$lr/$file"
3196
+ for mode in dry apply; do
3197
+ local options=()
3198
+ if [ "$mode" = apply ]; then options=(--apply); fi
3199
+ status=0; lct clean --stamp "$lv" ${options[@]+"${options[@]}"} > "$root/output" 2> "$root/stderr" || status=$?
3200
+ { [ "$status" -eq 2 ] && has "kept stamp $lv" "$root/output" && has "$lr/$file" "$root/output"; } || failed "self-test: $mode clean missed live $file"
3201
+ [ -f "$lf/nuget/Cratis.Live.$lv.nupkg" ] || failed 'self-test: live manifest artifact removed'
3202
+ passed=$((passed + 2))
3203
+ done
3204
+ if [ "$file" = Directory.Packages.props ]; then printf '<Project/>\n' > "$lr/$file"; else rm "$lr/$file"; fi
3205
+ done
3206
+ printf '%s\n' "$uv" | tr '[:lower:]' '[:upper:]' > "$lr/nested/packages.lock.json"
3207
+ for mode in dry apply; do
3208
+ local options=()
3209
+ if [ "$mode" = apply ]; then options=(--apply); fi
3210
+ status=0; lct clean --stamp "$uv" ${options[@]+"${options[@]}"} > "$root/output" 2> "$root/stderr" || status=$?
3211
+ { [ "$status" -eq 2 ] && has "kept stamp $uv" "$root/output" && [ -f "$lf/nuget/Cratis.Unused.$uv.nupkg" ]; } || failed 'self-test: case-insensitive train reference protection'
3212
+ passed=$((passed + 1))
3213
+ done
3214
+ printf '%s\n' "$lv" > "$lr/nested/packages.lock.json"
3215
+ lct clean --stamp "$uv" --apply > "$root/output" || failed 'self-test: unrelated live stamp blocks unreferenced cleanup'
3216
+ [ ! -e "$lf/nuget/Cratis.Unused.$uv.nupkg" ] && [ -f "$lf/nuget/Cratis.Live.$lv.nupkg" ] || failed 'self-test: unreferenced stamp selection'
3217
+ rm "$lr/nested/packages.lock.json"
3218
+ lct clean --stamp "$lv" > "$root/output"
3219
+ has "Cratis.Live.$lv.nupkg" "$root/output" && [ -d "$root/live-cache/cratis.live/$lv" ] || failed 'self-test: unreferenced dry-run'
3220
+ lct clean --stamp "$lv" --apply > "$root/output"
3221
+ [ ! -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'
3222
+ passed=$((passed + 4))
3223
+ }
3224
+
1479
3225
  # C fixtures: the printed closure is one plan, in order, and every line is valid shell.
1480
3226
  self_test_closure() {
1481
3227
  local name line a b c p last first fa fl fm
@@ -1692,8 +3438,12 @@ self_test_verify() {
1692
3438
  expect_status() {
1693
3439
  local want=$1 desc=$2 got=0
1694
3440
  shift 2
3441
+ printf 'self-test [%ss]: %s\n' "$SECONDS" "$desc" >&2
1695
3442
  "$@" > "$root/output" 2> "$root/stderr" || got=$?
1696
- [ "$got" -eq "$want" ] || failed "self-test: $desc (exit $got, wanted $want)"
3443
+ if [ "$got" -ne "$want" ]; then
3444
+ cat "$root/stderr" >&2
3445
+ failed "self-test: $desc (exit $got, wanted $want)"
3446
+ fi
1697
3447
  passed=$((passed + 1))
1698
3448
  }
1699
3449
  tsv() { local IFS=$'\t'; printf '%s\n' "$*"; }
@@ -1728,7 +3478,7 @@ self_test_matrix() {
1728
3478
  } > "$root/p1.expected"
1729
3479
  fxm plan App Lib Mid > "$root/output" 2> "$root/stderr" || failed 'self-test: P1 plan'
1730
3480
  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'
3481
+ has 'Lib -> Orl -> App' "$root/stderr" || { cat "$root/stderr" >&2; failed 'self-test: P1 unnamed-hop warning'; }
1732
3482
  if has 'WARNING' "$root/output"; then failed 'self-test: P1 warning must stay on stderr'; fi
1733
3483
  # P2/P3: the real map. An NuGet-only edge plans only the NuGet ecosystem of a dual-ecosystem producer;
1734
3484
  # npm-only ids on a NuGet edge cannot be planned. P3 must move to the fixture map if Screenplay ships NuGet ids.
@@ -1763,9 +3513,312 @@ self_test_matrix() {
1763
3513
  passed=$((passed + 3))
1764
3514
  self_test_closure
1765
3515
  self_test_verify
3516
+ self_test_symlink_feed
3517
+ self_test_composition
3518
+ }
3519
+
3520
+ # Registry paths allow system/configured symlink parents of the feed.
3521
+ self_test_symlink_feed() {
3522
+ local physical=$root/feed-physical-parent alias=$root/feed-symlink-parent
3523
+ local sr=$root/feed-symlink-repo sv=1.2.3-local.202610240544.gabcdef0
3524
+ mkdir -p "$physical" "$sr"
3525
+ ln -s "$physical" "$alias"
3526
+ git -C "$sr" init -q
3527
+ printf '.ai-work/\n' > "$sr/.gitignore"
3528
+ printf '<Project><ItemGroup><PackageVersion Include="Cratis.Symlink" Version="1.2.3"/></ItemGroup></Project>\n' > "$sr/Directory.Packages.props"
3529
+ git -C "$sr" add .gitignore Directory.Packages.props
3530
+ git -C "$sr" -c user.email=test@example.invalid -c user.name='Symlink feed fixture' commit -qm initial
3531
+ slf() { env -i HOME="$home" PATH="$system_path" TMPDIR="$TMP" CRATIS_LOCAL_FEED="$alias/missing/feed" CRATIS_LOCAL_SESSION_DIR="$sr/.ai-work/local-packages" /bin/bash "$SELF" "$@"; }
3532
+ expect_status 0 'symlink feed resolution does not create a missing feed' slf check "$sr"
3533
+ [ ! -e "$physical/missing" ] || failed 'symlink feed resolution created directories'
3534
+ mkdir -p "$physical/missing/feed/nuget"
3535
+ : > "$physical/missing/feed/nuget/Cratis.Symlink.$sv.nupkg"
3536
+ expect_status 0 'legacy use-nuget under symlinked feed parent' slf use-nuget "$sr" "$sv" Cratis.Symlink
3537
+ has "$physical/missing/feed/nuget" "$sr/.ai-work/local-packages/env" || failed 'legacy feed path was not resolved physically'
3538
+ expect_status 0 'legacy restore under symlinked feed parent' slf restore "$sr"
3539
+ has '0 leaks' "$root/output" || failed 'symlink feed restore leaked a local pin'
3540
+ expect_status 0 'legacy clean under symlinked feed parent' slf clean --stamp "$sv" --apply
3541
+ [ ! -e "$physical/missing/feed/nuget/Cratis.Symlink.$sv.nupkg" ] || failed 'symlink feed clean left the artifact'
3542
+ passed=$((passed + 4))
3543
+ }
3544
+
3545
+ # Composition fixtures are isolated, SDK-native and never restore/build a producer.
3546
+ self_test_composition() {
3547
+ local cf=$root/composition-parent/feed cr=$root/composition-repo cm=$root/composition.tsv
3548
+ local a=22.138.1-local.202610240544.gd1ad215 b=0.2.2-local.202610240544.g91916a3 c=0.1.18-local.202610240544.g524bdce
3549
+ local boundary kind base=$root/composition-prior cpkg=$root/composition-package
3550
+ 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
3551
+ printf 'SKIP: composition fixtures require node, dotnet SDK, zip and unzip\n'; skipped=$((skipped + 1)); return 0
3552
+ fi
3553
+ mkdir -p "$root/composition-physical-parent"
3554
+ ln -s "$root/composition-physical-parent" "$root/composition-parent"
3555
+ mkdir -p "$cf/nuget" "$cr" "$cpkg"
3556
+ : > "$root/.composition-fixture"
3557
+ git -C "$cr" init -q
3558
+ printf '.ai-work/\nobj/\n' > "$cr/.gitignore"
3559
+ printf '<Project><PropertyGroup><ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally></PropertyGroup><ItemGroup><PackageVersion Include="Cratis.Keep" Version="1.2.3"/></ItemGroup></Project>\n' > "$cr/Directory.Packages.props"
3560
+ mkdir -p "$cr/nested"
3561
+ printf '<Project><PropertyGroup><ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally></PropertyGroup></Project>\n' > "$cr/nested/Directory.Packages.props"
3562
+ 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"
3563
+ 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"
3564
+ git -C "$cr" add .gitignore Directory.Packages.props nuget.config nested
3565
+ git -C "$cr" -c user.email=test@example.invalid -c user.name='Composition fixture' commit -qm initial
3566
+ cp "$cr/Directory.Packages.props" "$root/composition-original.props"
3567
+ 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"
3568
+ 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" "$@"; }
3569
+ cmpkg() {
3570
+ local id=$1 ver=$2 dependencies=${3:-} xmlfile=$cpkg/$1.nuspec
3571
+ 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"
3572
+ rm -f -- "$cf/nuget/$id.$ver.nupkg"
3573
+ (cd "$cpkg" && zip -q "$cf/nuget/$id.$ver.nupkg" "$id.nuspec")
3574
+ }
3575
+ cmpkg Cratis.Chronicle.InProcess "$a"
3576
+ cmpkg Cratis.Screenplay "$b" '<group targetFramework="net10.0"><dependency id="Cratis.Chronicle.InProcess" version="[22.136.4,23.0.0)"/></group>'
3577
+ cmpkg Cratis.Stage "$c" "<dependency id=\"Cratis.Screenplay\" version=\"[$b]\"/>"
3578
+ expect_status 0 'composition native mixed-version pack' cst verify-pack --composition "$cm" --json
3579
+ node - "$root/output" "$a" "$b" "$c" <<'JSON'
3580
+ const fs = require('fs'); const [file,...versions] = process.argv.slice(2), data = JSON.parse(fs.readFileSync(file));
3581
+ if (data.packages.length !== 3 || !versions.every(v => data.packages.some(p => p.version === v && p.producer))) process.exit(1);
3582
+ JSON
3583
+ passed=$((passed + 1))
3584
+ mkdir -p "$root/no-sdk"
3585
+ printf '#!/bin/sh\nexit 127\n' > "$root/no-sdk/dotnet"; chmod +x "$root/no-sdk/dotnet"
3586
+ 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"
3587
+ mkdir -p "$root/composition-foreign-nuget" "$root/composition-foreign-lock"
3588
+ printf 'keep foreign lock state\n' > "$root/composition-foreign-lock/sentinel"
3589
+ mv "$cf/nuget" "$root/composition-original-nuget"
3590
+ ln -s "$root/composition-foreign-nuget" "$cf/nuget"
3591
+ expect_status 2 'composition rejects helper-owned nuget symlink' cst verify-pack --composition "$cm"
3592
+ [ -d "$root/composition-foreign-nuget" ] || failed 'composition removed foreign nuget directory'
3593
+ rm "$cf/nuget"; mv "$root/composition-original-nuget" "$cf/nuget"
3594
+ ln -s "$root/composition-foreign-lock" "$cf/.composition-lock"
3595
+ expect_status 2 'composition rejects helper-owned lock symlink' cst use-nuget "$cr" --composition "$cm"
3596
+ has 'keep foreign lock state' "$root/composition-foreign-lock/sentinel" || failed 'composition removed foreign lock state'
3597
+ rm "$cf/.composition-lock"
3598
+ passed=$((passed + 2))
3599
+ cp "$cm" "$root/composition-good.tsv"
3600
+ printf 'package\tStage\tcratis.screenplay\n' >> "$cm"
3601
+ expect_status 2 'composition duplicate case-insensitive owner' cst verify-pack --composition "$cm"
3602
+ cp "$root/composition-good.tsv" "$cm"
3603
+ expect_status 2 'composition flags cannot mix legacy stamp' cst verify-pack "$a" --composition "$cm"
3604
+ expect_status 2 'composition activation cannot mix legacy IDs' cst use-nuget "$cr" --composition "$cm" Cratis.Stage
3605
+ printf 'producer\tBad\t1.2.3\t%s\npackage\tBad\tBad\n' "$cr" > "$root/composition-bad.tsv"
3606
+ expect_status 2 'composition rejects nonlocal producer stamp' cst verify-pack --composition "$root/composition-bad.tsv"
3607
+ cmpkg Cratis.Screenplay "$b" '<group><dependency id="Cratis.Chronicle.InProcess" version="[22.0.0,22.1.0)"/></group>'
3608
+ expect_status 1 'composition incompatible native group range' cst verify-pack --composition "$cm"
3609
+ cmpkg Cratis.Screenplay "$b" '<dependency id="Cratis.Chronicle.InProcess" version="[broken"/>'
3610
+ expect_status 1 'composition malformed native range' cst verify-pack --composition "$cm"
3611
+ cmpkg Cratis.Screenplay "$b" "<dependency id=\"Cratis.Undeclared\" version=\"[$a]\"/>"
3612
+ expect_status 1 'composition undeclared local nuspec edge' cst verify-pack --composition "$cm"
3613
+ cmpkg Cratis.Screenplay "$b" '<group><dependency id="Cratis.Chronicle.InProcess" version="[22.136.4,23.0.0)"/></group>'
3614
+ cp "$cf/nuget/Cratis.Stage.$c.nupkg" "$root/stage.nupkg"
3615
+ cmpkg Cratis.Stage "$c"; printf '<package><metadata>' > "$cpkg/Cratis.Stage.nuspec"
3616
+ (cd "$cpkg" && zip -q "$cf/nuget/Cratis.Stage.$c.nupkg" Cratis.Stage.nuspec)
3617
+ expect_status 1 'composition malformed XML' cst verify-pack --composition "$cm"
3618
+ cp "$root/stage.nupkg" "$cf/nuget/Cratis.Stage.$c.nupkg"
3619
+ mv "$cf/nuget/Cratis.Stage.$c.nupkg" "$root/hidden-stage.nupkg"
3620
+ expect_status 1 'composition missing package' cst verify-pack --composition "$cm"
3621
+ mv "$root/hidden-stage.nupkg" "$cf/nuget/Cratis.Stage.$c.nupkg"
3622
+ mkdir -p "$cr/obj"
3623
+ cat > "$cr/obj/project.assets.json" <<JSON
3624
+ {"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]"}}}}}
3625
+ JSON
3626
+ cp "$cr/obj/project.assets.json" "$root/composition-assets.json"
3627
+ expect_status 0 'composition exact assets graph' cst verify-assets "$cr" --composition "$cm" --json
3628
+ node - "$root/composition-assets.json" "$cr/obj/project.assets.json" <<'JSON'
3629
+ const fs=require('fs'), [input,output]=process.argv.slice(2), data=JSON.parse(fs.readFileSync(input));
3630
+ const target=data.targets['net10.0']; delete target[Object.keys(target).find(k=>k.startsWith('Cratis.Chronicle'))];
3631
+ fs.writeFileSync(output,JSON.stringify(data));
3632
+ JSON
3633
+ expect_status 1 'composition missing consumed local dependency' cst verify-assets "$cr" --composition "$cm"
3634
+ node - "$root/composition-assets.json" "$cr/obj/project.assets.json" <<'JSON'
3635
+ const fs=require('fs'), [input,output]=process.argv.slice(2), data=JSON.parse(fs.readFileSync(input));
3636
+ for (const graph of [data.libraries,data.targets['net10.0']]) delete graph[Object.keys(graph).find(k=>k.startsWith('Cratis.Chronicle'))];
3637
+ const target=data.targets['net10.0']; target[Object.keys(target).find(k=>k.startsWith('Cratis.Screenplay'))].dependencies={};
3638
+ fs.writeFileSync(output,JSON.stringify(data));
3639
+ JSON
3640
+ expect_status 1 'composition nuspec local dependency absent from graph' cst verify-assets "$cr" --composition "$cm"
3641
+ 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"/>'
3642
+ expect_status 0 'composition native nearest group allows unused owned ID' cst verify-assets "$cr" --composition "$cm"
3643
+ cmpkg Cratis.Screenplay "$b" '<group><dependency id="Cratis.Chronicle.InProcess" version="[22.136.4,23.0.0)"/></group>'
3644
+ node - "$root/composition-assets.json" "$cr/obj/project.assets.json" "$a" <<'JSON'
3645
+ const fs=require('fs'), [input,output,a]=process.argv.slice(2), data=JSON.parse(fs.readFileSync(input));
3646
+ data.targets.net9=data.targets['net10.0']; data.targets.net9['Cratis.Chronicle.InProcess/'+a].dependencies={'Cratis.Stage':'[1.0.0,2.0.0)'};
3647
+ fs.writeFileSync(output,JSON.stringify(data));
3648
+ JSON
3649
+ expect_status 1 'composition bad edge in another target' cst verify-assets "$cr" --composition "$cm"
3650
+ node - "$root/composition-assets.json" "$cr/obj/project.assets.json" <<'JSON'
3651
+ const fs=require('fs'), [input,output]=process.argv.slice(2), data=JSON.parse(fs.readFileSync(input)), target=data.targets['net10.0'];
3652
+ Object.assign(data.libraries,{'Other.A/1.0.0':{type:'package'},'Other.B/2.0.0':{type:'package'}});
3653
+ Object.assign(target,{'Other.A/1.0.0':{type:'package',dependencies:{'Other.B':'[1.0.0]'}},'Other.B/2.0.0':{type:'package'}});
3654
+ fs.writeFileSync(output,JSON.stringify(data));
3655
+ JSON
3656
+ expect_status 0 'composition range conflict outside the composition is a warning' cst verify-assets "$cr" --composition "$cm" --json
3657
+ has 'WARNING: dependency range outside the composition' "$root/stderr" || failed 'composition outside-range warning on stderr'
3658
+ 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'
3659
+ node - "$root/composition-assets.json" "$cr/obj/project.assets.json" "$c" <<'JSON'
3660
+ const fs=require('fs'), [input,output,c]=process.argv.slice(2), data=JSON.parse(fs.readFileSync(input)), target=data.targets['net10.0'];
3661
+ data.libraries['Other.B/2.0.0']={type:'package'}; target['Other.B/2.0.0']={type:'package'};
3662
+ target['Cratis.Stage/'+c].dependencies['Other.B']='[1.0.0]';
3663
+ fs.writeFileSync(output,JSON.stringify(data));
3664
+ JSON
3665
+ expect_status 1 'composition range conflict on a composed edge is refused' cst verify-assets "$cr" --composition "$cm"
3666
+ node - "$root/composition-assets.json" "$cr/obj/project.assets.json" <<'JSON'
3667
+ const fs=require('fs'), [input,output]=process.argv.slice(2), data=JSON.parse(fs.readFileSync(input));
3668
+ data.targets.net9=JSON.parse(JSON.stringify(data.targets['net10.0']));
3669
+ Object.assign(data.libraries,{'Other.C/1.0.0':{type:'package'},'Other.C/2.0.0':{type:'package'}});
3670
+ data.targets.net9['Other.C/1.0.0']={type:'package'}; data.targets['net10.0']['Other.C/2.0.0']={type:'package'};
3671
+ fs.writeFileSync(output,JSON.stringify(data));
3672
+ JSON
3673
+ expect_status 0 'composition multitarget assets with one released ID at two versions' cst verify-assets "$cr" --composition "$cm"
3674
+ passed=$((passed + 3))
3675
+ sed "s/$a/22.136.4/g" "$root/composition-assets.json" > "$cr/obj/project.assets.json"
3676
+ expect_status 1 'composition published fallback version' cst verify-assets "$cr" --composition "$cm"
3677
+ printf '{"libraries":{},"targets":{"net10.0":{}}}\n' > "$cr/obj/project.assets.json"
3678
+ expect_status 1 'composition zero consumption' cst verify-assets "$cr" --composition "$cm"
3679
+ printf '{broken\n' > "$cr/obj/project.assets.json"
3680
+ expect_status 1 'composition malformed assets' cst verify-assets "$cr" --composition "$cm"
3681
+ cp "$root/composition-assets.json" "$cr/obj/project.assets.json"
3682
+ expect_status 0 'composition activation with transitive pinning' cst use-nuget "$cr" --composition "$cm" --pin-transitive
3683
+ composition_registry() {
3684
+ node - "$cf" "$cr" "$a" "$b" "$c" <<'NODE'
3685
+ const fs = require('node:fs'), path = require('node:path'); const [feed,repo,...stamps] = process.argv.slice(2);
3686
+ for (const stamp of stamps) {
3687
+ const r = JSON.parse(fs.readFileSync(path.join(feed,'stamps',stamp,'record.json')));
3688
+ if (r.schema !== 1 || r.consumers.length !== 1 || r.consumers[0].worktree !== repo || r.consumers[0].ecosystem !== 'nuget') process.exit(1);
3689
+ }
3690
+ NODE
3691
+ }
3692
+ expect_status 0 'registry composition consumer' composition_registry
3693
+ output=$cr/.ai-work/local-packages/composition
3694
+ grep -Fq 'CentralPackageTransitivePinningEnabled>true' "$output/Directory.Packages.props" || failed 'composition pin property'
3695
+ self_test_nuget_overlay "$output" Cratis.Stage "$c"
3696
+ passed=$((passed + 8))
3697
+ # Only exact owned IDs are removed remotely; broader patterns remain remote.
3698
+ [ "$(grep -c 'pattern="Cratis.Screenplay"' "$output/nuget.config")" -eq 1 ] || failed 'composition exclusive exact mapping'
3699
+ has 'pattern="*"' "$output/nuget.config" || failed 'composition removed remote wildcard'
3700
+ has 'pattern="Cratis.*"' "$output/nuget.config" || failed 'composition removed broader remote prefix'
3701
+ has 'pattern="Cratis.Screenplay*"' "$output/nuget.config" || failed 'composition removed overlapping remote prefix'
3702
+ has "$root/composition-physical-parent/feed/nuget" "$output/env" || failed 'composition feed path was not resolved physically'
3703
+ passed=$((passed + 4))
3704
+ cp "$output/Directory.Packages.props" "$root/composition-overlay.props"
3705
+ expect_status 0 'composition identical activation is a no-op' cst use-nuget "$cr" --composition "$cm" --pin-transitive
3706
+ cmp -s "$output/Directory.Packages.props" "$root/composition-overlay.props" || failed 'composition repeated activation drift'
3707
+ expect_status 2 'composition changed pinning refuses reactivation' cst use-nuget "$cr" --composition "$cm"
3708
+ expect_status 2 'composition rejects legacy activation while active' cst use-nuget "$cr" "$a" Cratis.Chronicle.InProcess
3709
+ expect_status 2 'composition active artifacts retained by clean' cst clean --stamp "$a" --apply
3710
+ expect_status 2 'composition refuses legacy npm activation' cst use-npm "$cr" "$b"
3711
+ cp "$output/env" "$root/composition-env"
3712
+ printf '# unrelated session edit\n' >> "$output/env"
3713
+ expect_status 2 'composition edited overlay retained on restore' cst restore "$cr"
3714
+ grep -Fq 'unrelated session edit' "$output/env" || failed 'composition deleted edited overlay'
3715
+ cp "$root/composition-env" "$output/env"
3716
+ cp "$output/cratis-local.targets" "$root/composition-targets"
3717
+ printf '<!-- unrelated targets edit -->\n' >> "$output/cratis-local.targets"
3718
+ expect_status 2 'composition edited targets retained on restore' cst restore "$cr"
3719
+ has 'unrelated targets edit' "$output/cratis-local.targets" || failed 'composition deleted edited targets'
3720
+ cp "$root/composition-targets" "$output/cratis-local.targets"
3721
+ printf '<!-- consumer edit -->\n' >> "$cr/Directory.Packages.props"
3722
+ cp "$cr/Directory.Packages.props" "$root/composition-original.props"
3723
+ expect_status 0 'composition restores preserving consumer edits' cst restore "$cr"
3724
+ has '0 leaks' "$root/output" || failed 'composition restore did not run check'
3725
+ expect_status 0 'composition repeated restore' cst restore "$cr"
3726
+ [ ! -e "$output" ] || failed 'composition overlay survives restore'
3727
+ cmp -s "$cr/Directory.Packages.props" "$root/composition-original.props" || failed 'composition consumer props mutated'
3728
+ # Whole-cache cleanup also cleans feed artifacts. Keep this fixture's feed
3729
+ # separate so the recovery fixtures below retain their declared packages.
3730
+ local composition_feed=$cf composition_cache
3731
+ cf=$root/private-composition-feed
3732
+ mkdir -p "$cf/nuget"
3733
+ cp "$composition_feed/nuget/"*.nupkg "$cf/nuget/"
3734
+ expect_status 0 'private-cache composition' cst use-nuget "$cr" --composition "$cm" --private-cache
3735
+ composition_cache=$(git -C "$cr" rev-parse --absolute-git-dir)/cratis-local-nuget
3736
+ env -i PATH=/usr/bin:/bin /bin/bash -c "source \"\$1\"; [ \"\$NUGET_PACKAGES\" = \"\$2\" ]" _ "$output/env" "$composition_cache" || failed 'private-cache composition env export'
3737
+ [ "$(cat "$cr/.ai-work/local-packages/nuget-cache")" = "$composition_cache" ] && [ "$(cat "$composition_cache/.cratis-local-cache")" = "$cr" ] || failed 'private-cache composition marker'
3738
+ expect_status 2 'private-cache composition live kept' cst clean "$cr" --private-cache --apply
3739
+ expect_status 0 'private-cache composition restore' cst restore "$cr"
3740
+ [ -d "$composition_cache" ] || failed 'private-cache composition restore removed cache'
3741
+ expect_status 0 'private-cache composition removal' cst clean "$cr" --private-cache --apply
3742
+ [ ! -e "$composition_cache" ] && [ ! -e "$cr/.ai-work/local-packages/nuget-cache" ] || failed 'private-cache composition removal left cache registration'
3743
+ cf=$composition_feed
3744
+ [ -f "$cf/nuget/Cratis.Chronicle.InProcess.$a.nupkg" ] && [ -f "$cf/nuget/Cratis.Screenplay.$b.nupkg" ] && [ -f "$cf/nuget/Cratis.Stage.$c.nupkg" ] || failed 'private-cache composition cleanup removed recovery feed packages'
3745
+ # Every live mutation boundary is exercised both as an ordinary failure and
3746
+ # an uncatchable parent/child interruption, then recovered by the next call.
3747
+ for kind in fail interrupt; do
3748
+ 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
3749
+ printf '%s\n' "$root/another-session" > "$cf/sessions"; cp "$cf/sessions" "$base"
3750
+ composition_fail=''; composition_interrupt=''
3751
+ if [ "$kind" = fail ]; then composition_fail=$boundary; else composition_interrupt=$boundary; fi
3752
+ status=0; cst use-nuget "$cr" --composition "$cm" > "$root/output" 2> "$root/error" || status=$?
3753
+ [ "$status" -ne 0 ] || failed "composition $kind $boundary returned success"
3754
+ [ ! -s "$root/output" ] || failed "composition $kind $boundary printed activation instructions"
3755
+ composition_fail=''; composition_interrupt=''
3756
+ # A recovery-triggering restore must roll back incomplete activation,
3757
+ # or finish a committed activation and then restore it.
3758
+ expect_status 0 "composition recovery $kind $boundary" cst restore "$cr"
3759
+ [ ! -e "$output" ] || failed "composition recovery left publication $boundary"
3760
+ if [ "$boundary" != committed ]; then cmp -s "$cf/sessions" "$base" || failed "composition registry rollback $boundary"; fi
3761
+ cmp -s "$cr/Directory.Packages.props" "$root/composition-original.props" || failed "composition consumer drift $boundary"
3762
+ passed=$((passed + 3))
3763
+ done
3764
+ done
3765
+ expect_status 0 'composition activate before interrupted restoration' cst use-nuget "$cr" --composition "$cm"
3766
+ for boundary in restore-journal restore-renamed restore-committed; do
3767
+ composition_interrupt=$boundary
3768
+ status=0; cst restore "$cr" > "$root/output" 2> "$root/error" || status=$?
3769
+ [ "$status" -ne 0 ] || failed "composition interrupted restoration $boundary"
3770
+ composition_interrupt=''
3771
+ expect_status 0 "composition recover interrupted restoration $boundary" cst restore "$cr"
3772
+ [ ! -e "$output" ] || failed 'composition incomplete restoration recovery'
3773
+ expect_status 0 'composition reactivation after restoration recovery' cst use-nuget "$cr" --composition "$cm"
3774
+ done
3775
+ expect_status 0 'composition final restore' cst restore "$cr"
3776
+ # Another recoverer holds the transition guard after observing this dead owner.
3777
+ # A contender must not touch the stale lock (or its journal) until that transition ends.
3778
+ local dead_owner
3779
+ dead_owner=$(node -p process.pid)
3780
+ kill -0 "$dead_owner" 2>/dev/null && failed 'composition fixture owner is still alive'
3781
+ mkdir "$cf/.composition-lock" "$cf/.composition-recovery"
3782
+ printf '%s\n' "$dead_owner" > "$cf/.composition-lock/owner"
3783
+ expect_status 2 'composition competing stale-lock recoverer is excluded' cst restore "$cr"
3784
+ [ "$(cat "$cf/.composition-lock/owner")" = "$dead_owner" ] || failed 'composition contender removed stale lock'
3785
+ [ -d "$cf/.composition-recovery" ] || failed 'composition contender removed recovery guard'
3786
+ rmdir "$cf/.composition-recovery"
3787
+ expect_status 0 'composition stale-lock takeover after recovery guard release' cst restore "$cr"
3788
+ [ ! -e "$cf/.composition-lock" ] || failed 'composition stale-lock takeover left lock'
3789
+ mkdir -p "$cf/.composition-lock"; printf 'unknown owner\n' > "$cf/.composition-lock/owner"
3790
+ expect_status 2 'composition unknown ownership fails closed' cst use-nuget "$cr" --composition "$cm"
3791
+ grep -Fq 'unknown owner' "$cf/.composition-lock/owner" || failed 'composition deleted unknown owner'
3792
+ rm "$cf/.composition-lock/owner"; rmdir "$cf/.composition-lock"
3793
+ mkdir -p "$cf/.composition-lock"; printf '%s\n' "$$" > "$cf/.composition-lock/owner"
3794
+ expect_status 2 'composition concurrent activation lock' cst use-nuget "$cr" --composition "$cm"
3795
+ rm "$cf/.composition-lock/owner"; rmdir "$cf/.composition-lock"
3796
+ mkdir -p "$cr/.ai-work/local-packages/npm-backup"; printf 'unrelated npm backup\n' > "$cr/.ai-work/local-packages/npm-backup/package.json"
3797
+ expect_status 2 'composition preserves incompatible npm backup' cst use-nuget "$cr" --composition "$cm"
3798
+ grep -Fq 'unrelated npm backup' "$cr/.ai-work/local-packages/npm-backup/package.json" || failed 'composition npm backup drift'
3799
+ passed=$((passed + 1))
1766
3800
  }
1767
3801
 
3802
+ TASK=''
3803
+ case "$MODE" in
3804
+ pack-nuget|pack-npm|use-nuget|use-npm|finish)
3805
+ TASK=${CRATIS_LOCAL_TASK:-}
3806
+ ARGS=()
3807
+ while [ $# -gt 0 ]; do
3808
+ if [ "$1" = --task ]; then
3809
+ [ $# -ge 2 ] || die '--task requires an ID'
3810
+ TASK=$2; [ -n "$TASK" ] || die 'invalid task ID'; shift 2
3811
+ else ARGS+=("$1"); shift; fi
3812
+ done
3813
+ set -- ${ARGS[@]+"${ARGS[@]}"}
3814
+ if [ -n "$TASK" ]; then [[ "$TASK" =~ ^[A-Za-z0-9][A-Za-z0-9._#:/-]{0,127}$ ]] || die 'invalid task ID'; fi ;;
3815
+ esac
1768
3816
  case "$MODE" in
3817
+ list) registry list "$@" ;;
3818
+ finish)
3819
+ [ -n "$TASK" ] || die 'finish requires --task ID or CRATIS_LOCAL_TASK'
3820
+ [ $# -eq 0 ] || { [ $# -eq 1 ] && [ "$1" = --apply ]; } || die 'finish accepts --task ID and --apply'
3821
+ if [ "${1:-}" = --apply ] && [ "$DRY" -eq 0 ]; then registry_update finish --apply; else registry finish; fi ;;
1769
3822
  stamp) stamp "$@" ;;
1770
3823
  plan) plan_command "$@" ;;
1771
3824
  closure) closure "$@" ;;
@@ -1773,11 +3826,11 @@ case "$MODE" in
1773
3826
  verify-assets) verify_assets "$@" ;;
1774
3827
  pack-nuget) pack_nuget "$@" ;;
1775
3828
  pack-npm) pack_npm "$@" ;;
1776
- use-nuget) use_nuget "$@" ;;
1777
- use-npm) use_npm "$@" ;;
1778
- restore) restore "$@" ;;
3829
+ use-nuget) acquire_session_lock; use_nuget "$@" ;;
3830
+ use-npm) acquire_session_lock; use_npm "$@" ;;
3831
+ restore) acquire_session_lock; restore "$@" ;;
1779
3832
  check) check "$@" ;;
1780
- clean) clean "$@" ;;
3833
+ clean) acquire_session_lock; clean "$@" ;;
1781
3834
  --self-test) self_test "$@" ;;
1782
3835
  *) die "unknown command: $MODE (use --help)" ;;
1783
3836
  esac