forgedock 1.0.17 → 1.0.19
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/README.md +34 -1
- package/bin/cross-publish.mjs +344 -0
- package/bin/forgedock.mjs +229 -17
- package/commands/autopilot.md +77 -0
- package/commands/design-bench.md +176 -0
- package/commands/design-render-critique-loop.md +103 -0
- package/commands/design.md +259 -0
- package/commands/issue.md +7 -3
- package/commands/orchestrate.md +121 -0
- package/commands/{resume.md → pipeline-resume.md} +4 -4
- package/commands/{status.md → pipeline-status.md} +1 -1
- package/commands/qa-sweep.md +37 -2
- package/commands/quality-gate.md +57 -0
- package/commands/review-pr-staging.md +158 -2
- package/commands/test-gate.md +929 -0
- package/commands/work-on/build/implement.md +5 -3
- package/commands/work-on.md +53 -3
- package/docs/CI.md +177 -0
- package/docs/FORGE-PROTOCOL.md +324 -0
- package/docs/articles/b-vs-c-baseline.md +210 -0
- package/docs/articles/full-abc-benchmark.md +205 -0
- package/docs/articles/selective-spec-loading-token-savings.md +140 -0
- package/docs/design/abc-benchmark.md +195 -0
- package/docs/design/design-architect-rationale.md +123 -0
- package/docs/design/design-memory.md +61 -0
- package/docs/design/design-spec-schema.md +248 -0
- package/docs/design/design-system-lint.md +150 -0
- package/docs/design/divergent-generation.md +106 -0
- package/docs/design/effects-appropriateness.md +133 -0
- package/docs/design/fixtures/briefs/README.md +47 -0
- package/docs/design/fixtures/briefs/cadence.md +39 -0
- package/docs/design/fixtures/briefs/plume.md +39 -0
- package/docs/design/fixtures/briefs/slipstream.md +39 -0
- package/docs/design/fixtures/briefs/tender.md +39 -0
- package/docs/design/fixtures/briefs/voltage.md +39 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-1.html +364 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-2.html +420 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-3.html +506 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-1.html +611 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-2.html +594 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-3.html +817 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/runs.json +175 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/scorecard.json +138 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-1.html +373 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-2.html +398 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-3.html +595 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-1.html +531 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-2.html +565 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-3.html +674 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-1.html +388 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-2.html +463 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-3.html +568 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-1.html +562 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-2.html +618 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-3.html +618 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-1.html +364 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-2.html +420 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-3.html +506 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-1.html +527 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-2.html +445 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-3.html +483 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-1.html +611 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-2.html +594 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-3.html +817 -0
- package/docs/design/fixtures/runs/full-abc/runs.json +190 -0
- package/docs/design/fixtures/runs/full-abc/scorecard.json +144 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-1.html +496 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-2.html +447 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-3.html +564 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-1.html +373 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-2.html +398 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-3.html +595 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-1.html +622 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-2.html +475 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-3.html +660 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-1.html +531 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-2.html +565 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-3.html +674 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-1.html +525 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-2.html +446 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-3.html +463 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-1.html +388 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-2.html +463 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-3.html +568 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-1.html +1068 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-2.html +1423 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-3.html +1245 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-1.html +305 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-2.html +327 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-3.html +428 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-1.html +1270 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-2.html +1172 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-3.html +1532 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-1.html +315 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-2.html +342 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-3.html +353 -0
- package/docs/design/fixtures/runs/full-abc-opus/runs.json +190 -0
- package/docs/design/fixtures/runs/full-abc-opus/scorecard.json +144 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-1.html +345 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-2.html +396 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-3.html +502 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-1.html +408 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-2.html +446 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-3.html +496 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-1.html +813 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-2.html +711 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-3.html +721 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-1.html +280 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-2.html +291 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-3.html +366 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-1.html +1010 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-2.html +836 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-3.html +930 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-1.html +407 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-2.html +396 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-3.html +529 -0
- package/docs/design/foundations/brutalist.css +294 -0
- package/docs/design/foundations/editorial.css +300 -0
- package/docs/design/foundations/minimal-luxury.css +295 -0
- package/docs/design/foundations/technical.css +293 -0
- package/docs/design/foundations/warm-photo.css +299 -0
- package/docs/design/reference-corpus.md +1726 -0
- package/docs/design/render-critique-loop.md +57 -0
- package/docs/spec-graph-schema.md +397 -0
- package/package.json +1 -1
- package/scripts/bench-scorecard.mjs +239 -0
- package/scripts/build-spec-graph.mjs +509 -0
- package/scripts/check-native-conflicts.sh +200 -0
- package/scripts/classify-lane.sh +0 -7
- package/scripts/design-system-lint.mjs +993 -0
- package/scripts/graph-query.sh +486 -0
- package/scripts/transition-label.sh +10 -2
- package/scripts/validate-spec-graph.sh +423 -0
- 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
|
+
}
|