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,509 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+ /**
5
+ * scripts/build-spec-graph.mjs — ForgeDock Spec Knowledge Graph builder
6
+ *
7
+ * Builds a queryable, zero-dependency self-map of ForgeDock's own command
8
+ * specs (commands/*.md), scripts (scripts/*.sh), and devdocs (devdocs/**\/*.md)
9
+ * into a single JSON document at `.forgedock/graph/spec-graph.json`.
10
+ *
11
+ * The graph lets agents load only task-relevant specs and run impact analysis
12
+ * on the pipeline's information flow (which command writes which FORGE
13
+ * annotation, which command reads it, which labels a command transitions, etc).
14
+ *
15
+ * ZERO DEPENDENCIES: Node.js built-ins only (fs, path, url). No rg/jq
16
+ * subprocess, no tree-sitter, no SQLite, no MCP. The `rg`-equivalent scan is
17
+ * done in-process so the builder is a single portable artifact.
18
+ *
19
+ * Usage:
20
+ * node scripts/build-spec-graph.mjs [--root <repo-root>] [--out <path>] [--stdout] [--hash] [--quiet] [--help]
21
+ *
22
+ * Options:
23
+ * --root <dir> Repo root to scan (default: parent of this script's dir)
24
+ * --out <path> Output JSON path (default: <root>/.forgedock/graph/spec-graph.json)
25
+ * --stdout Print the graph JSON to stdout instead of writing a file
26
+ * --hash Print ONLY the input fingerprint (sha256 of the scanned spec
27
+ * corpus) to stdout and exit. No graph is built or written.
28
+ * This is the cheap staleness-probe used by graph-query.sh.
29
+ * --quiet Suppress the summary + self-check output on stderr
30
+ * --help Show this help
31
+ *
32
+ * Output node types:
33
+ * command — a top-level commands/*.md spec
34
+ * sub-phase — a nested commands/<cmd>/.../*.md spec (e.g. work-on:build:implement)
35
+ * annotation— a distinct FORGE:* HTML-comment marker
36
+ * label — a distinct workflow:* label
37
+ * script — a scripts/*.sh file on disk
38
+ * devdoc — a devdocs/**\/*.md reference doc
39
+ *
40
+ * Output edge types (all command/sub-phase -> X):
41
+ * WRITES — command posts a FORGE annotation (gh ... comment --body "<!-- FORGE:X -->")
42
+ * READS — command consumes a FORGE annotation (contains("FORGE:X") / "read the FORGE:X")
43
+ * TRANSITIONS — command sets a workflow label (--add-label "workflow:X")
44
+ * CONTAINS — command -> sub-phase (directory nesting + Skill(skill="X") invocations)
45
+ * INVOKES — command runs a script (references a real scripts/*.sh file)
46
+ * REQUIRES — command/spec must read a devdoc (authority: required)
47
+ *
48
+ * Determinism: all node/edge arrays are sorted by a stable composite key and
49
+ * JSON is emitted with sorted object keys, so re-runs produce byte-identical
50
+ * output (idempotent). See docs/spec-graph-schema.md for the full schema.
51
+ *
52
+ * Staleness: the graph carries `builtFromHash`, a sha256 fingerprint of the
53
+ * scanned input corpus (every commands/scripts/devdocs file's repo-relative
54
+ * path + content, in sorted order). It is purely input-derived — no timestamps
55
+ * or absolute paths — so identical inputs yield an identical hash and the output
56
+ * stays byte-identical. graph-query.sh recomputes this hash (via `--hash`) on
57
+ * query and rebuilds the graph if the persisted fingerprint no longer matches.
58
+ * No daemon / file-watcher: staleness is checked pull-based, on demand.
59
+ */
60
+
61
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from "node:fs";
62
+ import { join, dirname, relative, basename, sep } from "node:path";
63
+ import { fileURLToPath } from "node:url";
64
+ import { createHash } from "node:crypto";
65
+
66
+ const SCHEMA_VERSION = 1;
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Argument parsing
70
+ // ---------------------------------------------------------------------------
71
+
72
+ const args = process.argv.slice(2);
73
+ const opts = { root: null, out: null, stdout: false, quiet: false, hash: false };
74
+ for (let i = 0; i < args.length; i++) {
75
+ const a = args[i];
76
+ if (a === "--help" || a === "-h") {
77
+ printHelp();
78
+ process.exit(0);
79
+ } else if (a === "--root") {
80
+ opts.root = args[++i];
81
+ } else if (a === "--out") {
82
+ opts.out = args[++i];
83
+ } else if (a === "--stdout") {
84
+ opts.stdout = true;
85
+ } else if (a === "--hash") {
86
+ opts.hash = true;
87
+ } else if (a === "--quiet") {
88
+ opts.quiet = true;
89
+ } else {
90
+ console.error(`Unknown argument: ${a}`);
91
+ printHelp();
92
+ process.exit(2);
93
+ }
94
+ }
95
+
96
+ function printHelp() {
97
+ // Print the leading JSDoc block (between the first /** and */).
98
+ const self = readFileSync(fileURLToPath(import.meta.url), "utf8");
99
+ const m = self.match(/\/\*\*([\s\S]*?)\*\//);
100
+ if (m) {
101
+ console.log(
102
+ m[1]
103
+ .split("\n")
104
+ .map((l) => l.replace(/^\s*\*?/, "").trimEnd())
105
+ .join("\n")
106
+ .trim(),
107
+ );
108
+ }
109
+ }
110
+
111
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
112
+ const ROOT = opts.root ? opts.root : dirname(SCRIPT_DIR);
113
+ const OUT = opts.out ? opts.out : join(ROOT, ".forgedock", "graph", "spec-graph.json");
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Filesystem helpers (zero-dependency recursive glob)
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /** Recursively list files under `dir` matching `predicate(relPath)`. Returns
120
+ * repo-relative POSIX paths, sorted. Missing dirs return []. */
121
+ function walk(dir, predicate) {
122
+ const out = [];
123
+ let entries;
124
+ try {
125
+ entries = readdirSync(dir, { withFileTypes: true });
126
+ } catch {
127
+ return out;
128
+ }
129
+ for (const ent of entries) {
130
+ const full = join(dir, ent.name);
131
+ if (ent.isDirectory()) {
132
+ if (ent.name === "node_modules" || ent.name.startsWith(".")) continue;
133
+ out.push(...walk(full, predicate));
134
+ } else if (ent.isFile()) {
135
+ const rel = toPosix(relative(ROOT, full));
136
+ if (predicate(rel)) out.push(rel);
137
+ }
138
+ }
139
+ return out.sort();
140
+ }
141
+
142
+ function toPosix(p) {
143
+ return p.split(sep).join("/");
144
+ }
145
+
146
+ function readFile(rel) {
147
+ try {
148
+ return readFileSync(join(ROOT, rel), "utf8");
149
+ } catch {
150
+ return "";
151
+ }
152
+ }
153
+
154
+ function exists(rel) {
155
+ try {
156
+ statSync(join(ROOT, rel));
157
+ return true;
158
+ } catch {
159
+ return false;
160
+ }
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Input discovery + fingerprint (shared by the full build and `--hash` mode)
165
+ // ---------------------------------------------------------------------------
166
+
167
+ /**
168
+ * Discover the three input corpora the graph is built from. The full build and
169
+ * the `--hash` staleness probe MUST call this same helper so their file sets
170
+ * can never drift (drift would make every query false-positive as stale).
171
+ * Returns { commandFiles, scriptFiles, devdocFiles } — each a sorted array of
172
+ * repo-relative POSIX paths.
173
+ */
174
+ function discoverInputFiles() {
175
+ return {
176
+ commandFiles: walk(join(ROOT, "commands"), (rel) => rel.endsWith(".md")),
177
+ scriptFiles: walk(join(ROOT, "scripts"), (rel) => rel.endsWith(".sh")),
178
+ devdocFiles: walk(join(ROOT, "devdocs"), (rel) => rel.endsWith(".md")),
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Compute the deterministic input fingerprint over a discovered file set.
184
+ *
185
+ * The hash is sha256 over each file's repo-relative path + content, in a single
186
+ * sorted order across all three corpora, with NUL separators so no path/content
187
+ * boundary is ambiguous. It is purely input-derived: no timestamps, no mtimes,
188
+ * no absolute paths — so identical inputs always yield the same hash and the
189
+ * emitted graph stays byte-identical (idempotent).
190
+ */
191
+ function computeInputHash(files) {
192
+ const allRel = [...files.commandFiles, ...files.scriptFiles, ...files.devdocFiles].sort();
193
+ const h = createHash("sha256");
194
+ for (const rel of allRel) {
195
+ h.update(rel);
196
+ h.update("\0");
197
+ h.update(readFile(rel));
198
+ h.update("\0");
199
+ }
200
+ return h.digest("hex");
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Node-id derivation
205
+ // ---------------------------------------------------------------------------
206
+
207
+ /**
208
+ * Derive a command/sub-phase node id from a `commands/...` markdown relpath.
209
+ * commands/work-on.md -> { id: "cmd:work-on", type: "command", name: "work-on" }
210
+ * commands/work-on/build.md -> { id: "cmd:work-on:build", type: "sub-phase", name: "work-on:build" }
211
+ * commands/work-on/build/implement.md -> { id: "cmd:work-on:build:implement", type: "sub-phase", name: "work-on:build:implement" }
212
+ */
213
+ function commandNodeFromPath(rel) {
214
+ // strip "commands/" prefix and ".md" suffix
215
+ const stem = rel.replace(/^commands\//, "").replace(/\.md$/, "");
216
+ const parts = stem.split("/");
217
+ const name = parts.join(":");
218
+ const isTopLevel = parts.length === 1;
219
+ return {
220
+ id: `cmd:${name}`,
221
+ type: isTopLevel ? "command" : "sub-phase",
222
+ name,
223
+ path: rel,
224
+ };
225
+ }
226
+
227
+ /** Map a Skill(skill="X") target to a command node id. The skill name uses the
228
+ * same `:`-delimited convention as our sub-phase names (e.g. "work-on:build").
229
+ * Specs also write the `/`-delimited form (e.g. "work-on/review"); normalize it
230
+ * to the colon form so both resolve to the same node (kept in sync with the
231
+ * dangling-ref check in validate-spec-graph.sh). */
232
+ function skillTargetToId(skill) {
233
+ return `cmd:${skill.replace(/\//g, ":")}`;
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Build the graph
238
+ // ---------------------------------------------------------------------------
239
+
240
+ function build() {
241
+ const nodes = new Map(); // id -> node object
242
+ const edgeSet = new Set(); // dedupe key -> true
243
+ const edges = [];
244
+
245
+ function addNode(node) {
246
+ if (!nodes.has(node.id)) nodes.set(node.id, node);
247
+ return nodes.get(node.id);
248
+ }
249
+
250
+ function addEdge(from, type, to, evidence) {
251
+ const key = `${from}|${type}|${to}`;
252
+ if (edgeSet.has(key)) return;
253
+ edgeSet.add(key);
254
+ edges.push({ from, type, to, ...(evidence ? { evidence } : {}) });
255
+ }
256
+
257
+ // --- Discover files -------------------------------------------------------
258
+ // Use the shared discovery helper so the full build and the `--hash` probe
259
+ // operate on an identical file set (otherwise queries false-positive stale).
260
+ const inputFiles = discoverInputFiles();
261
+ const { commandFiles, scriptFiles, devdocFiles } = inputFiles;
262
+
263
+ // Input fingerprint — sha256 of the scanned corpus (path+content, sorted).
264
+ const builtFromHash = computeInputHash(inputFiles);
265
+
266
+ // Known real script basenames (for INVOKES resolution).
267
+ const realScripts = new Set(scriptFiles.map((rel) => basename(rel)));
268
+
269
+ // --- Command / sub-phase nodes -------------------------------------------
270
+ const commandNodes = commandFiles.map(commandNodeFromPath);
271
+ for (const n of commandNodes) addNode(n);
272
+
273
+ // --- Script nodes ---------------------------------------------------------
274
+ for (const rel of scriptFiles) {
275
+ addNode({ id: `script:${basename(rel)}`, type: "script", name: basename(rel), path: rel });
276
+ }
277
+
278
+ // --- Devdoc nodes ---------------------------------------------------------
279
+ for (const rel of devdocFiles) {
280
+ addNode({ id: `devdoc:${rel}`, type: "devdoc", name: rel.replace(/^devdocs\//, ""), path: rel });
281
+ }
282
+
283
+ // Regexes (module-level reuse is fine; we recreate with /g per-scan to reset lastIndex)
284
+ const ANNOTATION_RE = /FORGE:[A-Z][A-Z0-9_]*/g;
285
+ const LABEL_RE = /workflow:[a-z][a-z0-9-]*/g;
286
+
287
+ // --- First pass: register all annotation + label nodes from the whole corpus
288
+ // (so even annotations that are only ever READ get a node). ------------
289
+ const allSpecText = commandFiles.map(readFile);
290
+ for (const text of allSpecText) {
291
+ for (const m of text.matchAll(ANNOTATION_RE)) {
292
+ addNode({ id: `ann:${m[0]}`, type: "annotation", name: m[0] });
293
+ }
294
+ for (const m of text.matchAll(LABEL_RE)) {
295
+ addNode({ id: `label:${m[0]}`, type: "label", name: m[0] });
296
+ }
297
+ }
298
+
299
+ // --- Second pass: per-command edge extraction ----------------------------
300
+ for (let ci = 0; ci < commandFiles.length; ci++) {
301
+ const rel = commandFiles[ci];
302
+ const node = commandNodes[ci];
303
+ const fromId = node.id;
304
+ const text = allSpecText[ci];
305
+ const lines = text.split("\n");
306
+
307
+ for (let li = 0; li < lines.length; li++) {
308
+ const line = lines[li];
309
+ const next = li + 1 < lines.length ? lines[li + 1] : "";
310
+
311
+ // WRITES: command posts a FORGE annotation via `gh ... comment --body`.
312
+ // The marker may be on the same line as --body or on the next line.
313
+ if (/gh\s+(issue|pr)\s+comment[\s\S]*--body/.test(line)) {
314
+ const window = line + "\n" + next;
315
+ // Only count a FORGE marker that appears inside an HTML comment posted
316
+ // as the comment body (i.e. immediately after --body, possibly quoted).
317
+ const bodyTail = window.slice(window.indexOf("--body"));
318
+ // Match both the plain marker `<!-- FORGE:X -->` and the value-carrying
319
+ // handshake form `<!-- FORGE:X: <value> -->` (e.g. FORGE:KNOWLEDGE_GIST,
320
+ // FORGE:MILESTONE_INDEX, FORGE:PRIOR_GIST). The `:\s+` (colon + space)
321
+ // requirement distinguishes a value handshake from a `:COMPLETE`/`:PARTIAL`
322
+ // sentinel suffix (e.g. `FORGE:PHASE:COMPLETE`), which is NOT a write.
323
+ for (const m of bodyTail.matchAll(/<!--\s*(FORGE:[A-Z][A-Z0-9_]*)\s*(?::\s+[^>]*?)?\s*-->/g)) {
324
+ addEdge(fromId, "WRITES", `ann:${m[1]}`, { file: rel, line: li + 1 });
325
+ }
326
+ }
327
+
328
+ // READS: command consumes a FORGE annotation.
329
+ // jq forms: contains("FORGE:X"), contains("FORGE:X:"),
330
+ // test("<!-- FORGE:X: ..."), capture("<!-- FORGE:X: ...")
331
+ // prose form: read[s]/re-read the FORGE:X
332
+ // The `[^"']*?` prefix lets the value-carrying handshake forms (which embed
333
+ // the marker after `<!-- `) be recognized, not just bare contains().
334
+ for (const m of line.matchAll(/\b(?:contains|test|capture)\(\s*["'][^"']*?(FORGE:[A-Z][A-Z0-9_]*)/g)) {
335
+ addEdge(fromId, "READS", `ann:${m[1]}`, { file: rel, line: li + 1 });
336
+ }
337
+ for (const m of line.matchAll(/\bre-?reads?\b[^.\n]*?(FORGE:[A-Z][A-Z0-9_]*)/gi)) {
338
+ addEdge(fromId, "READS", `ann:${m[1]}`, { file: rel, line: li + 1 });
339
+ }
340
+
341
+ // TRANSITIONS: command sets a workflow label.
342
+ for (const m of line.matchAll(/--add-label\s+["']([^"']*workflow:[a-z0-9-]+[^"']*)["']/g)) {
343
+ // a single --add-label may carry a comma-separated list
344
+ for (const lm of m[1].matchAll(LABEL_RE)) {
345
+ addEdge(fromId, "TRANSITIONS", `label:${lm[0]}`, { file: rel, line: li + 1 });
346
+ }
347
+ }
348
+
349
+ // CONTAINS (Skill invocation): command -> sub-command/skill it invokes.
350
+ // Matches all invocation forms — positional Skill("X"), keyword
351
+ // Skill(skill="X"), and colon Skill(skill: "X") — with `/`-delimited
352
+ // sub-phase targets and uppercase/leading-digit names. Kept in sync with
353
+ // the dangling-ref check in validate-spec-graph.sh.
354
+ for (const m of line.matchAll(/Skill\(\s*(?:skill\s*[:=]\s*)?["']([A-Za-z0-9][A-Za-z0-9:_/-]*)["']/g)) {
355
+ const targetId = skillTargetToId(m[1]);
356
+ // Only link if the target resolves to a known command/sub-phase node.
357
+ if (nodes.has(targetId)) {
358
+ addEdge(fromId, "CONTAINS", targetId, { file: rel, line: li + 1, via: "skill" });
359
+ }
360
+ }
361
+
362
+ // INVOKES: command references a real scripts/*.sh file.
363
+ for (const m of line.matchAll(/\b([A-Za-z0-9_-]+\.sh)\b/g)) {
364
+ if (realScripts.has(m[1])) {
365
+ addEdge(fromId, "INVOKES", `script:${m[1]}`, { file: rel, line: li + 1 });
366
+ }
367
+ }
368
+
369
+ // REQUIRES: command/spec must read a devdoc (authority: required).
370
+ // Heuristic: a line that both references a devdocs/*.md path AND carries a
371
+ // requirement verb (Read/REQUIRED/before/must read).
372
+ const devdocRefs = [...line.matchAll(/devdocs\/[A-Za-z0-9/_-]+\.md/g)];
373
+ if (devdocRefs.length && /\b(read|required|must|before)\b/i.test(line)) {
374
+ for (const m of devdocRefs) {
375
+ const ddId = `devdoc:${m[0]}`;
376
+ if (nodes.has(ddId)) {
377
+ addEdge(fromId, "REQUIRES", ddId, { file: rel, line: li + 1, authority: "required" });
378
+ }
379
+ }
380
+ }
381
+ }
382
+ }
383
+
384
+ // --- CONTAINS (directory nesting): parent spec -> nested sub-phase --------
385
+ // e.g. cmd:work-on -> cmd:work-on:build -> cmd:work-on:build:implement.
386
+ for (const n of commandNodes) {
387
+ if (n.type !== "sub-phase") continue;
388
+ const parts = n.name.split(":");
389
+ const parentName = parts.slice(0, -1).join(":");
390
+ const parentId = `cmd:${parentName}`;
391
+ if (nodes.has(parentId)) {
392
+ addEdge(parentId, "CONTAINS", n.id, { via: "directory" });
393
+ }
394
+ }
395
+
396
+ // --- Finalize: deterministic ordering ------------------------------------
397
+ const nodeArr = [...nodes.values()].sort((a, b) => cmp(a.id, b.id));
398
+ const edgeArr = edges.sort(
399
+ (a, b) => cmp(a.from, b.from) || cmp(a.type, b.type) || cmp(a.to, b.to),
400
+ );
401
+
402
+ const counts = {};
403
+ for (const n of nodeArr) counts[n.type] = (counts[n.type] || 0) + 1;
404
+ const edgeCounts = {};
405
+ for (const e of edgeArr) edgeCounts[e.type] = (edgeCounts[e.type] || 0) + 1;
406
+
407
+ return {
408
+ graph: {
409
+ schemaVersion: SCHEMA_VERSION,
410
+ generator: "build-spec-graph.mjs",
411
+ root: toPosix(relative(ROOT, ROOT)) || ".",
412
+ builtFromHash,
413
+ stats: {
414
+ nodes: nodeArr.length,
415
+ edges: edgeArr.length,
416
+ nodesByType: sortObj(counts),
417
+ edgesByType: sortObj(edgeCounts),
418
+ },
419
+ nodes: nodeArr,
420
+ edges: edgeArr,
421
+ },
422
+ };
423
+ }
424
+
425
+ function cmp(a, b) {
426
+ return a < b ? -1 : a > b ? 1 : 0;
427
+ }
428
+
429
+ function sortObj(o) {
430
+ const out = {};
431
+ for (const k of Object.keys(o).sort()) out[k] = o[k];
432
+ return out;
433
+ }
434
+
435
+ /** Deterministic JSON.stringify with recursively sorted object keys. */
436
+ function stableStringify(value, indent = 2) {
437
+ function sortKeys(v) {
438
+ if (Array.isArray(v)) return v.map(sortKeys);
439
+ if (v && typeof v === "object") {
440
+ const out = {};
441
+ for (const k of Object.keys(v).sort()) out[k] = sortKeys(v[k]);
442
+ return out;
443
+ }
444
+ return v;
445
+ }
446
+ return JSON.stringify(sortKeys(value), null, indent);
447
+ }
448
+
449
+ // ---------------------------------------------------------------------------
450
+ // Self-check: assert the acceptance spot-checks hold.
451
+ // ---------------------------------------------------------------------------
452
+
453
+ function selfCheck(graph, quiet) {
454
+ const edges = graph.graph.edges;
455
+ const has = (from, type, to) =>
456
+ edges.some((e) => e.from === from && e.type === type && e.to === to);
457
+
458
+ const hash = graph.graph.builtFromHash;
459
+ const checks = [
460
+ ["work-on WRITES FORGE:TRAJECTORY", has("cmd:work-on", "WRITES", "ann:FORGE:TRAJECTORY")],
461
+ ["review-pr READS FORGE:CONTRACT", has("cmd:review-pr", "READS", "ann:FORGE:CONTRACT")],
462
+ ["builtFromHash is a sha256 hex digest", typeof hash === "string" && /^[0-9a-f]{64}$/.test(hash)],
463
+ ];
464
+
465
+ let allPass = true;
466
+ for (const [label, ok] of checks) {
467
+ if (!ok) allPass = false;
468
+ if (!quiet) console.error(` ${ok ? "PASS" : "FAIL"} ${label}`);
469
+ }
470
+ return allPass;
471
+ }
472
+
473
+ // ---------------------------------------------------------------------------
474
+ // Main
475
+ // ---------------------------------------------------------------------------
476
+
477
+ // `--hash`: cheap staleness probe — print just the input fingerprint and exit.
478
+ // No graph is built or written. graph-query.sh uses this to detect a stale
479
+ // persisted graph without paying for a full build on the no-change path.
480
+ if (opts.hash) {
481
+ process.stdout.write(computeInputHash(discoverInputFiles()) + "\n");
482
+ process.exit(0);
483
+ }
484
+
485
+ const t0 = Date.now();
486
+ const graph = build();
487
+ const json = stableStringify(graph) + "\n";
488
+
489
+ if (opts.stdout) {
490
+ process.stdout.write(json);
491
+ } else {
492
+ mkdirSync(dirname(OUT), { recursive: true });
493
+ writeFileSync(OUT, json);
494
+ }
495
+
496
+ if (!opts.quiet) {
497
+ const ms = Date.now() - t0;
498
+ const s = graph.graph.stats;
499
+ console.error(`spec-graph: ${s.nodes} nodes, ${s.edges} edges in ${ms}ms`);
500
+ console.error(` nodesByType: ${JSON.stringify(s.nodesByType)}`);
501
+ console.error(` edgesByType: ${JSON.stringify(s.edgesByType)}`);
502
+ if (!opts.stdout) console.error(` written -> ${toPosix(relative(ROOT, OUT))}`);
503
+ }
504
+
505
+ const ok = selfCheck(graph, opts.quiet);
506
+ if (!ok) {
507
+ console.error("spec-graph: SELF-CHECK FAILED — acceptance spot-checks did not hold");
508
+ process.exit(1);
509
+ }
@@ -71,13 +71,6 @@ MILESTONE_TITLE=$(gh issue view "$ISSUE_NUMBER" "${GH_REPO_ARGS[@]}" --json mile
71
71
  }
72
72
  rm -f "$GH_STDERR_TMP"
73
73
 
74
- # Export universal script environment so per-repo scripts can call back into universal scripts.
75
- # Per-repo scripts (.forgedock/scripts/{operation}.sh) source these to delegate to universal ones.
76
- export FORGEDOCK_SCRIPTS
77
- FORGEDOCK_SCRIPTS="$(cd "$(dirname "$0")" && pwd)"
78
- export FORGEDOCK_HOME
79
- FORGEDOCK_HOME="$(cd "$(dirname "$0")/.." && pwd)"
80
-
81
74
  # Resolve staging branch name from forge.yaml (branches.staging), defaulting to "staging".
82
75
  # Uses yq if available; falls back gracefully when forge.yaml is absent or yq is not installed.
83
76
  STAGING_BRANCH=$(yq '.branches.staging // "staging"' forge.yaml 2>/dev/null || echo 'staging')