@sdd-method/verify-cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdd-method/verify-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Verify an SDD repository without changing it — validate, list, and read-only catalogue and pack checks. Ships only the code those verbs reach; it cannot write to the repository it is pointed at. Authoring and maintenance are in @sdd-method/sdd-cli.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -25,6 +25,7 @@
25
25
  },
26
26
  "files": [
27
27
  "dist",
28
+ "scripts",
28
29
  "README.md",
29
30
  "LICENSE",
30
31
  "NOTICE"
@@ -0,0 +1,644 @@
1
+ #!/bin/bash
2
+ #
3
+ # validate-app-dependencies.sh - Validate app platform dependency declarations
4
+ #
5
+ # Usage:
6
+ # ./validate-app-dependencies.sh --app-root <path> [--platform-root <path>]
7
+ #
8
+ # --platform-root is OPTIONAL. Without it the checks that need no platform surface
9
+ # still run, and the ones that do are reported as NOT RUN with their denominator —
10
+ # a partial run, never a met gate. See the block above the flag parsing.
11
+ #
12
+ # Checks (for each entry in app-platform-dependencies.yaml), split by whether they
13
+ # need a platform surface at all — LOCAL checks run wherever the app repo is:
14
+ # 1. (local) required_stability is a valid value (provisional | locked | unknown)
15
+ # 2a. (local) service_id is declared and non-empty
16
+ # 2b. (platform) service_id resolves against the platform source of truth:
17
+ # catalogues/services.csv (exact id match) when the catalogue exists,
18
+ # else docs/platform/service-architecture.md (word-anchored match)
19
+ # 3a. (local) a `locked` dependency declares a contract_path at all
20
+ # 3b. (platform) that contract_path resolves to a file under the platform root
21
+ #
22
+ # Outputs a dependency status table.
23
+ # Used as the Stage 6 lock-in gate in the App Exploration Workflow.
24
+ #
25
+ # THE STABILITY VOCABULARY, AND WHY IT IS WHAT IT IS
26
+ # `required_stability` states the CONSUMING app's expectation of contract
27
+ # stability. Three values express that:
28
+ #
29
+ # provisional the contract may still change; the app tolerates it
30
+ # locked the contract is fixed; a contract_path must exist (check 3)
31
+ # unknown the expectation is not determined
32
+ #
33
+ # `unknown` is accepted because the canonical schema's
34
+ # product_platform_dependencies table and the distributed reverse template
35
+ # both offer it, and this gate was the only surface rejecting it — an adopter
36
+ # filling in the method's own template produced a file the method's own gate
37
+ # hard-failed. Reverse-derived declarations legitimately cannot always
38
+ # determine the expectation, and naming that is better than encoding it as
39
+ # silence (M-ADR 0196). It does not require a contract_path.
40
+ #
41
+ # `external` and `legacy` are NOT stability values and are reported as
42
+ # warnings, not failures. They answer different questions — where the target
43
+ # lives, and what lifecycle its implementation is in — on a field that asks
44
+ # how stable the consumer needs the contract to be. A third-party API can be
45
+ # more stable than an internal one, so the two axes are orthogonal. They are
46
+ # warned rather than failed because they encode real information for which no
47
+ # replacement field exists yet: an `external` target is a declaration that the
48
+ # dependency does not resolve against platform services at all, which is the
49
+ # method's external-system concept (M-ADR 0189), not a stability claim. Failing
50
+ # them before that route exists would force an adopter to discard the
51
+ # information to get green. They become failures when the locus field ships.
52
+
53
+ set -euo pipefail
54
+
55
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
56
+ ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
57
+
58
+ RED='\033[0;31m'
59
+ GREEN='\033[0;32m'
60
+ YELLOW='\033[1;33m'
61
+ BLUE='\033[0;34m'
62
+ NC='\033[0m'
63
+
64
+ log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
65
+ log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
66
+ log_fail() { echo -e "${RED}[FAIL]${NC} $1"; }
67
+ log_pass() { echo -e "${GREEN}[PASS]${NC} $1"; }
68
+ log_section() {
69
+ echo -e "\n${BLUE}═══════════════════════════════════════════════════════════${NC}"
70
+ echo -e "${BLUE} $1${NC}"
71
+ echo -e "${BLUE}═══════════════════════════════════════════════════════════${NC}\n"
72
+ }
73
+
74
+ APP_ROOT=""
75
+ PLATFORM_ROOT=""
76
+
77
+ usage() {
78
+ cat <<USAGE
79
+ Usage: $(basename "$0") --app-root <path> [--platform-root <path>]
80
+
81
+ Options:
82
+ --app-root Path to the app repository root (must contain app-platform-dependencies.yaml)
83
+ --platform-root OPTIONAL. Path to the platform repository root (service_id checks use
84
+ catalogues/services.csv when present, else docs/platform/service-architecture.md).
85
+ Omitted: the stability vocabulary and the local halves of the
86
+ service_id/contract_path checks run; the resolutions are reported
87
+ as not run. The gate is not met by a partial run.
88
+ USAGE
89
+ }
90
+
91
+ while [[ $# -gt 0 ]]; do
92
+ case "$1" in
93
+ --app-root)
94
+ APP_ROOT="${2:-}"
95
+ shift 2
96
+ ;;
97
+ --platform-root)
98
+ PLATFORM_ROOT="${2:-}"
99
+ shift 2
100
+ ;;
101
+ -h|--help)
102
+ usage
103
+ exit 0
104
+ ;;
105
+ *)
106
+ log_fail "Unknown argument: $1"
107
+ usage
108
+ exit 1
109
+ ;;
110
+ esac
111
+ done
112
+
113
+ if [[ -z "$APP_ROOT" ]]; then
114
+ log_fail "--app-root is required"
115
+ usage
116
+ exit 1
117
+ fi
118
+
119
+ if [[ ! -d "$APP_ROOT" ]]; then
120
+ log_fail "App root not found: $APP_ROOT"
121
+ exit 1
122
+ fi
123
+
124
+ # --platform-root is OPTIONAL, and this is the whole of why:
125
+ #
126
+ # Only checks 2 and 3 need a platform surface. Check 1 is local to the app repo.
127
+ # While the flag was mandatory, an app repo standing alone could run NONE of the
128
+ # three — the validator was skipped wholesale by the dispatcher, so it had never
129
+ # executed in any of the 70 dependency files across two estates, and the suite
130
+ # reported "All validators passed" over the skip.
131
+ #
132
+ # Without a platform root the checks that CAN run, run, and the checks that
133
+ # cannot are named as not-run with their denominator. Absent evidence is
134
+ # reported as absent, never as a pass (M-ADR 0200 §4.3) — the same treatment
135
+ # validate-gate-readiness.sh gives an empty registry.
136
+ PLATFORM_AVAILABLE=1
137
+ if [[ -z "$PLATFORM_ROOT" ]]; then
138
+ PLATFORM_AVAILABLE=0
139
+ elif [[ ! -d "$PLATFORM_ROOT" ]]; then
140
+ # An explicitly-given root that does not exist is an error, not a downgrade:
141
+ # the caller asserted a platform surface that is not there.
142
+ log_fail "Platform root not found: $PLATFORM_ROOT"
143
+ exit 1
144
+ fi
145
+
146
+ APP_DEPS_FILE="$APP_ROOT/app-platform-dependencies.yaml"
147
+
148
+ # TWO AUTHORING SOURCES, NOT ONE.
149
+ # The catalogue builder does multi-source ingestion under the ADR 0157 parity
150
+ # principle: `app-platform-dependencies.yaml` AND every
151
+ # `reverse-evidence/**/feature-grounding-evidence.yaml`'s platform_dependencies
152
+ # block, merged by primary key with forward winning. They are equal-status
153
+ # authoring surfaces.
154
+ #
155
+ # This gate read only the forward file, so an entire equal-status source went
156
+ # ungated. Measured in one reverse estate: 15 evidence files declaring 65
157
+ # further dependencies, 38 of them `locked` — precisely the lock-in population
158
+ # a Stage 6 lock-in gate exists to check — none of which this gate could see.
159
+ #
160
+ # A source that the catalogue reads and the gate does not is a coverage gap by
161
+ # construction, so the gate now reads both.
162
+ DEP_SOURCES=()
163
+ if [[ -f "$APP_DEPS_FILE" ]]; then
164
+ DEP_SOURCES+=("$APP_DEPS_FILE")
165
+ fi
166
+ if [[ -d "$APP_ROOT/reverse-evidence" ]]; then
167
+ while IFS= read -r evidence_file; do
168
+ [[ -n "$evidence_file" ]] || continue
169
+ DEP_SOURCES+=("$evidence_file")
170
+ done < <(find "$APP_ROOT/reverse-evidence" -name "feature-grounding-evidence.yaml" -type f | sort)
171
+ fi
172
+ PLATFORM_SERVICE_ARCH="$PLATFORM_ROOT/docs/platform/service-architecture.md"
173
+ PLATFORM_SERVICES_CSV="$PLATFORM_ROOT/catalogues/services.csv"
174
+
175
+ # One value per line: this array is the enforcement point, and the correspondence
176
+ # registry reads it line-wise to compare against what the distributed templates
177
+ # offer (instances app-dependency-stability-vocabulary-forward and -reverse).
178
+ VALID_STABILITY_VALUES=(
179
+ "provisional" # contract may still change; the app tolerates it
180
+ "locked" # contract is fixed; check 3 requires a resolvable contract_path
181
+ "unknown" # expectation not determined; no contract_path required
182
+ )
183
+
184
+ # M-ADR 0204: where the dependency's TARGET lives. One value per line — the
185
+ # correspondence registry reads this array line-wise against what the
186
+ # distributed templates offer, exactly as it does VALID_STABILITY_VALUES.
187
+ # Single-quoted deliberately: the correspondence extractor for the STABILITY
188
+ # array captures `^ "value"` lines, and two enums sharing one line shape
189
+ # would pollute each other's claimed sets — measured, not hypothetical.
190
+ VALID_TARGET_LOCUS_VALUES=(
191
+ 'platform' # the default when absent; the row is fully platform-validated
192
+ 'external' # third-party target; platform resolution + realiser join skipped, named
193
+ 'legacy' # off-platform predecessor system; same skips, different lifecycle
194
+ )
195
+
196
+ # The pre-0204 improvisation. These were WARNED while no locus field existed;
197
+ # M-ADR 0204 §4.4 flips them to FAILURES now that it does — the message names
198
+ # the home for what they meant.
199
+ DEPRECATED_STABILITY_VALUES=("external" "legacy")
200
+
201
+ # The M-ADR 0192 async set. A shared-stream subscription has NO single realiser
202
+ # service, so it binds to the stream contract plus the publishing capability
203
+ # instead — the canonical schema makes product_platform_dependencies.
204
+ # target_service_id conditionally nullable for exactly these contract types, and
205
+ # states that a synchronous contract_type still requires it.
206
+ #
207
+ # This gate did not know that. Check 2a hard-failed "service_id is empty or
208
+ # missing" on every async entry, which is the shape both distributed templates
209
+ # instruct an author to write (they say to OMIT service_id). Found the moment the
210
+ # check first ran: 3 of the reverse estate's 48 dependency files, all three async.
211
+ ASYNC_CONTRACT_TYPES=("event" "event-stream" "stream")
212
+
213
+ FAILURES=0
214
+ WARNINGS=0
215
+ TOTAL_DEPS=0
216
+ PASSED_DEPS=0
217
+
218
+ # What could not be checked for want of a platform surface. Counted, not ignored:
219
+ # the summary states these as a denominator so a run without a platform root can
220
+ # never read as a clean bill of health on the resolution checks.
221
+ UNPARSEABLE_SOURCES=0
222
+ SOURCE_DEP_COUNT=0
223
+ UNRESOLVED_SERVICE_IDS=0
224
+ UNRESOLVED_CONTRACT_PATHS=0
225
+
226
+ # ──────────────────────────────────────────────────────────────────────────────
227
+ check_prerequisites() {
228
+ log_section "Prerequisites"
229
+
230
+ if [[ ${#DEP_SOURCES[@]} -eq 0 ]]; then
231
+ log_fail "no dependency source found: neither $APP_DEPS_FILE nor any reverse-evidence/**/feature-grounding-evidence.yaml"
232
+ exit 1
233
+ fi
234
+ for src in "${DEP_SOURCES[@]}"; do
235
+ log_pass "dependency source: ${src#"$APP_ROOT"/}"
236
+ done
237
+
238
+ if ! command -v yq &>/dev/null; then
239
+ log_fail "yq is required but not installed. Install with: brew install yq"
240
+ exit 1
241
+ fi
242
+ log_pass "yq available"
243
+
244
+ if [[ "$PLATFORM_AVAILABLE" -eq 0 ]]; then
245
+ PLATFORM_SERVICES_CSV=""
246
+ PLATFORM_SERVICE_ARCH=""
247
+ log_info "No --platform-root given: check 1 (stability vocabulary) runs; checks 2 and 3 do not."
248
+ return 0
249
+ fi
250
+
251
+ if [[ -f "$PLATFORM_SERVICES_CSV" ]]; then
252
+ log_pass "Platform services catalogue found (canonical service_id source)"
253
+ else
254
+ PLATFORM_SERVICES_CSV=""
255
+ fi
256
+
257
+ if [[ ! -f "$PLATFORM_SERVICE_ARCH" ]]; then
258
+ log_warn "Platform service-architecture.md not found: $PLATFORM_SERVICE_ARCH"
259
+ PLATFORM_SERVICE_ARCH=""
260
+ else
261
+ log_pass "Platform service-architecture.md found"
262
+ fi
263
+
264
+ if [[ -z "$PLATFORM_SERVICES_CSV" && -z "$PLATFORM_SERVICE_ARCH" ]]; then
265
+ log_warn "Neither services catalogue nor service-architecture.md found — service_id validation will be skipped"
266
+ fi
267
+ }
268
+
269
+ # Is this value shaped like a path, as opposed to a sentinel or a prose note?
270
+ # Mirrors isPathShaped() in the CLI's contract-path-resolution.ts, which applies
271
+ # the same test at aggregation tier — one rule, asserted at both tiers.
272
+ is_path_shaped() {
273
+ local v="$1"
274
+ [[ -n "$v" && "$v" != "null" ]] || return 1
275
+ [[ "${v,,}" != n/a* ]] || return 1
276
+ [[ "${v:0:1}" != "[" ]] || return 1
277
+ [[ "$v" == */* ]] || return 1
278
+ # Prose gives itself away with whitespace before the first separator.
279
+ local first="${v%%/*}"
280
+ [[ "$first" != *[[:space:]]* ]] || return 1
281
+ return 0
282
+ }
283
+
284
+ # Word-anchored match: the id must not be a substring of a longer slug token.
285
+ service_id_in_doc() {
286
+ local service_id="$1"
287
+ grep -qE "(^|[^a-zA-Z0-9-])${service_id}([^a-zA-Z0-9-]|$)" "$PLATFORM_SERVICE_ARCH" 2>/dev/null
288
+ }
289
+
290
+ service_id_in_catalogue() {
291
+ local service_id="$1"
292
+ awk -F',' -v id="$service_id" 'NR>1 && $1==id {found=1} END {exit !found}' "$PLATFORM_SERVICES_CSV" 2>/dev/null
293
+ }
294
+
295
+ # ──────────────────────────────────────────────────────────────────────────────
296
+ # How many platform_dependencies entries does this source declare? Zero for a
297
+ # source that has no such block, and zero — with a reported finding — for one that
298
+ # cannot be parsed. An unparseable evidence file is NOT silently treated as empty:
299
+ # measured in one estate, a single unquoted colon made a 206-line authored file
300
+ # invisible, taking its dependency declarations with it.
301
+ # Sets SOURCE_DEP_COUNT, and increments UNPARSEABLE_SOURCES on a broken source.
302
+ #
303
+ # Deliberately NOT a function that echoes its answer: a command substitution runs
304
+ # in a subshell, so the counter increment would be discarded and the log line
305
+ # would be captured as part of the count. Both happened; both are silent in bash.
306
+ read_source_dep_count() {
307
+ local source_file="$1"
308
+ if ! SOURCE_DEP_COUNT="$(yq e ".platform_dependencies | length" "$source_file" 2>/dev/null)"; then
309
+ log_fail "${source_file#"$APP_ROOT"/}: cannot be parsed as YAML — every dependency it declares is invisible to this gate and to the catalogue"
310
+ UNPARSEABLE_SOURCES=$((UNPARSEABLE_SOURCES + 1))
311
+ SOURCE_DEP_COUNT=0
312
+ return
313
+ fi
314
+ [[ "$SOURCE_DEP_COUNT" =~ ^[0-9]+$ ]] || SOURCE_DEP_COUNT=0
315
+ }
316
+
317
+ # Quiet variant for the display pass — the failure was already reported when the
318
+ # sources were counted; reporting it twice would double-count nothing useful.
319
+ quiet_dep_count() {
320
+ local source_file="$1" count
321
+ count="$(yq e ".platform_dependencies | length" "$source_file" 2>/dev/null || echo 0)"
322
+ [[ "$count" =~ ^[0-9]+$ ]] || count=0
323
+ echo "$count"
324
+ }
325
+
326
+ validate_dependency() {
327
+ local source_file="$1"
328
+ local index="$2"
329
+ local dep_errors=0
330
+
331
+ local capability_id service_id contract_type required_stability contract_path
332
+
333
+ capability_id="$(yq e ".platform_dependencies[$index].capability_id // \"\"" "$source_file")"
334
+ service_id="$(yq e ".platform_dependencies[$index].service_id // \"\"" "$source_file")"
335
+ contract_type="$(yq e ".platform_dependencies[$index].contract_type // \"\"" "$source_file")"
336
+ required_stability="$(yq e ".platform_dependencies[$index].required_stability // \"\"" "$source_file")"
337
+ contract_path="$(yq e ".platform_dependencies[$index].contract_path // \"\"" "$source_file")"
338
+
339
+ local dep_label="${service_id:-dep[$index]}"
340
+ if [[ -n "$capability_id" && "$capability_id" != "null" ]]; then
341
+ dep_label="$capability_id / $service_id"
342
+ fi
343
+
344
+ # M-ADR 0204: target locus. Absent = platform. Invalid FAILS — this field is
345
+ # new, has no legacy corpus, and a tolerated typo would silently exempt the
346
+ # row from every platform check.
347
+ local target_locus
348
+ target_locus="$(yq e ".platform_dependencies[$index].target_locus // \"\"" "$source_file")"
349
+ [[ "$target_locus" == "null" ]] && target_locus=""
350
+ if [[ -z "$target_locus" ]]; then
351
+ target_locus="platform"
352
+ elif [[ " ${VALID_TARGET_LOCUS_VALUES[*]} " != *" $target_locus "* ]]; then
353
+ log_fail "$dep_label: invalid target_locus '$target_locus' (allowed: ${VALID_TARGET_LOCUS_VALUES[*]})"
354
+ ((dep_errors++))
355
+ target_locus="platform"
356
+ fi
357
+
358
+ # Check 1: required_stability is a valid value. Needs NO platform surface —
359
+ # it is the only check that can run in an app repo standing alone.
360
+ local stability_valid=0
361
+ for v in "${VALID_STABILITY_VALUES[@]}"; do
362
+ if [[ "$required_stability" == "$v" ]]; then
363
+ stability_valid=1
364
+ break
365
+ fi
366
+ done
367
+
368
+ if [[ "$stability_valid" -eq 0 && ( -z "$required_stability" || "$required_stability" == "null" ) ]]; then
369
+ # ABSENT is not INVALID. The canonical schema declares
370
+ # required_stability nullable on both tables that carry it, so an entry
371
+ # without one is schema-legal and the gate must not contradict its own
372
+ # schema — that is exactly the defect the `unknown` alignment fixed.
373
+ # Reported all the same: M-ADR 0196 prefers a named gap to a silent one,
374
+ # and `unknown` now exists to name this one explicitly.
375
+ log_warn "$dep_label: required_stability is absent — schema-legal (nullable), but state it explicitly as 'unknown' so the gap is named rather than inferred"
376
+ ((WARNINGS++))
377
+ elif [[ "$stability_valid" -eq 0 ]]; then
378
+ local stability_deprecated=0
379
+ for v in "${DEPRECATED_STABILITY_VALUES[@]}"; do
380
+ if [[ "$required_stability" == "$v" ]]; then
381
+ stability_deprecated=1
382
+ break
383
+ fi
384
+ done
385
+
386
+ if [[ "$stability_deprecated" -eq 1 ]]; then
387
+ log_fail "$dep_label: required_stability '$required_stability' is not a stability value (M-ADR 0204 §4.4). Declare target_locus: '$required_stability' on the entry and give required_stability a real value (${VALID_STABILITY_VALUES[*]}) — a third-party contract can be locked."
388
+ ((dep_errors++))
389
+ else
390
+ log_fail "$dep_label: invalid required_stability '$required_stability' (allowed: ${VALID_STABILITY_VALUES[*]})"
391
+ ((dep_errors++))
392
+ fi
393
+ fi
394
+
395
+ # M-ADR 0204: an off-platform row skips platform resolution and the realiser
396
+ # join — NAMED, never silent. It must still bind to something (an
397
+ # external-system id in service_id, or a contract_path), and a capability_id
398
+ # on it is reported per §4.3.
399
+ if [[ "$target_locus" != "platform" ]]; then
400
+ if [[ -n "$capability_id" && "$capability_id" != "null" ]]; then
401
+ log_warn "$dep_label: capability_id on a target_locus=$target_locus row — an off-platform target has no platform capability (M-ADR 0204 §4.3); move the intent to notes"
402
+ ((WARNINGS++))
403
+ fi
404
+ if [[ ( -z "$service_id" || "$service_id" == "null" ) && ( -z "$contract_path" || "$contract_path" == "null" ) ]]; then
405
+ log_fail "$dep_label: target_locus=$target_locus entry names neither an external-system id (service_id) nor a contract_path — it binds to nothing"
406
+ ((dep_errors++))
407
+ else
408
+ log_info "$dep_label: target_locus=$target_locus — platform service/contract resolution NOT APPLICABLE (skipped, M-ADR 0204 §4.1); corroboration against external systems happens at aggregation"
409
+ fi
410
+ if [[ "$dep_errors" -eq 0 ]]; then
411
+ log_pass "$dep_label: locus=$target_locus stability=$required_stability type=$contract_type"
412
+ ((PASSED_DEPS++))
413
+ return 0
414
+ fi
415
+ FAILURES=$((FAILURES + dep_errors))
416
+ return 1
417
+ fi
418
+
419
+ # Check 2: service_id is declared (2a, local) and resolves against the platform
420
+ # source of truth (2b, needs the platform). The services catalogue (when present)
421
+ # is canonical — the same source the aggregator FK-validates dependency edges
422
+ # against. The prose service-architecture.md is the fallback for pre-catalogue
423
+ # platforms.
424
+ #
425
+ # 2a and 2b are separated because only 2b needs a platform surface: whether a
426
+ # service_id was declared at all is answerable in the app repo alone, and it is
427
+ # the more basic defect of the two.
428
+ local is_async=0
429
+ if [[ " ${ASYNC_CONTRACT_TYPES[*]} " == *" $contract_type "* ]]; then
430
+ is_async=1
431
+ fi
432
+
433
+ if [[ -z "$service_id" || "$service_id" == "null" ]]; then
434
+ if [[ "$is_async" -eq 1 ]]; then
435
+ # No realiser service by design (M-ADR 0192). It must still name what it
436
+ # DOES bind to, or nothing identifies the dependency at all — the
437
+ # aggregator drops such a row, so an unchecked hole here is a silent drop.
438
+ if [[ -z "$contract_path" || "$contract_path" == "null" ]]; then
439
+ log_fail "$dep_label: async dependency (contract_type '$contract_type') has neither service_id nor contract_path — it binds to nothing and cannot be recorded"
440
+ ((dep_errors++))
441
+ fi
442
+ else
443
+ log_fail "$dep_label: service_id is empty or missing"
444
+ ((dep_errors++))
445
+ fi
446
+ elif [[ "$PLATFORM_AVAILABLE" -eq 0 ]]; then
447
+ ((UNRESOLVED_SERVICE_IDS++))
448
+ elif [[ -n "$PLATFORM_SERVICES_CSV" ]]; then
449
+ if ! service_id_in_catalogue "$service_id"; then
450
+ log_fail "$dep_label: service_id '$service_id' not found in platform services catalogue (catalogues/services.csv)"
451
+ ((dep_errors++))
452
+ elif [[ -n "$PLATFORM_SERVICE_ARCH" ]] && ! service_id_in_doc "$service_id"; then
453
+ log_warn "$dep_label: service_id '$service_id' is in the services catalogue but not mentioned in service-architecture.md (doc drift)"
454
+ ((WARNINGS++))
455
+ fi
456
+ elif [[ -n "$PLATFORM_SERVICE_ARCH" ]]; then
457
+ if ! service_id_in_doc "$service_id"; then
458
+ log_fail "$dep_label: service_id '$service_id' not found in platform service-architecture.md"
459
+ ((dep_errors++))
460
+ fi
461
+ fi
462
+
463
+ # Check 3c (local): a DECLARED contract_path must actually be a path. The field
464
+ # was long typed "<platform-relative-path-or-unknown>", which invited sentinels
465
+ # (`n/a`) and prose ("Kafka (<vendor> bus): topic-a") into a field every consumer
466
+ # resolves as a path. Reported where it is authored; the aggregator reports the
467
+ # same class as `convention` when it tries to resolve them. Warned, not failed:
468
+ # the corpus predates the rule and the explanation belongs in `notes` (0200 §4.6).
469
+ if [[ -n "$contract_path" && "$contract_path" != "null" ]] && ! is_path_shaped "$contract_path"; then
470
+ log_warn "$dep_label: contract_path \"$contract_path\" is not a path — nothing can resolve it; omit the field and put the explanation in notes"
471
+ ((WARNINGS++))
472
+ fi
473
+
474
+ # Check 3: a locked dependency declares a contract_path (3a, local) and that path
475
+ # resolves to a file (3b, needs the platform). Same split, same reason: "locked
476
+ # with no path at all" is an app-repo defect that needs nothing external to catch.
477
+ if [[ "$required_stability" == "locked" ]]; then
478
+ if [[ -z "$contract_path" || "$contract_path" == "null" ]]; then
479
+ log_fail "$dep_label: required_stability is 'locked' but contract_path is empty"
480
+ ((dep_errors++))
481
+ elif [[ "$PLATFORM_AVAILABLE" -eq 0 ]]; then
482
+ ((UNRESOLVED_CONTRACT_PATHS++))
483
+ else
484
+ local full_contract_path="$PLATFORM_ROOT/$contract_path"
485
+ if [[ ! -f "$full_contract_path" ]]; then
486
+ log_fail "$dep_label: locked contract_path '$contract_path' does not exist in platform repo"
487
+ ((dep_errors++))
488
+ fi
489
+ fi
490
+ elif [[ "$required_stability" == "provisional" || "$required_stability" == "unknown" ]]; then
491
+ # Neither requires a contract_path. If one is declared it is still worth
492
+ # reporting when it does not resolve — silently accepting a path that
493
+ # points at nothing is how a locked dependency later inherits a dead path.
494
+ if [[ -n "$contract_path" && "$contract_path" != "null" && "$PLATFORM_AVAILABLE" -eq 1 ]]; then
495
+ local full_contract_path="$PLATFORM_ROOT/$contract_path"
496
+ if [[ ! -f "$full_contract_path" ]]; then
497
+ log_warn "$dep_label: contract_path '$contract_path' declared but not yet present ($required_stability — acceptable)"
498
+ ((WARNINGS++))
499
+ fi
500
+ fi
501
+ fi
502
+
503
+ if [[ "$dep_errors" -eq 0 ]]; then
504
+ log_pass "$dep_label: stability=$required_stability type=$contract_type"
505
+ ((PASSED_DEPS++))
506
+ return 0
507
+ fi
508
+
509
+ FAILURES=$((FAILURES + dep_errors))
510
+ return 1
511
+ }
512
+
513
+ # ──────────────────────────────────────────────────────────────────────────────
514
+ print_status_table() {
515
+ log_section "Dependency Status Table"
516
+
517
+ local app_id
518
+ app_id="$(yq e ".app_id // \"unknown\"" "$APP_DEPS_FILE" 2>/dev/null || echo unknown)"
519
+ log_info "App: $app_id"
520
+
521
+ printf "\n %-30s %-15s %-12s %s\n" "Service ID" "Stability" "Contract" "Status"
522
+ printf " %-30s %-15s %-12s %s\n" "──────────────────────────────" "───────────────" "────────────" "──────────"
523
+
524
+ local source_file count
525
+ for source_file in "${DEP_SOURCES[@]}"; do
526
+ count="$(quiet_dep_count "$source_file")"
527
+ [[ "$count" -gt 0 ]] || continue
528
+ if [[ ${#DEP_SOURCES[@]} -gt 1 ]]; then
529
+ printf " %s\n" "── ${source_file#"$APP_ROOT"/}"
530
+ fi
531
+
532
+ for ((i = 0; i < count; i++)); do
533
+ local service_id required_stability contract_type contract_path dep_status
534
+ service_id="$(yq e ".platform_dependencies[$i].service_id // \"(missing)\"" "$source_file")"
535
+ required_stability="$(yq e ".platform_dependencies[$i].required_stability // \"(missing)\"" "$source_file")"
536
+ contract_type="$(yq e ".platform_dependencies[$i].contract_type // \"(missing)\"" "$source_file")"
537
+ contract_path="$(yq e ".platform_dependencies[$i].contract_path // \"\"" "$source_file")"
538
+
539
+ # Determine display status
540
+ if [[ "$required_stability" == "locked" ]]; then
541
+ if [[ -z "$contract_path" || "$contract_path" == "null" ]]; then
542
+ dep_status="${RED}LOCKED UNMET${NC}"
543
+ elif [[ "$PLATFORM_AVAILABLE" -eq 0 ]]; then
544
+ # A declared path that was never resolved is not "OK" and not "UNMET".
545
+ # Reporting either would be a claim the run cannot support.
546
+ dep_status="${YELLOW}LOCKED UNRESOLVED${NC}"
547
+ elif [[ -f "$PLATFORM_ROOT/$contract_path" ]]; then
548
+ dep_status="${GREEN}LOCKED OK${NC}"
549
+ else
550
+ dep_status="${RED}LOCKED UNMET${NC}"
551
+ fi
552
+ elif [[ "$required_stability" == "provisional" ]]; then
553
+ dep_status="${YELLOW}PROVISIONAL${NC}"
554
+ elif [[ "$required_stability" == "unknown" ]]; then
555
+ dep_status="${YELLOW}UNKNOWN${NC}"
556
+ elif [[ " ${DEPRECATED_STABILITY_VALUES[*]} " == *" $required_stability "* ]]; then
557
+ dep_status="${YELLOW}NOT A STABILITY VALUE${NC}"
558
+ else
559
+ dep_status="${RED}INVALID${NC}"
560
+ fi
561
+
562
+ printf " %-30s %-15s %-12s " "$service_id" "$required_stability" "$contract_type"
563
+ echo -e "$dep_status"
564
+ done
565
+ done
566
+ echo ""
567
+ }
568
+
569
+ # ──────────────────────────────────────────────────────────────────────────────
570
+ main() {
571
+ log_section "App Dependency Validation"
572
+ log_info "App root: $APP_ROOT"
573
+ log_info "Platform root: $PLATFORM_ROOT"
574
+
575
+ check_prerequisites
576
+
577
+ log_section "Validating Dependencies"
578
+
579
+ local source_file dep_count
580
+ TOTAL_DEPS=0
581
+ for source_file in "${DEP_SOURCES[@]}"; do
582
+ read_source_dep_count "$source_file"
583
+ dep_count="$SOURCE_DEP_COUNT"
584
+ [[ "$dep_count" -gt 0 ]] || continue
585
+ if [[ ${#DEP_SOURCES[@]} -gt 1 ]]; then
586
+ log_info "${source_file#"$APP_ROOT"/}: $dep_count dependency(ies)"
587
+ fi
588
+ TOTAL_DEPS=$((TOTAL_DEPS + dep_count))
589
+ for ((i = 0; i < dep_count; i++)); do
590
+ validate_dependency "$source_file" "$i" || true
591
+ done
592
+ done
593
+
594
+ if [[ "$TOTAL_DEPS" -eq 0 ]]; then
595
+ if [[ "$UNPARSEABLE_SOURCES" -gt 0 ]]; then
596
+ # Zero dependencies BECAUSE the source could not be read is not the
597
+ # same result as zero dependencies declared, and must not exit alike.
598
+ log_fail "$UNPARSEABLE_SOURCES dependency source(s) could not be parsed — 0 dependencies checked, which is an absence of evidence, not a pass."
599
+ exit 1
600
+ fi
601
+ log_warn "No platform_dependencies entries found in ${#DEP_SOURCES[@]} source(s)"
602
+ log_info "If this app has no platform deps, this is acceptable."
603
+ exit 0
604
+ fi
605
+
606
+ print_status_table
607
+
608
+ log_section "Summary"
609
+ log_info "Total dependencies: $TOTAL_DEPS"
610
+ log_info "Passed: $PASSED_DEPS"
611
+ log_info "Warnings: $WARNINGS"
612
+ log_info "Failures: $FAILURES"
613
+
614
+ if [[ "$UNPARSEABLE_SOURCES" -gt 0 ]]; then
615
+ log_fail "$UNPARSEABLE_SOURCES dependency source(s) could not be parsed. Whatever they declare was checked by nothing, so no verdict here covers them."
616
+ exit 1
617
+ fi
618
+
619
+ if [[ "$FAILURES" -gt 0 ]]; then
620
+ log_fail "$FAILURES dependency check(s) failed. Stage 6 lock-in gate NOT met."
621
+ exit 1
622
+ fi
623
+
624
+ # Without a platform surface the gate cannot claim to have been met, however
625
+ # clean the checks it DID run. Naming the un-run checks and their denominator
626
+ # is the whole point: "no failures" and "the gate is met" are different claims,
627
+ # and conflating them is how this validator came to report green for months
628
+ # while never executing (M-ADR 0200 §4.3).
629
+ if [[ "$PLATFORM_AVAILABLE" -eq 0 ]]; then
630
+ log_warn "Ran check 1 (stability vocabulary) and the local halves of checks 2-3 over $TOTAL_DEPS dependency(ies): $FAILURES failure(s), $WARNINGS warning(s)."
631
+ log_warn "NOT RUN, for want of --platform-root: $UNRESOLVED_SERVICE_IDS service_id resolution(s) and $UNRESOLVED_CONTRACT_PATHS locked contract_path resolution(s)."
632
+ log_info "This is a partial run, not a met gate — pass --platform-root to complete it."
633
+ exit 0
634
+ fi
635
+
636
+ if [[ "$WARNINGS" -gt 0 ]]; then
637
+ log_warn "All required checks passed with $WARNINGS warning(s)."
638
+ else
639
+ log_pass "All dependency checks passed. Stage 6 lock-in gate met."
640
+ fi
641
+ exit 0
642
+ }
643
+
644
+ main "$@"
@@ -0,0 +1,185 @@
1
+ #!/bin/bash
2
+ #
3
+ # validate-app-docs.sh - Validate app repo documentation structure
4
+ #
5
+ # Usage: ./validate-app-docs.sh [--app-root <path>]
6
+ #
7
+ # Checks:
8
+ # 1. service-architecture.md is present
9
+ # 2. feature-tree.md is present (may be at docs/ or repo root)
10
+ # 3. app-platform-dependencies.yaml is present
11
+ # 4. Feature spec files under features/*/ have required sections:
12
+ # - ## Overview
13
+ # - ## Acceptance Criteria
14
+ # - ## Platform Contract Dependencies
15
+ #
16
+ # Does NOT check:
17
+ # - Glossary alignment (no domain glossary in app repos)
18
+ # - Cascade completeness (no domain model or capability tier)
19
+ # - DDD artifacts (no bounded contexts or aggregates)
20
+ # - Traceability manifests (no capability traceability in app repos)
21
+
22
+ set -euo pipefail
23
+
24
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
25
+ ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
26
+
27
+ RED='\033[0;31m'
28
+ GREEN='\033[0;32m'
29
+ YELLOW='\033[1;33m'
30
+ BLUE='\033[0;34m'
31
+ NC='\033[0m'
32
+
33
+ log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
34
+ log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
35
+ log_fail() { echo -e "${RED}[FAIL]${NC} $1"; }
36
+ log_pass() { echo -e "${GREEN}[PASS]${NC} $1"; }
37
+ log_section() {
38
+ echo -e "\n${BLUE}═══════════════════════════════════════════════════════════${NC}"
39
+ echo -e "${BLUE} $1${NC}"
40
+ echo -e "${BLUE}═══════════════════════════════════════════════════════════${NC}\n"
41
+ }
42
+
43
+ APP_ROOT="$ROOT_DIR"
44
+
45
+ while [[ $# -gt 0 ]]; do
46
+ case "$1" in
47
+ --app-root)
48
+ APP_ROOT="${2:-}"
49
+ shift 2
50
+ ;;
51
+ -h|--help)
52
+ echo "Usage: $(basename "$0") [--app-root <path>]"
53
+ exit 0
54
+ ;;
55
+ *)
56
+ log_fail "Unknown argument: $1"
57
+ exit 1
58
+ ;;
59
+ esac
60
+ done
61
+
62
+ if [[ ! -d "$APP_ROOT" ]]; then
63
+ log_fail "App root not found: $APP_ROOT"
64
+ exit 1
65
+ fi
66
+
67
+ FAILURES=0
68
+
69
+ # ──────────────────────────────────────────────────────────────────────────────
70
+ check_required_files() {
71
+ log_section "Group 1: Required App Repo Files"
72
+
73
+ local checks_passed=0
74
+ local checks_failed=0
75
+
76
+ # 1a. service-architecture.md
77
+ local service_arch=""
78
+ if [[ -f "$APP_ROOT/docs/service-architecture.md" ]]; then
79
+ service_arch="$APP_ROOT/docs/service-architecture.md"
80
+ elif [[ -f "$APP_ROOT/service-architecture.md" ]]; then
81
+ service_arch="$APP_ROOT/service-architecture.md"
82
+ fi
83
+
84
+ if [[ -z "$service_arch" ]]; then
85
+ log_fail "service-architecture.md not found (checked docs/service-architecture.md and service-architecture.md)"
86
+ checks_failed=$((checks_failed + 1))
87
+ else
88
+ log_pass "service-architecture.md found: ${service_arch#$APP_ROOT/}"
89
+ checks_passed=$((checks_passed + 1))
90
+ fi
91
+
92
+ # 1b. feature-tree.md
93
+ local feature_tree=""
94
+ if [[ -f "$APP_ROOT/docs/platform/feature-tree.md" ]]; then
95
+ feature_tree="$APP_ROOT/docs/platform/feature-tree.md"
96
+ elif [[ -f "$APP_ROOT/docs/feature-tree.md" ]]; then
97
+ feature_tree="$APP_ROOT/docs/feature-tree.md"
98
+ elif [[ -f "$APP_ROOT/feature-tree.md" ]]; then
99
+ feature_tree="$APP_ROOT/feature-tree.md"
100
+ fi
101
+
102
+ if [[ -z "$feature_tree" ]]; then
103
+ log_warn "feature-tree.md not found (checked docs/platform/, docs/, and repo root) — create when features are classified"
104
+ # warn only; not a hard fail at exploration stage
105
+ else
106
+ log_pass "feature-tree.md found: ${feature_tree#$APP_ROOT/}"
107
+ checks_passed=$((checks_passed + 1))
108
+ fi
109
+
110
+ # 1c. app-platform-dependencies.yaml
111
+ if [[ -f "$APP_ROOT/app-platform-dependencies.yaml" ]]; then
112
+ log_pass "app-platform-dependencies.yaml found"
113
+ checks_passed=$((checks_passed + 1))
114
+ else
115
+ log_fail "app-platform-dependencies.yaml not found in repo root"
116
+ checks_failed=$((checks_failed + 1))
117
+ fi
118
+
119
+ if [[ "$checks_failed" -gt 0 ]]; then
120
+ FAILURES=$((FAILURES + checks_failed))
121
+ fi
122
+
123
+ log_info "Group 1: $checks_passed passed, $checks_failed failed"
124
+ }
125
+
126
+ # ──────────────────────────────────────────────────────────────────────────────
127
+ check_feature_specs() {
128
+ log_section "Group 2: Feature Spec Section Validation"
129
+
130
+ local required_sections=("## Overview" "## Acceptance Criteria" "## Platform Contract Dependencies")
131
+ local specs_found=0
132
+ local specs_failed=0
133
+ local specs_passed=0
134
+
135
+ # Discover feature specs under features/*/
136
+ local feature_spec
137
+ while IFS= read -r feature_spec; do
138
+ specs_found=$((specs_found + 1))
139
+ local rel="${feature_spec#$APP_ROOT/}"
140
+ local file_failures=0
141
+
142
+ for section in "${required_sections[@]}"; do
143
+ if ! grep -q "^${section}" "$feature_spec" 2>/dev/null; then
144
+ log_fail "$rel: missing required section '$section'"
145
+ file_failures=$((file_failures + 1))
146
+ fi
147
+ done
148
+
149
+ if [[ "$file_failures" -eq 0 ]]; then
150
+ log_pass "$rel: all required sections present"
151
+ specs_passed=$((specs_passed + 1))
152
+ else
153
+ specs_failed=$((specs_failed + 1))
154
+ FAILURES=$((FAILURES + file_failures))
155
+ fi
156
+ done < <(find "$APP_ROOT/features" -type f -name "*.md" 2>/dev/null | sort)
157
+
158
+ if [[ "$specs_found" -eq 0 ]]; then
159
+ log_info "No feature specs found under features/ — skipping section validation"
160
+ return 0
161
+ fi
162
+
163
+ log_info "Group 2: $specs_passed/$specs_found feature specs passed, $specs_failed failed"
164
+ }
165
+
166
+ # ──────────────────────────────────────────────────────────────────────────────
167
+ main() {
168
+ log_section "App Documentation Validation"
169
+ log_info "App root: $APP_ROOT"
170
+
171
+ check_required_files
172
+ check_feature_specs
173
+
174
+ log_section "Summary"
175
+
176
+ if [[ "$FAILURES" -eq 0 ]]; then
177
+ log_pass "All app documentation checks passed."
178
+ exit 0
179
+ else
180
+ log_fail "$FAILURES check(s) failed."
181
+ exit 1
182
+ fi
183
+ }
184
+
185
+ main "$@"
@@ -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 "$@"