@danmoisan/drm-copilot-mcp 1.0.21 → 1.0.23

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 (43) hide show
  1. package/out/mcp-server.js +1730 -139
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/agent-memory/epic-orchestrator/MEMORY.md +5 -1
  4. package/resources/claude-customizations/.claude/agent-memory/epic-orchestrator/feedback_commit_push_memory_before_pr.md +48 -2
  5. package/resources/claude-customizations/.claude/agent-memory/epic-orchestrator/feedback_no_sendmessage_tool.md +35 -0
  6. package/resources/claude-customizations/.claude/agent-memory/epic-orchestrator/feedback_worktree_isolation_branches_from_main.md +45 -0
  7. package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +257 -0
  8. package/resources/claude-customizations/.claude/agents/parallel-planner.md +183 -0
  9. package/resources/claude-customizations/.claude/hooks/enforce-epic-invocation-origin.ps1 +23 -11
  10. package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +259 -0
  11. package/resources/claude-customizations/.claude/hooks/enforce-parallel-cohort-barrier.ps1 +499 -0
  12. package/resources/claude-customizations/.claude/hooks/enforce-parallel-drift-gate-helpers.ps1 +302 -0
  13. package/resources/claude-customizations/.claude/hooks/enforce-parallel-drift-gate.ps1 +359 -0
  14. package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +244 -0
  15. package/resources/claude-customizations/.claude/lib/bash/compute-cohorts.sh +143 -0
  16. package/resources/claude-customizations/.claude/lib/bash/compute-concurrency-batches.sh +122 -0
  17. package/resources/claude-customizations/.claude/lib/bash/parallel-cohorts.sh +330 -0
  18. package/resources/claude-customizations/.claude/lib/bash/parallel-common.sh +238 -0
  19. package/resources/claude-customizations/.claude/lib/bash/parallel-items-validate.sh +244 -0
  20. package/resources/claude-customizations/.claude/lib/bash/parallel-manifest-validate.sh +187 -0
  21. package/resources/claude-customizations/.claude/lib/bash/parallel-yaml-emit.sh +340 -0
  22. package/resources/claude-customizations/.claude/lib/bash/parallel-yaml-scan.sh +335 -0
  23. package/resources/claude-customizations/.claude/lib/bash/validate-parallel-manifest.sh +134 -0
  24. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +379 -0
  25. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConfig.psm1 +491 -0
  26. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 +490 -0
  27. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusGlob.psm1 +429 -0
  28. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusValidation.psm1 +366 -0
  29. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +184 -0
  30. package/resources/claude-customizations/.claude/rules/shell.md +7 -2
  31. package/resources/claude-customizations/.claude/settings.json +28 -0
  32. package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +152 -0
  33. package/resources/claude-customizations/.claude/skills/parallel-close/SKILL.md +93 -0
  34. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +971 -0
  35. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +461 -0
  36. package/resources/claude-customizations/.claude/skills/parallel-remove/SKILL.md +176 -0
  37. package/resources/claude-customizations/.claude/skills/parallel-run/SKILL.md +56 -0
  38. package/resources/claude-customizations/config/blast-radius.json +16 -0
  39. package/resources/claude-customizations/config/orchestration-routing.json +355 -0
  40. package/resources/claude-customizations/pack-manifests/core.json +32 -1
  41. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  42. package/resources/config/orchestration-routing.json +22 -0
  43. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +29 -0
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env bash
2
+ # parallel-yaml-emit.sh: structural half of the restricted block-YAML parser.
3
+ # Walks the indentation of the frontmatter body lines produced by
4
+ # parallel-yaml-scan.sh and populates that module's node table in document
5
+ # order. Sourcing this file also sources the scanning half, so a consumer needs
6
+ # only this one source line.
7
+ #
8
+ # Algorithm. A stack of open containers is kept, each carrying the indent
9
+ # column its children sit at, its rendered path, whether it is a mapping or a
10
+ # sequence, and how many children it has taken. A `key:` line with no inline
11
+ # value opens a pending node whose kind is not yet known: the next content line
12
+ # decides it, becoming a mapping or sequence when it is indented deeper, and
13
+ # leaving the pending node a null scalar when it is not. That one-line lookahead
14
+ # is the whole of the parser's ambiguity handling.
15
+ #
16
+ # Failure modes are the two the scanning half defines. A malformed line sets
17
+ # YP_STATUS=yaml_error and is rendered by the caller through the M1 message; a
18
+ # construct that is valid YAML but outside the subset sets
19
+ # YP_STATUS=out_of_subset and makes the caller refuse to answer rather than
20
+ # risk disagreeing with the Python authority.
21
+ #
22
+ # shellcheck disable=SC2034
23
+ # SC2034 is disabled file-wide because the node-table and status globals this
24
+ # module writes -- YP_TYPE, YP_COUNT, YP_STATUS -- are declared in
25
+ # parallel-yaml-scan.sh and read by parallel-items-validate.sh and
26
+ # parallel-manifest-validate.sh, which shellcheck analyses as separate files.
27
+
28
+ # Resolve this file's own directory so the scanning half sources regardless of
29
+ # the caller's working directory.
30
+ YP_EMIT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
31
+ # shellcheck source=.claude/lib/bash/parallel-yaml-scan.sh
32
+ # shellcheck disable=SC1091
33
+ source "$YP_EMIT_DIR/parallel-yaml-scan.sh"
34
+
35
+ # Path label for the document root in key-scoped errors.
36
+ YP_ROOT_LABEL="<root>"
37
+
38
+ # Open-container stack, parallel arrays indexed by depth.
39
+ YP_STACK_INDENT=()
40
+ YP_STACK_PATH=()
41
+ YP_STACK_KIND=()
42
+ YP_STACK_COUNT=()
43
+
44
+ # The pending node opened by a `key:` or bare `-` line, if any.
45
+ YP_PENDING_PATH=""
46
+ YP_PENDING_INDENT=-1
47
+ YP_PENDING_ACTIVE=0
48
+
49
+ yp_stack_reset() {
50
+ # Clear the container stack and the pending-node slot.
51
+ YP_STACK_INDENT=()
52
+ YP_STACK_PATH=()
53
+ YP_STACK_KIND=()
54
+ YP_STACK_COUNT=()
55
+ YP_PENDING_PATH=""
56
+ YP_PENDING_INDENT=-1
57
+ YP_PENDING_ACTIVE=0
58
+ }
59
+
60
+ yp_stack_push() {
61
+ # Push one open container onto the stack.
62
+ #
63
+ # Args: $1 = indent column of the container's children, $2 = rendered path,
64
+ # $3 = kind (map or seq).
65
+ YP_STACK_INDENT+=("$1")
66
+ YP_STACK_PATH+=("$2")
67
+ YP_STACK_KIND+=("$3")
68
+ YP_STACK_COUNT+=(0)
69
+ }
70
+
71
+ yp_stack_depth() {
72
+ # Echo the number of open containers.
73
+ printf '%s' "${#YP_STACK_INDENT[@]}"
74
+ }
75
+
76
+ yp_stack_pop() {
77
+ # Discard the innermost open container by truncating every stack array.
78
+ local keep=$((${#YP_STACK_INDENT[@]} - 1))
79
+ ((keep >= 0)) || return 0
80
+ YP_STACK_INDENT=("${YP_STACK_INDENT[@]:0:keep}")
81
+ YP_STACK_PATH=("${YP_STACK_PATH[@]:0:keep}")
82
+ YP_STACK_KIND=("${YP_STACK_KIND[@]:0:keep}")
83
+ YP_STACK_COUNT=("${YP_STACK_COUNT[@]:0:keep}")
84
+ }
85
+
86
+ yp_stack_bump() {
87
+ # Increment the innermost container's child count and mirror it into the
88
+ # node table so yp_count_of reports sequence lengths and key counts.
89
+ local last=$((${#YP_STACK_INDENT[@]} - 1))
90
+ ((last >= 0)) || return 0
91
+ YP_STACK_COUNT[last]=$((YP_STACK_COUNT[last] + 1))
92
+ local path="${YP_STACK_PATH[last]}"
93
+ if [[ -n $path ]]; then
94
+ YP_COUNT["$path"]=${YP_STACK_COUNT[last]}
95
+ fi
96
+ }
97
+
98
+ yp_render_child_path() {
99
+ # Echo the path of a mapping key inside a parent container.
100
+ #
101
+ # Args: $1 = parent path (empty at the document root), $2 = key name.
102
+ if [[ -z $1 ]]; then
103
+ printf '%s' "$2"
104
+ else
105
+ printf '%s.%s' "$1" "$2"
106
+ fi
107
+ }
108
+
109
+ yp_render_parent_label() {
110
+ # Echo the path label Python uses for the mapping that owns a key.
111
+ #
112
+ # Args: $1 = parent path. The document root renders as `<root>` so
113
+ # prohibited-key errors reproduce the Python wording exactly.
114
+ if [[ -z $1 ]]; then
115
+ printf '%s' "$YP_ROOT_LABEL"
116
+ else
117
+ printf '%s' "$1"
118
+ fi
119
+ }
120
+
121
+ yp_open_pending() {
122
+ # Mark a node as awaiting the next line to decide its kind.
123
+ #
124
+ # Args: $1 = node path, $2 = indent column of the line that opened it.
125
+ YP_PENDING_PATH="$1"
126
+ YP_PENDING_INDENT="$2"
127
+ YP_PENDING_ACTIVE=1
128
+ }
129
+
130
+ yp_resolve_pending() {
131
+ # Decide the kind of a pending node using the next content line.
132
+ #
133
+ # Args: $1 = indent of the next content line, $2 = 1 when that line is a
134
+ # sequence entry. Returns 0 always; the pending slot is cleared.
135
+ local indent="$1" is_seq="$2"
136
+ ((YP_PENDING_ACTIVE == 1)) || return 0
137
+ # A deeper line, or a sequence entry at the same column, opens a container;
138
+ # anything else means the key carried no value and reads as null.
139
+ if ((indent > YP_PENDING_INDENT)) || ((indent == YP_PENDING_INDENT && is_seq == 1)); then
140
+ local kind="map"
141
+ ((is_seq == 1)) && kind="seq"
142
+ YP_TYPE["$YP_PENDING_PATH"]="$kind"
143
+ YP_COUNT["$YP_PENDING_PATH"]=0
144
+ yp_stack_push "$indent" "$YP_PENDING_PATH" "$kind"
145
+ else
146
+ YP_TYPE["$YP_PENDING_PATH"]="null"
147
+ fi
148
+ YP_PENDING_ACTIVE=0
149
+ YP_PENDING_PATH=""
150
+ YP_PENDING_INDENT=-1
151
+ }
152
+
153
+ yp_emit_map_entry() {
154
+ # Parse one `key: value` or `key:` line into the node table.
155
+ #
156
+ # Args: $1 = owning mapping path (empty at the root), $2 = line content
157
+ # with indentation stripped, $3 = indent column of the line.
158
+ # Returns 0 on success, 1 with YP_STATUS set on a malformed or refused line.
159
+ local parent="$1" content="$2" indent="$3"
160
+ local key rest
161
+ if [[ $content == *": "* ]]; then
162
+ key=${content%%": "*}
163
+ rest=$(yp_trim "${content#*": "}")
164
+ elif [[ $content == *":" ]]; then
165
+ key=${content%":"}
166
+ rest=""
167
+ else
168
+ yp_yaml_error "line is neither a mapping entry nor a sequence entry: $content"
169
+ return 1
170
+ fi
171
+ # Keys in a machine-authored manifest are bare identifiers; a quoted or
172
+ # otherwise exotic key is refused rather than guessed at.
173
+ if [[ ! $key =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then
174
+ yp_reject "mapping keys must be bare identifiers; found: $key"
175
+ return 1
176
+ fi
177
+ local path parent_label
178
+ path=$(yp_render_child_path "$parent" "$key")
179
+ parent_label=$(yp_render_parent_label "$parent")
180
+
181
+ # An inline empty flow collection is a complete container; an empty value
182
+ # defers the decision to the next line; anything else is a scalar.
183
+ if [[ $rest == "[]" ]]; then
184
+ yp_node_add "$path" "seq" "" "$parent_label" "$key"
185
+ elif [[ $rest == "{}" ]]; then
186
+ yp_node_add "$path" "map" "" "$parent_label" "$key"
187
+ elif [[ -z $rest && $content == *":" ]]; then
188
+ yp_node_add "$path" "null" "" "$parent_label" "$key"
189
+ yp_open_pending "$path" "$indent"
190
+ else
191
+ yp_classify_scalar "$rest" || return 1
192
+ yp_node_add "$path" "$YP_SCALAR_TYPE" "$YP_SCALAR_VALUE" "$parent_label" "$key"
193
+ fi
194
+ yp_stack_bump
195
+ return 0
196
+ }
197
+
198
+ yp_emit_seq_entry() {
199
+ # Parse one `- ...` line into the node table.
200
+ #
201
+ # Args: $1 = owning sequence path, $2 = the sequence index to use, $3 = the
202
+ # text after the dash with surrounding whitespace trimmed, $4 = indent
203
+ # column of the dash. Returns 0 on success, 1 with YP_STATUS set otherwise.
204
+ local parent="$1" index="$2" rest="$3" indent="$4"
205
+ local path="${parent}[${index}]"
206
+
207
+ # A bare dash defers to the next line; a `key: value` payload opens a
208
+ # compact mapping whose entries sit two columns right of the dash; anything
209
+ # else is a scalar element.
210
+ if [[ -z $rest ]]; then
211
+ yp_node_add "$path" "null" "" "" ""
212
+ yp_open_pending "$path" "$indent"
213
+ return 0
214
+ fi
215
+ if [[ $rest == *": "* || $rest == *":" ]]; then
216
+ yp_node_add "$path" "map" "" "" ""
217
+ yp_stack_push $((indent + 2)) "$path" "map"
218
+ yp_emit_map_entry "$path" "$rest" $((indent + 2)) || return 1
219
+ return 0
220
+ fi
221
+ yp_classify_scalar "$rest" || return 1
222
+ yp_node_add "$path" "$YP_SCALAR_TYPE" "$YP_SCALAR_VALUE" "" ""
223
+ return 0
224
+ }
225
+
226
+ yp_scan_line_guards() {
227
+ # Reject the line-level constructs the subset does not model.
228
+ #
229
+ # Args: $1 = the raw line, $2 = the line with indentation stripped.
230
+ # Returns 0 when the line may be parsed, 1 with YP_STATUS set otherwise.
231
+ local line="$1" content="$2"
232
+ if [[ $line == *$'\t'* ]]; then
233
+ yp_yaml_error "tab characters are not permitted in YAML indentation"
234
+ return 1
235
+ fi
236
+ if [[ $content == *' #'* ]]; then
237
+ yp_reject "trailing comments are outside the subset: $content"
238
+ return 1
239
+ fi
240
+ if [[ $content == '---' || $content == '...' ]]; then
241
+ yp_reject "multi-document streams are outside the subset"
242
+ return 1
243
+ fi
244
+ return 0
245
+ }
246
+
247
+ yp_close_deeper_containers() {
248
+ # Pop every open container whose children sit right of the given column.
249
+ #
250
+ # Args: $1 = the current line's indent column.
251
+ local indent="$1" top
252
+ # Unwinding stops at the first container whose child column is at or left
253
+ # of this line, which is the container the line belongs to.
254
+ while ((${#YP_STACK_INDENT[@]} > 0)); do
255
+ top=$((${#YP_STACK_INDENT[@]} - 1))
256
+ ((indent < YP_STACK_INDENT[top])) || break
257
+ yp_stack_pop
258
+ done
259
+ }
260
+
261
+ yp_parse_body() {
262
+ # Parse YP_BODY_LINES into the node table.
263
+ #
264
+ # Returns 0 when YP_STATUS is ok. Sets YP_STATUS to not_a_mapping when the
265
+ # body is empty or its root is a sequence, and to yaml_error or
266
+ # out_of_subset for the failure modes the module header documents.
267
+ yp_reset
268
+ yp_stack_reset
269
+ local root_seen=0
270
+ local total=${#YP_BODY_LINES[@]}
271
+ local index line content indent is_seq rest depth seq_index
272
+
273
+ # Walk every body line, resolving the pending node from the previous line
274
+ # before deciding what the current line contributes.
275
+ for ((index = 0; index < total; index++)); do
276
+ line="${YP_BODY_LINES[index]}"
277
+ [[ -n $(yp_trim "$line") ]] || continue
278
+ content=${line#"${line%%[![:space:]]*}"}
279
+ [[ $content == '#'* ]] && continue
280
+ yp_scan_line_guards "$line" "$content" || return 1
281
+ indent=$((${#line} - ${#content}))
282
+ is_seq=0
283
+ [[ $content == "-" || $content == "- "* ]] && is_seq=1
284
+
285
+ yp_resolve_pending "$indent" "$is_seq"
286
+
287
+ # The first content line fixes the document's root kind; a sequence
288
+ # root is reported as not-a-mapping rather than parsed.
289
+ if ((root_seen == 0)); then
290
+ root_seen=1
291
+ if ((is_seq == 1)); then
292
+ YP_STATUS="not_a_mapping"
293
+ return 1
294
+ fi
295
+ ((${#YP_STACK_INDENT[@]} > 0)) || yp_stack_push "$indent" "" "map"
296
+ fi
297
+
298
+ yp_close_deeper_containers "$indent"
299
+ if ((${#YP_STACK_INDENT[@]} == 0)); then
300
+ yp_yaml_error "indentation closes the document root: $content"
301
+ return 1
302
+ fi
303
+ depth=$((${#YP_STACK_INDENT[@]} - 1))
304
+ if ((indent != YP_STACK_INDENT[depth])); then
305
+ yp_yaml_error "inconsistent indentation at column $indent: $content"
306
+ return 1
307
+ fi
308
+
309
+ # Route the line against the container it belongs to; a mismatch means
310
+ # the document mixes a sequence entry into a mapping or the reverse.
311
+ if ((is_seq == 1)); then
312
+ if [[ ${YP_STACK_KIND[depth]} != seq ]]; then
313
+ yp_yaml_error "sequence entry inside a mapping: $content"
314
+ return 1
315
+ fi
316
+ rest=$(yp_trim "${content#-}")
317
+ seq_index="${YP_STACK_COUNT[depth]}"
318
+ yp_stack_bump
319
+ yp_emit_seq_entry "${YP_STACK_PATH[depth]}" "$seq_index" "$rest" "$indent" || return 1
320
+ else
321
+ if [[ ${YP_STACK_KIND[depth]} != map ]]; then
322
+ yp_yaml_error "mapping entry inside a sequence: $content"
323
+ return 1
324
+ fi
325
+ yp_emit_map_entry "${YP_STACK_PATH[depth]}" "$content" "$indent" || return 1
326
+ fi
327
+ done
328
+
329
+ # A pending node left open at end of input carried no value.
330
+ if ((YP_PENDING_ACTIVE == 1)); then
331
+ YP_TYPE["$YP_PENDING_PATH"]="null"
332
+ YP_PENDING_ACTIVE=0
333
+ fi
334
+ if ((root_seen == 0)); then
335
+ YP_STATUS="not_a_mapping"
336
+ return 1
337
+ fi
338
+ YP_STATUS="ok"
339
+ return 0
340
+ }
@@ -0,0 +1,335 @@
1
+ #!/usr/bin/env bash
2
+ # parallel-yaml-scan.sh: scanning half of the hand-written parser for the
3
+ # restricted block-YAML subset that a machine-authored parallel-run manifest is
4
+ # written in. It owns line splitting, frontmatter fence extraction, scalar
5
+ # lexing, and the node-table storage and accessors; the structural half that
6
+ # walks indentation and populates the table lives in parallel-yaml-emit.sh,
7
+ # which sources this file. The pair exists so the manifest validator can run in
8
+ # a destination workspace that has no Python, no yq, and no Node; it is
9
+ # deliberately NOT a general YAML implementation and fails closed on every
10
+ # construct outside the subset.
11
+ #
12
+ # The module is split across two files because the combined implementation
13
+ # exceeds the 500-line ceiling in .claude/rules/general-code-change.md.
14
+ #
15
+ # Accepted subset:
16
+ # - block mappings `key: value` and `key:` followed by an indented block,
17
+ # - block sequences `- scalar`, `- key: value` (compact mapping), and `-`
18
+ # followed by an indented block,
19
+ # - two-space indentation steps, spaces only,
20
+ # - empty flow collections `[]` and `{}`,
21
+ # - scalars: null (`~`, `null`, empty), the YAML 1.1 boolean words, decimal
22
+ # integers matching `[-+]?(0|[1-9][0-9]*)`, single-quoted and double-quoted
23
+ # strings without escape sequences, and plain strings,
24
+ # - full-line `#` comments and blank lines.
25
+ #
26
+ # Rejected fail-closed (YP_STATUS=out_of_subset): non-empty flow collections,
27
+ # anchors, aliases, tags, block scalars (`|`, `>`), floats, unquoted dates and
28
+ # timestamps, `.inf`/`.nan`, escape sequences inside double-quoted scalars,
29
+ # trailing comments, and multi-document streams. Rejecting these is the point:
30
+ # silently mis-parsing one would produce a validator verdict that disagrees with
31
+ # the Python authority, which is worse than refusing to answer.
32
+ #
33
+ # Reported as a YAML error (YP_STATUS=yaml_error): unterminated quotes, tab
34
+ # indentation, misaligned indentation, and lines that are neither a mapping
35
+ # entry nor a sequence entry. The manifest validator renders these through the
36
+ # M1 "frontmatter is not valid YAML" message, whose text is a declared
37
+ # divergence class scoped to its prefix.
38
+ #
39
+ # Output model. A successful parse populates a node table addressed by path:
40
+ # `parallel`, `items`, `items[0]`, `items[0].blast_radius.paths[1]`. Document
41
+ # order is preserved in the indexed array YP_ORDER; the associative arrays are
42
+ # used for lookup only and are never iterated, so no hash order reaches output.
43
+ #
44
+ # shellcheck disable=SC2034
45
+ # SC2034 is disabled file-wide because this module is the storage half of a
46
+ # two-file library: the node table and the parse-status globals declared below
47
+ # are written here and read by parallel-yaml-emit.sh, parallel-items-validate.sh,
48
+ # and parallel-manifest-validate.sh, which shellcheck analyses as separate
49
+ # files and therefore cannot see the use.
50
+
51
+ # Node paths in document order. Every ordered traversal walks this array.
52
+ YP_ORDER=()
53
+
54
+ # Lexical type per path: map, seq, null, bool, int, float, or str.
55
+ declare -gA YP_TYPE=()
56
+
57
+ # Decoded scalar value per path; empty string for container nodes.
58
+ declare -gA YP_VALUE=()
59
+
60
+ # Mapping-key name per path, for nodes that came from a `key:` entry.
61
+ declare -gA YP_KEY=()
62
+
63
+ # Rendered path of the mapping that owns each key node; `<root>` at the top.
64
+ declare -gA YP_PARENT=()
65
+
66
+ # Child count per container path: sequence length or mapping key count.
67
+ declare -gA YP_COUNT=()
68
+
69
+ # Parse outcome: ok, not_a_mapping, yaml_error, or out_of_subset.
70
+ YP_STATUS="ok"
71
+
72
+ # Human-readable detail for a yaml_error or out_of_subset outcome.
73
+ YP_DETAIL=""
74
+
75
+ # Source lines produced by yp_split_lines.
76
+ YP_LINES=()
77
+
78
+ # Frontmatter body lines produced by yp_extract_frontmatter_body.
79
+ YP_BODY_LINES=()
80
+
81
+ # The single M1 error produced by a failed fence extraction.
82
+ YP_FENCE_ERROR=""
83
+
84
+ # Scratch outputs of yp_classify_scalar.
85
+ YP_SCALAR_TYPE=""
86
+ YP_SCALAR_VALUE=""
87
+
88
+ # The fence line that opens and closes a frontmatter block.
89
+ YP_FENCE="---"
90
+
91
+ yp_trim() {
92
+ # Echo a string with leading and trailing whitespace removed.
93
+ #
94
+ # Args: $1 = the raw string. Mirrors Python's str.strip() for the ASCII
95
+ # whitespace the manifest subset can contain.
96
+ local value="$1"
97
+ value=${value#"${value%%[![:space:]]*}"}
98
+ value=${value%"${value##*[![:space:]]}"}
99
+ printf '%s' "$value"
100
+ }
101
+
102
+ yp_split_lines() {
103
+ # Populate YP_LINES by splitting text on any of the three line terminators.
104
+ #
105
+ # The CRLF replacement runs first so a carriage-return/line-feed pair is
106
+ # consumed as one terminator rather than as a CR followed by an empty line,
107
+ # matching the ordered alternation in the Python module's regex.
108
+ #
109
+ # Args: $1 = raw document text.
110
+ local text="$1"
111
+ text=${text//$'\r\n'/$'\n'}
112
+ text=${text//$'\r'/$'\n'}
113
+ YP_LINES=()
114
+ local line
115
+ # A here-string appends one newline, so a text that already ends in a
116
+ # terminator yields the same trailing empty element Python's split does.
117
+ while IFS= read -r line; do
118
+ YP_LINES+=("$line")
119
+ done <<<"$text"
120
+ }
121
+
122
+ yp_extract_frontmatter_body() {
123
+ # Populate YP_BODY_LINES with the leading frontmatter block (invariant M1).
124
+ #
125
+ # Args: $1 = raw document text.
126
+ # Returns 0 on success. On failure returns 1 and sets YP_FENCE_ERROR to the
127
+ # single M1 error string, using the caller-supplied context prefix in $2.
128
+ local text="$1" context="$2"
129
+ yp_split_lines "$text"
130
+ YP_BODY_LINES=()
131
+ YP_FENCE_ERROR=""
132
+ if ((${#YP_LINES[@]} == 0)) || [[ $(yp_trim "${YP_LINES[0]}") != "$YP_FENCE" ]]; then
133
+ YP_FENCE_ERROR="$context must open with a '$YP_FENCE' frontmatter fence."
134
+ return 1
135
+ fi
136
+ local index
137
+ # Scan forward for the first closing fence: everything between the two
138
+ # fences is the body, and no second fence means the block is unterminated.
139
+ for ((index = 1; index < ${#YP_LINES[@]}; index++)); do
140
+ if [[ $(yp_trim "${YP_LINES[index]}") == "$YP_FENCE" ]]; then
141
+ local body_index
142
+ for ((body_index = 1; body_index < index; body_index++)); do
143
+ YP_BODY_LINES+=("${YP_LINES[body_index]}")
144
+ done
145
+ return 0
146
+ fi
147
+ done
148
+ YP_FENCE_ERROR="$context frontmatter block is not terminated by '$YP_FENCE'."
149
+ return 1
150
+ }
151
+
152
+ yp_reject() {
153
+ # Record an out-of-subset rejection and return 1.
154
+ #
155
+ # Args: $1 = detail describing the refused construct.
156
+ YP_STATUS="out_of_subset"
157
+ YP_DETAIL="$1"
158
+ return 1
159
+ }
160
+
161
+ yp_yaml_error() {
162
+ # Record a YAML-level parse failure and return 1.
163
+ #
164
+ # Args: $1 = detail describing the malformed input.
165
+ YP_STATUS="yaml_error"
166
+ YP_DETAIL="$1"
167
+ return 1
168
+ }
169
+
170
+ yp_classify_scalar() {
171
+ # Classify one scalar token, setting YP_SCALAR_TYPE and YP_SCALAR_VALUE.
172
+ #
173
+ # Args: $1 = the raw token with surrounding whitespace already trimmed.
174
+ # Returns 0 when the token is inside the subset, 1 otherwise, with
175
+ # YP_STATUS and YP_DETAIL set by yp_reject or yp_yaml_error.
176
+ local raw="$1"
177
+ YP_SCALAR_TYPE=""
178
+ YP_SCALAR_VALUE=""
179
+
180
+ # Quoted forms are decided first: a quote character makes every later
181
+ # lexical rule inapplicable, and an unterminated quote is a YAML error
182
+ # rather than an out-of-subset construct.
183
+ if [[ $raw == '"'* ]]; then
184
+ if [[ ${#raw} -lt 2 || $raw != *'"' ]]; then
185
+ yp_yaml_error "unterminated double-quoted scalar: $raw"
186
+ return 1
187
+ fi
188
+ local body=${raw:1:${#raw}-2}
189
+ local backslash=$'\\'
190
+ if [[ $body == *"$backslash"* || $body == *'"'* ]]; then
191
+ yp_reject "escape sequences inside double-quoted scalars are outside the subset"
192
+ return 1
193
+ fi
194
+ YP_SCALAR_TYPE="str"
195
+ YP_SCALAR_VALUE="$body"
196
+ return 0
197
+ fi
198
+ if [[ $raw == "'"* ]]; then
199
+ if [[ ${#raw} -lt 2 || $raw != *"'" ]]; then
200
+ yp_yaml_error "unterminated single-quoted scalar: $raw"
201
+ return 1
202
+ fi
203
+ local single_body=${raw:1:${#raw}-2}
204
+ YP_SCALAR_TYPE="str"
205
+ YP_SCALAR_VALUE="${single_body//\'\'/\'}"
206
+ return 0
207
+ fi
208
+
209
+ # Structural sigils that introduce constructs this parser does not model.
210
+ case "$raw" in
211
+ '&'* | '*'* | '!'* | '|'* | '>'* | '?'* | '%'* | '@'* | '\`'*)
212
+ yp_reject "value begins with the unsupported YAML indicator: $raw"
213
+ return 1
214
+ ;;
215
+ '[]' | '{}') ;;
216
+ '['* | '{'*)
217
+ yp_reject "non-empty flow collections are outside the subset: $raw"
218
+ return 1
219
+ ;;
220
+ esac
221
+
222
+ # Null, then boolean, then integer: the resolver order PyYAML applies to a
223
+ # plain scalar, restricted to the members the manifest subset admits.
224
+ case "$raw" in
225
+ '' | '~' | null | Null | NULL)
226
+ YP_SCALAR_TYPE="null"
227
+ YP_SCALAR_VALUE=""
228
+ return 0
229
+ ;;
230
+ true | True | TRUE | yes | Yes | YES | on | On | ON)
231
+ YP_SCALAR_TYPE="bool"
232
+ YP_SCALAR_VALUE="true"
233
+ return 0
234
+ ;;
235
+ false | False | FALSE | no | No | NO | off | Off | OFF)
236
+ YP_SCALAR_TYPE="bool"
237
+ YP_SCALAR_VALUE="false"
238
+ return 0
239
+ ;;
240
+ .inf | .Inf | .INF | -.inf | -.Inf | -.INF | .nan | .NaN | .NAN)
241
+ yp_reject "special float values are outside the subset: $raw"
242
+ return 1
243
+ ;;
244
+ esac
245
+
246
+ if [[ $raw =~ ^[-+]?(0|[1-9][0-9]*)$ ]]; then
247
+ YP_SCALAR_TYPE="int"
248
+ YP_SCALAR_VALUE="${raw#+}"
249
+ return 0
250
+ fi
251
+
252
+ # A token that looks numeric but is not a clean decimal integer is refused
253
+ # rather than silently demoted to a string, because PyYAML would resolve
254
+ # many of these to int, float, or datetime.
255
+ if [[ $raw =~ ^[-+]?[0-9][0-9_]*$ ]] ||
256
+ [[ $raw =~ ^[-+]?0[xXoObB] ]] ||
257
+ [[ $raw =~ ^[-+]?[0-9]*\.[0-9]* ]] ||
258
+ [[ $raw =~ ^[-+]?[0-9]+[eE][-+]?[0-9]+$ ]] ||
259
+ [[ $raw =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2} ]]; then
260
+ yp_reject "numeric, float, or timestamp scalar outside the subset: $raw"
261
+ return 1
262
+ fi
263
+
264
+ YP_SCALAR_TYPE="str"
265
+ YP_SCALAR_VALUE="$raw"
266
+ return 0
267
+ }
268
+
269
+ yp_node_add() {
270
+ # Register one node in the table and append it to the document order.
271
+ #
272
+ # Args: $1 = path, $2 = type, $3 = value, $4 = owning-map path or empty,
273
+ # $5 = mapping key name or empty.
274
+ local path="$1" type="$2" value="$3" parent="$4" key="$5"
275
+ YP_ORDER+=("$path")
276
+ YP_TYPE["$path"]="$type"
277
+ YP_VALUE["$path"]="$value"
278
+ if [[ -n $key ]]; then
279
+ YP_KEY["$path"]="$key"
280
+ YP_PARENT["$path"]="$parent"
281
+ fi
282
+ if [[ $type == map || $type == seq ]]; then
283
+ YP_COUNT["$path"]=0
284
+ fi
285
+ }
286
+
287
+ yp_reset() {
288
+ # Clear the node table so a fresh parse starts from an empty state.
289
+ YP_ORDER=()
290
+ YP_TYPE=()
291
+ YP_VALUE=()
292
+ YP_KEY=()
293
+ YP_PARENT=()
294
+ YP_COUNT=()
295
+ YP_STATUS="ok"
296
+ YP_DETAIL=""
297
+ }
298
+
299
+ yp_has() {
300
+ # Return 0 when a path exists in the node table.
301
+ #
302
+ # Args: $1 = path.
303
+ [[ -n ${YP_TYPE["$1"]+set} ]]
304
+ }
305
+
306
+ yp_type_of() {
307
+ # Echo the lexical type at a path, or `absent` when the path is unknown.
308
+ #
309
+ # Args: $1 = path.
310
+ if yp_has "$1"; then
311
+ printf '%s' "${YP_TYPE["$1"]}"
312
+ else
313
+ printf 'absent'
314
+ fi
315
+ }
316
+
317
+ yp_value_of() {
318
+ # Echo the decoded scalar value at a path, or the empty string when absent.
319
+ #
320
+ # Args: $1 = path.
321
+ if yp_has "$1"; then
322
+ printf '%s' "${YP_VALUE["$1"]}"
323
+ fi
324
+ }
325
+
326
+ yp_count_of() {
327
+ # Echo the child count at a container path, or 0 when the path is absent.
328
+ #
329
+ # Args: $1 = path.
330
+ if [[ -n ${YP_COUNT["$1"]+set} ]]; then
331
+ printf '%s' "${YP_COUNT["$1"]}"
332
+ else
333
+ printf '0'
334
+ fi
335
+ }