forgedock 1.0.17 → 1.0.18

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 (131) hide show
  1. package/README.md +34 -1
  2. package/bin/cross-publish.mjs +344 -0
  3. package/bin/forgedock.mjs +229 -17
  4. package/commands/autopilot.md +77 -0
  5. package/commands/design-bench.md +176 -0
  6. package/commands/design-render-critique-loop.md +103 -0
  7. package/commands/design.md +259 -0
  8. package/commands/issue.md +7 -3
  9. package/commands/orchestrate.md +121 -0
  10. package/commands/qa-sweep.md +37 -2
  11. package/commands/quality-gate.md +30 -0
  12. package/commands/review-pr-staging.md +158 -2
  13. package/commands/test-gate.md +929 -0
  14. package/commands/work-on/build/implement.md +5 -3
  15. package/commands/work-on.md +53 -3
  16. package/docs/CI.md +177 -0
  17. package/docs/FORGE-PROTOCOL.md +324 -0
  18. package/docs/articles/b-vs-c-baseline.md +210 -0
  19. package/docs/articles/full-abc-benchmark.md +205 -0
  20. package/docs/articles/selective-spec-loading-token-savings.md +140 -0
  21. package/docs/design/abc-benchmark.md +195 -0
  22. package/docs/design/design-architect-rationale.md +123 -0
  23. package/docs/design/design-memory.md +61 -0
  24. package/docs/design/design-spec-schema.md +248 -0
  25. package/docs/design/design-system-lint.md +150 -0
  26. package/docs/design/divergent-generation.md +106 -0
  27. package/docs/design/effects-appropriateness.md +133 -0
  28. package/docs/design/fixtures/briefs/README.md +47 -0
  29. package/docs/design/fixtures/briefs/cadence.md +39 -0
  30. package/docs/design/fixtures/briefs/plume.md +39 -0
  31. package/docs/design/fixtures/briefs/slipstream.md +39 -0
  32. package/docs/design/fixtures/briefs/tender.md +39 -0
  33. package/docs/design/fixtures/briefs/voltage.md +39 -0
  34. package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-1.html +364 -0
  35. package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-2.html +420 -0
  36. package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-3.html +506 -0
  37. package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-1.html +611 -0
  38. package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-2.html +594 -0
  39. package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-3.html +817 -0
  40. package/docs/design/fixtures/runs/b-vs-c-baseline/runs.json +175 -0
  41. package/docs/design/fixtures/runs/b-vs-c-baseline/scorecard.json +138 -0
  42. package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-1.html +373 -0
  43. package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-2.html +398 -0
  44. package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-3.html +595 -0
  45. package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-1.html +531 -0
  46. package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-2.html +565 -0
  47. package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-3.html +674 -0
  48. package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-1.html +388 -0
  49. package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-2.html +463 -0
  50. package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-3.html +568 -0
  51. package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-1.html +562 -0
  52. package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-2.html +618 -0
  53. package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-3.html +618 -0
  54. package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-1.html +364 -0
  55. package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-2.html +420 -0
  56. package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-3.html +506 -0
  57. package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-1.html +527 -0
  58. package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-2.html +445 -0
  59. package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-3.html +483 -0
  60. package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-1.html +611 -0
  61. package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-2.html +594 -0
  62. package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-3.html +817 -0
  63. package/docs/design/fixtures/runs/full-abc/runs.json +190 -0
  64. package/docs/design/fixtures/runs/full-abc/scorecard.json +144 -0
  65. package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-1.html +496 -0
  66. package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-2.html +447 -0
  67. package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-3.html +564 -0
  68. package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-1.html +373 -0
  69. package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-2.html +398 -0
  70. package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-3.html +595 -0
  71. package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-1.html +622 -0
  72. package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-2.html +475 -0
  73. package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-3.html +660 -0
  74. package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-1.html +531 -0
  75. package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-2.html +565 -0
  76. package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-3.html +674 -0
  77. package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-1.html +525 -0
  78. package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-2.html +446 -0
  79. package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-3.html +463 -0
  80. package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-1.html +388 -0
  81. package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-2.html +463 -0
  82. package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-3.html +568 -0
  83. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-1.html +1068 -0
  84. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-2.html +1423 -0
  85. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-3.html +1245 -0
  86. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-1.html +305 -0
  87. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-2.html +327 -0
  88. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-3.html +428 -0
  89. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-1.html +1270 -0
  90. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-2.html +1172 -0
  91. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-3.html +1532 -0
  92. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-1.html +315 -0
  93. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-2.html +342 -0
  94. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-3.html +353 -0
  95. package/docs/design/fixtures/runs/full-abc-opus/runs.json +190 -0
  96. package/docs/design/fixtures/runs/full-abc-opus/scorecard.json +144 -0
  97. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-1.html +345 -0
  98. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-2.html +396 -0
  99. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-3.html +502 -0
  100. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-1.html +408 -0
  101. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-2.html +446 -0
  102. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-3.html +496 -0
  103. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-1.html +813 -0
  104. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-2.html +711 -0
  105. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-3.html +721 -0
  106. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-1.html +280 -0
  107. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-2.html +291 -0
  108. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-3.html +366 -0
  109. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-1.html +1010 -0
  110. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-2.html +836 -0
  111. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-3.html +930 -0
  112. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-1.html +407 -0
  113. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-2.html +396 -0
  114. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-3.html +529 -0
  115. package/docs/design/foundations/brutalist.css +294 -0
  116. package/docs/design/foundations/editorial.css +300 -0
  117. package/docs/design/foundations/minimal-luxury.css +295 -0
  118. package/docs/design/foundations/technical.css +293 -0
  119. package/docs/design/foundations/warm-photo.css +299 -0
  120. package/docs/design/reference-corpus.md +1726 -0
  121. package/docs/design/render-critique-loop.md +57 -0
  122. package/docs/spec-graph-schema.md +397 -0
  123. package/package.json +1 -1
  124. package/scripts/bench-scorecard.mjs +239 -0
  125. package/scripts/build-spec-graph.mjs +509 -0
  126. package/scripts/classify-lane.sh +0 -7
  127. package/scripts/design-system-lint.mjs +993 -0
  128. package/scripts/graph-query.sh +486 -0
  129. package/scripts/transition-label.sh +10 -2
  130. package/scripts/validate-spec-graph.sh +423 -0
  131. package/templates/workflows/forgedock-review.yml +96 -0
@@ -0,0 +1,486 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # graph-query.sh — Read-only query interface over the Spec Knowledge Graph
6
+ #
7
+ # Queries the spec knowledge graph produced by `build-spec-graph.mjs`
8
+ # (`.forgedock/graph/spec-graph.json`). The graph is a self-map of ForgeDock's
9
+ # own command specs: which command WRITES/READS which FORGE annotation, which
10
+ # scripts a command INVOKES, which devdocs it REQUIRES, etc. This script is the
11
+ # consumer-facing query API so agents (and humans) don't hand-write jq.
12
+ #
13
+ # Usage:
14
+ # graph-query.sh <subcommand> <arg> [--human] [--graph <path>]
15
+ #
16
+ # Subcommands:
17
+ # readers <annotation> — commands/specs that READ the annotation
18
+ # writers <annotation> — commands/specs that WRITE (post) the annotation
19
+ # impact <node> — blast radius: everything transitively affected if
20
+ # <node> changes (annotation | label | script | command/spec)
21
+ # deps <command> — what a command reads/invokes/requires/contains
22
+ # (its full input + output set), grouped by edge type
23
+ # load-set <command> — minimal spec set to read for a command: the command
24
+ # itself plus its transitively reachable sub-phases
25
+ # (CONTAINS) and required devdocs (REQUIRES), as a flat
26
+ # list of repo-relative file paths. This is the inverse
27
+ # of `impact` (forward instead of reverse reachability)
28
+ # and powers selective spec loading in the pipeline.
29
+ # search <term> — substring lookup over node ids / names / paths
30
+ #
31
+ # Arguments are normalized, so all of these are accepted:
32
+ # readers CONTRACT == readers FORGE:CONTRACT == readers ann:FORGE:CONTRACT
33
+ # deps work-on == deps cmd:work-on
34
+ # deps work-on:build:implement == deps cmd:work-on:build:implement
35
+ # load-set work-on == load-set cmd:work-on
36
+ # impact classify-lane.sh == impact script:classify-lane.sh
37
+ # impact workflow:merged == impact label:workflow:merged
38
+ #
39
+ # Staleness detection (no daemon):
40
+ # A persisted graph carries `builtFromHash`, a sha256 fingerprint of the spec
41
+ # corpus it was built from. On each query against a persisted graph, this
42
+ # script cheaply recomputes the current fingerprint (build-spec-graph.mjs
43
+ # --hash) and compares. If they differ, the persisted graph is stale: a
44
+ # `stale-graph` banner is printed to STDERR and the graph is transparently
45
+ # rebuilt for this query (stdout stays pure JSON). The no-change path does one
46
+ # cheap hash compare and no rebuild — no background process, no watcher.
47
+ #
48
+ # Flags:
49
+ # --human Render an aligned table instead of compact JSON.
50
+ # --graph <path> Use a specific graph JSON (default:
51
+ # <repo>/.forgedock/graph/spec-graph.json). If the default is
52
+ # absent (it is gitignored), the graph is auto-built on the fly
53
+ # via build-spec-graph.mjs — no committed graph is required.
54
+ # --strict-stale Do NOT auto-rebuild a stale persisted graph — error (exit 1)
55
+ # instead. Useful in CI to assert the committed graph is fresh.
56
+ # --no-stale-check Skip the staleness check entirely (query the persisted graph
57
+ # as-is, even if the spec corpus has changed).
58
+ # -h | --help Show this help.
59
+ #
60
+ # Output:
61
+ # Default is compact, agent-consumable JSON (a JSON array for
62
+ # readers/writers/impact/search/load-set; a JSON object keyed by edge type
63
+ # for deps). load-set returns a sorted, de-duplicated array of repo-relative
64
+ # file paths. With --human, an aligned text table is printed instead.
65
+ #
66
+ # Exit codes: 0 = success, 1 = error (bad args, missing deps, unknown node).
67
+ #
68
+ # This is a UNIVERSAL-tier script (ships with the npm package). It is read-only:
69
+ # it never mutates the repo or the graph (it only writes a temp cache under
70
+ # $TMPDIR when it must auto-build, removed on exit). See
71
+ # devdocs/project/architecture.md for the scripts-layer precedence model and
72
+ # docs/spec-graph-schema.md for the graph schema.
73
+
74
+ set -euo pipefail
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Paths & dependency checks
78
+ # ---------------------------------------------------------------------------
79
+
80
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
81
+ REPO_ROOT="$(dirname "$SCRIPT_DIR")"
82
+ BUILDER="$SCRIPT_DIR/build-spec-graph.mjs"
83
+ DEFAULT_GRAPH="$REPO_ROOT/.forgedock/graph/spec-graph.json"
84
+
85
+ # Exported so per-repo adaptive scripts can delegate (see architecture.md).
86
+ export FORGEDOCK_SCRIPTS="$SCRIPT_DIR"
87
+ export FORGEDOCK_HOME="$REPO_ROOT"
88
+
89
+ print_help() {
90
+ # Echo the leading comment block (between the shebang and `set -euo`).
91
+ sed -n '3,/^set -euo pipefail/p' "${BASH_SOURCE[0]}" \
92
+ | sed '$d' \
93
+ | sed 's/^# \{0,1\}//'
94
+ }
95
+
96
+ die() {
97
+ echo "ERROR: $*" >&2
98
+ exit 1
99
+ }
100
+
101
+ require_dep() {
102
+ command -v "$1" >/dev/null 2>&1 || die "required dependency '$1' not found on PATH"
103
+ }
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Argument parsing
107
+ # ---------------------------------------------------------------------------
108
+
109
+ SUBCMD=""
110
+ ARG=""
111
+ HUMAN=0
112
+ GRAPH_PATH=""
113
+ STRICT_STALE=0
114
+ STALE_CHECK=1
115
+
116
+ if [ "$#" -eq 0 ]; then
117
+ print_help
118
+ exit 1
119
+ fi
120
+
121
+ POSITIONAL=()
122
+ while [ "$#" -gt 0 ]; do
123
+ case "$1" in
124
+ -h|--help)
125
+ print_help
126
+ exit 0
127
+ ;;
128
+ --human)
129
+ HUMAN=1
130
+ shift
131
+ ;;
132
+ --graph)
133
+ [ "$#" -ge 2 ] || die "--graph requires a path"
134
+ GRAPH_PATH="$2"
135
+ shift 2
136
+ ;;
137
+ --strict-stale)
138
+ STRICT_STALE=1
139
+ shift
140
+ ;;
141
+ --no-stale-check)
142
+ STALE_CHECK=0
143
+ shift
144
+ ;;
145
+ --)
146
+ shift
147
+ while [ "$#" -gt 0 ]; do POSITIONAL+=("$1"); shift; done
148
+ ;;
149
+ -*)
150
+ die "unknown flag: $1"
151
+ ;;
152
+ *)
153
+ POSITIONAL+=("$1")
154
+ shift
155
+ ;;
156
+ esac
157
+ done
158
+
159
+ if [ "${#POSITIONAL[@]}" -ge 1 ]; then SUBCMD="${POSITIONAL[0]}"; fi
160
+ if [ "${#POSITIONAL[@]}" -ge 2 ]; then ARG="${POSITIONAL[1]}"; fi
161
+ if [ "${#POSITIONAL[@]}" -gt 2 ]; then
162
+ die "too many arguments: expected '<subcommand> <arg>', got ${#POSITIONAL[@]} positionals"
163
+ fi
164
+
165
+ [ -n "$SUBCMD" ] || die "no subcommand given (one of: readers writers impact deps load-set search)"
166
+
167
+ case "$SUBCMD" in
168
+ readers|writers|impact|deps|load-set|search) ;;
169
+ *) die "unknown subcommand '$SUBCMD' (one of: readers writers impact deps load-set search)" ;;
170
+ esac
171
+
172
+ [ -n "$ARG" ] || die "subcommand '$SUBCMD' requires an argument"
173
+
174
+ require_dep jq
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Resolve the graph JSON (auto-build if absent — the graph is gitignored)
178
+ # ---------------------------------------------------------------------------
179
+
180
+ GRAPH=""
181
+ TMP_GRAPH=""
182
+
183
+ cleanup() {
184
+ [ -n "$TMP_GRAPH" ] && [ -f "$TMP_GRAPH" ] && rm -f "$TMP_GRAPH" || true
185
+ }
186
+ trap cleanup EXIT
187
+
188
+ # Rebuild the graph to a temp file and point GRAPH at it. Shared by the
189
+ # absent-graph auto-build path and the stale-graph rebuild path.
190
+ build_graph_to_temp() {
191
+ require_dep node
192
+ [ -f "$BUILDER" ] || die "graph is absent and builder not found: $BUILDER"
193
+ TMP_GRAPH="$(mktemp "${TMPDIR:-/tmp}/spec-graph.XXXXXX.json")"
194
+ if ! node "$BUILDER" --root "$REPO_ROOT" --stdout --quiet >"$TMP_GRAPH" 2>/dev/null; then
195
+ die "failed to build spec graph via $BUILDER"
196
+ fi
197
+ GRAPH="$TMP_GRAPH"
198
+ }
199
+
200
+ # Staleness check for a PERSISTED graph (default path or --graph). Cheaply
201
+ # recompute the current spec-corpus fingerprint and compare to the graph's
202
+ # stored builtFromHash. On mismatch: warn (stderr) and rebuild, unless
203
+ # --strict-stale (error) is set. A graph with no builtFromHash (pre-staleness
204
+ # schema) is treated as stale. Diagnostics go to stderr only; stdout stays pure.
205
+ check_staleness() {
206
+ [ "$STALE_CHECK" -eq 1 ] || return 0
207
+ # `node` is required to recompute the hash; if it is unavailable, skip the
208
+ # check rather than failing an otherwise-valid query.
209
+ command -v node >/dev/null 2>&1 || return 0
210
+ [ -f "$BUILDER" ] || return 0
211
+
212
+ local stored current
213
+ stored="$(jq -r '.graph.builtFromHash // ""' "$GRAPH" 2>/dev/null || echo "")"
214
+ current="$(node "$BUILDER" --root "$REPO_ROOT" --hash --quiet 2>/dev/null || echo "")"
215
+
216
+ # If we could not compute the current hash, do not block the query.
217
+ [ -n "$current" ] || return 0
218
+ # Fresh: stored hash matches current corpus → use the persisted graph as-is.
219
+ [ "$stored" = "$current" ] && return 0
220
+
221
+ # Stale (mismatch, or missing builtFromHash on an older graph).
222
+ if [ "$STRICT_STALE" -eq 1 ]; then
223
+ die "stale-graph: persisted graph ($GRAPH) is out of date — spec corpus changed since it was built (run: node $BUILDER). --strict-stale set, refusing to auto-rebuild."
224
+ fi
225
+ echo "stale-graph: spec corpus changed since graph was built — rebuilding for this query" >&2
226
+ build_graph_to_temp
227
+ }
228
+
229
+ resolve_graph() {
230
+ if [ -n "$GRAPH_PATH" ]; then
231
+ [ -f "$GRAPH_PATH" ] || die "graph file not found: $GRAPH_PATH"
232
+ GRAPH="$GRAPH_PATH"
233
+ check_staleness
234
+ return
235
+ fi
236
+ if [ -f "$DEFAULT_GRAPH" ]; then
237
+ GRAPH="$DEFAULT_GRAPH"
238
+ check_staleness
239
+ return
240
+ fi
241
+ # Auto-build to a temp file (the default path is gitignored / may not exist).
242
+ # A freshly built graph is current by construction — no staleness check needed.
243
+ build_graph_to_temp
244
+ }
245
+
246
+ resolve_graph
247
+
248
+ # Validate the graph document shape early so jq filters can assume it.
249
+ jq -e '.graph.nodes and .graph.edges' "$GRAPH" >/dev/null 2>&1 \
250
+ || die "not a valid spec-graph document (missing .graph.nodes/.graph.edges): $GRAPH"
251
+
252
+ # ---------------------------------------------------------------------------
253
+ # Argument normalization to canonical node ids
254
+ # annotation -> ann:FORGE:<NAME>
255
+ # label -> label:workflow:<name>
256
+ # script -> script:<file>.sh
257
+ # command -> cmd:<name[:sub[:sub]]>
258
+ # ---------------------------------------------------------------------------
259
+
260
+ normalize_ann() {
261
+ # Accept: CONTRACT | FORGE:CONTRACT | ann:FORGE:CONTRACT
262
+ local a="$1"
263
+ case "$a" in
264
+ ann:FORGE:*) echo "$a" ;;
265
+ FORGE:*) echo "ann:$a" ;;
266
+ *) echo "ann:FORGE:$a" ;;
267
+ esac
268
+ }
269
+
270
+ normalize_cmd() {
271
+ # Accept: work-on | work-on:build:implement | cmd:work-on
272
+ local c="$1"
273
+ case "$c" in
274
+ cmd:*) echo "$c" ;;
275
+ *) echo "cmd:$c" ;;
276
+ esac
277
+ }
278
+
279
+ # For `impact`, the node may be any type. Resolve to a canonical id by trying,
280
+ # in order: exact id match in the graph, then type-specific normalizers.
281
+ normalize_node() {
282
+ local n="$1"
283
+ # Already a fully-qualified id present in the graph?
284
+ if node_exists "$n"; then echo "$n"; return; fi
285
+ case "$n" in
286
+ ann:*|label:*|script:*|cmd:*|devdoc:*)
287
+ echo "$n" ;; # explicit prefix, trust it
288
+ FORGE:*)
289
+ echo "ann:$n" ;;
290
+ workflow:*)
291
+ echo "label:$n" ;;
292
+ *.sh)
293
+ echo "script:$n" ;;
294
+ *)
295
+ # Bare token: prefer annotation, then command, then label — whichever
296
+ # exists in the graph. Fall back to annotation form.
297
+ if node_exists "ann:FORGE:$n"; then echo "ann:FORGE:$n"
298
+ elif node_exists "cmd:$n"; then echo "cmd:$n"
299
+ elif node_exists "label:workflow:$n"; then echo "label:workflow:$n"
300
+ elif node_exists "script:$n"; then echo "script:$n"
301
+ else echo "ann:FORGE:$n"
302
+ fi
303
+ ;;
304
+ esac
305
+ }
306
+
307
+ node_exists() {
308
+ jq -e --arg id "$1" 'any(.graph.nodes[]; .id == $id)' "$GRAPH" >/dev/null 2>&1
309
+ }
310
+
311
+ # ---------------------------------------------------------------------------
312
+ # Queries
313
+ # ---------------------------------------------------------------------------
314
+
315
+ # List .from of edges of a given type pointing at a target node. JSON array.
316
+ query_edge_from() {
317
+ local etype="$1" target="$2"
318
+ jq -c --arg t "$etype" --arg to "$target" \
319
+ '[.graph.edges[] | select(.type == $t and .to == $to) | .from] | unique' "$GRAPH"
320
+ }
321
+
322
+ # All outbound edges from a command, grouped by edge type. JSON object.
323
+ query_deps() {
324
+ local from="$1"
325
+ jq -c --arg from "$from" '
326
+ [.graph.edges[] | select(.from == $from)]
327
+ | reduce .[] as $e ({}; .[$e.type] += [$e.to])
328
+ | map_values(unique)
329
+ ' "$GRAPH"
330
+ }
331
+
332
+ # Transitive reverse-reachability: everything that (transitively) depends on the
333
+ # seed node. Walk edges where .to is in the frontier, collecting .from, to a
334
+ # fixpoint. Excludes the seed. JSON array of node ids.
335
+ query_impact() {
336
+ local seed="$1"
337
+ jq -c --arg seed "$seed" '
338
+ .graph.edges as $edges
339
+ | def step(set):
340
+ ( [ $edges[] | select(.to as $to | set | index($to)) | .from ] ) as $froms
341
+ | (set + $froms | unique) as $next
342
+ | if ($next | length) == (set | length) then set else step($next) end;
343
+ (step([$seed]) - [$seed]) | unique
344
+ ' "$GRAPH"
345
+ }
346
+
347
+ # Transitive FORWARD-reachability: the minimal spec set to read for a command.
348
+ # Seeds at the command node, then walks CONTAINS (sub-phases) and REQUIRES
349
+ # (devdocs) edges in the forward direction (.from in frontier -> collect .to) to
350
+ # a fixpoint. The resolved node ids are mapped to their repo-relative `.path`
351
+ # (nodes without a path — e.g. annotations — are dropped). Includes the seed
352
+ # command's own path. Output is a sorted, de-duplicated JSON array of paths.
353
+ #
354
+ # This is the inverse of query_impact (forward instead of reverse) and shares
355
+ # its cycle-safe length-equality termination, so a spurious self-CONTAINS edge
356
+ # cannot cause a non-terminating walk. Only CONTAINS/REQUIRES are followed —
357
+ # READS/WRITES/TRANSITIONS/INVOKES are NOT part of the spec-read set (annotations
358
+ # and labels have no spec file; scripts are resolved separately at invoke time).
359
+ query_load_set() {
360
+ local seed="$1"
361
+ jq -c --arg seed "$seed" '
362
+ [.graph.edges[] | select(.type == "CONTAINS" or .type == "REQUIRES")] as $edges
363
+ | (reduce .graph.nodes[] as $n ({}; .[$n.id] = ($n.path // null))) as $path
364
+ | def step(set):
365
+ ( [ $edges[] | select(.from as $f | set | index($f)) | .to ] ) as $tos
366
+ | (set + $tos | unique) as $next
367
+ | if ($next | length) == (set | length) then set else step($next) end;
368
+ step([$seed])
369
+ | map($path[.] // empty)
370
+ | unique
371
+ ' "$GRAPH"
372
+ }
373
+
374
+ # Substring search over node id / name / path. JSON array of {id,type,name}.
375
+ query_search() {
376
+ local term="$1"
377
+ jq -c --arg term "$term" '
378
+ ($term | ascii_downcase) as $t
379
+ | [ .graph.nodes[]
380
+ | select(
381
+ (.id | ascii_downcase | contains($t)) or
382
+ (.name | ascii_downcase | contains($t)) or
383
+ ((.path // "") | ascii_downcase | contains($t))
384
+ )
385
+ | {id, type, name} ]
386
+ | sort_by(.id)
387
+ ' "$GRAPH"
388
+ }
389
+
390
+ # ---------------------------------------------------------------------------
391
+ # Human-readable rendering
392
+ # ---------------------------------------------------------------------------
393
+
394
+ render_list_human() {
395
+ # stdin: JSON array of strings. Print one per line, or "(none)".
396
+ local title="$1"
397
+ local rows
398
+ rows="$(jq -r '.[]' 2>/dev/null || true)"
399
+ echo "$title"
400
+ if [ -z "$rows" ]; then
401
+ echo " (none)"
402
+ else
403
+ echo "$rows" | sed 's/^/ /'
404
+ fi
405
+ }
406
+
407
+ render_deps_human() {
408
+ # stdin: JSON object {EDGE_TYPE: [to, ...]}. Group by edge type.
409
+ local obj
410
+ obj="$(cat)"
411
+ if [ "$(echo "$obj" | jq 'length')" -eq 0 ]; then
412
+ echo "(no outbound edges)"
413
+ return
414
+ fi
415
+ echo "$obj" | jq -r 'to_entries[] | "\(.key):\n" + ([.value[] | " " + .] | join("\n"))'
416
+ }
417
+
418
+ render_search_human() {
419
+ # stdin: JSON array of {id,type,name}. Aligned 2-col table.
420
+ jq -r '.[] | "\(.type)\t\(.id)"' | column -t -s $'\t' 2>/dev/null \
421
+ || jq -r '.[] | "\(.type) \(.id)"'
422
+ }
423
+
424
+ # ---------------------------------------------------------------------------
425
+ # Dispatch
426
+ # ---------------------------------------------------------------------------
427
+
428
+ case "$SUBCMD" in
429
+ readers)
430
+ target="$(normalize_ann "$ARG")"
431
+ result="$(query_edge_from READS "$target")"
432
+ if [ "$HUMAN" -eq 1 ]; then
433
+ echo "$result" | render_list_human "Specs that READ $target:"
434
+ else
435
+ echo "$result"
436
+ fi
437
+ ;;
438
+ writers)
439
+ target="$(normalize_ann "$ARG")"
440
+ result="$(query_edge_from WRITES "$target")"
441
+ if [ "$HUMAN" -eq 1 ]; then
442
+ echo "$result" | render_list_human "Specs that WRITE $target:"
443
+ else
444
+ echo "$result"
445
+ fi
446
+ ;;
447
+ deps)
448
+ from="$(normalize_cmd "$ARG")"
449
+ node_exists "$from" || die "no command/spec node '$from' in the graph (try: graph-query.sh search ${ARG})"
450
+ result="$(query_deps "$from")"
451
+ if [ "$HUMAN" -eq 1 ]; then
452
+ echo "Dependencies of $from:"
453
+ echo "$result" | render_deps_human
454
+ else
455
+ echo "$result"
456
+ fi
457
+ ;;
458
+ load-set)
459
+ from="$(normalize_cmd "$ARG")"
460
+ node_exists "$from" || die "no command/spec node '$from' in the graph (try: graph-query.sh search ${ARG})"
461
+ result="$(query_load_set "$from")"
462
+ if [ "$HUMAN" -eq 1 ]; then
463
+ echo "$result" | render_list_human "Minimal spec set for $from:"
464
+ else
465
+ echo "$result"
466
+ fi
467
+ ;;
468
+ impact)
469
+ seed="$(normalize_node "$ARG")"
470
+ node_exists "$seed" || die "no node '$seed' in the graph (try: graph-query.sh search ${ARG})"
471
+ result="$(query_impact "$seed")"
472
+ if [ "$HUMAN" -eq 1 ]; then
473
+ echo "$result" | render_list_human "Impact (blast radius) of changing $seed:"
474
+ else
475
+ echo "$result"
476
+ fi
477
+ ;;
478
+ search)
479
+ result="$(query_search "$ARG")"
480
+ if [ "$HUMAN" -eq 1 ]; then
481
+ echo "$result" | render_search_human
482
+ else
483
+ echo "$result"
484
+ fi
485
+ ;;
486
+ esac
@@ -138,8 +138,16 @@ if [ -n "${FORGE_LABEL_MAP:-}" ] && [ "${FORGE_LABEL_MAP:-}" != "{}" ]; then
138
138
  if command -v jq >/dev/null 2>&1; then
139
139
  MAPPED=$(echo "$FORGE_LABEL_MAP" | jq -r --arg key "$CANONICAL_LABEL" '.[$key] // empty' 2>/dev/null || true)
140
140
  if [ -n "$MAPPED" ]; then
141
- EFFECTIVE_LABEL="$MAPPED"
142
- echo "Label map override: $CANONICAL_LABEL → $EFFECTIVE_LABEL"
141
+ # Validate: reject any mapped value starting with '-' to prevent CLI flag injection.
142
+ # A forge.yaml learned.label_map entry like "workflow:investigating": "--json" would
143
+ # otherwise be passed directly to gh issue edit --add-label, interpreted as a flag.
144
+ if [[ "$MAPPED" == -* ]]; then
145
+ echo "ERROR: FORGE_LABEL_MAP value for '$CANONICAL_LABEL' is not a valid label name: '$MAPPED'" >&2
146
+ echo " Label names must not start with '-'. Using canonical label fallback: $CANONICAL_LABEL" >&2
147
+ else
148
+ EFFECTIVE_LABEL="$MAPPED"
149
+ echo "Label map override: $CANONICAL_LABEL → $EFFECTIVE_LABEL"
150
+ fi
143
151
  fi
144
152
  else
145
153
  echo "WARNING: FORGE_LABEL_MAP is set but jq is not available — using canonical label ($CANONICAL_LABEL)" >&2