@sdd-method/sdd-cli 1.0.0 → 1.1.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.
Files changed (70) hide show
  1. package/dist/lib/catalogue/aggregate.d.ts.map +1 -1
  2. package/dist/lib/catalogue/aggregate.js +111 -4
  3. package/dist/lib/catalogue/aggregate.js.map +1 -1
  4. package/dist/lib/catalogue/builders/external-system-exemption.d.ts.map +1 -1
  5. package/dist/lib/catalogue/builders/external-system-exemption.js +32 -3
  6. package/dist/lib/catalogue/builders/external-system-exemption.js.map +1 -1
  7. package/dist/lib/catalogue/builders/product-edges.d.ts.map +1 -1
  8. package/dist/lib/catalogue/builders/product-edges.js +9 -0
  9. package/dist/lib/catalogue/builders/product-edges.js.map +1 -1
  10. package/dist/lib/catalogue/builders/product-platform-dependencies.d.ts +12 -0
  11. package/dist/lib/catalogue/builders/product-platform-dependencies.d.ts.map +1 -1
  12. package/dist/lib/catalogue/builders/product-platform-dependencies.js +99 -0
  13. package/dist/lib/catalogue/builders/product-platform-dependencies.js.map +1 -1
  14. package/dist/lib/catalogue/canonical-schema.yaml +8 -6
  15. package/dist/lib/catalogue/contract-path-resolution.d.ts +55 -0
  16. package/dist/lib/catalogue/contract-path-resolution.d.ts.map +1 -0
  17. package/dist/lib/catalogue/contract-path-resolution.js +149 -0
  18. package/dist/lib/catalogue/contract-path-resolution.js.map +1 -0
  19. package/dist/lib/init/ci-workflow.js +16 -7
  20. package/dist/lib/init/ci-workflow.js.map +1 -1
  21. package/dist/lib/sync/apply.d.ts +29 -1
  22. package/dist/lib/sync/apply.d.ts.map +1 -1
  23. package/dist/lib/sync/apply.js +77 -2
  24. package/dist/lib/sync/apply.js.map +1 -1
  25. package/dist/lib/sync/index.d.ts.map +1 -1
  26. package/dist/lib/sync/index.js +23 -1
  27. package/dist/lib/sync/index.js.map +1 -1
  28. package/dist/lib/sync/settings-merge.d.ts +29 -0
  29. package/dist/lib/sync/settings-merge.d.ts.map +1 -1
  30. package/dist/lib/sync/settings-merge.js +31 -0
  31. package/dist/lib/sync/settings-merge.js.map +1 -1
  32. package/dist/lib/validate/dispatch.d.ts.map +1 -1
  33. package/dist/lib/validate/dispatch.js +19 -17
  34. package/dist/lib/validate/dispatch.js.map +1 -1
  35. package/dist/lib/validate/index.d.ts.map +1 -1
  36. package/dist/lib/validate/index.js +23 -4
  37. package/dist/lib/validate/index.js.map +1 -1
  38. package/dist/verbs/conformance.d.ts.map +1 -1
  39. package/dist/verbs/conformance.js +3 -2
  40. package/dist/verbs/conformance.js.map +1 -1
  41. package/dist/verbs/contracts.d.ts.map +1 -1
  42. package/dist/verbs/contracts.js +3 -2
  43. package/dist/verbs/contracts.js.map +1 -1
  44. package/dist/verbs/generate-briefing.d.ts.map +1 -1
  45. package/dist/verbs/generate-briefing.js +2 -1
  46. package/dist/verbs/generate-briefing.js.map +1 -1
  47. package/dist/verbs/init.d.ts.map +1 -1
  48. package/dist/verbs/init.js +3 -2
  49. package/dist/verbs/init.js.map +1 -1
  50. package/dist/verbs/pack.d.ts.map +1 -1
  51. package/dist/verbs/pack.js +2 -1
  52. package/dist/verbs/pack.js.map +1 -1
  53. package/dist/verbs/path-option.d.ts +22 -0
  54. package/dist/verbs/path-option.d.ts.map +1 -0
  55. package/dist/verbs/path-option.js +25 -0
  56. package/dist/verbs/path-option.js.map +1 -0
  57. package/dist/verbs/plan.d.ts.map +1 -1
  58. package/dist/verbs/plan.js +2 -1
  59. package/dist/verbs/plan.js.map +1 -1
  60. package/dist/verbs/scaffold.d.ts.map +1 -1
  61. package/dist/verbs/scaffold.js +2 -1
  62. package/dist/verbs/scaffold.js.map +1 -1
  63. package/dist/verbs/sync.d.ts.map +1 -1
  64. package/dist/verbs/sync.js +2 -1
  65. package/dist/verbs/sync.js.map +1 -1
  66. package/dist/verbs/validate.d.ts.map +1 -1
  67. package/dist/verbs/validate.js +3 -2
  68. package/dist/verbs/validate.js.map +1 -1
  69. package/package.json +1 -1
  70. package/scripts/validate-app-dependencies.sh +378 -58
@@ -3,17 +3,52 @@
3
3
  # validate-app-dependencies.sh - Validate app platform dependency declarations
4
4
  #
5
5
  # Usage:
6
- # ./validate-app-dependencies.sh --app-root <path> --platform-root <path>
6
+ # ./validate-app-dependencies.sh --app-root <path> [--platform-root <path>]
7
7
  #
8
- # Checks (for each entry in app-platform-dependencies.yaml):
9
- # 1. required_stability is a valid value (provisional | locked)
10
- # 2. service_id resolves against the platform source of truth:
11
- # catalogues/services.csv (exact id match) when the catalogue exists,
12
- # else docs/platform/service-architecture.md (word-anchored match)
13
- # 3. contract_path exists (as a file) when required_stability is "locked"
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
14
21
  #
15
22
  # Outputs a dependency status table.
16
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.
17
52
 
18
53
  set -euo pipefail
19
54
 
@@ -41,12 +76,15 @@ PLATFORM_ROOT=""
41
76
 
42
77
  usage() {
43
78
  cat <<USAGE
44
- Usage: $(basename "$0") --app-root <path> --platform-root <path>
79
+ Usage: $(basename "$0") --app-root <path> [--platform-root <path>]
45
80
 
46
81
  Options:
47
82
  --app-root Path to the app repository root (must contain app-platform-dependencies.yaml)
48
- --platform-root Path to the platform repository root (service_id checks use
49
- catalogues/services.csv when present, else docs/platform/service-architecture.md)
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.
50
88
  USAGE
51
89
  }
52
90
 
@@ -78,42 +116,124 @@ if [[ -z "$APP_ROOT" ]]; then
78
116
  exit 1
79
117
  fi
80
118
 
81
- if [[ -z "$PLATFORM_ROOT" ]]; then
82
- log_fail "--platform-root is required"
83
- usage
84
- exit 1
85
- fi
86
-
87
119
  if [[ ! -d "$APP_ROOT" ]]; then
88
120
  log_fail "App root not found: $APP_ROOT"
89
121
  exit 1
90
122
  fi
91
123
 
92
- if [[ ! -d "$PLATFORM_ROOT" ]]; then
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.
93
142
  log_fail "Platform root not found: $PLATFORM_ROOT"
94
143
  exit 1
95
144
  fi
96
145
 
97
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
98
172
  PLATFORM_SERVICE_ARCH="$PLATFORM_ROOT/docs/platform/service-architecture.md"
99
173
  PLATFORM_SERVICES_CSV="$PLATFORM_ROOT/catalogues/services.csv"
100
174
 
101
- VALID_STABILITY_VALUES=("provisional" "locked")
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")
102
212
 
103
213
  FAILURES=0
104
214
  WARNINGS=0
105
215
  TOTAL_DEPS=0
106
216
  PASSED_DEPS=0
107
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
+
108
226
  # ──────────────────────────────────────────────────────────────────────────────
109
227
  check_prerequisites() {
110
228
  log_section "Prerequisites"
111
229
 
112
- if [[ ! -f "$APP_DEPS_FILE" ]]; then
113
- log_fail "app-platform-dependencies.yaml not found: $APP_DEPS_FILE"
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"
114
232
  exit 1
115
233
  fi
116
- log_pass "app-platform-dependencies.yaml found"
234
+ for src in "${DEP_SOURCES[@]}"; do
235
+ log_pass "dependency source: ${src#"$APP_ROOT"/}"
236
+ done
117
237
 
118
238
  if ! command -v yq &>/dev/null; then
119
239
  log_fail "yq is required but not installed. Install with: brew install yq"
@@ -121,6 +241,13 @@ check_prerequisites() {
121
241
  fi
122
242
  log_pass "yq available"
123
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
+
124
251
  if [[ -f "$PLATFORM_SERVICES_CSV" ]]; then
125
252
  log_pass "Platform services catalogue found (canonical service_id source)"
126
253
  else
@@ -139,6 +266,21 @@ check_prerequisites() {
139
266
  fi
140
267
  }
141
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
+
142
284
  # Word-anchored match: the id must not be a substring of a longer slug token.
143
285
  service_id_in_doc() {
144
286
  local service_id="$1"
@@ -151,24 +293,70 @@ service_id_in_catalogue() {
151
293
  }
152
294
 
153
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
+
154
326
  validate_dependency() {
155
- local index="$1"
327
+ local source_file="$1"
328
+ local index="$2"
156
329
  local dep_errors=0
157
330
 
158
331
  local capability_id service_id contract_type required_stability contract_path
159
332
 
160
- capability_id="$(yq e ".platform_dependencies[$index].capability_id // \"\"" "$APP_DEPS_FILE")"
161
- service_id="$(yq e ".platform_dependencies[$index].service_id // \"\"" "$APP_DEPS_FILE")"
162
- contract_type="$(yq e ".platform_dependencies[$index].contract_type // \"\"" "$APP_DEPS_FILE")"
163
- required_stability="$(yq e ".platform_dependencies[$index].required_stability // \"\"" "$APP_DEPS_FILE")"
164
- contract_path="$(yq e ".platform_dependencies[$index].contract_path // \"\"" "$APP_DEPS_FILE")"
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")"
165
338
 
166
339
  local dep_label="${service_id:-dep[$index]}"
167
340
  if [[ -n "$capability_id" && "$capability_id" != "null" ]]; then
168
341
  dep_label="$capability_id / $service_id"
169
342
  fi
170
343
 
171
- # Check 1: required_stability is a valid value
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.
172
360
  local stability_valid=0
173
361
  for v in "${VALID_STABILITY_VALUES[@]}"; do
174
362
  if [[ "$required_stability" == "$v" ]]; then
@@ -177,18 +365,86 @@ validate_dependency() {
177
365
  fi
178
366
  done
179
367
 
180
- if [[ "$stability_valid" -eq 0 ]]; then
181
- log_fail "$dep_label: invalid required_stability '$required_stability' (allowed: ${VALID_STABILITY_VALUES[*]})"
182
- ((dep_errors++))
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
183
431
  fi
184
432
 
185
- # Check 2: service_id resolves against the platform source of truth.
186
- # The services catalogue (when present) is canonical — it is the same source
187
- # the aggregator FK-validates dependency edges against. The prose
188
- # service-architecture.md is the fallback for pre-catalogue platforms.
189
433
  if [[ -z "$service_id" || "$service_id" == "null" ]]; then
190
- log_fail "$dep_label: service_id is empty or missing"
191
- ((dep_errors++))
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++))
192
448
  elif [[ -n "$PLATFORM_SERVICES_CSV" ]]; then
193
449
  if ! service_id_in_catalogue "$service_id"; then
194
450
  log_fail "$dep_label: service_id '$service_id' not found in platform services catalogue (catalogues/services.csv)"
@@ -204,11 +460,26 @@ validate_dependency() {
204
460
  fi
205
461
  fi
206
462
 
207
- # Check 3: contract_path exists when required_stability is locked
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.
208
477
  if [[ "$required_stability" == "locked" ]]; then
209
478
  if [[ -z "$contract_path" || "$contract_path" == "null" ]]; then
210
479
  log_fail "$dep_label: required_stability is 'locked' but contract_path is empty"
211
480
  ((dep_errors++))
481
+ elif [[ "$PLATFORM_AVAILABLE" -eq 0 ]]; then
482
+ ((UNRESOLVED_CONTRACT_PATHS++))
212
483
  else
213
484
  local full_contract_path="$PLATFORM_ROOT/$contract_path"
214
485
  if [[ ! -f "$full_contract_path" ]]; then
@@ -216,11 +487,14 @@ validate_dependency() {
216
487
  ((dep_errors++))
217
488
  fi
218
489
  fi
219
- elif [[ "$required_stability" == "provisional" ]]; then
220
- if [[ -n "$contract_path" && "$contract_path" != "null" ]]; then
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
221
495
  local full_contract_path="$PLATFORM_ROOT/$contract_path"
222
496
  if [[ ! -f "$full_contract_path" ]]; then
223
- log_warn "$dep_label: contract_path '$contract_path' declared but not yet present (provisional — acceptable)"
497
+ log_warn "$dep_label: contract_path '$contract_path' declared but not yet present ($required_stability — acceptable)"
224
498
  ((WARNINGS++))
225
499
  fi
226
500
  fi
@@ -241,31 +515,46 @@ print_status_table() {
241
515
  log_section "Dependency Status Table"
242
516
 
243
517
  local app_id
244
- app_id="$(yq e ".app_id // \"unknown\"" "$APP_DEPS_FILE")"
518
+ app_id="$(yq e ".app_id // \"unknown\"" "$APP_DEPS_FILE" 2>/dev/null || echo unknown)"
245
519
  log_info "App: $app_id"
246
520
 
247
521
  printf "\n %-30s %-15s %-12s %s\n" "Service ID" "Stability" "Contract" "Status"
248
522
  printf " %-30s %-15s %-12s %s\n" "──────────────────────────────" "───────────────" "────────────" "──────────"
249
523
 
250
- local count
251
- count="$(yq e ".platform_dependencies | length" "$APP_DEPS_FILE")"
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
252
531
 
253
532
  for ((i = 0; i < count; i++)); do
254
533
  local service_id required_stability contract_type contract_path dep_status
255
- service_id="$(yq e ".platform_dependencies[$i].service_id // \"(missing)\"" "$APP_DEPS_FILE")"
256
- required_stability="$(yq e ".platform_dependencies[$i].required_stability // \"(missing)\"" "$APP_DEPS_FILE")"
257
- contract_type="$(yq e ".platform_dependencies[$i].contract_type // \"(missing)\"" "$APP_DEPS_FILE")"
258
- contract_path="$(yq e ".platform_dependencies[$i].contract_path // \"\"" "$APP_DEPS_FILE")"
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")"
259
538
 
260
539
  # Determine display status
261
540
  if [[ "$required_stability" == "locked" ]]; then
262
- if [[ -n "$contract_path" && "$contract_path" != "null" && -f "$PLATFORM_ROOT/$contract_path" ]]; 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
263
548
  dep_status="${GREEN}LOCKED OK${NC}"
264
549
  else
265
550
  dep_status="${RED}LOCKED UNMET${NC}"
266
551
  fi
267
552
  elif [[ "$required_stability" == "provisional" ]]; then
268
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}"
269
558
  else
270
559
  dep_status="${RED}INVALID${NC}"
271
560
  fi
@@ -273,6 +562,7 @@ print_status_table() {
273
562
  printf " %-30s %-15s %-12s " "$service_id" "$required_stability" "$contract_type"
274
563
  echo -e "$dep_status"
275
564
  done
565
+ done
276
566
  echo ""
277
567
  }
278
568
 
@@ -286,20 +576,33 @@ main() {
286
576
 
287
577
  log_section "Validating Dependencies"
288
578
 
289
- local dep_count
290
- dep_count="$(yq e ".platform_dependencies | length" "$APP_DEPS_FILE")"
291
- TOTAL_DEPS="$dep_count"
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
292
593
 
293
- if [[ "$dep_count" -eq 0 ]]; then
294
- log_warn "No platform_dependencies entries found in app-platform-dependencies.yaml"
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)"
295
602
  log_info "If this app has no platform deps, this is acceptable."
296
603
  exit 0
297
604
  fi
298
605
 
299
- for ((i = 0; i < dep_count; i++)); do
300
- validate_dependency "$i" || true
301
- done
302
-
303
606
  print_status_table
304
607
 
305
608
  log_section "Summary"
@@ -308,11 +611,28 @@ main() {
308
611
  log_info "Warnings: $WARNINGS"
309
612
  log_info "Failures: $FAILURES"
310
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
+
311
619
  if [[ "$FAILURES" -gt 0 ]]; then
312
620
  log_fail "$FAILURES dependency check(s) failed. Stage 6 lock-in gate NOT met."
313
621
  exit 1
314
622
  fi
315
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
+
316
636
  if [[ "$WARNINGS" -gt 0 ]]; then
317
637
  log_warn "All required checks passed with $WARNINGS warning(s)."
318
638
  else