@sdd-method/sdd-cli 0.110.2 → 0.111.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/scripts/README.md CHANGED
@@ -1,10 +1,17 @@
1
1
  # Bundled upstream scripts
2
2
 
3
- Frozen copies of bash scripts from
4
- [`sdd-method`](https://github.com/tomfosteruk/sdd-method) that
5
- `sdd-cli` either shells out to (`validate-*`) or retains as a regression
6
- oracle (`sync-method-baseline.sh`, which is superseded by native TypeScript
7
- sync in v0.2.0 but kept so `tests/parity/` can keep catching drift).
3
+ Copies of bash scripts from
4
+ [`sdd-method`](https://github.com/tomfosteruk/sdd-method) that `sdd-cli` shells
5
+ out to at runtime (`validate-*`). These SHIP in the npm package, because the
6
+ `sdd-cli validate` dispatcher spawns them.
7
+
8
+ ⚠ `sync-method-baseline.sh` is no longer here. It was never shelled out to — it is
9
+ a parity oracle — and it now lives at `tests/parity/fixtures/`, which is where its
10
+ only consumer is and, crucially, is not published. While it sat in this directory it
11
+ shipped to adopters, and it had drifted 386 lines behind upstream: it wrote a lock
12
+ with a single bundle entry, which would have dropped every other bundle's entry on
13
+ the 97-of-105 adopter locks that are multi-bundle. Adopters receive the
14
+ authoritative script in `platform-baseline`; nothing needed a copy here.
8
15
 
9
16
  Files in this directory are **byte-identical** to their upstream source. Do
10
17
  not edit here — edit upstream, then run the refresh procedure below to copy
@@ -13,9 +20,13 @@ signal.
13
20
 
14
21
  ## Provenance
15
22
 
23
+ ⚠ **These SHAs are historical.** Byte-identity is now asserted by
24
+ `tests/parity/vendored-script-drift.test.ts` against the sibling sdd-method checkout,
25
+ so the pin is the test rather than this table. The table is retained only as a record
26
+ of where each file came from.
27
+
16
28
  | File | Upstream path | Pinned to |
17
29
  |---|---|---|
18
- | `sync-method-baseline.sh` | `orchestration/scripts/sync-method-baseline.sh` | `4d80826` |
19
30
  | `validate-app-docs.sh` | `orchestration/scripts/validate-app-docs.sh` | `db4cb25` |
20
31
  | `validate-app-dependencies.sh` | `orchestration/scripts/validate-app-dependencies.sh` | `db4cb25` |
21
32
  | `validate-integration-docs.sh` | `orchestration/scripts/validate-integration-docs.sh` | `db4cb25` |
@@ -34,14 +45,29 @@ cp ../sdd-method/orchestration/scripts/<script>.sh scripts/
34
45
  # Then update the SHA in this README and commit both together.
35
46
  ```
36
47
 
37
- Automation (pre-build sync step) is a later milestone. Refresh is currently
38
- manual and visible.
48
+ **Manual-and-visible turned out to be manual-and-invisible**: this README mandated byte-identity ("do not edit here …
49
+ byte-identity keeps `diff` usable as a drift-detection signal") while its Lifecycle
50
+ section simultaneously described the sync oracle as "frozen at 4a8cd05" — and the
51
+ provenance table pinned a *different* commit. Either reading was defensible, so a
52
+ 386-line drift looked intentional to anyone who checked. The parity oracle now has
53
+ the automation this section called "a later milestone":
54
+ `scripts/sync-vendored-script.sh` plus `tests/parity/vendored-script-drift.test.ts`,
55
+ which fails with a re-sync instruction when any copy differs.
56
+
57
+ **All four files are now covered**, which mattered: when the guard was generalised it
58
+ immediately found that two of the three validators had ALSO drifted —
59
+ `validate-app-dependencies.sh` by 35 lines and `validate-integration-docs.sh` by 182,
60
+ the latter missing an entire check family. Those files are executed by
61
+ `sdd-cli validate`, so adopters had been running weaker validation than the bundle
62
+ provides. Re-synced.
39
63
 
40
64
  ## Lifecycle
41
65
 
42
- The `sync-method-baseline.sh` oracle is frozen at 4a8cd05 and stays for
43
- regression testing the native TS sync is tested against it in
44
- `tests/parity/`.
66
+ The `sync-method-baseline.sh` oracle stays for regression testing the native TS
67
+ sync is compared against it in `tests/parity/` — but it is tracked at upstream HEAD,
68
+ not frozen. Frozen was the wrong model: parity against a frozen oracle stops testing
69
+ the CLI against what adopters run and starts testing it against history, which is how
70
+ a real functional gap (the CLI not applying ADR transitions) stayed invisible.
45
71
 
46
72
  The `validate-*.sh` scripts are what `sdd-cli validate` spawns. Native TS
47
73
  reimplementation of individual validators is v0.3.0+, smallest-first
@@ -1,43 +1,83 @@
1
1
  #!/usr/bin/env bash
2
- # Sync the authoritative `sync-method-baseline.sh` from sdd-method into sdd-cli's
3
- # vendored copy. Mirrors scripts/sync-canonical-schema.sh: direction of truth is
4
- # one-way, sdd-method -> vendored, and the vendored copy is never hand-edited.
2
+ # Sync every vendored bash script from sdd-method into this repo. Mirrors
3
+ # scripts/sync-canonical-schema.sh: direction of truth is one-way,
4
+ # sdd-method -> vendored, and a vendored copy is never hand-edited.
5
5
  #
6
- # The vendored copy matters twice over: the parity suite compares sdd-cli against
7
- # it, and it ships in the npm package (`files` includes `scripts`), so an adopter
8
- # can invoke it from node_modules. It once fell 386 lines behind and, among other
9
- # losses, wrote a lock with a single bundle entry — dropping every other bundle's
10
- # entry, on an estate where 97 of 105 locks are multi-bundle. Because parity
11
- # compared against the stale copy, nothing caught it.
6
+ # FOUR FILES, TWO DESTINATIONS, and the distinction matters:
7
+ #
8
+ # scripts/validate-*.sh SHIP in the npm package, and the CLI shells out to
9
+ # them at runtime via the `sdd-cli validate`
10
+ # dispatcher. A stale copy here means adopters run
11
+ # weaker validation than the bundle provides and
12
+ # two of the three were found 35 and 182 lines behind,
13
+ # the latter missing an entire check family.
14
+ #
15
+ # tests/parity/fixtures/ the sync ORACLE, kept only so tests/parity can
16
+ # sync-method-baseline.sh compare the TS implementation against the reference.
17
+ # Deliberately NOT in scripts/, because that directory
18
+ # ships: while it lived there it was 386 lines stale
19
+ # and would have written a single-bundle lock,
20
+ # dropping entries from the 97-of-105 adopter locks
21
+ # that are multi-bundle. Adopters receive the
22
+ # authoritative script in platform-baseline.
23
+ #
24
+ # `scripts/README.md` pinned each file to an upstream SHA by hand and called refresh
25
+ # "manual and visible". It was manual and INVISIBLE: three of four copies had drifted.
26
+ # This script plus tests/parity/vendored-script-drift.test.ts replace that.
12
27
  #
13
28
  # Usage:
14
29
  # scripts/sync-vendored-script.sh # from $SDD_METHOD_PATH or ~/Projects/method/sdd-method
15
30
  # scripts/sync-vendored-script.sh /path/to/sdd-method # explicit path
16
31
  #
17
- # After syncing, run `npx vitest run tests/parity` to confirm the CLI still matches
18
- # the reference — a diff there is a real finding, not a fixture to adjust.
32
+ # After syncing run `npx vitest run tests/parity` a diff there is a real finding
33
+ # about the TS implementation, not a fixture to adjust.
19
34
 
20
35
  set -euo pipefail
21
36
 
22
37
  SDD_METHOD_PATH="${1:-${SDD_METHOD_PATH:-$HOME/Projects/method/sdd-method}}"
23
- SOURCE="$SDD_METHOD_PATH/orchestration/scripts/sync-method-baseline.sh"
24
- DEST="$(dirname "$0")/sync-method-baseline.sh"
38
+ SRC_DIR="$SDD_METHOD_PATH/orchestration/scripts"
39
+ HERE="$(cd "$(dirname "$0")" && pwd)"
25
40
 
26
- if [[ ! -f "$SOURCE" ]]; then
27
- echo "ERROR: authoritative script not found at $SOURCE" >&2
41
+ # name:destination-relative-to-repo-root
42
+ VENDORED=(
43
+ "sync-method-baseline.sh:tests/parity/fixtures"
44
+ "validate-app-docs.sh:scripts"
45
+ "validate-app-dependencies.sh:scripts"
46
+ "validate-integration-docs.sh:scripts"
47
+ )
48
+
49
+ if [[ ! -d "$SRC_DIR" ]]; then
50
+ echo "ERROR: sdd-method scripts not found at $SRC_DIR" >&2
28
51
  echo " Pass the sdd-method repo path as the first argument." >&2
29
52
  exit 1
30
53
  fi
31
54
 
32
- if cmp -s "$SOURCE" "$DEST"; then
33
- echo "Vendored sync-method-baseline.sh already matches sdd-method ($(wc -l < "$DEST" | tr -d ' ') lines)."
34
- exit 0
35
- fi
55
+ changed=0
56
+ for entry in "${VENDORED[@]}"; do
57
+ name="${entry%%:*}"
58
+ dest_dir="${entry#*:}"
59
+ src="$SRC_DIR/$name"
60
+ dest="$HERE/../$dest_dir/$name"
36
61
 
37
- OLD_LINES=0
38
- [[ -f "$DEST" ]] && OLD_LINES=$(wc -l < "$DEST" | tr -d ' ')
39
- cp "$SOURCE" "$DEST"
40
- NEW_LINES=$(wc -l < "$DEST" | tr -d ' ')
62
+ if [[ ! -f "$src" ]]; then
63
+ echo "ERROR: authoritative script missing: $src" >&2
64
+ exit 1
65
+ fi
66
+ if cmp -s "$src" "$dest"; then
67
+ printf ' ok %-32s (%s lines)\n' "$name" "$(wc -l < "$dest" | tr -d ' ')"
68
+ continue
69
+ fi
70
+ old=0
71
+ [[ -f "$dest" ]] && old=$(wc -l < "$dest" | tr -d ' ')
72
+ mkdir -p "$(dirname "$dest")"
73
+ cp "$src" "$dest"
74
+ printf ' SYNCED %-32s %s -> %s lines\n' "$name" "$old" "$(wc -l < "$dest" | tr -d ' ')"
75
+ changed=$((changed + 1))
76
+ done
41
77
 
42
- echo "Synced sync-method-baseline.sh: ${OLD_LINES} -> ${NEW_LINES} lines"
43
- echo "Run: npx vitest run tests/parity"
78
+ echo
79
+ if [[ "$changed" -gt 0 ]]; then
80
+ echo "$changed file(s) re-synced. Run: npx vitest run tests/parity"
81
+ else
82
+ echo "All vendored scripts already match sdd-method."
83
+ fi
@@ -7,7 +7,9 @@
7
7
  #
8
8
  # Checks (for each entry in app-platform-dependencies.yaml):
9
9
  # 1. required_stability is a valid value (provisional | locked)
10
- # 2. service_id exists in platform service-architecture.md
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)
11
13
  # 3. contract_path exists (as a file) when required_stability is "locked"
12
14
  #
13
15
  # Outputs a dependency status table.
@@ -43,7 +45,8 @@ Usage: $(basename "$0") --app-root <path> --platform-root <path>
43
45
 
44
46
  Options:
45
47
  --app-root Path to the app repository root (must contain app-platform-dependencies.yaml)
46
- --platform-root Path to the platform repository root (must contain docs/service-architecture.md)
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)
47
50
  USAGE
48
51
  }
49
52
 
@@ -93,6 +96,7 @@ fi
93
96
 
94
97
  APP_DEPS_FILE="$APP_ROOT/app-platform-dependencies.yaml"
95
98
  PLATFORM_SERVICE_ARCH="$PLATFORM_ROOT/docs/platform/service-architecture.md"
99
+ PLATFORM_SERVICES_CSV="$PLATFORM_ROOT/catalogues/services.csv"
96
100
 
97
101
  VALID_STABILITY_VALUES=("provisional" "locked")
98
102
 
@@ -117,13 +121,33 @@ check_prerequisites() {
117
121
  fi
118
122
  log_pass "yq available"
119
123
 
124
+ if [[ -f "$PLATFORM_SERVICES_CSV" ]]; then
125
+ log_pass "Platform services catalogue found (canonical service_id source)"
126
+ else
127
+ PLATFORM_SERVICES_CSV=""
128
+ fi
129
+
120
130
  if [[ ! -f "$PLATFORM_SERVICE_ARCH" ]]; then
121
131
  log_warn "Platform service-architecture.md not found: $PLATFORM_SERVICE_ARCH"
122
- log_warn "service_id validation will be skipped"
123
132
  PLATFORM_SERVICE_ARCH=""
124
133
  else
125
134
  log_pass "Platform service-architecture.md found"
126
135
  fi
136
+
137
+ if [[ -z "$PLATFORM_SERVICES_CSV" && -z "$PLATFORM_SERVICE_ARCH" ]]; then
138
+ log_warn "Neither services catalogue nor service-architecture.md found — service_id validation will be skipped"
139
+ fi
140
+ }
141
+
142
+ # Word-anchored match: the id must not be a substring of a longer slug token.
143
+ service_id_in_doc() {
144
+ local service_id="$1"
145
+ grep -qE "(^|[^a-zA-Z0-9-])${service_id}([^a-zA-Z0-9-]|$)" "$PLATFORM_SERVICE_ARCH" 2>/dev/null
146
+ }
147
+
148
+ service_id_in_catalogue() {
149
+ local service_id="$1"
150
+ awk -F',' -v id="$service_id" 'NR>1 && $1==id {found=1} END {exit !found}' "$PLATFORM_SERVICES_CSV" 2>/dev/null
127
151
  }
128
152
 
129
153
  # ──────────────────────────────────────────────────────────────────────────────
@@ -158,12 +182,23 @@ validate_dependency() {
158
182
  ((dep_errors++))
159
183
  fi
160
184
 
161
- # Check 2: service_id exists in platform service-architecture.md
162
- if [[ -n "$PLATFORM_SERVICE_ARCH" ]]; then
163
- if [[ -z "$service_id" || "$service_id" == "null" ]]; then
164
- log_fail "$dep_label: service_id is empty or missing"
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
+ if [[ -z "$service_id" || "$service_id" == "null" ]]; then
190
+ log_fail "$dep_label: service_id is empty or missing"
191
+ ((dep_errors++))
192
+ elif [[ -n "$PLATFORM_SERVICES_CSV" ]]; then
193
+ if ! service_id_in_catalogue "$service_id"; then
194
+ log_fail "$dep_label: service_id '$service_id' not found in platform services catalogue (catalogues/services.csv)"
165
195
  ((dep_errors++))
166
- elif ! grep -q "$service_id" "$PLATFORM_SERVICE_ARCH" 2>/dev/null; then
196
+ elif [[ -n "$PLATFORM_SERVICE_ARCH" ]] && ! service_id_in_doc "$service_id"; then
197
+ log_warn "$dep_label: service_id '$service_id' is in the services catalogue but not mentioned in service-architecture.md (doc drift)"
198
+ ((WARNINGS++))
199
+ fi
200
+ elif [[ -n "$PLATFORM_SERVICE_ARCH" ]]; then
201
+ if ! service_id_in_doc "$service_id"; then
167
202
  log_fail "$dep_label: service_id '$service_id' not found in platform service-architecture.md"
168
203
  ((dep_errors++))
169
204
  fi
@@ -159,6 +159,181 @@ check_manifest() {
159
159
  *)
160
160
  ;;
161
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}"
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
162
337
  }
163
338
 
164
339
  check_grounding_doc() {
@@ -197,6 +372,13 @@ check_grounding_doc() {
197
372
  FAILURES=$((FAILURES + 1))
198
373
  fi
199
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"
200
382
  }
201
383
 
202
384
  main() {