@sdd-method/verify-cli 0.1.0 → 0.2.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.
@@ -0,0 +1,505 @@
1
+ #!/bin/bash
2
+ #
3
+ # validate-integration-docs.sh - Validate integration repo documentation structure
4
+ #
5
+ # Usage: ./validate-integration-docs.sh [--repo-root <path>]
6
+
7
+ set -euo pipefail
8
+
9
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
11
+ REPO_ROOT="$ROOT_DIR"
12
+ EXPECTED_REPO_KIND="integration-product"
13
+
14
+ RED='\033[0;31m'
15
+ GREEN='\033[0;32m'
16
+ YELLOW='\033[1;33m'
17
+ BLUE='\033[0;34m'
18
+ NC='\033[0m'
19
+
20
+ log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
21
+ log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
22
+ log_fail() { echo -e "${RED}[FAIL]${NC} $1"; }
23
+ log_pass() { echo -e "${GREEN}[PASS]${NC} $1"; }
24
+ log_section() {
25
+ echo -e "\n${BLUE}═══════════════════════════════════════════════════════════${NC}"
26
+ echo -e "${BLUE} $1${NC}"
27
+ echo -e "${BLUE}═══════════════════════════════════════════════════════════${NC}\n"
28
+ }
29
+
30
+ while [[ $# -gt 0 ]]; do
31
+ case "$1" in
32
+ --repo-root)
33
+ REPO_ROOT="${2:-}"
34
+ shift 2
35
+ ;;
36
+ -h|--help)
37
+ echo "Usage: $(basename "$0") [--repo-root <path>]"
38
+ exit 0
39
+ ;;
40
+ *)
41
+ log_fail "Unknown argument: $1"
42
+ exit 1
43
+ ;;
44
+ esac
45
+ done
46
+
47
+ if [[ ! -d "$REPO_ROOT" ]]; then
48
+ log_fail "Repo root not found: $REPO_ROOT"
49
+ exit 1
50
+ fi
51
+
52
+ command -v yq >/dev/null 2>&1 || {
53
+ log_fail "yq is required. Install it before running $(basename "$0")."
54
+ exit 1
55
+ }
56
+
57
+ FAILURES=0
58
+
59
+ check_repo_kind() {
60
+ log_section "Group 1: Repo Kind"
61
+
62
+ local marker="$REPO_ROOT/.sdd-repo-kind"
63
+ if [[ ! -f "$marker" ]]; then
64
+ log_fail ".sdd-repo-kind not found"
65
+ FAILURES=$((FAILURES + 1))
66
+ return
67
+ fi
68
+
69
+ local value
70
+ value="$(tr -d '[:space:]' < "$marker")"
71
+ if [[ "$value" != "$EXPECTED_REPO_KIND" ]]; then
72
+ log_fail "Expected repo kind '$EXPECTED_REPO_KIND', found '$value'"
73
+ FAILURES=$((FAILURES + 1))
74
+ else
75
+ log_pass "Repo kind marker present: $value"
76
+ fi
77
+ }
78
+
79
+ check_required_files() {
80
+ log_section "Group 2: Required Files"
81
+
82
+ local files=(
83
+ "integration-manifest.yaml"
84
+ "docs/service-architecture.md"
85
+ "docs/method/methods/templates/feature-grounding-integration-template.md"
86
+ "docs/method/methods/workflows/integration-delivery-workflow-guide.md"
87
+ "docs/method/methods/templates/integration-service-architecture-template.md"
88
+ )
89
+ local rel
90
+
91
+ for rel in "${files[@]}"; do
92
+ if [[ -f "$REPO_ROOT/$rel" ]]; then
93
+ log_pass "$rel found"
94
+ else
95
+ log_fail "$rel missing"
96
+ FAILURES=$((FAILURES + 1))
97
+ fi
98
+ done
99
+ }
100
+
101
+ check_manifest() {
102
+ log_section "Group 3: Manifest Structure"
103
+
104
+ local manifest="$REPO_ROOT/integration-manifest.yaml"
105
+ if [[ ! -f "$manifest" ]]; then
106
+ log_fail "integration-manifest.yaml missing; cannot validate structure"
107
+ FAILURES=$((FAILURES + 1))
108
+ return
109
+ fi
110
+
111
+ local required_scalars=("integrationId" "providerName" "version" "profileType" "sdkVersion" "publishedBy" "bindingModel")
112
+ local field value
113
+ for field in "${required_scalars[@]}"; do
114
+ value="$(yq e ".${field} // \"\"" "$manifest")"
115
+ if [[ -z "$value" || "$value" == "null" || "$value" == "TBD" ]]; then
116
+ log_fail "integration-manifest.yaml: '$field' missing or placeholder"
117
+ FAILURES=$((FAILURES + 1))
118
+ else
119
+ log_pass "integration-manifest.yaml: '$field' set"
120
+ fi
121
+ done
122
+
123
+ local inbound outbound asset_count profile
124
+ inbound="$(yq e '.capabilityFlags.inbound' "$manifest")"
125
+ outbound="$(yq e '.capabilityFlags.outbound' "$manifest")"
126
+ profile="$(yq e '.profileType // ""' "$manifest")"
127
+ asset_count="$(yq e '.supportedAssetTypes | length' "$manifest" 2>/dev/null || echo 0)"
128
+
129
+ if [[ "$inbound" != "true" && "$inbound" != "false" ]]; then
130
+ log_fail "integration-manifest.yaml: capabilityFlags.inbound must be true or false"
131
+ FAILURES=$((FAILURES + 1))
132
+ else
133
+ log_pass "integration-manifest.yaml: capabilityFlags.inbound set"
134
+ fi
135
+
136
+ if [[ "$outbound" != "true" && "$outbound" != "false" ]]; then
137
+ log_fail "integration-manifest.yaml: capabilityFlags.outbound must be true or false"
138
+ FAILURES=$((FAILURES + 1))
139
+ else
140
+ log_pass "integration-manifest.yaml: capabilityFlags.outbound set"
141
+ fi
142
+
143
+ if [[ "$asset_count" -lt 1 ]]; then
144
+ log_fail "integration-manifest.yaml: supportedAssetTypes must contain at least one value"
145
+ FAILURES=$((FAILURES + 1))
146
+ else
147
+ log_pass "integration-manifest.yaml: supportedAssetTypes populated"
148
+ fi
149
+
150
+ case "$profile" in
151
+ workflow|financial-services|data-hub|data-feed)
152
+ if [[ "$outbound" != "false" ]]; then
153
+ log_fail "integration-manifest.yaml: profileType '$profile' should default outbound capability to false"
154
+ FAILURES=$((FAILURES + 1))
155
+ else
156
+ log_pass "integration-manifest.yaml: inbound-only profile directionality is coherent"
157
+ fi
158
+ ;;
159
+ *)
160
+ ;;
161
+ esac
162
+
163
+ # Interaction patterns (ADR 0171) — optional fields, validated only when present.
164
+ # Existing manifests predate the field; absence is not a failure. Directionality
165
+ # mismatch (pattern present but the matching capability flag is false) is a warning.
166
+ local ingestion invocation
167
+ ingestion="$(yq e '.ingestionPattern // ""' "$manifest")"
168
+ invocation="$(yq e '.invocationPattern // ""' "$manifest")"
169
+
170
+ if [[ -n "$ingestion" && "$ingestion" != "null" ]]; then
171
+ case "$ingestion" in
172
+ event-stream-consume|webhook|socket-listen|poll)
173
+ log_pass "integration-manifest.yaml: ingestionPattern '$ingestion' valid"
174
+ # if/fi for the same reason as the invocationPattern warn below:
175
+ # safe only because more code follows, which is not a property
176
+ # worth relying on.
177
+ if [[ "$inbound" != "true" ]]; then
178
+ log_warn "integration-manifest.yaml: ingestionPattern set but capabilityFlags.inbound is not true"
179
+ fi
180
+ ;;
181
+ *)
182
+ log_fail "integration-manifest.yaml: ingestionPattern '$ingestion' not in {event-stream-consume, webhook, socket-listen, poll} — if this provider is none of the four, pick the pattern matching the MECHANICS and move the distinguishing semantics to patternQualifier (free-text); minting a pattern value is a method change"
183
+ FAILURES=$((FAILURES + 1))
184
+ ;;
185
+ esac
186
+ fi
187
+
188
+ if [[ -n "$invocation" && "$invocation" != "null" ]]; then
189
+ case "$invocation" in
190
+ request-response|async-request|publish)
191
+ log_pass "integration-manifest.yaml: invocationPattern '$invocation' valid"
192
+ # Explicit if/fi, NOT `[[ ... ]] && log_warn`. As the last
193
+ # statement in this function, a false test made the function
194
+ # return 1, and under `set -e` that aborted the whole run before
195
+ # Group 4 — so the check fired on the CORRECT case (a manifest
196
+ # that declares invocationPattern and properly sets
197
+ # outbound: true), and the better-formed the manifest, the more
198
+ # certainly its grounding evidence went unchecked.
199
+ if [[ "$outbound" != "true" ]]; then
200
+ log_warn "integration-manifest.yaml: invocationPattern set but capabilityFlags.outbound is not true"
201
+ fi
202
+ ;;
203
+ *)
204
+ log_fail "integration-manifest.yaml: invocationPattern '$invocation' not in {request-response, async-request, publish}"
205
+ FAILURES=$((FAILURES + 1))
206
+ ;;
207
+ esac
208
+ fi
209
+ }
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # Unedited-scaffold-residue lint (WARN-only).
213
+ #
214
+ # WHAT THIS MEASURES, and what it deliberately does not: residue measures
215
+ # **authoring completeness** — how much of the scaffold is still untouched. It is
216
+ # NOT a signal for whether an SDD models a real provider. Those are independent
217
+ # properties, measured to be so: on one estate 5 SDDs are demonstrations and 11
218
+ # carry residue, because six REAL integrations are simply not authored yet. A
219
+ # residue list is therefore an authoring WORK QUEUE, never a census of
220
+ # demonstrations. Do not re-couple it to the `exemplar` trait.
221
+ #
222
+ # THE TEMPLATE IS THE ORACLE. Residue is defined as template text that survived
223
+ # into the authored document, so the shipped template is read at runtime and the
224
+ # comparison is exact. This matters for two reasons:
225
+ # 1. It is self-maintaining. An earlier attempt at this check hardcoded a list
226
+ # of placeholder strings; the list silently went stale, so tokens from other
227
+ # templates read as real content.
228
+ # 2. It separates residue from a brace that is legitimately authored. A JSON
229
+ # example containing `{` is not residue; the exact string `{provider field}`
230
+ # is, because the template is where it comes from.
231
+ #
232
+ # WHAT IS LEGITIMATELY RETAINED — state this half or the rule flags correct
233
+ # authoring. Template text kept as STRUCTURE is right and must never be reported:
234
+ # - section headings (`^#`) — a cohort of SDDs sharing one standard deliberately
235
+ # keeps the method's section shape while filling it with real content, and that
236
+ # is exactly correct authoring;
237
+ # - table header and separator rows (`^|`);
238
+ # - short shared lines — a bare path, a field label — dropped by a length floor.
239
+ # Residue is template text left IN THE SLOTS, never template text AS STRUCTURE.
240
+ #
241
+ # THREE SHAPES OF RESIDUE, and only two are detectable:
242
+ # 1. Unfilled placeholder tokens — detected (exact token match).
243
+ # 2. Leftover instruction prose — detected (exact line match).
244
+ # 3. Unmodified example table rows — NOT ATTEMPTED, on evidence rather than
245
+ # difficulty: every placeholder-free table row in the shipped templates is a
246
+ # table HEADER row, which an authored document is supposed to retain. Rows
247
+ # that do carry placeholders are already caught by shape 1. So verbatim-row
248
+ # matching would flag precisely the lines that should survive and add no
249
+ # signal that shape 1 misses.
250
+ # ---------------------------------------------------------------------------
251
+
252
+ # Templates that may reach an integration SDD (forward via integration-baseline,
253
+ # reverse via reverse-extension-baseline). Whichever are present are used.
254
+ GROUNDING_TEMPLATES=(
255
+ "docs/method/methods/templates/feature-grounding-integration-template.md"
256
+ "docs/method/methods/reverse/templates/integration-product/docs/design/integration/feature-grounding.md"
257
+ )
258
+
259
+ # Cap on reported items per class — an untouched scaffold would otherwise bury
260
+ # the rest of the run. The count is always reported in full, per no-silent-caps.
261
+ RESIDUE_REPORT_CAP=5
262
+
263
+ check_grounding_residue() {
264
+ local grounding="$1"
265
+ local rel="docs/design/integration/feature-grounding.md"
266
+
267
+ local -a templates=()
268
+ local t
269
+ for t in "${GROUNDING_TEMPLATES[@]}"; do
270
+ [[ -f "$REPO_ROOT/$t" ]] && templates+=("$REPO_ROOT/$t")
271
+ done
272
+
273
+ # Never pass silently for want of an oracle: "no residue found" and "could
274
+ # not look" must not be the same output.
275
+ if [[ ${#templates[@]} -eq 0 ]]; then
276
+ log_warn "$rel: cannot check scaffold residue — no grounding template present in this repo (expected one of: ${GROUNDING_TEMPLATES[*]})"
277
+ return
278
+ fi
279
+
280
+ # --- Shape 1: unfilled placeholder tokens -------------------------------
281
+ # `|| true` on every match pipeline below: grep exits 1 on no-match, and
282
+ # under `set -euo pipefail` that aborts the run at the assignment. "Found
283
+ # nothing" is the expected result for a well-authored document, so it must not
284
+ # be fatal — this is the same trap that was silently killing Group 4.
285
+ local tokens
286
+ tokens="$(grep -ohE '\{[^}]{1,80}\}' "${templates[@]}" 2>/dev/null | sort -u || true)"
287
+
288
+ local -a hits=()
289
+ local token
290
+ while IFS= read -r token; do
291
+ [[ -z "$token" ]] && continue
292
+ local lines
293
+ lines="$(grep -Fn -- "$token" "$grounding" 2>/dev/null | cut -d: -f1 | paste -sd, - || true)"
294
+ [[ -n "$lines" ]] && hits+=("line ${lines}: ${token}")
295
+ done <<< "$tokens"
296
+
297
+ if [[ ${#hits[@]} -gt 0 ]]; then
298
+ log_warn "$rel: ${#hits[@]} unfilled placeholder token(s) from the shipped template — authoring incomplete (not a demonstration marker)"
299
+ local i
300
+ for ((i = 0; i < ${#hits[@]} && i < RESIDUE_REPORT_CAP; i++)); do
301
+ echo " - ${hits[$i]}"
302
+ done
303
+ if [[ ${#hits[@]} -gt $RESIDUE_REPORT_CAP ]]; then
304
+ echo " - … and $(( ${#hits[@]} - RESIDUE_REPORT_CAP )) more"
305
+ fi
306
+ fi
307
+
308
+ # --- Shape 2: leftover template instruction prose -----------------------
309
+ # Prose only: headings and table rows are structure an authored document
310
+ # legitimately keeps. The length floor drops short shared lines (a bare path
311
+ # or a field label) that carry no instruction and would false-positive.
312
+ local -a prose_hits=()
313
+ local line
314
+ while IFS= read -r line; do
315
+ [[ ${#line} -lt 60 ]] && continue
316
+ if grep -Fxq -- "$line" "$grounding" 2>/dev/null; then
317
+ prose_hits+=("$line")
318
+ fi
319
+ done < <(grep -hvE '^#|^\||^$|^[[:space:]]*$' "${templates[@]}" 2>/dev/null | sort -u || true)
320
+
321
+ if [[ ${#prose_hits[@]} -gt 0 ]]; then
322
+ log_warn "$rel: ${#prose_hits[@]} line(s) of template instruction prose retained verbatim"
323
+ local j
324
+ for ((j = 0; j < ${#prose_hits[@]} && j < RESIDUE_REPORT_CAP; j++)); do
325
+ local shown="${prose_hits[$j]}"
326
+ [[ ${#shown} -gt 90 ]] && shown="${shown:0:90}…"
327
+ echo " - ${shown}"
328
+ done
329
+ if [[ ${#prose_hits[@]} -gt $RESIDUE_REPORT_CAP ]]; then
330
+ echo " - … and $(( ${#prose_hits[@]} - RESIDUE_REPORT_CAP )) more"
331
+ fi
332
+ fi
333
+
334
+ if [[ ${#hits[@]} -eq 0 && ${#prose_hits[@]} -eq 0 ]]; then
335
+ log_pass "$rel: no unedited scaffold residue detected"
336
+ fi
337
+ }
338
+
339
+ check_grounding_doc() {
340
+ log_section "Group 4: Grounding Evidence"
341
+
342
+ local grounding="$REPO_ROOT/docs/design/integration/feature-grounding.md"
343
+ if [[ ! -f "$grounding" ]]; then
344
+ log_warn "docs/design/integration/feature-grounding.md not found — create before lock-in"
345
+ return
346
+ fi
347
+
348
+ local manifest="$REPO_ROOT/integration-manifest.yaml"
349
+ local outbound
350
+ outbound="$(yq e '.capabilityFlags.outbound' "$manifest" 2>/dev/null || echo "")"
351
+
352
+ local sections=("## Overview" "## Platform Contract Boundary" "## Provider-to-Canonical Field Mapping" "## Identity Resolution and Routing" "## Security and Secrets")
353
+ local section
354
+ local missing=0
355
+ for section in "${sections[@]}"; do
356
+ if ! grep -q "^${section}" "$grounding"; then
357
+ log_fail "docs/design/integration/feature-grounding.md missing section '$section'"
358
+ FAILURES=$((FAILURES + 1))
359
+ missing=1
360
+ fi
361
+ done
362
+
363
+ if [[ "$missing" -eq 0 ]]; then
364
+ log_pass "docs/design/integration/feature-grounding.md contains required sections"
365
+ fi
366
+
367
+ if [[ "$outbound" == "true" ]]; then
368
+ if grep -q "^## Command Mapping" "$grounding"; then
369
+ log_pass "docs/design/integration/feature-grounding.md includes command mapping for bidirectional integration"
370
+ else
371
+ log_fail "docs/design/integration/feature-grounding.md missing section '## Command Mapping' for bidirectional integration"
372
+ FAILURES=$((FAILURES + 1))
373
+ fi
374
+ fi
375
+
376
+ # Substance, not just presence. The checks above establish that the required
377
+ # sections EXIST; this one asks whether they were filled in. WARN-only and
378
+ # never incrementing FAILURES: an estate must not turn red for being honest
379
+ # about its authoring state, and an exploration-stage integration is a
380
+ # legitimate thing to have.
381
+ check_grounding_residue "$grounding"
382
+ }
383
+
384
+ main() {
385
+ log_section "Integration Documentation Validation"
386
+ log_info "Repo root: $REPO_ROOT"
387
+
388
+ check_repo_kind
389
+ check_required_files
390
+ check_manifest
391
+ check_grounding_doc
392
+ check_components_declaration
393
+
394
+ log_section "Summary"
395
+
396
+ if [[ "$FAILURES" -eq 0 ]]; then
397
+ log_pass "All integration documentation checks passed."
398
+ exit 0
399
+ fi
400
+
401
+ log_fail "$FAILURES check(s) failed."
402
+ exit 1
403
+ }
404
+
405
+ # Components declaration (M-ADR 0212-era coordination: the agreed four-state design).
406
+ #
407
+ # Asks only for the INVENTORY, never for per-component patterns: `id` is the sole
408
+ # required field of a components[] entry, so an inventory-only block is complete and
409
+ # conformant. Measured authoring cost was under a minute per SDD for the inventory and
410
+ # up to twenty-five for the patterns, and a warn cannot make the second cheaper — a
411
+ # warn nobody can afford to clear becomes a warn everybody filters.
412
+ #
413
+ # Counts components from each repo's OWN `profile` in reverse-metadata.yaml, never from
414
+ # catalogue row counts: catalogue rows include support repos, so row counting over-counts
415
+ # every SDD that mirrors a shared library.
416
+ #
417
+ # Zero is three different facts and they are reported separately. Collapsing them is what
418
+ # would bury the only one that is a defect.
419
+ check_components_declaration() {
420
+ local manifest="$REPO_ROOT/integration-manifest.yaml"
421
+ [[ -f "$manifest" ]] || return 0
422
+
423
+ local repos_dir="$REPO_ROOT/repos"
424
+ local components=0 support=0 unreadable=0
425
+
426
+ if [[ -d "$repos_dir" ]]; then
427
+ local d meta profile
428
+ for d in "$repos_dir"/*/; do
429
+ meta="$d/reverse-metadata.yaml"
430
+ [[ -f "$meta" ]] || continue
431
+ # A file that EXISTS and yields no profile is not the same fact as one that
432
+ # is absent, and the fallback treats them identically. Say so — this is the
433
+ # defect that hid a mis-typed repo through an otherwise correct rebuild.
434
+ if ! profile="$(yq e '.profile // ""' "$meta" 2>/dev/null)" || [[ -z "$profile" || "$profile" == "null" ]]; then
435
+ unreadable=$((unreadable + 1))
436
+ log_warn "repos/$(basename "$d")/reverse-metadata.yaml: no readable \`profile\` — this repo is not counted, so the component total below may be low"
437
+ continue
438
+ fi
439
+ case "$profile" in
440
+ integration-product) components=$((components + 1)) ;;
441
+ support-repo) support=$((support + 1)) ;;
442
+ esac
443
+ done
444
+ fi
445
+
446
+ local catalogue="$REPO_ROOT/catalogues/repositories.csv"
447
+ local catalogued=-1
448
+ if [[ -f "$catalogue" ]]; then
449
+ catalogued="$(awk -F, 'NR>1 && $5=="integration-product"' "$catalogue" 2>/dev/null | wc -l)"
450
+ fi
451
+
452
+ # Fall back to the catalogue only when there is no per-repo evidence at all.
453
+ if [[ "$components" -eq 0 && "$support" -eq 0 && "$unreadable" -eq 0 && "$catalogued" -gt 0 ]]; then
454
+ components="$catalogued"
455
+ fi
456
+
457
+ local declared="no"
458
+ yq e 'has("components")' "$manifest" 2>/dev/null | grep -q true && declared="yes"
459
+
460
+ # (c) The two sources disagree in the one direction that is a defect: components
461
+ # exist on disk and the catalogue holds none. Reported whatever the count bucket,
462
+ # and deliberately louder than the declaration warn — it is the only state here
463
+ # that means something is broken rather than merely undeclared.
464
+ if [[ "$components" -gt 0 && "$catalogued" -eq 0 ]]; then
465
+ log_warn "catalogues/repositories.csv holds NO integration-product rows while repos/ holds $components — the catalogue was never built for this SDD. Declaring components will not fix it; rebuild the catalogue."
466
+ fi
467
+
468
+ if [[ "$components" -gt 1 ]]; then
469
+ if [[ "$declared" == "yes" ]]; then
470
+ local entries
471
+ entries="$(yq e '.components | length' "$manifest" 2>/dev/null || echo 0)"
472
+ [[ "$entries" =~ ^[0-9]+$ ]] || entries=0
473
+ # Asymmetric on purpose. Declaring MORE than repos/ holds is legitimate —
474
+ # components that are generated or held elsewhere are real and uncloned
475
+ # (the same case as the support-repo-only SDDs below). Declaring FEWER means
476
+ # mirrored components the inventory does not mention, which is a declaration
477
+ # that was true when written and quietly is not now.
478
+ if [[ "$entries" -lt "$components" ]]; then
479
+ log_warn "integration-manifest.yaml: \`components:\` declares $entries entr(ies) but repos/ holds $components component repos — the inventory omits $((components - entries)). A declared inventory that stops being true is worse than an absent one."
480
+ else
481
+ log_pass "integration-manifest.yaml: components declared ($entries entr(ies), $components component repos in repos/)"
482
+ fi
483
+ else
484
+ log_warn "integration-manifest.yaml: $components component repos in repos/, no \`components:\` block — the inventory is undeclared. Only \`id\` is required per entry; patterns are optional."
485
+ fi
486
+ return 0
487
+ fi
488
+
489
+ if [[ "$components" -eq 1 ]]; then
490
+ log_pass "integration-manifest.yaml: single-component integration — the manifest's own patterns are the whole truth"
491
+ return 0
492
+ fi
493
+
494
+ # (a) Entries exist, none of them a component: the components live elsewhere.
495
+ if [[ "$support" -gt 0 ]]; then
496
+ log_info "0 component repos; $support support repo(s) — components are not cloned into this SDD (generated, or held elsewhere). Nothing owed."
497
+ return 0
498
+ fi
499
+
500
+ # (b) Nothing mirrored at all: a spec-first SDD being silent about components is
501
+ # a spec-first SDD behaving correctly.
502
+ log_info "0 component repos and nothing mirrored — no inventory to declare yet."
503
+ }
504
+
505
+ main "$@"