@cratis/pi 7.103.4 → 7.103.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. package/package/corpus/harnesses/copilot/instructions/local-work-artifacts.instructions.md +7 -7
  2. package/package/corpus/harnesses/copilot/instructions/specs.csharp.instructions.md +7 -5
  3. package/package/corpus/hooks/README.md +16 -0
  4. package/package/corpus/hooks/scripts/cratis-cleanup-worktrees.sh +148 -24
  5. package/package/corpus/rules/local-work-artifacts.md +7 -7
  6. package/package/corpus/rules/specs.csharp.md +7 -5
  7. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/SKILL.md +12 -4
  8. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/references/dependency-map.md +76 -0
  9. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/references/dotnet-nuget.md +3 -0
  10. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/references/npm-yarn.md +6 -4
  11. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/references/train-mode.md +43 -0
  12. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/references/worked-examples.md +166 -28
  13. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/scripts/cratis-local-packages.sh +1065 -60
  14. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/scripts/dependency-map.tsv +68 -0
  15. package/package/corpus/skills/cratis-specifications-csharp/SKILL.md +20 -13
  16. package/package/corpus/skills/cratis-specifications-csharp/references/running-specs.md +90 -0
  17. package/package/corpus/skills/cratis-specifications-csharp/references/shared-lifecycle.md +55 -23
  18. package/package/corpus/skills/cratis-specifications-csharp/verification.json +5 -3
  19. package/package/corpus/skills/filter-syntax/SKILL.md +3 -1
  20. package/package/corpus/skills/run-tests/SKILL.md +1 -1
  21. package/package.json +1 -1
@@ -10,22 +10,68 @@ 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
13
+ stamp PRODUCER [PRODUCER...]
14
14
  Next patch of the highest reachable vX.Y.Z tag, UTC minute and HEAD sha7.
15
- pack-nuget PRODUCER STAMP PROJECT-OR-SLN...
16
- Release-pack each target into the NuGet feed; requires dotnet.
15
+ With several producers the highest next patch wins (one stamp for a multi-hop
16
+ chain, so no stamped package sorts below a released pin) and sha7 is the last
17
+ producer's; every HEAD is printed to stderr.
18
+ stamp --train PRODUCER [PRODUCER...]
19
+ Train stamp X.Y.Z-train.g<sha7>: the same next patch, no minute, so one code
20
+ state always yields one stamp. Accepted everywhere a local stamp is.
21
+ closure [--map FILE] [--train] CONSUMER=PATH PRODUCER=PATH...
22
+ Read-only. Print, from the same plan, the commands to run in order: one stamp
23
+ (--train: the train stamp), per producer the NuGet overlay of its upstream
24
+ producers, one pack command per map pack row (with its --property arguments)
25
+ and the restore, then ONE verify-pack for the whole plan, then the consumer
26
+ overlay, its verify-assets, restore, check and clean. List producers in dependency order.
27
+ Nothing is printed when the plan is invalid (exit 2). Warns when a producer
28
+ reaches the consumer through an unnamed hop.
29
+ plan [--map FILE] CONSUMER PRODUCER...
30
+ Read-only. Print the plan derived from scripts/dependency-map.tsv (or
31
+ CRATIS_LOCAL_MAP) for repository names listed in dependency order: pack rows
32
+ (producer, ecosystem), overlay rows (producer, upstream) and consume rows
33
+ (the consumer's own edges). Exit 2, nothing printed: unknown or repeated name,
34
+ no edge, wrong order, a planned pack without ids or pack rows.
35
+ verify-pack STAMP --consumer CONSUMER --producer PRODUCER... [--json] [--map FILE]
36
+ Check exactly the plan that "plan" prints for those names: every planned package
37
+ exists in the feed at exactly STAMP (never another stamp), and each nuspec
38
+ dependency on another planned NuGet ID is exactly STAMP, [STAMP] or [STAMP, )
39
+ (an unreadable element fails). Run once,
40
+ after the last pack. Exit 1 lists the findings ("missing: ..." / "dependency: ...").
41
+ Exit 2: invalid plan, or a plan without pack rows (image-only). --json prints one
42
+ line, {"stamp":..,"packages":[{"id":..,"version":..}]}, and sends findings to stderr.
43
+ verify-pack STAMP NAME... [--ecosystem nuget|npm] [--json]
44
+ Manual, weaker form: the IDs the map lists for each named repository (optionally
45
+ one ecosystem) exist at STAMP and their planned NuGet dependencies point at STAMP.
46
+ verify-assets CONSUMER-PATH STAMP [--npm] [--dir REL] [--json] --consumer C --producer P...
47
+ Checks the same plan inside the consumer. Exit 1: a planned package missing from
48
+ the feed; a planned ID resolved at another version ("ID VERSION PATH"); a producer
49
+ the consumer uses in this ecosystem with none of its IDs resolved ("unconsumed:").
50
+ NuGet reads obj/project.assets.json (none: exit 2); --npm reads installed copies
51
+ under node_modules (under REL if given). --json: the consumer's resolved ids and versions.
52
+ verify-assets CONSUMER-PATH STAMP [--npm] [--dir REL] [--json]
53
+ Manual, weaker form without a plan: every package at STAMP in the feed that the
54
+ consumer resolves must be STAMP (none resolved: exit 1).
55
+ pack-nuget PRODUCER STAMP [--property NAME=VALUE]... PROJECT-OR-SLN...
56
+ Release-pack each target into the NuGet feed; requires dotnet. Each --property
57
+ becomes one -p:NAME=VALUE argument of that command only (closure prints the
58
+ properties of each map pack row); no environment variable carries them.
17
59
  pack-npm PRODUCER STAMP
18
- Requires node and Yarn 4, and a completed repository build. Temporarily
19
- version non-private workspaces, pack with %s-%v.tgz (Yarn slugifies scoped
20
- names), and restore tracked manifests even on failure. Pack lifecycle hooks
21
- are Yarn's own behavior; this helper does not run a separate build.
22
- use-nuget CONSUMER STAMP [PACKAGE-ID...]
60
+ Requires node, npm and Yarn 4, and a completed repository build. Temporarily
61
+ version non-private workspaces, pack each with npm pack --ignore-scripts (the
62
+ publish workflows' tool, so the tarball matches the published one) as
63
+ <scope>-<name>-<version>.tgz, and restore tracked manifests even on failure.
64
+ This helper does not build or run lifecycle scripts.
65
+ use-nuget CONSUMER STAMP [--pin-transitive] [PACKAGE-ID...]
23
66
  Create a CPM overlay importing the repository's own Directory.Packages.props.
67
+ Transitive pinning stays as the consumer sets it; --pin-transitive forces it on so
68
+ stamped IDs the consumer does not pin still take effect (can raise NU1109 floors).
24
69
  Defaults to every stamped package ID in the feed. Prints properties and sourceable
25
70
  env. With source mapping, also prints the required restore --configfile.
26
71
  Non-CPM: manually use an untracked project copy with explicit PackageReference
27
72
  versions and dotnet restore --source FEED/nuget; do not edit shipped versions.
28
- use-npm CONSUMER STAMP
73
+ use-npm CONSUMER STAMP [--dir REL]
74
+ --dir REL names a Yarn project in a subdirectory (restore and clean follow it).
29
75
  Requires node and tar. Back up package.json, yarn.lock (including existing edits),
30
76
  and git diff; add file-tarball resolutions for every stamped feed package.
31
77
  Run yarn install afterward. Only one active npm overlay per session.
@@ -37,7 +83,7 @@ Commands (COMMAND --help shows these instructions too):
37
83
  check REPO [--staged]
38
84
  Scan added/changed tracked lines (staged-only if requested), plus all contents
39
85
  of untracked, non-ignored files. Reports path:line:pattern and file counts.
40
- clean [CONSUMER] [--stamp STAMP | --all] [--apply]
86
+ clean [CONSUMER] [--stamp STAMP | --all] [--apply] [--dir REL]
41
87
  Default: list all local feed/cache/image/session subjects, without deleting.
42
88
  --apply deletes listed subjects (docker rmi, never force). Refuses more than
43
89
  CRATIS_LOCAL_CLEAN_MAX subjects (default 500), or unresolved npm backups.
@@ -117,7 +163,7 @@ heavy() {
117
163
  }
118
164
  repo_root() { git -C "$1" rev-parse --show-toplevel 2>/dev/null || die "not a Git repository: $1"; }
119
165
  valid_stamp() {
120
- [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+-local\.[0-9]{12}\.g[0-9a-f]{7}$ ]] || die "invalid local stamp: $1"
166
+ [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(local\.[0-9]{12}|train)\.g[0-9a-f]{7}$ ]] || die "invalid local stamp: $1"
121
167
  }
122
168
  session_path() {
123
169
  local path git_dir
@@ -141,7 +187,7 @@ validate_session() {
141
187
  [ -e "$entry" ] || [ -L "$entry" ] || continue
142
188
  [ ! -L "$entry" ] || die "symlink in session: $entry"
143
189
  case "${entry##*/}" in
144
- repo|stamp|env|Directory.Packages.props|nuget.config) [ -f "$entry" ] || die "unexpected session entry: $entry" ;;
190
+ repo|stamp|env|npm-dir|Directory.Packages.props|nuget.config) [ -f "$entry" ] || die "unexpected session entry: $entry" ;;
145
191
  npm-backup) [ -d "$entry" ] || die "unexpected session entry: $entry" ;;
146
192
  *) die "unrecognized file in session (preserved): $entry" ;;
147
193
  esac
@@ -160,30 +206,70 @@ register_session() {
160
206
  }
161
207
  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"; }
162
208
 
163
- stamp() {
164
- [ $# -eq 1 ] || die 'stamp requires a producer repository'
165
- local repo tag major minor patch sha
166
- repo=$(repo_root "$1")
167
- tag=$(git -C "$repo" tag --merged HEAD | awk '/^v[0-9]+\.[0-9]+\.[0-9]+$/' | sort -t. -k1.2,1n -k2,2n -k3,3n | tail -n 1)
209
+ # Next patch of the highest reachable vX.Y.Z tag; sets NEXT_BASE.
210
+ next_base() {
211
+ local tag major minor patch
212
+ tag=$(git -C "$1" tag --merged HEAD | awk '/^v[0-9]+\.[0-9]+\.[0-9]+$/' | sort -t. -k1.2,1n -k2,2n -k3,3n | tail -n 1)
168
213
  if [ -n "$tag" ]; then
169
214
  IFS=. read -r major minor patch <<< "${tag#v}"
170
- tag="$major.$minor.$((10#$patch + 1))"
215
+ NEXT_BASE="$major.$minor.$((10#$patch + 1))"
171
216
  else
172
- printf 'WARNING: no reachable vX.Y.Z tag; using 0.1.0\n' >&2
173
- tag=0.1.0
217
+ printf 'WARNING: no reachable vX.Y.Z tag in %s; using 0.1.0\n' "$1" >&2
218
+ NEXT_BASE=0.1.0
219
+ fi
220
+ }
221
+ # True when version $1 sorts above $2 (major.minor.patch).
222
+ base_above() {
223
+ local top
224
+ top=$(printf '%s\n%s\n' "$1" "$2" | sort -t. -k1,1n -k2,2n -k3,3n | tail -n 1)
225
+ [ "$top" = "$1" ] && [ "$1" != "$2" ]
226
+ }
227
+
228
+ stamp() {
229
+ [ $# -ge 1 ] || die 'stamp requires a producer repository'
230
+ local repo base='' sha='' arg train=0 lines='' rname origin
231
+ if [ "$1" = --train ]; then train=1; shift; [ $# -ge 1 ] || die 'stamp --train requires a producer repository'; fi
232
+ for arg in "$@"; do
233
+ repo=$(repo_root "$arg")
234
+ next_base "$repo"
235
+ if [ -z "$base" ] || base_above "$NEXT_BASE" "$base"; then base=$NEXT_BASE; fi
236
+ sha=$(git -C "$repo" rev-parse HEAD) || die 'producer has no HEAD commit'
237
+ if [ $# -gt 1 ]; then printf 'HEAD %s %s\n' "$repo" "$sha" >&2; fi
238
+ # The repository name is its origin's last path part (stable across checkouts), else its folder name.
239
+ origin=$(git -C "$repo" remote get-url origin 2>/dev/null || true)
240
+ rname=$(basename "${origin:-$repo}"); rname=${rname%.git}
241
+ lines="$lines$rname $sha"$'\n'
242
+ done
243
+ # With several producers the highest base wins, so no stamped package sorts
244
+ # below a released pin of another stamped package (NU1605).
245
+ # A train stamp has no minute and its sha7 covers every producer: the first 7 hex characters of the
246
+ # SHA-1 of the sorted "<repo> <full HEAD sha>" lines. One combined code state always yields the same
247
+ # stamp, and a change in any producer changes it. The grammar stays X.Y.Z-train.g<sha7>.
248
+ if [ "$train" -eq 1 ]; then
249
+ sha=$(printf '%s' "$lines" | LC_ALL=C sort -u | shasum -a 1) || die 'cannot hash the producer HEADs'
250
+ printf '%s-train.g%.7s\n' "$base" "${sha%% *}"; return 0
174
251
  fi
175
- sha=$(git -C "$repo" rev-parse HEAD) || die 'producer has no HEAD commit'
176
- printf '%s-local.%s.g%.7s\n' "$tag" "$(date -u +%Y%m%d%H%M)" "$sha"
252
+ printf '%s-local.%s.g%.7s\n' "$base" "$(date -u +%Y%m%d%H%M)" "$sha"
177
253
  }
178
254
 
179
255
  pack_nuget() {
180
256
  [ $# -ge 3 ] || die 'pack-nuget requires producer, stamp and targets'
181
- local repo version target path count=0
257
+ local repo version target path count=0 property
258
+ local properties=()
182
259
  repo=$(repo_root "$1"); version=$2; shift 2
183
260
  valid_stamp "$version"; need dotnet
261
+ # Extra MSBuild properties (from the dependency map) go on the command line, never the environment:
262
+ # an environment variable also reaches nested builds such as Chronicle's `dotnet run` code generator.
263
+ while [ "${1:-}" = --property ]; do
264
+ [ $# -ge 2 ] || die '--property requires NAME=VALUE'
265
+ property=$2; shift 2
266
+ [[ "$property" =~ ^[A-Za-z_][A-Za-z0-9_]*=[A-Za-z0-9_.,-]*$ ]] || die "invalid pack property: $property"
267
+ properties+=("-p:$property")
268
+ done
269
+ [ $# -ge 1 ] || die 'pack-nuget requires at least one target after the properties'
184
270
  mutate mkdir -p "$FEED/nuget"
185
271
  for target in "$@"; do
186
- (cd "$repo" && heavy build "pack NuGet $target" dotnet pack "$target" -c Release "-p:PackageVersion=$version" "-p:Version=$version" -o "$FEED/nuget")
272
+ (cd "$repo" && heavy build "pack NuGet $target" dotnet pack "$target" -c Release "-p:PackageVersion=$version" "-p:Version=$version" ${properties[@]+"${properties[@]}"} -o "$FEED/nuget")
187
273
  done
188
274
  [ "$DRY" -eq 0 ] || return 0
189
275
  while IFS= read -r -d '' path; do
@@ -193,11 +279,27 @@ pack_nuget() {
193
279
  printf 'produced %s nupkgs\n' "$count"
194
280
  }
195
281
 
282
+ # Pack one workspace with npm pack (what publish does) into the feed as <scope>-<name>-<version>.tgz.
283
+ pack_npm_workspace() {
284
+ local repo=$1 manifest=$2 version=$3 dir name slug
285
+ dir=$repo/$(dirname "$manifest")
286
+ name=$(node -p "require(process.argv[1]).name" "$dir/package.json") || failed "cannot read package name: $manifest"
287
+ slug=${name//\//-}
288
+ if [ "$DRY" -eq 1 ]; then
289
+ quote_command npm pack --ignore-scripts --pack-destination "$FEED/npm" "(in $dir)"
290
+ return 0
291
+ fi
292
+ # Packing is a file copy, not a build: no queue slot per workspace.
293
+ (cd "$dir" && mutate npm pack --ignore-scripts --pack-destination "$FEED/npm") >/dev/null
294
+ [ "$FEED/npm/${slug#@}-$version.tgz" = "$FEED/npm/$slug-$version.tgz" ] || \
295
+ mv -f "$FEED/npm/${slug#@}-$version.tgz" "$FEED/npm/$slug-$version.tgz" || failed "npm pack did not produce $name"
296
+ }
297
+
196
298
  pack_npm() {
197
299
  [ $# -eq 2 ] || die 'pack-npm requires producer and stamp'
198
300
  local repo version path count=0
199
301
  repo=$(repo_root "$1"); version=$2
200
- valid_stamp "$version"; need node; need yarn; scratch
302
+ valid_stamp "$version"; need node; need npm; need yarn; scratch
201
303
  git -C "$repo" diff --name-only HEAD -- 'package.json' '*/package.json' > "$TMP/modified"
202
304
  git -C "$repo" diff --cached --name-only -- 'package.json' '*/package.json' >> "$TMP/modified"
203
305
  git -C "$repo" diff --name-only -- 'package.json' '*/package.json' >> "$TMP/modified"
@@ -234,7 +336,10 @@ for (const file of files) {
234
336
  NODE
235
337
  fi
236
338
  mutate mkdir -p "$FEED/npm"
237
- (cd "$repo" && heavy build 'pack npm workspaces' yarn workspaces foreach -A --no-private pack --out "$FEED/npm/%s-%v.tgz")
339
+ # npm pack, like the publish workflows' npm publish: yarn pack drops files of a gitignored dist folder.
340
+ while IFS= read -r -d '' path; do
341
+ pack_npm_workspace "$repo" "$path" "$version"
342
+ done < "$TMP/manifests"
238
343
  if [ "$DRY" -eq 1 ]; then quote_command git -C "$repo" checkout -- '<versioned manifests>'; return 0; fi
239
344
  git -C "$repo" checkout -- "${PACK_MANIFESTS[@]}"
240
345
  PACK_MANIFESTS=()
@@ -294,9 +399,13 @@ source_mapping() {
294
399
 
295
400
  use_nuget() {
296
401
  [ $# -ge 2 ] || die 'use-nuget requires consumer and stamp'
297
- local repo version session props id file config='' props_key sources_key action
402
+ local repo version session props id file config='' props_key sources_key action pin=0 args=()
298
403
  local ids=()
299
404
  repo=$(repo_root "$1"); version=$2; shift 2
405
+ for id in "$@"; do
406
+ if [ "$id" = --pin-transitive ]; then pin=1; else args+=("$id"); fi
407
+ done
408
+ set -- ${args[@]+"${args[@]}"}
300
409
  valid_stamp "$version"; props=$repo/Directory.Packages.props
301
410
  [ -f "$props" ] || die 'no Directory.Packages.props (non-CPM). Manual alternative: untracked project copy, explicit PackageReference versions, dotnet restore --source FEED/nuget'
302
411
  session=$(session_path "$repo"); scratch
@@ -335,7 +444,10 @@ use_nuget() {
335
444
  register_session "$repo" "$session" "$version"
336
445
  {
337
446
  printf '<Project>\n <Import Project="%s" />\n' "$(xml_escape "$props")"
338
- printf ' <PropertyGroup><CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled></PropertyGroup>\n <ItemGroup>\n'
447
+ if [ "$pin" -eq 1 ]; then
448
+ printf ' <PropertyGroup><CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled></PropertyGroup>\n'
449
+ fi
450
+ printf ' <ItemGroup>\n'
339
451
  for id in "${ids[@]}"; do
340
452
  action=Include
341
453
  if grep -Fxiq -- "$id" "$TMP/consumer-ids"; then action=Update; fi
@@ -359,15 +471,488 @@ use_nuget() {
359
471
  printf 'overrode %s NuGet package IDs\n' "${#ids[@]}"
360
472
  }
361
473
 
474
+ # Dependency map: tab-separated rows, '#' comments (see references/dependency-map.md).
475
+ # Everything below is derived from these rows; no function reads a feed listing to decide what to expect.
476
+ MAP=${CRATIS_LOCAL_MAP:-$(dirname "$SELF")/dependency-map.tsv}
477
+ map_require() {
478
+ [ -f "$MAP" ] || die "dependency map not found: $MAP"
479
+ awk -F'\t' '
480
+ /^#/ || NF == 0 { next }
481
+ $2 == "" { bad = 1 }
482
+ $1 == "pack" && (NF == 4 || NF == 5) && ($3 == "nuget" || $3 == "npm") && $4 != "" && $2 != "" { next }
483
+ $1 == "ids" && NF == 4 && ($3 == "nuget" || $3 == "npm") && $4 != "" && $2 != "" { next }
484
+ $1 == "uses" && NF == 5 && ($4 == "nuget" || $4 == "npm" || $4 == "image") && $3 != "" && $5 != "" && $2 != "" { next }
485
+ $1 == "image" && NF == 4 && $2 != "" { next }
486
+ { printf "%s:%d: malformed dependency map row: %s\n", FILENAME, NR, $0 > "/dev/stderr"; bad = 1 }
487
+ END { exit bad ? 1 : 0 }' "$MAP" || exit 2
488
+ }
489
+ map_rows() { awk -F'\t' -v kind="$1" '/^#/ || NF == 0 { next } $1 == kind' "$MAP"; }
490
+ map_known() { awk -F'\t' -v n="$1" '/^#/ || NF == 0 { next } $2 == n || ($1 == "uses" && $3 == n) { found = 1 } END { exit !found }' "$MAP"; }
491
+ # Edge rows "eco<TAB>manifest" for consumer $1 using producer $2.
492
+ map_edge() { map_rows uses | awk -F'\t' -v c="$1" -v p="$2" '$2 == c && $3 == p { print $4 "\t" $5 }'; }
493
+ # Expected package IDs of producer $1 in ecosystem $2, one per line; return 1 when the map has no ids row.
494
+ map_ids() {
495
+ local rows
496
+ rows=$(map_rows ids | awk -F'\t' -v r="$1" -v e="$2" '$2 == r && $3 == e { n = split($4, a, ","); for (i = 1; i <= n; i++) if (a[i] != "") print a[i] }')
497
+ [ -n "$rows" ] || return 1
498
+ printf '%s\n' "$rows"
499
+ }
500
+ # Pack rows of producer $1 in ecosystem $2 as "target<TAB>PROP=VAL,..." (properties may be empty).
501
+ map_pack_rows() { map_rows pack | awk -F'\t' -v r="$1" -v e="$2" '$2 == r && $3 == e { print $4 "\t" $5 }'; }
502
+ # True when the manifest rows of uses edge "$1 uses $2" include ecosystem $3.
503
+ map_uses() { map_edge "$1" "$2" | awk -F'\t' -v e="$3" '$1 == e { found = 1 } END { exit !found }'; }
504
+
505
+ # plan_derive OUTFILE CONSUMER PRODUCER...: the one plan every other command works from. Producers are in
506
+ # dependency order. Rows (TSV) are written to OUTFILE only when the whole plan is valid; any problem is die (exit 2).
507
+ # Call it directly, never inside $(...) or a pipeline, so the exit cannot be lost in a subshell.
508
+ # pack <producer> <eco> eco nuget|npm: a named downstream repository uses it in that ecosystem
509
+ # overlay <producer> <upstream> producer consumes an earlier-named upstream through NuGet
510
+ # consume <producer> <eco> <path> the consumer's own edge (eco nuget|npm|image) and its manifest
511
+ plan_derive() {
512
+ local out=$1 consumer=$2 i j eco name edge manifests
513
+ shift 2
514
+ [ $# -ge 1 ] || die 'a plan needs a consumer and at least one producer'
515
+ local names=("$consumer" "$@")
516
+ map_require
517
+ for ((i=0; i<${#names[@]}; i++)); do
518
+ map_known "${names[$i]}" || die "repository not in the dependency map: ${names[$i]}"
519
+ for ((j=i+1; j<${#names[@]}; j++)); do
520
+ [ "${names[$i]}" != "${names[$j]}" ] || die "repository named twice (a consumer cannot also be a producer): ${names[$i]}"
521
+ done
522
+ done
523
+ for ((i=1; i<${#names[@]}; i++)); do
524
+ for ((j=i+1; j<${#names[@]}; j++)); do
525
+ [ -z "$(map_edge "${names[$i]}" "${names[$j]}")" ] || die "order: ${names[$i]} uses ${names[$j]}; list ${names[$j]} first"
526
+ done
527
+ done
528
+ : > "$out"
529
+ for ((i=1; i<${#names[@]}; i++)); do
530
+ name=${names[$i]}
531
+ edge=0
532
+ for eco in nuget npm image; do
533
+ for ((j=0; j<${#names[@]}; j++)); do
534
+ [ "$j" -eq 0 ] || [ "$j" -gt "$i" ] || continue
535
+ if map_uses "${names[$j]}" "$name" "$eco"; then
536
+ edge=1
537
+ if [ "$eco" != image ]; then plan_add_pack "$out" "$name" "$eco"; fi
538
+ break
539
+ fi
540
+ done
541
+ done
542
+ [ "$edge" -eq 1 ] || die "no edge: nothing named after $name uses it (check the order and the map)"
543
+ done
544
+ for ((i=2; i<${#names[@]}; i++)); do
545
+ for ((j=1; j<i; j++)); do
546
+ if map_uses "${names[$i]}" "${names[$j]}" nuget; then printf 'overlay\t%s\t%s\n' "${names[$i]}" "${names[$j]}" >> "$out"; fi
547
+ done
548
+ done
549
+ for ((i=1; i<${#names[@]}; i++)); do
550
+ map_edge "$consumer" "${names[$i]}" | awk -F'\t' -v p="${names[$i]}" '{ print "consume\t" p "\t" $1 "\t" $2 }' >> "$out"
551
+ done
552
+ manifests=$(awk -F'\t' '$1 == "consume" && $3 == "npm" { print $4 }' "$out" | sort -u | wc -l | tr -d ' ')
553
+ [ "$manifests" -le 1 ] || die "$consumer uses npm producers from different directories; only one npm overlay per session is supported"
554
+ plan_warn_unnamed_hops "${names[@]}"
555
+ }
556
+ # Append "pack <producer> <eco>" after checking the map can verify it (an ids row) and build it (a pack row).
557
+ plan_add_pack() {
558
+ map_ids "$2" "$3" > /dev/null || die "no ids row for $2 ($3): cannot verify the planned pack"
559
+ [ -n "$(map_pack_rows "$2" "$3")" ] || die "no pack row for $2 ($3) in the map"
560
+ printf 'pack\t%s\t%s\n' "$2" "$3" >> "$1"
561
+ }
562
+ # A producer that reaches the consumer through a repository that is not named keeps the released copy of it.
563
+ plan_warn_unnamed_hops() {
564
+ local consumer=$1 kind x p eco manifest named
565
+ shift
566
+ named=$(printf '%s\n' "$@")
567
+ map_rows uses | while IFS=$'\t' read -r kind x p eco manifest; do
568
+ [ "$kind" = uses ] && [ "$eco" != '' ] || continue
569
+ printf '%s\n' "$named" | grep -Fxq "$x" && continue
570
+ printf '%s\n' "$named" | grep -Fxq "$p" || continue
571
+ [ "$p" != "$consumer" ] || continue
572
+ [ -n "$(map_edge "$consumer" "$x")" ] || continue
573
+ printf 'WARNING: %s -> %s -> %s: %s is not named, so the released %s is kept; name it to rebuild it (a binary break is a finding)\n' "$p" "$x" "$consumer" "$x" "$x" >&2
574
+ done
575
+ }
576
+
577
+ # closure [--map FILE] [--train] CONSUMER=PATH PRODUCER=PATH...
578
+ # A printer over plan_derive: it decides nothing the plan does not already say, and prints nothing until the
579
+ # whole plan is valid. Every verify command it prints names the consumer and ALL producers in the given order,
580
+ # so each one re-derives the identical plan (no state is stored between commands).
581
+ # shellcheck disable=SC2016 # printed commands keep a literal $STAMP
582
+ closure() {
583
+ local arg name path i j eco row target props prop manifest mapopt='' train='' overlaid scope='' upstreams dirarg=''
584
+ local names=() paths=()
585
+ while [ $# -gt 0 ]; do
586
+ arg=$1; shift
587
+ case "$arg" in
588
+ --map) [ $# -gt 0 ] || die '--map requires a file'; MAP=$1; printf -v mapopt ' --map %q' "$1"; shift ;;
589
+ --train) train=' --train' ;;
590
+ *=*) name=${arg%%=*}; path=${arg#*=}; names+=("$name"); paths+=("$(repo_root "$path")") ;;
591
+ *) die "closure arguments are NAME=PATH; got: $arg" ;;
592
+ esac
593
+ done
594
+ [ ${#names[@]} -ge 2 ] || die 'closure requires CONSUMER=PATH and at least one PRODUCER=PATH'
595
+ scratch
596
+ plan_derive "$TMP/closure-plan" "${names[@]}"
597
+ for ((i=1; i<${#names[@]}; i++)); do printf -v arg ' --producer %q' "${names[$i]}"; scope="$scope$arg"; done
598
+ scope=" --consumer $(printf '%q' "${names[0]}")$scope$mapopt"
599
+ printf '# closure for %s (printed, not executed; run each step and check its exit code)\n' "${names[0]}"
600
+ printf 'STAMP=$(bash %q stamp%s' "$SELF" "$train"
601
+ for ((i=1; i<${#paths[@]}; i++)); do printf ' %q' "${paths[$i]}"; done
602
+ printf ')\n'
603
+ for ((i=1; i<${#names[@]}; i++)); do
604
+ name=${names[$i]}; overlaid=0
605
+ upstreams=$(awk -F'\t' -v p="$name" '$1 == "overlay" && $2 == p { printf "%s ", $3 }' "$TMP/closure-plan")
606
+ if [ -n "$upstreams" ]; then
607
+ overlaid=1
608
+ printf '# %s consumes %s: overlay it, then pack in a shell that sourced the printed env file\n' "$name" "${upstreams% }"
609
+ printf 'bash %q use-nuget %q "$STAMP"\n' "$SELF" "${paths[$i]}"
610
+ fi
611
+ for eco in nuget npm; do
612
+ grep -Fxq "$(printf 'pack\t%s\t%s' "$name" "$eco")" "$TMP/closure-plan" || continue
613
+ if [ "$eco" = npm ]; then
614
+ printf '( cd %q && bash %q pack-npm . "$STAMP" ) # needs yarn install --immutable and the repository build first; pack-npm does not build\n' "${paths[$i]}" "$SELF"
615
+ continue
616
+ fi
617
+ while IFS=$'\t' read -r target props; do
618
+ printf '( cd %q && ' "${paths[$i]}"
619
+ if [ "$overlaid" -eq 1 ]; then printf '. %q && ' "$(session_path "${paths[$i]}")/env"; fi
620
+ printf 'bash %q pack-nuget . "$STAMP"' "$SELF"
621
+ for prop in ${props//,/ }; do printf ' --property %q' "$prop"; done
622
+ printf ' %q )\n' "$target"
623
+ done < <(map_pack_rows "$name" nuget)
624
+ done
625
+ if [ "$overlaid" -eq 1 ]; then printf 'bash %q restore %q\n' "$SELF" "${paths[$i]}"; fi
626
+ for ((j=i+1; j<${#names[@]}; j++)); do
627
+ while IFS=$'\t' read -r eco manifest; do
628
+ case "$eco" in npm|image) printf '# note: %s uses %s (%s, %s): not overlaid here; wire it by hand if the %s build needs the local %s\n' "${names[$j]}" "$name" "$eco" "$manifest" "${names[$j]}" "$name" ;; esac
629
+ done < <(map_edge "${names[$j]}" "$name")
630
+ done
631
+ done
632
+ if grep -q '^pack' "$TMP/closure-plan"; then
633
+ printf 'bash %q verify-pack "$STAMP"%s\n' "$SELF" "$scope"
634
+ else
635
+ printf '# no pack rows in this plan (image-only edges): nothing to verify in the feed\n'
636
+ fi
637
+ for eco in nuget npm image; do
638
+ row=$(awk -F'\t' -v e="$eco" '$1 == "consume" && $3 == e { print $4; exit }' "$TMP/closure-plan")
639
+ [ -n "$row" ] || continue
640
+ if [ "$eco" = npm ] && [ "$row" != . ]; then printf -v dirarg ' --dir %q' "$row"; fi
641
+ case "$eco" in
642
+ nuget) printf 'bash %q use-nuget %q "$STAMP"\n# source the printed env file, restore and build the consumer, then:\nbash %q verify-assets %q "$STAMP"%s\n' "$SELF" "${paths[0]}" "$SELF" "${paths[0]}" "$scope" ;;
643
+ npm) printf 'bash %q use-npm %q "$STAMP"%s # then yarn install (not --immutable)\nbash %q verify-assets %q "$STAMP" --npm%s%s\n' "$SELF" "${paths[0]}" "$dirarg" "$SELF" "${paths[0]}" "$dirarg" "$scope" ;;
644
+ image) printf '# image edge: build the producer image per references/docker-images.md, tag it with "$STAMP"\n' ;;
645
+ esac
646
+ done
647
+ printf 'bash %q restore %q\nbash %q check %q\nbash %q clean %q --stamp "$STAMP"%s # review, then repeat with --apply\n' "$SELF" "${paths[0]}" "$SELF" "${paths[0]}" "$SELF" "${paths[0]}" "$dirarg"
648
+ }
649
+
650
+ # plan [--map FILE] CONSUMER PRODUCER...: print the plan rows (read-only).
651
+ plan_command() {
652
+ local args=()
653
+ while [ $# -gt 0 ]; do
654
+ case "$1" in
655
+ --map) [ $# -ge 2 ] || die '--map requires a file'; MAP=$2; shift 2 ;;
656
+ --*) die "unknown plan option: $1" ;;
657
+ *) args+=("$1"); shift ;;
658
+ esac
659
+ done
660
+ [ ${#args[@]} -ge 2 ] || die 'plan requires CONSUMER and at least one PRODUCER (repository names from the map)'
661
+ scratch
662
+ plan_derive "$TMP/plan" "${args[@]}"
663
+ cat "$TMP/plan"
664
+ }
665
+
666
+ # ---- Verification primitives. Each one reads only what it is passed and writes only to the paths it is given. ----
667
+ # One private scratch directory per command; stateless: nothing survives between two verify invocations.
668
+ SCR=''
669
+ fn_scratch() { scratch; SCR=$(mktemp -d "$TMP/fn.XXXXXX") || die 'cannot create a scratch directory'; }
670
+
671
+ # One line per planned artifact: "id<TAB>eco<TAB>producer". Derived from the plan rows and the map's ids rows only.
672
+ expected_artifacts() {
673
+ local plan=$1 out=$2 kind p eco id
674
+ : > "$out"
675
+ while IFS=$'\t' read -r kind p eco; do
676
+ [ "$kind" = pack ] || continue
677
+ while IFS= read -r id; do printf '%s\t%s\t%s\n' "$id" "$eco" "$p" >> "$out"; done < <(map_ids "$p" "$eco")
678
+ done < "$plan"
679
+ }
680
+
681
+ # The exact feed file of one artifact. Never a glob and never another stamp.
682
+ feed_path() {
683
+ case "$2" in
684
+ nuget) printf '%s/nuget/%s.%s.nupkg\n' "$FEED" "$3" "$1" ;;
685
+ npm) printf '%s/npm/%s-%s.tgz\n' "$FEED" "${3//\//-}" "$1" ;;
686
+ esac
687
+ }
688
+
689
+ # Split EXPECTED into PRESENT_OUT (same rows) and FINDINGS_OUT ("missing: ID STAMP (producer eco)").
690
+ feed_check() {
691
+ local stamp=$1 expected=$2 present=$3 findings=$4 id eco p
692
+ : > "$present"; : > "$findings"
693
+ while IFS=$'\t' read -r id eco p; do
694
+ if [ -f "$(feed_path "$stamp" "$eco" "$id")" ]; then printf '%s\t%s\t%s\n' "$id" "$eco" "$p" >> "$present"
695
+ else printf 'missing: %s %s (%s %s)\n' "$id" "$stamp" "$p" "$eco" >> "$findings"; fi
696
+ done < "$expected"
697
+ }
698
+
699
+ # A dependency matches only when it is exactly the stamp: STAMP, [STAMP] or [STAMP, ) (an inclusive lower bound
700
+ # with no upper bound, which is how a floor is written). Exclusive bounds, upper bounds and anything else fail.
701
+ dep_matches() {
702
+ local range=${2//[[:space:]]/}
703
+ [ "$range" = "$1" ] || [ "$range" = "[$1]" ] || [ "$range" = "[$1,)" ]
704
+ }
705
+
706
+ # "dep<TAB>id<TAB>version" for every <dependency> element of a nuspec, whatever the attribute order, quote
707
+ # style or line breaks; "bad<TAB>element" for one whose id or version cannot be read (the caller fails on it).
708
+ nuspec_dependencies() {
709
+ tr '\r\n\t' ' ' < "$1" | awk '
710
+ function attr(el, n, v) {
711
+ if (match(el, "[ ]" n "[ ]*=[ ]*\"[^\"]*\"")) { v = substr(el, RSTART, RLENGTH); sub(/^[^"]*"/, "", v); sub(/"$/, "", v); return v }
712
+ if (match(el, "[ ]" n "[ ]*=[ ]*\047[^\047]*\047")) { v = substr(el, RSTART, RLENGTH); sub(/^[^\047]*\047/, "", v); sub(/\047$/, "", v); return v }
713
+ return ""
714
+ }
715
+ { s = $0
716
+ while (match(s, /<dependency([^A-Za-z]|$)/)) {
717
+ rest = substr(s, RSTART); e = index(rest, ">")
718
+ if (e == 0) { print "bad\t" substr(rest, 1, 80); break }
719
+ el = substr(rest, 1, e); s = substr(rest, e + 1)
720
+ id = attr(el, "id"); ver = attr(el, "version")
721
+ if (id == "" || ver == "" || index(substr(el, 2), "<") > 0) print "bad\t" el
722
+ else print "dep\t" id "\t" ver
723
+ } }'
724
+ }
725
+
726
+ # Every nuspec dependency of a present planned .nupkg whose ID is in DEPSET must point at the stamp.
727
+ # Appends to FINDINGS_OUT and sets NUSPEC_EDGES to the number of dependency edges checked.
728
+ NUSPEC_EDGES=0
729
+ nuspec_findings() {
730
+ local stamp=$1 depset=$2 present=$3 findings=$4 id eco p kind dep range nuspec
731
+ NUSPEC_EDGES=0
732
+ nuspec=$findings.nuspec
733
+ while IFS=$'\t' read -r id eco p; do
734
+ [ "$eco" = nuget ] || continue
735
+ if ! unzip -p "$(feed_path "$stamp" nuget "$id")" '*.nuspec' > "$nuspec" 2>/dev/null; then
736
+ printf 'unreadable nuspec: %s\n' "$id" >> "$findings"; continue
737
+ fi
738
+ while IFS=$'\t' read -r kind dep range; do
739
+ if [ "$kind" = bad ]; then printf 'unparseable dependency: %s: %s\n' "$id" "$dep" >> "$findings"; continue; fi
740
+ grep -Fxiq -- "$dep" "$depset" || continue
741
+ NUSPEC_EDGES=$((NUSPEC_EDGES + 1))
742
+ dep_matches "$stamp" "$range" || printf 'dependency: %s -> %s %s (expected %s)\n' "$id" "$dep" "$range" "$stamp" >> "$findings"
743
+ done < <(nuspec_dependencies "$nuspec")
744
+ done < "$present"
745
+ rm -f -- "$nuspec"
746
+ }
747
+
748
+ # "id<TAB>version<TAB>assets file" for every package in IDSFILE that the consumer's restore resolved.
749
+ # Library keys are "Id/Version"; project references are not packages. Exit 2 without an assets file.
750
+ nuget_resolutions() {
751
+ local consumer=$1 idsfile=$2 out=$3 path files=()
752
+ while IFS= read -r -d '' path; do files+=("$path"); done < <(find "$consumer" -name node_modules -prune -o -path '*/obj/project.assets.json' -type f -print0)
753
+ [ ${#files[@]} -gt 0 ] || { printf 'ERROR: no obj/project.assets.json under %s (restore the consumer first)\n' "$consumer" >&2; return 2; }
754
+ need node
755
+ node - "$idsfile" "${files[@]}" > "$out" <<'NODE' || { printf 'ERROR: cannot read the consumer assets\n' >&2; return 2; }
756
+ const fs = require('fs');
757
+ const [idsfile, ...files] = process.argv.slice(2);
758
+ const ids = new Set(fs.readFileSync(idsfile, 'utf8').split('\n').filter(Boolean).map(s => s.toLowerCase()));
759
+ for (const file of files) {
760
+ let json;
761
+ try { json = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { console.error('invalid JSON: ' + file); process.exit(1); }
762
+ if (!json || typeof json.libraries !== 'object' || json.libraries === null) { console.error('no libraries object: ' + file); process.exit(1); }
763
+ for (const [key, lib] of Object.entries(json.libraries)) {
764
+ const cut = key.indexOf('/');
765
+ if (cut < 1 || cut === key.length - 1 || !lib || typeof lib.type !== 'string') { console.error('unreadable library ' + key + ': ' + file); process.exit(1); }
766
+ if (lib.type !== 'project' && ids.has(key.slice(0, cut).toLowerCase())) console.log(key.slice(0, cut) + '\t' + key.slice(cut + 1) + '\t' + file);
767
+ }
768
+ }
769
+ NODE
770
+ }
771
+
772
+ # "name<TAB>version<TAB>package.json" for every installed copy of a package in IDSFILE under NM.
773
+ npm_installed() {
774
+ local nm=$1 idsfile=$2 out=$3 name file version
775
+ need node
776
+ [ -d "$nm" ] || { printf 'ERROR: no node_modules: %s (run yarn install first)\n' "$nm" >&2; return 2; }
777
+ : > "$out"
778
+ while IFS= read -r name; do
779
+ while IFS= read -r -d '' file; do
780
+ version=$(node -e 'const v = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version; if (typeof v !== "string" || v === "") process.exit(1); process.stdout.write(v)' "$file") \
781
+ || { printf 'ERROR: unreadable package.json (no version): %s\n' "$file" >&2; return 2; }
782
+ printf '%s\t%s\t%s\n' "$name" "$version" "$file" >> "$out"
783
+ done < <(find "$nm" -path "*/node_modules/$name/package.json" -type f -print0)
784
+ done < "$idsfile"
785
+ }
786
+
787
+ # Every producer the consumer uses in ECO must have at least one of its planned IDs resolved.
788
+ # A consumer may use only part of a producer, so one ID is enough.
789
+ consumption_findings() {
790
+ local plan=$1 eco=$2 resolved=$3 findings=$4 kind p e manifest ids
791
+ while IFS=$'\t' read -r kind p e manifest; do
792
+ [ "$kind" = consume ] && [ "$e" = "$eco" ] || continue
793
+ ids=$(map_ids "$p" "$eco") || ids=''
794
+ if ! awk -F'\t' 'NR == FNR { want[tolower($0)] = 1; next } tolower($1) in want { found = 1 } END { exit !found }' <(printf '%s\n' "$ids") "$resolved"; then
795
+ printf 'unconsumed: %s (%s): none of %s resolved in the consumer\n' "$p" "$eco" "$(printf '%s' "$ids" | tr '\n' ',' | sed 's/,$//; s/,/, /g')" >> "$findings"
796
+ fi
797
+ done < "$plan"
798
+ }
799
+
800
+ # One JSON line from tab-separated "id<TAB>version" rows in $2, for machine consumers (pi-train).
801
+ json_report() {
802
+ awk -F'\t' -v stamp="$1" 'BEGIN { printf "{\"stamp\":\"%s\",\"packages\":[", stamp }
803
+ { printf "%s{\"id\":\"%s\",\"version\":\"%s\"}", (NR > 1 ? "," : ""), $1, $2 }
804
+ END { print "]}" }' "$2"
805
+ }
806
+
807
+ # Print findings to stdout, or stderr when stdout carries JSON, then fail. Returns only when there are none.
808
+ report_findings() {
809
+ local command=$1 findings=$2 out=$3 count
810
+ count=$(wc -l < "$findings" | tr -d ' ')
811
+ [ "$count" -eq 0 ] && return 0
812
+ cat "$findings" >&"$out"
813
+ failed "$command: $count findings"
814
+ }
815
+
816
+ # verify-pack STAMP (--consumer C --producer P... | NAME... [--ecosystem E]) [--json] [--map FILE]
817
+ # Plan mode checks exactly the plan the closure printed. Name mode (manual, weaker) builds the plan rows from
818
+ # the named repositories' ids rows instead of from edges.
819
+ verify_pack() {
820
+ [ $# -ge 2 ] || die 'verify-pack requires STAMP and either --consumer C --producer P... or repository NAMEs'
821
+ local stamp=$1 json=0 out=1 ecosystem='' consumer='' name eco d found
822
+ local producers=() names=()
823
+ shift
824
+ while [ $# -gt 0 ]; do
825
+ case "$1" in
826
+ --json) json=1; out=2; shift ;;
827
+ --map) [ $# -ge 2 ] || die '--map requires a file'; MAP=$2; shift 2 ;;
828
+ --consumer) [ $# -ge 2 ] || die '--consumer requires a repository name'; consumer=$2; shift 2 ;;
829
+ --producer) [ $# -ge 2 ] || die '--producer requires a repository name'; producers+=("$2"); shift 2 ;;
830
+ --ecosystem) [ $# -ge 2 ] || die '--ecosystem requires nuget or npm'; ecosystem=$2; shift 2
831
+ case "$ecosystem" in nuget|npm) ;; *) die 'invalid ecosystem' ;; esac ;;
832
+ --*) die "unknown verify-pack option: $1" ;;
833
+ *) names+=("$1"); shift ;;
834
+ esac
835
+ done
836
+ valid_stamp "$stamp"; fn_scratch; d=$SCR
837
+ if [ -n "$consumer" ] || [ ${#producers[@]} -gt 0 ]; then
838
+ [ -n "$consumer" ] && [ ${#producers[@]} -gt 0 ] || die 'verify-pack plan mode needs both --consumer and --producer'
839
+ [ ${#names[@]} -eq 0 ] && [ -z "$ecosystem" ] || die 'verify-pack takes either --consumer/--producer or repository names, not both'
840
+ plan_derive "$d/plan" "$consumer" "${producers[@]}"
841
+ else
842
+ [ ${#names[@]} -ge 1 ] || die 'verify-pack requires --consumer/--producer or at least one repository name from the map'
843
+ map_require
844
+ : > "$d/plan"
845
+ for name in "${names[@]}"; do
846
+ map_known "$name" || die "repository not in the dependency map: $name"
847
+ found=0
848
+ for eco in nuget npm; do
849
+ [ -z "$ecosystem" ] || [ "$eco" = "$ecosystem" ] || continue
850
+ if map_ids "$name" "$eco" > /dev/null; then printf 'pack\t%s\t%s\n' "$name" "$eco" >> "$d/plan"; found=1; fi
851
+ done
852
+ [ "$found" -eq 1 ] || die "no ids row for $name (${ecosystem:-all ecosystems}) in the map"
853
+ done
854
+ fi
855
+ grep -q '^pack' "$d/plan" || die 'the plan has no pack rows (image-only edges): nothing to verify'
856
+ expected_artifacts "$d/plan" "$d/expected"
857
+ awk -F'\t' '$2 == "nuget" { print $1 }' "$d/expected" > "$d/depset"
858
+ [ ! -s "$d/depset" ] || need unzip
859
+ feed_check "$stamp" "$d/expected" "$d/present" "$d/findings"
860
+ nuspec_findings "$stamp" "$d/depset" "$d/present" "$d/findings"
861
+ report_findings verify-pack "$d/findings" "$out"
862
+ if [ "$json" -eq 1 ]; then
863
+ awk -F'\t' -v s="$stamp" '{ print $1 "\t" s }' "$d/present" > "$d/json"
864
+ json_report "$stamp" "$d/json"
865
+ return 0
866
+ fi
867
+ printf 'verify-pack: %s planned artifacts present at %s (%s dependency edges checked)\n' "$(wc -l < "$d/present" | tr -d ' ')" "$stamp" "$NUSPEC_EDGES"
868
+ }
869
+
870
+ # verify-assets CONSUMER-PATH STAMP [--npm] [--dir REL] [--json] [--consumer C --producer P...] [--map FILE]
871
+ # Plan mode scans for exactly the planned IDs of one ecosystem; feed-wide mode (no --consumer) scans every
872
+ # package at the stamp in the feed, which is manual and weaker.
873
+ verify_assets() {
874
+ [ $# -ge 2 ] || die 'verify-assets requires CONSUMER-PATH, STAMP, [--npm] [--dir REL]'
875
+ local path=$1 stamp=$2 npm=0 dir='' json=0 out=1 consumer='' eco=nuget d name file matched nm unit
876
+ local producers=()
877
+ shift 2
878
+ while [ $# -gt 0 ]; do
879
+ case "$1" in
880
+ --npm) npm=1; eco=npm; shift ;;
881
+ --json) json=1; out=2; shift ;;
882
+ --dir) [ $# -ge 2 ] || die '--dir requires a relative directory'; dir=${2%/}; shift 2 ;;
883
+ --map) [ $# -ge 2 ] || die '--map requires a file'; MAP=$2; shift 2 ;;
884
+ --consumer) [ $# -ge 2 ] || die '--consumer requires a repository name'; consumer=$2; shift 2 ;;
885
+ --producer) [ $# -ge 2 ] || die '--producer requires a repository name'; producers+=("$2"); shift 2 ;;
886
+ *) die "unknown verify-assets option: $1" ;;
887
+ esac
888
+ done
889
+ valid_stamp "$stamp"; [ -d "$path" ] || die "not a directory: $path"
890
+ case "$dir" in /*|..|../*|*/..|*/../*) die "--dir must be a relative path inside the consumer: $dir" ;; esac
891
+ if [ -n "$consumer" ] || [ ${#producers[@]} -gt 0 ]; then
892
+ [ -n "$consumer" ] && [ ${#producers[@]} -gt 0 ] || die 'verify-assets needs --consumer and --producer together'
893
+ fi
894
+ fn_scratch; d=$SCR
895
+ : > "$d/findings"
896
+ if [ -n "$consumer" ]; then
897
+ plan_derive "$d/plan" "$consumer" "${producers[@]}"
898
+ awk -F'\t' -v e="$eco" '$1 == "consume" && $3 == e { found = 1 } END { exit !found }' "$d/plan" || die "$consumer has no $eco edge to the named producers"
899
+ expected_artifacts "$d/plan" "$d/all-expected"
900
+ awk -F'\t' -v e="$eco" '$2 == e' "$d/all-expected" > "$d/expected"
901
+ feed_check "$stamp" "$d/expected" "$d/present" "$d/findings"
902
+ cut -f1 "$d/expected" | sort -u > "$d/ids"
903
+ else
904
+ : > "$d/ids"
905
+ if [ "$npm" -eq 1 ]; then
906
+ for file in "$FEED/npm/"*"-$stamp.tgz"; do
907
+ [ -f "$file" ] || continue
908
+ name=$(tar -xOf "$file" package/package.json | node -e 'const n = JSON.parse(require("fs").readFileSync(0, "utf8")).name; if (typeof n !== "string" || n === "") process.exit(1); process.stdout.write(n)') \
909
+ || die "cannot read the package name: $file"
910
+ printf '%s\n' "$name" >> "$d/ids"
911
+ done
912
+ else
913
+ for file in "$FEED/nuget/"*".$stamp.nupkg"; do
914
+ [ -f "$file" ] || continue
915
+ name=${file##*/}; printf '%s\n' "${name%."$stamp".nupkg}" >> "$d/ids"
916
+ done
917
+ [ -s "$d/ids" ] || die "no packages at $stamp in the feed"
918
+ fi
919
+ fi
920
+ if [ "$npm" -eq 1 ]; then
921
+ nm=$path${dir:+/$dir}/node_modules
922
+ npm_installed "$nm" "$d/ids" "$d/resolved" || return $?
923
+ else
924
+ nuget_resolutions "$path" "$d/ids" "$d/resolved" || return $?
925
+ fi
926
+ matched=$(wc -l < "$d/resolved" | tr -d ' ')
927
+ awk -F'\t' -v s="$stamp" '$2 != s { print $1 " " $2 " " $3 }' "$d/resolved" >> "$d/findings"
928
+ if [ -n "$consumer" ]; then
929
+ consumption_findings "$d/plan" "$eco" "$d/resolved" "$d/findings"
930
+ elif [ "$matched" -eq 0 ]; then
931
+ failed "verify-assets: no stamped $eco package is resolved in the consumer"
932
+ fi
933
+ report_findings verify-assets "$d/findings" "$out"
934
+ if [ "$json" -eq 1 ]; then
935
+ awk -F'\t' '{ print $1 "\t" $2 }' "$d/resolved" | sort -u > "$d/json"
936
+ json_report "$stamp" "$d/json"
937
+ return 0
938
+ fi
939
+ unit=resolutions
940
+ if [ "$npm" -eq 1 ]; then unit='installed copies'; fi
941
+ printf 'verify-assets: %s %s, all at %s\n' "$matched" "$unit" "$stamp"
942
+ }
943
+
362
944
  use_npm() {
363
- [ $# -eq 2 ] || die 'use-npm requires consumer and stamp'
364
- local repo version session backup
945
+ [ $# -eq 2 ] || { [ $# -eq 4 ] && [ "$3" = --dir ]; } || die 'use-npm requires consumer, stamp and optional --dir REL'
946
+ local repo version session backup rel='' dir
365
947
  repo=$(repo_root "$1"); version=$2
948
+ if [ $# -eq 4 ]; then rel=${4%/}; fi
949
+ case "$rel" in /*|..|../*|*/..|*/../*) die "--dir must be a relative path inside the consumer: $rel" ;; esac
950
+ dir=$repo${rel:+/$rel}
366
951
  valid_stamp "$version"; need node; need tar; scratch
367
952
  session=$(session_path "$repo"); backup=$session/npm-backup
368
- [ -f "$repo/package.json" ] || die 'consumer has no root package.json'
953
+ [ -f "$dir/package.json" ] || die "consumer has no package.json in ${rel:-the root}; use --dir REL for a Yarn project in a subdirectory"
369
954
  [ ! -e "$backup" ] || die "active npm backup exists: $backup; restore first"
370
- node - "$repo" "$FEED/npm" "$version" "$TMP/package.json" <<'NODE'
955
+ node - "$dir" "$FEED/npm" "$version" "$TMP/package.json" <<'NODE'
371
956
  const fs = require('fs'), path = require('path'), {execFileSync} = require('child_process');
372
957
  const [repo, feed, stamp, output] = process.argv.slice(2);
373
958
  const root = JSON.parse(fs.readFileSync(path.join(repo, 'package.json'), 'utf8'));
@@ -389,39 +974,42 @@ console.log(`overrode ${count} npm packages`);
389
974
  NODE
390
975
  if [ "$DRY" -eq 1 ]; then
391
976
  quote_command mkdir -p "$backup"
392
- quote_command cp "$TMP/package.json" "$repo/package.json"
977
+ quote_command cp "$TMP/package.json" "$dir/package.json"
393
978
  return 0
394
979
  fi
395
980
  register_session "$repo" "$session" "$version"
396
981
  mkdir "$backup"
397
- cp -p "$repo/package.json" "$backup/package.json"
398
- if [ -f "$repo/yarn.lock" ]; then cp -p "$repo/yarn.lock" "$backup/yarn.lock"; fi
399
- git -C "$repo" diff HEAD -- package.json yarn.lock > "$backup/before.diff"
982
+ if [ -n "$rel" ]; then printf '%s\n' "$rel" > "$session/npm-dir"; fi
983
+ cp -p "$dir/package.json" "$backup/package.json"
984
+ if [ -f "$dir/yarn.lock" ]; then cp -p "$dir/yarn.lock" "$backup/yarn.lock"; fi
985
+ git -C "$dir" diff HEAD -- package.json yarn.lock > "$backup/before.diff"
400
986
  cp "$TMP/package.json" "$backup/expected-package.json"
401
- cp "$TMP/package.json" "$repo/package.json"
402
- printf 'Next: cd %q && yarn install\n' "$repo"
987
+ cp "$TMP/package.json" "$dir/package.json"
988
+ printf 'Next: cd %q && yarn install\n' "$dir"
403
989
  printf 'Vite: clear stale prebundles with rm -rf node_modules/.vite\n'
404
990
  }
405
991
 
406
992
  restore() {
407
993
  [ $# -eq 1 ] || die 'restore requires a consumer'
408
- local repo session backup
994
+ local repo session backup dir
409
995
  repo=$(repo_root "$1"); session=$(session_path "$repo"); backup=$session/npm-backup
410
996
  validate_session "$session"
997
+ dir=$repo
998
+ if [ -f "$session/npm-dir" ]; then dir=$repo/$(cat "$session/npm-dir"); fi
411
999
  if [ -f "$session/repo" ] && [ "$(cat "$session/repo")" != "$repo" ]; then die 'session belongs to another checkout'; fi
412
1000
  if [ -d "$backup" ]; then
413
- if ! cmp -s "$repo/package.json" "$backup/expected-package.json" ||
414
- { [ -f "$backup/yarn.lock" ] && ! cmp -s "$repo/yarn.lock" "$backup/yarn.lock"; } ||
415
- { [ ! -f "$backup/yarn.lock" ] && [ -e "$repo/yarn.lock" ]; }; then
1001
+ if ! cmp -s "$dir/package.json" "$backup/expected-package.json" ||
1002
+ { [ -f "$backup/yarn.lock" ] && ! cmp -s "$dir/yarn.lock" "$backup/yarn.lock"; } ||
1003
+ { [ ! -f "$backup/yarn.lock" ] && [ -e "$dir/yarn.lock" ]; }; then
416
1004
  need node
417
- node "$(dirname "$SELF")/npm-restore-check.cjs" "$repo" "$backup" ||
1005
+ node "$(dirname "$SELF")/npm-restore-check.cjs" "$dir" "$backup" ||
418
1006
  die "npm files changed beyond the overlay; review $backup (nothing restored)"
419
1007
  fi
420
- mutate cp -p "$backup/package.json" "$repo/package.json"
421
- if [ -f "$backup/yarn.lock" ]; then mutate cp -p "$backup/yarn.lock" "$repo/yarn.lock"; else mutate rm -f -- "$repo/yarn.lock"; fi
1008
+ mutate cp -p "$backup/package.json" "$dir/package.json"
1009
+ if [ -f "$backup/yarn.lock" ]; then mutate cp -p "$backup/yarn.lock" "$dir/yarn.lock"; else mutate rm -f -- "$dir/yarn.lock"; fi
422
1010
  mutate rm -rf -- "$backup"
423
1011
  fi
424
- mutate rm -f -- "$session/Directory.Packages.props" "$session/nuget.config" "$session/env" "$session/stamp"
1012
+ mutate rm -f -- "$session/Directory.Packages.props" "$session/nuget.config" "$session/env" "$session/stamp" "$session/npm-dir"
425
1013
  if [ "$DRY" -eq 0 ]; then check "$repo"; fi
426
1014
  }
427
1015
 
@@ -468,6 +1056,7 @@ scan_lines() {
468
1056
  {
469
1057
  number=$0; sub(/:.*/,"",number); line=$0; sub(/^[^:]*:/,"",line)
470
1058
  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
+ 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")
471
1060
  if (index(line,feed)) hit("local feed path")
472
1061
  if (index(line,"DirectoryPackages" "PropsPath")) hit("CPM overlay property")
473
1062
  if (index(line,"RestoreAdditional" "ProjectSources")) hit("additional restore sources")
@@ -496,10 +1085,10 @@ bounded_docker() {
496
1085
  return "$status"
497
1086
  }
498
1087
  local_artifact() {
499
- [[ "$1" =~ -local\.[0-9]{12}\.g[0-9a-f]{7}($|[^0-9A-Za-z]) ]]
1088
+ [[ "$1" =~ -(local\.[0-9]{12}|train)\.g[0-9a-f]{7}($|[^0-9A-Za-z]) ]]
500
1089
  }
501
1090
  clean() {
502
- local version='' apply=0 consumer='' arg path cache image session maximum count=0
1091
+ local version='' apply=0 consumer='' arg path cache image session maximum count=0 rel=''
503
1092
  local subjects=() kinds=()
504
1093
  while [ $# -gt 0 ]; do
505
1094
  arg=$1; shift
@@ -507,6 +1096,7 @@ clean() {
507
1096
  --stamp) [ $# -gt 0 ] || die '--stamp requires a stamp'; [ -z "$version" ] || die 'choose --stamp or --all'; version=$1; shift; valid_stamp "$version" ;;
508
1097
  --all) [ -z "$version" ] || die 'choose --stamp or --all'; version=all ;;
509
1098
  --apply) apply=1 ;;
1099
+ --dir) [ $# -gt 0 ] || die '--dir requires a relative directory'; rel=${1%/}; shift ;;
510
1100
  -*) die "unknown clean option: $arg" ;;
511
1101
  *) [ -z "$consumer" ] || die 'clean takes at most one consumer'; consumer=$(repo_root "$arg") ;;
512
1102
  esac
@@ -522,12 +1112,12 @@ clean() {
522
1112
  local_artifact "${path##*/}" || continue
523
1113
  if [ -n "$version" ] && [ "$version" != all ]; then case "$path" in *"$version"*) ;; *) continue ;; esac; fi
524
1114
  subjects+=("$path"); kinds+=(file)
525
- done < <(find "$cache" -type f \( -name '*.nupkg' -o -name '*.tgz' \) -print0)
1115
+ done < <(find "$cache" -type f \( -name '*.nupkg' -o -name '*.snupkg' -o -name '*.tgz' \) -print0)
526
1116
  done
527
1117
  if [ -d "$NUGET_CACHE" ]; then
528
1118
  for cache in "$NUGET_CACHE"/*; do
529
1119
  [ -d "$cache" ] && [ ! -L "$cache" ] || continue
530
- for path in "$cache"/*-local.*; do
1120
+ for path in "$cache"/*-local.* "$cache"/*-train.*; do
531
1121
  [ -d "$path" ] && [ ! -L "$path" ] || continue
532
1122
  local_artifact "${path##*/}" || continue
533
1123
  if [ -n "$version" ] && [ "$version" != all ] && [ "${path##*/}" != "$version" ]; then continue; fi
@@ -535,14 +1125,16 @@ clean() {
535
1125
  done
536
1126
  done
537
1127
  fi
538
- if command -v yarn >/dev/null 2>&1 && [ -n "$consumer" ]; then
539
- cache=$(cd "$consumer" && yarn config get cacheFolder) || die 'cannot discover Yarn cache'
1128
+ if [ -n "$consumer" ] && [ ! -f "$consumer${rel:+/$rel}/package.json" ]; then
1129
+ printf 'SKIP: no package.json in %s; pass --dir REL for a Yarn project in a subdirectory\n' "$consumer${rel:+/$rel}" >&2
1130
+ elif command -v yarn >/dev/null 2>&1 && [ -n "$consumer" ]; then
1131
+ cache=$(cd "$consumer${rel:+/$rel}" && yarn config get cacheFolder) || die 'cannot discover Yarn cache'
540
1132
  if [ -d "$cache" ]; then
541
1133
  while IFS= read -r -d '' path; do
542
1134
  local_artifact "${path##*/}" || continue
543
1135
  if [ -n "$version" ] && [ "$version" != all ]; then case "$path" in *"$version"*) ;; *) continue ;; esac; fi
544
1136
  subjects+=("$path"); kinds+=(file)
545
- done < <(find "$cache" -type f -name '*-local.*' -print0)
1137
+ done < <(find "$cache" -type f \( -name '*-local.*' -o -name '*-train.*' \) -print0)
546
1138
  fi
547
1139
  else
548
1140
  printf 'SKIP: Yarn cache discovery needs yarn and an explicit consumer\n' >&2
@@ -585,7 +1177,7 @@ clean() {
585
1177
  session)
586
1178
  [ -f "$path/stamp" ] && [ ! -L "$path" ] && [ ! -e "$path/npm-backup" ] || die "session changed: $path"
587
1179
  validate_session "$path"
588
- rm -f -- "$path/repo" "$path/stamp" "$path/env" "$path/Directory.Packages.props" "$path/nuget.config"
1180
+ rm -f -- "$path/repo" "$path/stamp" "$path/env" "$path/npm-dir" "$path/Directory.Packages.props" "$path/nuget.config"
589
1181
  rmdir -- "$path" ;;
590
1182
  esac
591
1183
  done
@@ -593,14 +1185,17 @@ clean() {
593
1185
  else printf 'dry-run: %s subjects (use --apply to remove)\n' "$count"; fi
594
1186
  }
595
1187
 
1188
+ # shellcheck disable=SC2016 # fixtures assert literal $STAMP in printed commands
596
1189
  self_test() {
597
1190
  [ $# -eq 0 ] || die '--self-test takes no arguments'
598
- local root home repo feed version output status passed=0 skipped=0 test_key arg
1191
+ local root home repo feed version output status passed=0 skipped=0 test_key arg map
599
1192
  scratch; root=$TMP/self-test; mkdir -p "$root/home" "$root/repo"
600
- home=$root/home; repo=$root/repo; feed=$home/.cache/cratis/local-feed
1193
+ map=$root/map.tsv; home=$root/home; repo=$root/repo; feed=$home/.cache/cratis/local-feed
601
1194
  # System-only tests stay hermetic; npm tests inherit PATH to discover node.
602
1195
  # Both isolate HOME/config and omit the host adapter.
603
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" "$@"; }
1198
+ has() { grep -Fq -- "$1" "$2"; }
604
1199
  st_npm() { env -i HOME="$home" PATH="$PATH" TMPDIR="$TMP" /bin/bash "$SELF" "$@"; }
605
1200
  git -C "$repo" init -q
606
1201
  repo=$(repo_root "$repo")
@@ -658,7 +1253,13 @@ LOCK
658
1253
  grep -Fq "Import Project=\"$repo/Directory.Packages.props\"" "$output/Directory.Packages.props" || failed 'self-test: overlay import'
659
1254
  grep -Fq "PackageVersion Update=\"Cratis.Test\" Version=\"$version\"" "$output/Directory.Packages.props" || failed 'self-test: pinned package Update'
660
1255
  grep -Fq "PackageVersion Include=\"Cratis.Transitive\" Version=\"$version\"" "$output/Directory.Packages.props" || failed 'self-test: transitive package Include'
661
- grep -Fq 'CentralPackageTransitivePinningEnabled>true' "$output/Directory.Packages.props" || failed 'self-test: transitive pinning'
1256
+ if grep -Fq 'CentralPackageTransitivePinningEnabled' "$output/Directory.Packages.props"; then failed 'self-test: transitive pinning must be opt-in'; fi
1257
+ st use-nuget "$repo" "$version" --pin-transitive > "$root/output"
1258
+ grep -Fq 'CentralPackageTransitivePinningEnabled>true' "$output/Directory.Packages.props" || failed 'self-test: --pin-transitive enables pinning'
1259
+ grep -Fq "PackageVersion Include=\"Cratis.Transitive\" Version=\"$version\"" "$output/Directory.Packages.props" || failed 'self-test: --pin-transitive keeps the IDs'
1260
+ st use-nuget "$repo" "$version" Cratis.Test --pin-transitive > "$root/output" || failed 'self-test: --pin-transitive with explicit IDs'
1261
+ st use-nuget "$repo" "$version" > "$root/output"
1262
+ passed=$((passed + 3))
662
1263
  test_key=DirectoryPackages"PropsPath"
663
1264
  grep -Fq "export $test_key=" "$output/env" || failed 'self-test: env property'
664
1265
  passed=$((passed + 5))
@@ -693,8 +1294,27 @@ LOCK
693
1294
  st_npm use-npm "$repo" "$version"
694
1295
  grep -Fq '"@cratis/transitive": "file:' "$repo/package.json" || failed 'self-test: transitive npm resolution'
695
1296
  grep -Fq '"@cratis/test": "file:' "$repo/package.json" || failed 'self-test: direct npm resolution'
696
- st_npm restore "$repo" > "$root/output"
697
- passed=$((passed + 2))
1297
+ # Execute the consumer tail printed by closure, with a fixture installation between
1298
+ # overlay and verification. In particular, check must not run before restore.
1299
+ {
1300
+ tsv pack Lib npm .
1301
+ tsv ids Lib npm @cratis/test,@cratis/transitive
1302
+ tsv uses App Lib npm .
1303
+ } > "$root/npm-closure.tsv"
1304
+ st_npm closure --map "$root/npm-closure.tsv" App="$repo" Lib="$repo" > "$root/npm-closure.out"
1305
+ printf 'node_modules/\n' >> "$repo/.git/info/exclude"
1306
+ for arg in test transitive; do
1307
+ mkdir -p "$repo/node_modules/@cratis/$arg"
1308
+ tar -xzf "$feed/npm/@cratis-$arg-$version.tgz" -C "$repo/node_modules/@cratis/$arg" --strip-components=1
1309
+ done
1310
+ status=0; st_npm check "$repo" > "$root/output" || status=$?
1311
+ [ "$status" -eq 1 ] || failed 'self-test: npm overlay needs restore before check'
1312
+ awk '/ verify-assets / { consumer=1 } consumer' "$root/npm-closure.out" > "$root/npm-closure-tail.sh"
1313
+ env -i HOME="$home" PATH="$PATH" TMPDIR="$TMP" STAMP="$version" \
1314
+ /bin/bash -e "$root/npm-closure-tail.sh" > "$root/output" || failed 'self-test: npm closure verification, restore, check and clean'
1315
+ cmp -s "$repo/package.json" <(git -C "$repo" show HEAD:package.json) || failed 'self-test: npm closure restores tracked manifest'
1316
+ grep -Fq 'dry-run:' "$root/output" || failed 'self-test: npm closure reaches clean'
1317
+ passed=$((passed + 6))
698
1318
  printf '{"private":true,"dependencies":{"@cratis/test":"1.2.3"},"userSetting":true}\n' > "$repo/package.json"
699
1319
  cp "$repo/package.json" "$root/original-package.json"
700
1320
  cp "$repo/yarn.lock" "$root/original-yarn.lock"
@@ -741,6 +1361,95 @@ NODE
741
1361
  passed=$((passed + 3))
742
1362
  git -C "$repo" checkout -- package.json
743
1363
  else printf 'SKIP: npm mutation/restore needs node on inherited PATH\n'; skipped=$((skipped + 10)); fi
1364
+ # pack-npm uses npm pack so a nested, gitignored dist directory listed in files is packed whole.
1365
+ if command -v npm >/dev/null 2>&1 && command -v node >/dev/null 2>&1; then
1366
+ local pk_dir=$root/npm-workspace pk_feed=$root/npm-feed pk_ver=1.2.3-local.202601010000.gabcdef0 pk_count
1367
+ mkdir -p "$pk_dir/dist/bundles/sub" "$pk_feed/npm"
1368
+ printf '{"name":"@cratis/nested","version":"%s","files":["dist/bundles","dist/**/*.d.ts"]}\n' "$pk_ver" > "$pk_dir/package.json"
1369
+ printf 'dist\n' > "$pk_dir/.gitignore"
1370
+ printf 'a\n' > "$pk_dir/dist/bundles/a.js"; printf 'b\n' > "$pk_dir/dist/bundles/sub/b.js"
1371
+ printf 'export {}\n' > "$pk_dir/dist/a.d.ts"
1372
+ git -C "$pk_dir" init -q
1373
+ (FEED=$pk_feed; pack_npm_workspace "$pk_dir" package.json "$pk_ver") >/dev/null || failed 'self-test: npm workspace pack'
1374
+ [ -f "$pk_feed/npm/@cratis-nested-$pk_ver.tgz" ] || failed 'self-test: npm tarball name'
1375
+ pk_count=$(tar tzf "$pk_feed/npm/@cratis-nested-$pk_ver.tgz" | grep -c 'dist/bundles/') || true
1376
+ [ "$pk_count" -eq 2 ] || failed "self-test: nested dist files packed ($pk_count of 2)"
1377
+ tar tzf "$pk_feed/npm/@cratis-nested-$pk_ver.tgz" | grep -Fq 'dist/a.d.ts' || failed 'self-test: declaration glob packed'
1378
+ printf '{"name":"plainpkg","version":"%s","files":["dist/bundles"]}\n' "$pk_ver" > "$pk_dir/package.json"
1379
+ (FEED=$pk_feed; pack_npm_workspace "$pk_dir" package.json "$pk_ver") >/dev/null || failed 'self-test: unscoped npm workspace pack'
1380
+ [ -f "$pk_feed/npm/plainpkg-$pk_ver.tgz" ] || failed 'self-test: unscoped npm tarball kept'
1381
+ passed=$((passed + 5))
1382
+ else printf 'SKIP: npm pack needs node and npm on inherited PATH\n'; skipped=$((skipped + 5)); fi
1383
+ # Multi-repo stamp: the highest next patch wins and every HEAD is reported.
1384
+ local repo2 v2
1385
+ repo2=$root/repo2; mkdir -p "$repo2"; git -C "$repo2" init -q
1386
+ git -C "$repo2" config user.email test@example.invalid; git -C "$repo2" config user.name 'Local package self-test'
1387
+ printf 'x\n' > "$repo2/f"; git -C "$repo2" add f; git -C "$repo2" commit -qm initial; git -C "$repo2" tag v2.0.0
1388
+ v2=$(st stamp "$repo" "$repo2" 2> "$root/stderr")
1389
+ [[ "$v2" =~ ^2\.0\.1-local\.[0-9]{12}\.g$(git -C "$repo2" rev-parse --short=7 HEAD)$ ]] || failed 'self-test: multi-repo stamp uses highest base and last sha'
1390
+ [ "$(grep -c '^HEAD ' "$root/stderr")" -eq 2 ] || failed 'self-test: multi-repo stamp prints each HEAD'
1391
+ v2=$(st stamp "$repo2" "$repo" 2> /dev/null)
1392
+ [[ "$v2" =~ ^2\.0\.1-local\. ]] || failed 'self-test: multi-repo stamp order independent base'
1393
+ passed=$((passed + 3))
1394
+ # A train stamp covers every producer: changing a non-last producer changes it, order does not.
1395
+ local ta tb tc
1396
+ ta=$(st stamp --train "$repo" "$repo2" 2> /dev/null); tb=$(st stamp --train "$repo2" "$repo" 2> /dev/null)
1397
+ [ "$ta" = "$tb" ] || failed 'self-test: train stamp is independent of producer order'
1398
+ [[ "$ta" =~ ^2\.0\.1-train\.g[0-9a-f]{7}$ ]] || failed "self-test: multi-producer train grammar ($ta)"
1399
+ printf 'y\n' > "$repo/g"; git -C "$repo" add g; git -C "$repo" commit -qm second
1400
+ tc=$(st stamp --train "$repo" "$repo2" 2> /dev/null)
1401
+ [ "$tc" != "$ta" ] || failed 'self-test: a change in a non-last producer must change the train stamp'
1402
+ git -C "$repo" reset -q --hard HEAD~1
1403
+ [ "$(st stamp --train "$repo" "$repo2" 2> /dev/null)" = "$ta" ] || failed 'self-test: train stamp returns with the same state'
1404
+ passed=$((passed + 4))
1405
+ # Train mode: a deterministic X.Y.Z-train.g<sha7> stamp, accepted everywhere a local stamp is.
1406
+ local tv tv2 tsha tleak
1407
+ tsha=$(printf '%s %s\n' "$(basename "$repo")" "$(git -C "$repo" rev-parse HEAD)" | shasum -a 1 | cut -c1-7)
1408
+ tv=$(st stamp --train "$repo") || failed 'self-test: stamp --train'
1409
+ tv2=$(st stamp --train "$repo")
1410
+ [[ "$tv" =~ ^1\.2\.4-train\.g$tsha$ ]] || failed "self-test: train stamp grammar ($tv)"
1411
+ [ "$tv" = "$tv2" ] || failed 'self-test: train stamp is deterministic'
1412
+ : > "$feed/nuget/Cratis.Train.$tv.nupkg"; : > "$feed/nuget/Cratis.Train.1.2.4-local.200001010000.g1234567.nupkg"
1413
+ st --dry-run use-nuget "$repo" "$tv" Cratis.Train > "$root/output" || failed 'self-test: use-nuget accepts a train stamp'
1414
+ for tleak in 1.2.4-train.1234567 1.2.4-train.200001010000.g1234567 1.2.4-train.gABCDEF0 1.2.4-train.g123456; do
1415
+ status=0; st --dry-run use-nuget "$repo" "$tleak" Cratis.Train > "$root/output" 2>&1 || status=$?
1416
+ [ "$status" -eq 2 ] || failed "self-test: invalid train stamp accepted ($tleak)"
1417
+ done
1418
+ st clean --stamp "$tv" > "$root/output" || failed 'self-test: clean accepts a train stamp'
1419
+ has "Cratis.Train.$tv.nupkg" "$root/output" || failed 'self-test: clean lists the train artifact'
1420
+ if has '200001010000' "$root/output"; then failed 'self-test: clean --stamp train must not list a local stamp'; fi
1421
+ rm "$feed/nuget/Cratis.Train.$tv.nupkg" "$feed/nuget/Cratis.Train.1.2.4-local.200001010000.g1234567.nupkg"
1422
+ printf 'Version="%s"\n' "$tv" > "$repo/probe-train"
1423
+ status=0; st check "$repo" > "$root/output" 2>&1 || status=$?
1424
+ [ "$status" -eq 1 ] || failed 'self-test: check misses an untracked train version'
1425
+ has 'train stamp' "$root/output" || failed 'self-test: check names the train stamp'
1426
+ git -C "$repo" add probe-train
1427
+ status=0; st check "$repo" > "$root/output" 2>&1 || status=$?
1428
+ [ "$status" -eq 1 ] || failed 'self-test: check misses a staged train version'
1429
+ status=0; st check "$repo" --staged > "$root/output" 2>&1 || status=$?
1430
+ [ "$status" -eq 1 ] || failed 'self-test: check --staged misses a train version'
1431
+ git -C "$repo" rm -qf --cached probe-train; rm "$repo/probe-train"
1432
+ st check "$repo" > "$root/output" || failed 'self-test: check clean after removing the train probe'
1433
+ passed=$((passed + 13))
1434
+ # use-npm --dir for a Yarn project in a subdirectory (no root package.json).
1435
+ if command -v node >/dev/null 2>&1; then
1436
+ local sub=$root/repo3
1437
+ mkdir -p "$sub/Source/Workbench"; git -C "$sub" init -q
1438
+ git -C "$sub" config user.email test@example.invalid; git -C "$sub" config user.name 'Local package self-test'
1439
+ cp "$root/original-package.json" "$sub/Source/Workbench/package.json" 2>/dev/null || printf '{"private":true,"dependencies":{"@cratis/test":"1.2.3"}}\n' > "$sub/Source/Workbench/package.json"
1440
+ printf '# lock\n' > "$sub/Source/Workbench/yarn.lock"
1441
+ git -C "$sub" add .; git -C "$sub" commit -qm initial
1442
+ status=0; st_npm use-npm "$sub" "$version" > "$root/output" 2>&1 || status=$?
1443
+ [ "$status" -eq 2 ] || failed 'self-test: use-npm without --dir needs a root package.json'
1444
+ st_npm use-npm "$sub" "$version" --dir Source/Workbench > "$root/output" || failed 'self-test: use-npm --dir'
1445
+ grep -Fq '"@cratis/test": "file:' "$sub/Source/Workbench/package.json" || failed 'self-test: use-npm --dir overlays the subdirectory manifest'
1446
+ status=0; st_npm use-npm "$sub" "$version" --dir ../x > "$root/output" 2>&1 || status=$?
1447
+ [ "$status" -eq 2 ] || failed 'self-test: use-npm --dir rejects escapes'
1448
+ st_npm restore "$sub" > "$root/output" || failed 'self-test: restore follows --dir'
1449
+ git -C "$sub" diff --quiet || failed 'self-test: restore --dir leaves the tracked files clean'
1450
+ [ ! -e "$(git -C "$sub" rev-parse --absolute-git-dir)/cratis-local/npm-dir" ] || failed 'self-test: restore removes npm-dir'
1451
+ passed=$((passed + 6))
1452
+ else printf 'SKIP: use-npm --dir needs node\n'; skipped=$((skipped + 6)); fi
744
1453
  mkdir -p "$home/.nuget/packages/cratis.test/$version" "$home/.nuget/packages/cratis.test/1.2.3"
745
1454
  mkdir -p "$home/.nuget/packages/cratis.test/1.2.3-local.keep"
746
1455
  for arg in nuget npm; do
@@ -756,16 +1465,312 @@ NODE
756
1465
  st clean --stamp "$version" > "$root/output"
757
1466
  grep -Fq "$home/.nuget/packages/cratis.test/$version" "$root/output" || failed 'self-test: clean lists local cache'
758
1467
  [ -d "$home/.nuget/packages/cratis.test/$version" ] || failed 'self-test: dry-run mutated cache'
1468
+ : > "$feed/nuget/Cratis.Test.$version.snupkg"
759
1469
  st clean --stamp "$version" --apply > "$root/output"
1470
+ [ ! -e "$feed/nuget/Cratis.Test.$version.snupkg" ] || failed 'self-test: clean left a symbols package'
760
1471
  [ ! -e "$home/.nuget/packages/cratis.test/$version" ] || failed 'self-test: clean failed to remove cache'
761
1472
  [ -d "$home/.nuget/packages/cratis.test/1.2.3" ] || failed 'self-test: clean removed real version'
762
- passed=$((passed + 4))
1473
+ passed=$((passed + 5))
763
1474
  printf 'SKIP: real dotnet/Yarn/Docker packing is not a hermetic self-test\n'; skipped=$((skipped + 3))
1475
+ self_test_matrix
764
1476
  printf 'self-test: %s passed, %s skipped\n' "$passed" "$skipped"
765
1477
  }
766
1478
 
1479
+ # C fixtures: the printed closure is one plan, in order, and every line is valid shell.
1480
+ self_test_closure() {
1481
+ local name line a b c p last first fa fl fm
1482
+ for name in App Lib Mid; do
1483
+ mkdir -p "$root/fx-$name"; git -C "$root/fx-$name" init -q
1484
+ git -C "$root/fx-$name" config user.email test@example.invalid; git -C "$root/fx-$name" config user.name 'Local package self-test'
1485
+ git -C "$root/fx-$name" commit -q --allow-empty -m initial
1486
+ done
1487
+ fa=$(git -C "$root/fx-App" rev-parse --show-toplevel); fl=$(git -C "$root/fx-Lib" rev-parse --show-toplevel); fm=$(git -C "$root/fx-Mid" rev-parse --show-toplevel)
1488
+ expect_status 0 'C1 closure for the fixture plan' fxm closure App="$root/fx-App" Lib="$root/fx-Lib" Mid="$root/fx-Mid"
1489
+ cp "$root/output" "$root/closure.out"
1490
+ bash -n "$root/closure.out" || failed 'self-test: C2 every printed closure line must be valid shell'
1491
+ [ "$(grep -c ' verify-pack ' "$root/closure.out")" -eq 1 ] || failed 'self-test: C1 exactly one verify-pack'
1492
+ a=$(grep -n ' verify-pack ' "$root/closure.out" | cut -d: -f1)
1493
+ last=$(grep -nE ' pack-(nuget|npm) ' "$root/closure.out" | tail -n 1 | cut -d: -f1)
1494
+ first=$(grep -n " use-nuget $fa " "$root/closure.out" | head -n 1 | cut -d: -f1)
1495
+ { [ "$a" -gt "$last" ] && [ "$a" -lt "$first" ]; } || failed 'self-test: C1 verify-pack sits after the last pack and before the consumer overlay'
1496
+ line=$(grep ' verify-pack ' "$root/closure.out")
1497
+ case "$line" in *' --consumer App --producer Lib --producer Mid'*) ;; *) failed 'self-test: C1 verify-pack carries the whole plan' ;; esac
1498
+ grep -q "pack-nuget . \"\$STAMP\" --property A=1 --property B=2 Lib.Extra.slnx" "$root/closure.out" || failed 'self-test: C2 the second Lib row carries both properties'
1499
+ [ "$(grep -c "pack-nuget \. \"\$STAMP\" Lib.slnx" "$root/closure.out")" -eq 1 ] || failed 'self-test: C2 the first Lib row carries no properties'
1500
+ # C4: Mid's overlay, then its pack in a shell that sources the env file, then its restore.
1501
+ a=$(grep -n " use-nuget $fm " "$root/closure.out" | cut -d: -f1)
1502
+ b=$(grep -n "$fm && \. .*/env && .* pack-nuget " "$root/closure.out" | cut -d: -f1)
1503
+ c=$(grep -n " restore $fm" "$root/closure.out" | cut -d: -f1)
1504
+ { [ -n "$a" ] && [ -n "$b" ] && [ -n "$c" ] && [ "$a" -lt "$b" ] && [ "$b" -lt "$c" ]; } || failed 'self-test: C4 overlay, sourced pack and restore are in order'
1505
+ if grep -q " use-nuget $fl " "$root/closure.out"; then failed 'self-test: C4 Lib has no upstream to overlay'; fi
1506
+ passed=$((passed + 6))
1507
+ # C2 (real map): Chronicle's three pack properties are separate flags; C3: an invalid plan prints nothing.
1508
+ expect_status 0 'C2 real closure Studio <- Chronicle' st closure Studio="$root/fx-App" Chronicle="$root/fx-Lib"
1509
+ cp "$root/output" "$root/closure.out"
1510
+ bash -n "$root/closure.out" || failed 'self-test: C2 real closure is valid shell'
1511
+ p=$(grep 'pack-nuget' "$root/closure.out" | grep -o -- '--property' | wc -l | tr -d ' ')
1512
+ [ "$p" -eq 3 ] || failed "self-test: C2 Chronicle carries 3 --property flags (got $p)"
1513
+ expect_status 2 'C3 closure for an unplannable pair prints nothing' st closure Stage="$root/fx-App" Screenplay="$root/fx-Lib"
1514
+ [ ! -s "$root/output" ] || failed 'self-test: C3 stdout must be empty'
1515
+ expect_status 0 'closure --train' st closure --train Studio="$root/fx-App" Chronicle="$root/fx-Lib"
1516
+ has 'stamp --train' "$root/output" || failed 'self-test: closure --train prints a train stamp'
1517
+ passed=$((passed + 3))
1518
+ }
1519
+
1520
+ # V fixtures: verify-pack and verify-assets against a fixture feed (needs zip, unzip and tar).
1521
+ # Each fixture starts from the same complete feed so one case cannot hide behind another.
1522
+ self_test_verify() {
1523
+ local vfeed=$root/vfeed s=1.2.4-local.202601010000.gaaaaaaa old=1.2.4-local.200001010000.g1234567 args id
1524
+ local lib=(--consumer App --producer Lib --producer Mid)
1525
+ local nodedir; nodedir=$(dirname "$(command -v node)")
1526
+ fxv() { env -i HOME="$home" PATH="$nodedir:/usr/bin:/bin:/usr/sbin:/sbin" TMPDIR="$TMP" CRATIS_LOCAL_MAP="$fx" CRATIS_LOCAL_FEED="$vfeed" /bin/bash "$SELF" "$@"; }
1527
+ if ! command -v zip >/dev/null 2>&1 || ! command -v unzip >/dev/null 2>&1 || ! command -v tar >/dev/null 2>&1 || ! command -v node >/dev/null 2>&1; then
1528
+ printf 'SKIP: verify fixtures need zip, unzip, tar and node\n'; skipped=$((skipped + 30)); return 0
1529
+ fi
1530
+ # mkpkg ID STAMP [DEPENDENCY-ID RANGE]...: a .nupkg (a zip with a nuspec) or, for an @scope/name ID, an npm tarball.
1531
+ mkpkg() {
1532
+ local pid=$1 stamp=$2 deps='' dir=$root/pk
1533
+ shift 2
1534
+ mkdir -p "$dir" "$vfeed/nuget" "$vfeed/npm"
1535
+ case "$pid" in
1536
+ @*) mkdir -p "$dir/package"; printf '{"name":"%s","version":"%s"}\n' "$pid" "$stamp" > "$dir/package/package.json"
1537
+ (cd "$dir" && tar -czf "$vfeed/npm/${pid//\//-}-$stamp.tgz" package) ;;
1538
+ *) while [ $# -ge 2 ]; do deps="$deps<dependency id=\"$1\" version=\"$2\" />"; shift 2; done
1539
+ printf '<package><metadata><id>%s</id><dependencies>%s</dependencies></metadata></package>\n' "$pid" "$deps" > "$dir/$pid.nuspec"
1540
+ rm -f "$vfeed/nuget/$pid.$stamp.nupkg"; (cd "$dir" && zip -q "$vfeed/nuget/$pid.$stamp.nupkg" "$pid.nuspec") ;;
1541
+ esac
1542
+ }
1543
+ # mkfeed STAMP [MID-DEPENDENCY-RANGE]: the complete feed of the plan "App Lib Mid".
1544
+ mkfeed() {
1545
+ rm -rf "$vfeed"
1546
+ mkpkg Cratis.Lib "$1"; mkpkg Cratis.Lib.Core "$1"; mkpkg @cratis/lib "$1"
1547
+ mkpkg Cratis.Mid "$1" Cratis.Lib "${2:-$1}"
1548
+ mkpkg Cratis.Extra "$1"
1549
+ }
1550
+ # mkassets DIR ID/VERSION...: a restore result that resolves exactly these packages.
1551
+ mkassets() {
1552
+ local dir=$1 entry sep=''
1553
+ shift; mkdir -p "$dir/obj"
1554
+ { printf '{\n "libraries": {\n'
1555
+ for entry in "$@"; do printf '%s "%s": {\n "type": "package"\n }' "$sep" "$entry"; sep=$',\n'; done
1556
+ printf '\n }\n}\n'; } > "$dir/obj/project.assets.json"
1557
+ }
1558
+ # V1: complete feed, plan-mode verify and the exact JSON line.
1559
+ mkfeed "$s"
1560
+ expect_status 0 'V1 complete feed verifies' fxv verify-pack "$s" "${lib[@]}"
1561
+ has 'verify-pack: 4 planned artifacts present' "$root/output" || failed 'self-test: V1 summary'
1562
+ fxv verify-pack "$s" "${lib[@]}" --json > "$root/output" 2> "$root/stderr" || failed 'self-test: V1 json'
1563
+ printf '{"stamp":"%s","packages":[{"id":"Cratis.Lib","version":"%s"},{"id":"Cratis.Lib.Core","version":"%s"},{"id":"@cratis/lib","version":"%s"},{"id":"Cratis.Mid","version":"%s"}]}\n' "$s" "$s" "$s" "$s" "$s" > "$root/json.expected"
1564
+ cmp -s "$root/output" "$root/json.expected" || failed 'self-test: V1 json line grammar'
1565
+ passed=$((passed + 2))
1566
+ # V2: a cross-producer dependency at the wrong version.
1567
+ mkfeed "$s" 1.0.0
1568
+ expect_status 1 'V2 wrong-version cross-producer dependency' fxv verify-pack "$s" "${lib[@]}"
1569
+ has 'dependency: Cratis.Mid -> Cratis.Lib 1.0.0' "$root/output" || failed 'self-test: V2 names the dependency'
1570
+ passed=$((passed + 1))
1571
+ # V3: lower bound equal to the stamp passes; a range that merely contains the stamp does not.
1572
+ for args in "[$s, )" "$s" "[$s]"; do
1573
+ mkfeed "$s" "$args"
1574
+ expect_status 0 "V3 dependency range $args" fxv verify-pack "$s" "${lib[@]}"
1575
+ done
1576
+ mkfeed "$s" "[1.0.0, $s)"
1577
+ expect_status 1 'V3 range with the stamp as upper bound' fxv verify-pack "$s" "${lib[@]}"
1578
+ # V3b: strict nuspec parsing. mkraw ID STAMP DEPENDENCIES-XML writes the dependencies element verbatim.
1579
+ mkraw() {
1580
+ local pk=$root/pk; mkdir -p "$pk"
1581
+ printf '<package><metadata><id>%s</id><dependencies>%s</dependencies></metadata></package>\n' "$1" "$3" > "$pk/$1.nuspec"
1582
+ rm -f "$vfeed/nuget/$1.$2.nupkg"; (cd "$pk" && zip -q "$vfeed/nuget/$1.$2.nupkg" "$1.nuspec")
1583
+ }
1584
+ mkfeed "$s"; mkraw Cratis.Mid "$s" "<dependency version=\"1.0.0\" id=\"Cratis.Lib\" />"
1585
+ expect_status 1 'V3b reversed attributes are read' fxv verify-pack "$s" "${lib[@]}"
1586
+ has 'dependency: Cratis.Mid -> Cratis.Lib 1.0.0' "$root/output" || failed 'self-test: V3b reversed attributes name the dependency'
1587
+ mkfeed "$s"; mkraw Cratis.Mid "$s" "<dependency version='1.0.0' id='Cratis.Lib' />"
1588
+ expect_status 1 'V3b single quotes are read' fxv verify-pack "$s" "${lib[@]}"
1589
+ mkfeed "$s"; mkraw Cratis.Mid "$s" "<dependency
1590
+ id=\"Cratis.Lib\"
1591
+ version=\"1.0.0\"
1592
+ exclude=\"Build\" />"
1593
+ expect_status 1 'V3b multiline element is read' fxv verify-pack "$s" "${lib[@]}"
1594
+ mkfeed "$s"; mkraw Cratis.Mid "$s" "<dependency version='$s' id='Cratis.Lib' exclude='Build'/>"
1595
+ expect_status 0 'V3b reversed single-quoted element at the stamp passes' fxv verify-pack "$s" "${lib[@]}"
1596
+ for bad in "<dependency id=\"Cratis.Lib\" />" "<dependency version=\"$s\" />" "<dependency id=\"\" version=\"$s\" />" "<dependency id=\"Cratis.Lib\" version=\"$s\""; do
1597
+ mkfeed "$s"; mkraw Cratis.Mid "$s" "$bad"
1598
+ expect_status 1 "V3b malformed element fails: $bad" fxv verify-pack "$s" "${lib[@]}"
1599
+ has 'unparseable dependency: Cratis.Mid' "$root/output" || failed 'self-test: V3b malformed element is named'
1600
+ done
1601
+ for args in "($s,)" "($s, )" "[$s, $s]" "[$s,]" "[$s, 9.9.9)" "$s-x" "[ $s ]x"; do
1602
+ mkfeed "$s" "$args"
1603
+ expect_status 1 "V3b dependency range rejected: $args" fxv verify-pack "$s" "${lib[@]}"
1604
+ done
1605
+ # V4: a dependency outside the plan is ignored.
1606
+ mkfeed "$s"; mkpkg Cratis.Mid "$s" Cratis.Lib "$s" Cratis.Other 1.0.0
1607
+ expect_status 0 'V4 unplanned dependency is ignored' fxv verify-pack "$s" "${lib[@]}"
1608
+ # V5: a missing planned artifact is not satisfied by another stamp.
1609
+ mkfeed "$s"; rm "$vfeed/nuget/Cratis.Lib.Core.$s.nupkg"; mkpkg Cratis.Lib.Core "$old"
1610
+ expect_status 1 'V5 missing planned artifact' fxv verify-pack "$s" "${lib[@]}"
1611
+ has "missing: Cratis.Lib.Core $s (Lib nuget)" "$root/output" || failed 'self-test: V5 names the artifact'
1612
+ mkassets "$root/cons" "Cratis.Lib/$s" "Cratis.Lib.Core/$s" "Cratis.Mid/$s"
1613
+ expect_status 1 'V5 verify-assets plan mode also fails on the missing artifact' fxv verify-assets "$root/cons" "$s" "${lib[@]}"
1614
+ passed=$((passed + 2))
1615
+ # V6: a real NuGet-only plan never demands the npm tarballs of a dual-ecosystem producer.
1616
+ rm -rf "$vfeed"
1617
+ while IFS= read -r id; do mkpkg "$id" "$s"; done < <(awk -F'\t' '$1 == "ids" && $2 == "Arc" && $3 == "nuget" { n = split($4, a, ","); for (i = 1; i <= n; i++) print a[i] }' "$(dirname "$SELF")/dependency-map.tsv")
1618
+ expect_status 0 'V6 real Arc NuGet plan without npm tarballs' env -i HOME="$home" PATH=/usr/bin:/bin TMPDIR="$TMP" CRATIS_LOCAL_FEED="$vfeed" /bin/bash "$SELF" verify-pack "$s" --consumer Stage --producer Arc
1619
+ # V7: stale assets (an older local stamp) name the ID, version and path.
1620
+ mkfeed "$s"
1621
+ mkassets "$root/cons" "Cratis.Lib/$old" "Cratis.Lib.Core/$s" "Cratis.Mid/$s"
1622
+ expect_status 1 'V7 stale local stamp' fxv verify-assets "$root/cons" "$s" "${lib[@]}"
1623
+ has "Cratis.Lib $old $root/cons/obj/project.assets.json" "$root/output" || failed 'self-test: V7 names id, version and path'
1624
+ passed=$((passed + 1))
1625
+ # V8: a train stamp changes with a commit in a non-last producer, so the old feed is stale.
1626
+ local t1 t2
1627
+ t1=$(st stamp --train "$repo" "$repo2" 2> /dev/null); mkfeed "$t1"
1628
+ expect_status 0 'V8 train feed verifies at its own stamp' fxv verify-pack "$t1" "${lib[@]}"
1629
+ git -C "$repo" commit -q --allow-empty -m 'non-last producer change'
1630
+ t2=$(st stamp --train "$repo" "$repo2" 2> /dev/null)
1631
+ [ "$t1" != "$t2" ] || failed 'self-test: V8 stamp changes with a non-last producer commit'
1632
+ expect_status 1 'V8 stale train stamp' fxv verify-pack "$t2" "${lib[@]}"
1633
+ has "missing: Cratis.Lib $t2" "$root/output" || failed 'self-test: V8 missing at the new train stamp'
1634
+ passed=$((passed + 2))
1635
+ # V9: a second, stale installed copy of an npm package.
1636
+ mkfeed "$s"
1637
+ mkdir -p "$root/npmcons/node_modules/@cratis/lib"
1638
+ printf '{"name":"@cratis/lib","version":"%s"}\n' "$s" > "$root/npmcons/node_modules/@cratis/lib/package.json"
1639
+ expect_status 0 'V9 one installed copy at the stamp' fxv verify-assets "$root/npmcons" "$s" --npm "${lib[@]}"
1640
+ mkdir -p "$root/npmcons/node_modules/x/node_modules/@cratis/lib"
1641
+ printf '{"name":"@cratis/lib","version":"1.0.0"}\n' > "$root/npmcons/node_modules/x/node_modules/@cratis/lib/package.json"
1642
+ expect_status 1 'V9 stale second npm copy' fxv verify-assets "$root/npmcons" "$s" --npm "${lib[@]}"
1643
+ has "@cratis/lib 1.0.0" "$root/output" || failed 'self-test: V9 names the stale copy'
1644
+ expect_status 1 'V9 feed-wide npm mode flags the same copy' fxv verify-assets "$root/npmcons" "$s" --npm
1645
+ passed=$((passed + 1))
1646
+ # V10: a named producer the consumer never resolved.
1647
+ mkfeed "$s"
1648
+ mkassets "$root/cons" "Cratis.Mid/$s" "Cratis.Extra/$s"
1649
+ expect_status 1 'V10 unconsumed producer' fxv verify-assets "$root/cons" "$s" --consumer App --producer Lib --producer Mid --producer Extra
1650
+ has 'unconsumed: Lib (nuget)' "$root/output" || failed 'self-test: V10 names the unconsumed producer'
1651
+ mkassets "$root/cons" "Cratis.Mid/$s" "Cratis.Extra/$s" "Cratis.Lib/$s"
1652
+ expect_status 0 'V10 one consumed ID is enough' fxv verify-assets "$root/cons" "$s" --consumer App --producer Lib --producer Mid --producer Extra
1653
+ passed=$((passed + 1))
1654
+ # V11: an unrelated package at the stamp is ignored in plan mode and flagged by the feed-wide mode.
1655
+ mkfeed "$s"; mkpkg Cratis.Unrelated "$s"
1656
+ mkassets "$root/cons" "Cratis.Lib/$s" "Cratis.Mid/$s" "Cratis.Unrelated/9.9.9"
1657
+ expect_status 0 'V11 plan mode ignores an unrelated package' fxv verify-assets "$root/cons" "$s" "${lib[@]}"
1658
+ expect_status 1 'V11 feed-wide mode flags it' fxv verify-assets "$root/cons" "$s"
1659
+ # V12: --json lists only what the consumer resolved, whatever else the feed holds.
1660
+ mkassets "$root/cons" "Cratis.Lib/$s" "Cratis.Mid/$s"
1661
+ fxv verify-assets "$root/cons" "$s" "${lib[@]}" --json > "$root/output" 2> "$root/stderr" || failed 'self-test: V12 json'
1662
+ printf '{"stamp":"%s","packages":[{"id":"Cratis.Lib","version":"%s"},{"id":"Cratis.Mid","version":"%s"}]}\n' "$s" "$s" "$s" > "$root/json.expected"
1663
+ cmp -s "$root/output" "$root/json.expected" || failed 'self-test: V12 json lists only consumer resolutions'
1664
+ passed=$((passed + 1))
1665
+ # V15: unreadable evidence is a failure, never a skip: broken assets JSON, a package.json without a version,
1666
+ # and a tarball whose package.json has no name. A nested "version" key must not stand in for the top-level one.
1667
+ mkdir -p "$root/badassets/obj"; printf '{"libraries": {"Cratis.Lib/%s": {"type": "package"' "$s" > "$root/badassets/obj/project.assets.json"
1668
+ expect_status 2 'V15 truncated project.assets.json' fxv verify-assets "$root/badassets" "$s" "${lib[@]}"
1669
+ printf '{"version": 3}\n' > "$root/badassets/obj/project.assets.json"
1670
+ expect_status 2 'V15 assets without libraries' fxv verify-assets "$root/badassets" "$s" "${lib[@]}"
1671
+ mkfeed "$s"; rm -rf "$root/npmbad"; mkdir -p "$root/npmbad/node_modules/@cratis/lib"
1672
+ printf '{"name":"@cratis/lib"}\n' > "$root/npmbad/node_modules/@cratis/lib/package.json"
1673
+ expect_status 2 'V15 installed package.json without a version' fxv verify-assets "$root/npmbad" "$s" --npm "${lib[@]}"
1674
+ printf '{"name":"@cratis/lib","dependencies":{"x":{"version":"%s"}},"version":"1.0.0"}\n' "$s" > "$root/npmbad/node_modules/@cratis/lib/package.json"
1675
+ expect_status 1 'V15 nested version does not hide the real one' fxv verify-assets "$root/npmbad" "$s" --npm "${lib[@]}"
1676
+ # V13/V14: cannot-run cases exit 2.
1677
+ expect_status 2 'V13 no edge in that ecosystem' fxv verify-assets "$root/cons" "$s" --npm --consumer Mid --producer Lib
1678
+ mkdir -p "$root/noassets"
1679
+ expect_status 2 'V13 no project.assets.json' fxv verify-assets "$root/noassets" "$s" "${lib[@]}"
1680
+ expect_status 2 'V14 verify-pack --consumer without --producer' fxv verify-pack "$s" --consumer App
1681
+ expect_status 2 'V14 verify-pack --producer without --consumer' fxv verify-pack "$s" --producer Lib
1682
+ expect_status 2 'V14 verify-assets --consumer without --producer' fxv verify-assets "$root/cons" "$s" --consumer App
1683
+ expect_status 2 'V14 verify-assets --producer without --consumer' fxv verify-assets "$root/cons" "$s" --producer Lib
1684
+ # Manual forms stay available.
1685
+ expect_status 0 'name-mode verify-pack' fxv verify-pack "$s" Lib --ecosystem nuget
1686
+ expect_status 2 'name-mode verify-pack without ids' fxv verify-pack "$s" Nod
1687
+ }
1688
+
1689
+ # Fixture matrix for the plan, closure and verify commands. Runs inside self_test and uses its
1690
+ # root/home/feed/st/passed/skipped (dynamic scope). `fx` is the fixture map, `real` the shipped one.
1691
+ # expect_status WANT DESCRIPTION COMMAND...: run, capture stdout/stderr in $root/output and $root/stderr, require the exit code.
1692
+ expect_status() {
1693
+ local want=$1 desc=$2 got=0
1694
+ shift 2
1695
+ "$@" > "$root/output" 2> "$root/stderr" || got=$?
1696
+ [ "$got" -eq "$want" ] || failed "self-test: $desc (exit $got, wanted $want)"
1697
+ passed=$((passed + 1))
1698
+ }
1699
+ tsv() { local IFS=$'\t'; printf '%s\n' "$*"; }
1700
+ self_test_matrix() {
1701
+ local fx=$root/fx.tsv status
1702
+ fxm() { env -i HOME="$home" PATH=/usr/bin:/bin:/usr/sbin:/sbin TMPDIR="$TMP" CRATIS_LOCAL_MAP="$fx" /bin/bash "$SELF" "$@"; }
1703
+ {
1704
+ echo '# fixture map'
1705
+ tsv pack Lib nuget Lib.slnx
1706
+ tsv pack Lib nuget Lib.Extra.slnx A=1,B=2
1707
+ tsv ids Lib nuget Cratis.Lib,Cratis.Lib.Core
1708
+ tsv pack Lib npm .
1709
+ tsv ids Lib npm @cratis/lib
1710
+ tsv pack Mid nuget Mid.slnx
1711
+ tsv ids Mid nuget Cratis.Mid
1712
+ tsv pack Extra nuget Extra.slnx
1713
+ tsv ids Extra nuget Cratis.Extra
1714
+ tsv pack Nod nuget Nod.slnx
1715
+ tsv uses Mid Lib nuget Directory.Packages.props
1716
+ tsv uses App Mid nuget Directory.Packages.props
1717
+ tsv uses App Lib nuget Directory.Packages.props
1718
+ tsv uses App Extra nuget Directory.Packages.props
1719
+ tsv uses App Lib npm .
1720
+ tsv uses App Orl nuget Directory.Packages.props
1721
+ tsv uses Orl Lib nuget Directory.Packages.props
1722
+ tsv uses App Nod nuget Directory.Packages.props
1723
+ } > "$fx"
1724
+ # P1: exact plan rows and the unnamed-hop warning on stderr only.
1725
+ {
1726
+ tsv pack Lib nuget; tsv pack Lib npm; tsv pack Mid nuget; tsv overlay Mid Lib
1727
+ tsv consume Lib nuget Directory.Packages.props; tsv consume Lib npm .; tsv consume Mid nuget Directory.Packages.props
1728
+ } > "$root/p1.expected"
1729
+ fxm plan App Lib Mid > "$root/output" 2> "$root/stderr" || failed 'self-test: P1 plan'
1730
+ 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'
1732
+ if has 'WARNING' "$root/output"; then failed 'self-test: P1 warning must stay on stderr'; fi
1733
+ # P2/P3: the real map. An NuGet-only edge plans only the NuGet ecosystem of a dual-ecosystem producer;
1734
+ # npm-only ids on a NuGet edge cannot be planned. P3 must move to the fixture map if Screenplay ships NuGet ids.
1735
+ st plan Stage Arc > "$root/output" 2> "$root/stderr" || failed 'self-test: P2 real plan'
1736
+ { [ "$(grep -c '^pack' "$root/output")" -eq 1 ] && has "$(tsv pack Arc nuget)" "$root/output"; } || failed 'self-test: P2 plans exactly pack Arc nuget'
1737
+ status=0; st plan Stage Screenplay > "$root/output" 2> "$root/stderr" || status=$?
1738
+ { [ "$status" -eq 2 ] && [ ! -s "$root/output" ] && has 'Screenplay (nuget)' "$root/stderr"; } || failed 'self-test: P3 npm-only ids on a NuGet edge'
1739
+ passed=$((passed + 3))
1740
+ # P4-P7: invalid inputs exit 2 with nothing on stdout (unknown, repeated, consumer as producer, wrong order, no edge, no ids, no producer).
1741
+ local bad
1742
+ for bad in 'App Nope' 'App Lib Lib' 'App Lib App' 'App Mid Lib' 'Mid Extra' 'App Nod' 'App'; do
1743
+ status=0
1744
+ # shellcheck disable=SC2086
1745
+ fxm plan $bad > "$root/output" 2> "$root/stderr" || status=$?
1746
+ { [ "$status" -eq 2 ] && [ ! -s "$root/output" ]; } || failed "self-test: plan $bad must exit 2 with empty stdout (got $status)"
1747
+ passed=$((passed + 1))
1748
+ done
1749
+ # P8: a malformed row names its line.
1750
+ { cat "$fx"; printf 'uses\tApp\tLib\n'; } > "$root/bad.tsv"
1751
+ status=0; fx=$root/bad.tsv; fxm plan App Lib > "$root/output" 2> "$root/stderr" || status=$?
1752
+ fx=$root/fx.tsv
1753
+ { [ "$status" -eq 2 ] && has "bad.tsv:$(($(wc -l < "$root/bad.tsv"))): malformed" "$root/stderr"; } || failed 'self-test: P8 malformed map row'
1754
+ passed=$((passed + 1))
1755
+ # Pack properties are separate -p: arguments of the one command, never an environment variable.
1756
+ mkdir -p "$root/bin"; printf '#!/bin/sh\nexit 0\n' > "$root/bin/dotnet"; chmod +x "$root/bin/dotnet"
1757
+ env -i HOME="$home" PATH="$root/bin:/usr/bin:/bin" TMPDIR="$TMP" /bin/bash "$SELF" --dry-run pack-nuget "$repo" "$version" --property A=1 --property B=2 Lib.slnx > "$root/output" || failed 'self-test: pack-nuget --property dry run'
1758
+ { has ' -p:A=1 -p:B=2 ' "$root/output" && ! has CRATIS_LOCAL_PACK_PROPERTIES "$root/output"; } || failed 'self-test: pack-nuget prints separate -p: tokens'
1759
+ status=0; env -i HOME="$home" PATH="$root/bin:/usr/bin:/bin" TMPDIR="$TMP" /bin/bash "$SELF" --dry-run pack-nuget "$repo" "$version" --property 'A=1;B' Lib.slnx > "$root/output" 2>&1 || status=$?
1760
+ [ "$status" -eq 2 ] || failed 'self-test: pack-nuget rejects an invalid property'
1761
+ status=0; env -i HOME="$home" PATH="$root/bin:/usr/bin:/bin" TMPDIR="$TMP" /bin/bash "$SELF" --dry-run pack-nuget "$repo" "$version" --property A=1 > "$root/output" 2>&1 || status=$?
1762
+ [ "$status" -eq 2 ] || failed 'self-test: pack-nuget needs a target after the properties'
1763
+ passed=$((passed + 3))
1764
+ self_test_closure
1765
+ self_test_verify
1766
+ }
1767
+
767
1768
  case "$MODE" in
768
1769
  stamp) stamp "$@" ;;
1770
+ plan) plan_command "$@" ;;
1771
+ closure) closure "$@" ;;
1772
+ verify-pack) verify_pack "$@" ;;
1773
+ verify-assets) verify_assets "$@" ;;
769
1774
  pack-nuget) pack_nuget "$@" ;;
770
1775
  pack-npm) pack_npm "$@" ;;
771
1776
  use-nuget) use_nuget "$@" ;;