forgedock 1.2.0 → 1.4.0
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 +8 -2
- package/bin/batch-runner.mjs +31 -2
- package/bin/engine/admission.mjs +294 -0
- package/bin/engine/admission.test.mjs +265 -0
- package/bin/engine/invariants.mjs +17 -4
- package/bin/engine/phases.mjs +358 -42
- package/bin/engine/resolve.mjs +256 -0
- package/bin/engine/resolve.test.mjs +149 -0
- package/bin/engine/state.mjs +31 -7
- package/bin/engine-cli.mjs +138 -2
- package/bin/engine.mjs +414 -21
- package/bin/forgedock.mjs +552 -44
- package/bin/hooks/interactive-engine.mjs +50 -24
- package/bin/hooks/pre-tool-use.mjs +189 -19
- package/bin/journey.mjs +109 -11
- package/bin/labels.json +5 -0
- package/bin/runner.mjs +321 -10
- package/bin/watch-utils.mjs +255 -0
- package/commands/cleanup.md +7 -3
- package/commands/forge-stats.md +5 -4
- package/commands/issue.md +5 -1
- package/commands/orchestrate/config.md +41 -1
- package/commands/orchestrate/phase-1-resolve.md +234 -8
- package/commands/orchestrate/phase-3-dependency.md +27 -18
- package/commands/orchestrate/phase-4-execution.md +223 -22
- package/commands/qa-sweep.md +17 -0
- package/commands/quality-gate.md +6 -6
- package/commands/replay.md +16 -2
- package/commands/review-pr-agents/spec-cli.md +179 -0
- package/commands/review-pr.md +65 -0
- package/commands/upgrade-deps.md +523 -0
- package/commands/work-on/build/validate.md +61 -1
- package/commands/work-on/build.md +9 -1
- package/commands/work-on/decompose.md +10 -1
- package/commands/work-on/investigate.md +22 -4
- package/commands/work-on/remediate.md +2 -0
- package/commands/work-on/review.md +40 -1
- package/commands/work-on.md +19 -4
- package/docs/AIDER.md +264 -0
- package/docs/AUTOPILOT-CRON.md +130 -0
- package/docs/CONFIG.md +156 -0
- package/docs/CURSOR.md +135 -0
- package/docs/OPENCODE.md +312 -0
- package/docs/demo.gif +0 -0
- package/docs/eval/README.md +4 -4
- package/docs/launch/show-hn.md +3 -3
- package/docs/site/architecture.md +180 -0
- package/docs/site/command-reference.md +17 -0
- package/docs/site/configuration.md +243 -0
- package/docs/site/for-companies.md +4 -4
- package/docs/site/forge-annotation-protocol.md +1 -1
- package/docs/site/github-is-the-memory.md +2 -2
- package/docs/site/how-it-works.md +1 -1
- package/docs/site/public/llms-full.txt +380 -0
- package/docs/site/public/llms.txt +35 -0
- package/docs/spec/eval-run-result.md +8 -4
- package/docs/spec/label-state-machine.md +9 -0
- package/docs/spec-graph-schema.md +3 -3
- package/package.json +3 -3
- package/packages/protocol/README.md +75 -0
- package/packages/protocol/src/card.js +114 -0
- package/packages/protocol/src/cli.js +4 -77
- package/packages/protocol/src/emit.js +5 -11
- package/packages/protocol/src/html-comment-escape.js +68 -0
- package/packages/protocol/src/index.js +3 -1
- package/packages/protocol/src/parse.js +19 -11
- package/packages/protocol/src/phases.js +105 -0
- package/packages/protocol/src/types.js +18 -1
- package/packages/protocol/src/validate.js +11 -0
- package/scripts/check-admission-parity.mjs +167 -0
- package/scripts/check-command-side-effects.sh +376 -0
- package/scripts/check-phase-registry-drift.mjs +96 -0
- package/scripts/check-spec-markers.sh +274 -0
- package/scripts/classify-lane.sh +29 -7
- package/scripts/forge-run.sh +353 -0
- package/templates/cross-repo-trigger.yml +282 -0
- package/templates/cursor/forge.mdc +148 -0
- package/templates/github-actions/forgedock-autopilot.yml +113 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CI parity check: bin/engine/admission.mjs's `CASCADE_PRESETS` table is the
|
|
4
|
+
* typed, unit-tested reference implementation of the cascade admission
|
|
5
|
+
* policy (forge#2234). The logic actually EXECUTED at runtime is a
|
|
6
|
+
* hand-written bash/yq mirror in commands/orchestrate/phase-4-execution.md's
|
|
7
|
+
* Step 4A.pre `case "$CASCADE_POLICY_NAME" in ... esac` block — the two are
|
|
8
|
+
* kept in sync by hand, with no automated check catching divergence
|
|
9
|
+
* (forge#2340). This script closes that gap: it imports the real
|
|
10
|
+
* `CASCADE_PRESETS` table and diffs it against the values parsed out of the
|
|
11
|
+
* bash mirror, failing non-zero on any mismatch.
|
|
12
|
+
*
|
|
13
|
+
* Usage: node scripts/check-admission-parity.mjs
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const REPO_ROOT = path.resolve(__dirname, "..");
|
|
22
|
+
const ADMISSION_MJS_PATH = path.join(REPO_ROOT, "bin/engine/admission.mjs");
|
|
23
|
+
const SPEC_PATH = path.join(REPO_ROOT, "commands/orchestrate/phase-4-execution.md");
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse a single bash literal captured from the mirror's case arm into the
|
|
27
|
+
* same JS types `CASCADE_PRESETS` uses (number | "unlimited" | boolean).
|
|
28
|
+
* @param {string} raw
|
|
29
|
+
*/
|
|
30
|
+
function parseBashValue(raw) {
|
|
31
|
+
const trimmed = raw.trim().replace(/^"(.*)"$/, "$1");
|
|
32
|
+
if (trimmed === "true") return true;
|
|
33
|
+
if (trimmed === "false") return false;
|
|
34
|
+
if (trimmed === "unlimited") return "unlimited";
|
|
35
|
+
const n = Number(trimmed);
|
|
36
|
+
return Number.isFinite(n) && trimmed !== "" ? n : trimmed;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Extract each `PRESET_*` assignment from a single case-arm body.
|
|
41
|
+
* @param {string} body - text between `{name})` and the arm's `;;`
|
|
42
|
+
*/
|
|
43
|
+
function parseArmBody(body) {
|
|
44
|
+
const get = (key) => {
|
|
45
|
+
const m = body.match(new RegExp(`${key}=("?[^;"]*"?)`));
|
|
46
|
+
return m ? parseBashValue(m[1]) : undefined;
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
maxGeneration: get("PRESET_MAX_GEN"),
|
|
50
|
+
tokenBudget: get("PRESET_TOKEN_BUDGET"),
|
|
51
|
+
deferOnBatchGated: get("PRESET_DEFER_GATED"),
|
|
52
|
+
keywordHeuristic: get("PRESET_KEYWORD"),
|
|
53
|
+
p3SameFileDefer: get("PRESET_P3_SAME_FILE"),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Isolate the `case "$CASCADE_POLICY_NAME" in ... esac` block and return a
|
|
59
|
+
* map of arm-name -> resolved preset object. The wildcard `*` fallback arm
|
|
60
|
+
* is excluded (it re-declares "balanced", not a distinct preset name).
|
|
61
|
+
*/
|
|
62
|
+
function extractBashMirrorPresets(specText) {
|
|
63
|
+
const caseMatch = specText.match(/case "\$CASCADE_POLICY_NAME" in([\s\S]*?)\nesac/);
|
|
64
|
+
if (!caseMatch) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const caseBlock = caseMatch[1];
|
|
68
|
+
|
|
69
|
+
const armRe = /\n\s*([a-zA-Z0-9_*]+)\)([\s\S]*?);;/g;
|
|
70
|
+
const presets = {};
|
|
71
|
+
let m;
|
|
72
|
+
while ((m = armRe.exec(caseBlock))) {
|
|
73
|
+
const [, name, body] = m;
|
|
74
|
+
if (name === "*") continue; // wildcard fallback arm — not a named preset
|
|
75
|
+
presets[name] = parseArmBody(body);
|
|
76
|
+
}
|
|
77
|
+
return presets;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function main() {
|
|
81
|
+
let admissionModule;
|
|
82
|
+
try {
|
|
83
|
+
// pathToFileURL is required for cross-platform dynamic import() of an absolute
|
|
84
|
+
// path — a bare Windows path like "C:\..." is not a valid ESM specifier and
|
|
85
|
+
// throws ERR_UNSUPPORTED_ESM_URL_SCHEME ("Only URLs with a scheme in: file,
|
|
86
|
+
// data, and node are supported"). POSIX absolute paths import fine either way,
|
|
87
|
+
// but file:// URLs work identically on both, so always convert.
|
|
88
|
+
admissionModule = await import(pathToFileURL(ADMISSION_MJS_PATH));
|
|
89
|
+
} catch (err) {
|
|
90
|
+
console.error(`ERROR: failed to import ${ADMISSION_MJS_PATH}: ${err.message}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
const { CASCADE_PRESETS } = admissionModule;
|
|
94
|
+
if (!CASCADE_PRESETS) {
|
|
95
|
+
console.error(`ERROR: ${ADMISSION_MJS_PATH} does not export CASCADE_PRESETS — has it been renamed?`);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let specText;
|
|
100
|
+
try {
|
|
101
|
+
specText = readFileSync(SPEC_PATH, "utf8");
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.error(`ERROR: failed to read ${SPEC_PATH}: ${err.message}`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const bashPresets = extractBashMirrorPresets(specText);
|
|
108
|
+
if (!bashPresets) {
|
|
109
|
+
console.error(
|
|
110
|
+
`ERROR: could not locate the case "$CASCADE_POLICY_NAME" in ... esac bash mirror block in ${SPEC_PATH}.`,
|
|
111
|
+
);
|
|
112
|
+
console.error(
|
|
113
|
+
" (parity check has nothing to compare admission.mjs against — has the mirror been renamed or restructured?)",
|
|
114
|
+
);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let failed = false;
|
|
119
|
+
const mjsNames = Object.keys(CASCADE_PRESETS);
|
|
120
|
+
const bashNames = Object.keys(bashPresets);
|
|
121
|
+
|
|
122
|
+
for (const name of mjsNames) {
|
|
123
|
+
if (!(name in bashPresets)) {
|
|
124
|
+
console.error(
|
|
125
|
+
`MISMATCH: preset "${name}" exists in admission.mjs CASCADE_PRESETS but has no matching bash case arm in ${SPEC_PATH}`,
|
|
126
|
+
);
|
|
127
|
+
failed = true;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const mjsPreset = CASCADE_PRESETS[name];
|
|
131
|
+
const bashPreset = bashPresets[name];
|
|
132
|
+
for (const key of Object.keys(mjsPreset)) {
|
|
133
|
+
if (mjsPreset[key] !== bashPreset[key]) {
|
|
134
|
+
console.error(
|
|
135
|
+
`MISMATCH: preset "${name}".${key} — admission.mjs=${JSON.stringify(mjsPreset[key])} bash-mirror=${JSON.stringify(bashPreset[key])}`,
|
|
136
|
+
);
|
|
137
|
+
failed = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const name of bashNames) {
|
|
143
|
+
if (!(name in CASCADE_PRESETS)) {
|
|
144
|
+
console.error(
|
|
145
|
+
`MISMATCH: bash case arm "${name}" in ${SPEC_PATH} has no matching preset in admission.mjs CASCADE_PRESETS`,
|
|
146
|
+
);
|
|
147
|
+
failed = true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (failed) {
|
|
152
|
+
console.error("");
|
|
153
|
+
console.error(
|
|
154
|
+
"admission.mjs CASCADE_PRESETS and the phase-4-execution.md bash mirror have drifted out of sync.",
|
|
155
|
+
);
|
|
156
|
+
console.error(
|
|
157
|
+
"Update both together — see the admission.mjs module docstring and phase-4-execution.md Step 4A.pre ('Cascade admission policy resolution').",
|
|
158
|
+
);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
console.log(
|
|
163
|
+
`OK: admission.mjs CASCADE_PRESETS matches the phase-4-execution.md bash mirror for: ${mjsNames.join(", ")}`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
main();
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
# check-command-side-effects.sh — Scan commands/**/*.md for two classes of
|
|
6
|
+
# spec-as-code side-effect defects.
|
|
7
|
+
#
|
|
8
|
+
# Command specs are spec-as-code: they drive autonomous side effects (auto-merge,
|
|
9
|
+
# gh gist create, git push, label mutations, issue creation). This script catches
|
|
10
|
+
# two defect classes before they reach staging:
|
|
11
|
+
#
|
|
12
|
+
# Class A — Unconditionally prohibited patterns (full-corpus, always blocked):
|
|
13
|
+
# `gh gist create --public` or `gh gist edit --public` anywhere in a code block.
|
|
14
|
+
# Gists created by the pipeline MUST be secret — --public exposes private repo
|
|
15
|
+
# titles, root causes, and file paths to the world. (Ref: forge#1587)
|
|
16
|
+
#
|
|
17
|
+
# Class B — Side-effect verbs in code blocks with no DRY_RUN/governor guard (diff-aware):
|
|
18
|
+
# When added lines introduce a side-effect verb (gh issue create, gh pr merge,
|
|
19
|
+
# git push, gh issue edit|comment, --auto-merge, --add/remove-label) inside a
|
|
20
|
+
# code block that has no guard expression (DRY_RUN, GOVERNOR, --dry-run)
|
|
21
|
+
# anywhere in THAT SAME code block, the change is flagged. Guard/side-effect
|
|
22
|
+
# correlation is scoped to the individual fenced code block, not the whole
|
|
23
|
+
# section — a guard mentioned in an unrelated code block elsewhere in the
|
|
24
|
+
# same section (before or after the flagged verb) does not silence the
|
|
25
|
+
# finding. (Ref: forge#2289 — a decoy guard anywhere in the section, in any
|
|
26
|
+
# code block, previously silenced detection of an actually-unguarded side
|
|
27
|
+
# effect in a different block of that same section.)
|
|
28
|
+
# Operates on the diff (GITHUB_BASE_SHA or HEAD^) to avoid flagging legacy corpus.
|
|
29
|
+
# (Ref: forge#1609 — signal-planner.md DRY_RUN guard placed after the create it guards)
|
|
30
|
+
#
|
|
31
|
+
# Allowlist: add <!-- allowlist:check-command-side-effects --> on the same line as
|
|
32
|
+
# the side-effect verb to suppress that specific hit.
|
|
33
|
+
#
|
|
34
|
+
# Usage:
|
|
35
|
+
# check-command-side-effects.sh [--full] [<commands_dir>]
|
|
36
|
+
# --full Scan entire corpus (not just diff) for Class A violations only.
|
|
37
|
+
# Class B is diff-aware by design — --full does not change Class B.
|
|
38
|
+
# commands_dir Path to the commands/ directory (default: ./commands)
|
|
39
|
+
#
|
|
40
|
+
# Environment:
|
|
41
|
+
# GITHUB_BASE_SHA When set, used as the base commit for Class B diff mode.
|
|
42
|
+
#
|
|
43
|
+
# Exit codes:
|
|
44
|
+
# 0 no violations found
|
|
45
|
+
# 1 one or more violations found (listed to stderr)
|
|
46
|
+
# 2 usage / dependency error
|
|
47
|
+
#
|
|
48
|
+
# <!-- Added: forge#1609 -->
|
|
49
|
+
|
|
50
|
+
set -euo pipefail
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Argument parsing
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
FULL_MODE=0
|
|
57
|
+
COMMANDS_DIR="./commands"
|
|
58
|
+
|
|
59
|
+
for arg in "$@"; do
|
|
60
|
+
case "$arg" in
|
|
61
|
+
-h|--help)
|
|
62
|
+
sed -n '2,/^set -/p' "$0" | grep '^#' | sed 's/^# *//'
|
|
63
|
+
exit 0
|
|
64
|
+
;;
|
|
65
|
+
--full) FULL_MODE=1 ;;
|
|
66
|
+
-*) echo "ERROR: unknown option: $arg" >&2; exit 2 ;;
|
|
67
|
+
*) COMMANDS_DIR="$arg" ;;
|
|
68
|
+
esac
|
|
69
|
+
done
|
|
70
|
+
|
|
71
|
+
if [ ! -d "$COMMANDS_DIR" ]; then
|
|
72
|
+
echo "ERROR: commands directory not found: $COMMANDS_DIR" >&2
|
|
73
|
+
echo "Usage: $0 [--full] [<commands_dir>]" >&2
|
|
74
|
+
exit 2
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Constants
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
# Code fence marker — stored in variable to avoid backtick shell-expansion bugs.
|
|
82
|
+
# Do NOT inline as grep -E '^[[:space:]]*\`\`\`' — escaped backticks in grep
|
|
83
|
+
# patterns undergo shell expansion, turning the pattern into '^[[:space:]]*'
|
|
84
|
+
# which matches every line. (forge#1609)
|
|
85
|
+
FENCE='```'
|
|
86
|
+
|
|
87
|
+
# Guard expressions: any occurrence in a section's code blocks marks it as guarded
|
|
88
|
+
GUARD_PATTERN='DRY_RUN|GOVERNOR|--dry-run|DRY_RUN_MODE|DryRun'
|
|
89
|
+
|
|
90
|
+
# Class B side-effect verbs (in code blocks)
|
|
91
|
+
SIDE_EFFECT_PATTERN='gh[[:space:]]+(issue[[:space:]]+(create|edit|comment)|pr[[:space:]]+(merge|create|edit)|gist[[:space:]]+(create|edit))|git[[:space:]]+push|--auto-merge|--add-label|--remove-label'
|
|
92
|
+
|
|
93
|
+
ALLOWLIST_TOKEN='allowlist:check-command-side-effects'
|
|
94
|
+
|
|
95
|
+
VIOLATIONS=0
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Class A: Scan for gh gist create/edit --public in code blocks (full corpus)
|
|
99
|
+
# This runs regardless of --full or diff mode.
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
class_a_scan() {
|
|
103
|
+
local file="$1"
|
|
104
|
+
local IN_CB=0
|
|
105
|
+
local HAS_GIST=0
|
|
106
|
+
local BLOCK_LINES=""
|
|
107
|
+
local BLOCK_START=0
|
|
108
|
+
local LN=0
|
|
109
|
+
local line PUBLIC_LN ACTUAL_LINE IS_FENCE IS_BARE
|
|
110
|
+
|
|
111
|
+
# flush_a_block — evaluate the currently-accumulated block's HAS_GIST/
|
|
112
|
+
# BLOCK_LINES state for a 'gh gist create/edit --public' violation, then
|
|
113
|
+
# reset it. Defined inside class_a_scan (not at file scope) so it shares
|
|
114
|
+
# this invocation's `local` variables via bash's dynamic scoping — the same
|
|
115
|
+
# mechanism Class B's flush_block() relies on for its own loop-local state.
|
|
116
|
+
# Called from two sites: (1) the existing fence-close path below, when a
|
|
117
|
+
# bare fence brings IN_CB back to 0, and (2) once more, defensively, after
|
|
118
|
+
# the read loop ends — covering a file that ends with an unterminated
|
|
119
|
+
# fence (IN_CB still > 0 at EOF), which previously discarded any
|
|
120
|
+
# accumulated violation silently. (forge#2289 added the equivalent
|
|
121
|
+
# end-of-file flush for Class B only; this closes the same gap in Class A.)
|
|
122
|
+
flush_a_block() {
|
|
123
|
+
if [ "$HAS_GIST" -eq 1 ] && echo "$BLOCK_LINES" | grep -qE '^[[:space:]]*--public([[:space:]]|$)'; then
|
|
124
|
+
# Find the line number of --public within the block
|
|
125
|
+
PUBLIC_LN=$(echo "$BLOCK_LINES" | grep -n '^[[:space:]]*--public' | head -1 | cut -d: -f1)
|
|
126
|
+
ACTUAL_LINE=$((BLOCK_START + PUBLIC_LN))
|
|
127
|
+
if ! echo "$BLOCK_LINES" | grep -qF "$ALLOWLIST_TOKEN"; then
|
|
128
|
+
echo "HIGH | $file | line ~$ACTUAL_LINE | Class A: 'gh gist create/edit --public' in code block — gists MUST be secret (omit --public); --public exposes private repo data. (forge#1587)" >&2
|
|
129
|
+
VIOLATIONS=$((VIOLATIONS + 1))
|
|
130
|
+
fi
|
|
131
|
+
fi
|
|
132
|
+
HAS_GIST=0
|
|
133
|
+
BLOCK_LINES=""
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
while IFS= read -r line; do
|
|
137
|
+
LN=$((LN + 1))
|
|
138
|
+
|
|
139
|
+
# Fence detection is anchored to line-start (ignoring leading whitespace).
|
|
140
|
+
# IN_CB is a true nesting-DEPTH counter, not a 0/1 flag: a fence line
|
|
141
|
+
# carrying an info string (e.g. a nested ```bash) while already inside a
|
|
142
|
+
# block opens one more nesting level (increment); a bare fence (nothing
|
|
143
|
+
# after the backticks but optional trailing whitespace) closes exactly
|
|
144
|
+
# one level (decrement). The accumulated block is only evaluated once
|
|
145
|
+
# depth returns to 0 — i.e. once the OUTERMOST fence has actually closed,
|
|
146
|
+
# not merely an inner one. A binary flag cannot distinguish "still inside
|
|
147
|
+
# the outer fence, past an inner fence's close" from "outside all
|
|
148
|
+
# fences"; depth tracking can. (forge#2210 fixed anchoring/info-string-is-
|
|
149
|
+
# content; forge#2288 adds true depth so 2+ nesting levels close correctly.)
|
|
150
|
+
IS_FENCE=0
|
|
151
|
+
IS_BARE=0
|
|
152
|
+
if echo "$line" | grep -qE "^[[:space:]]*${FENCE}"; then
|
|
153
|
+
IS_FENCE=1
|
|
154
|
+
if echo "$line" | grep -qE "^[[:space:]]*${FENCE}[[:space:]]*\$"; then
|
|
155
|
+
IS_BARE=1
|
|
156
|
+
fi
|
|
157
|
+
fi
|
|
158
|
+
|
|
159
|
+
if [ "$IN_CB" -eq 0 ]; then
|
|
160
|
+
if [ "$IS_FENCE" -eq 1 ]; then
|
|
161
|
+
IN_CB=1
|
|
162
|
+
HAS_GIST=0
|
|
163
|
+
BLOCK_LINES=""
|
|
164
|
+
BLOCK_START=$LN
|
|
165
|
+
fi
|
|
166
|
+
continue
|
|
167
|
+
fi
|
|
168
|
+
|
|
169
|
+
# IN_CB >= 1 — inside a block, possibly nested.
|
|
170
|
+
if [ "$IS_FENCE" -eq 1 ]; then
|
|
171
|
+
if [ "$IS_BARE" -eq 1 ]; then
|
|
172
|
+
IN_CB=$((IN_CB - 1))
|
|
173
|
+
else
|
|
174
|
+
IN_CB=$((IN_CB + 1))
|
|
175
|
+
fi
|
|
176
|
+
if [ "$IN_CB" -eq 0 ]; then
|
|
177
|
+
# Fully closed (outermost fence) — evaluate the accumulated block now.
|
|
178
|
+
flush_a_block
|
|
179
|
+
continue
|
|
180
|
+
fi
|
|
181
|
+
# Still inside (nested transition) — the fence line itself is block content.
|
|
182
|
+
BLOCK_LINES="${BLOCK_LINES}${line}
|
|
183
|
+
"
|
|
184
|
+
continue
|
|
185
|
+
fi
|
|
186
|
+
|
|
187
|
+
# Regular content line while inside (at any depth).
|
|
188
|
+
BLOCK_LINES="${BLOCK_LINES}${line}
|
|
189
|
+
"
|
|
190
|
+
# Check if this line has gh gist create/edit (even with line continuation \)
|
|
191
|
+
if echo "$line" | grep -qE 'gh[[:space:]]+gist[[:space:]]+(create|edit)'; then
|
|
192
|
+
HAS_GIST=1
|
|
193
|
+
fi
|
|
194
|
+
done < "$file"
|
|
195
|
+
|
|
196
|
+
# Defensive final flush — evaluates any block state left accumulated if the
|
|
197
|
+
# file ended with an unterminated fence (IN_CB never returned to 0). A
|
|
198
|
+
# no-op if the last block already closed and was flushed above. Mirrors
|
|
199
|
+
# Class B's identical end-of-file `flush_block` call. (forge#2305)
|
|
200
|
+
flush_a_block
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
while IFS= read -r file; do
|
|
204
|
+
[ -f "$file" ] || continue
|
|
205
|
+
class_a_scan "$file"
|
|
206
|
+
done < <(find "$COMMANDS_DIR" -name '*.md' | sort)
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
# Class B: Diff-aware scan for unguarded side-effect verbs in added lines
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
# Determine base SHA for diff
|
|
213
|
+
BASE_SHA=""
|
|
214
|
+
if [ -n "${GITHUB_BASE_SHA:-}" ]; then
|
|
215
|
+
BASE_SHA="$GITHUB_BASE_SHA"
|
|
216
|
+
elif [ -n "${GITHUB_EVENT_PULL_REQUEST_BASE_SHA:-}" ]; then
|
|
217
|
+
BASE_SHA="$GITHUB_EVENT_PULL_REQUEST_BASE_SHA"
|
|
218
|
+
else
|
|
219
|
+
BASE_SHA="$(git rev-parse HEAD^ 2>/dev/null || echo '')"
|
|
220
|
+
fi
|
|
221
|
+
|
|
222
|
+
if [ -z "$BASE_SHA" ]; then
|
|
223
|
+
echo "INFO: No git base SHA available — Class B (diff-aware) check skipped" >&2
|
|
224
|
+
else
|
|
225
|
+
# Get list of changed command spec files
|
|
226
|
+
CHANGED_SPECS=$(git diff --name-only "$BASE_SHA"...HEAD -- "${COMMANDS_DIR}" 2>/dev/null \
|
|
227
|
+
| grep -E '\.md$' | grep -v '^$' || true)
|
|
228
|
+
|
|
229
|
+
if [ -z "$CHANGED_SPECS" ]; then
|
|
230
|
+
echo "OK (Class B): No commands/*.md files changed — diff check skipped"
|
|
231
|
+
else
|
|
232
|
+
echo "Class B: Checking changed spec files for unguarded side-effect verbs:"
|
|
233
|
+
echo "$CHANGED_SPECS"
|
|
234
|
+
echo ""
|
|
235
|
+
|
|
236
|
+
while IFS= read -r file; do
|
|
237
|
+
[ -f "$file" ] || continue
|
|
238
|
+
|
|
239
|
+
# Get added lines for this file
|
|
240
|
+
ADDED_CONTENT=$(git diff "$BASE_SHA"...HEAD -- "$file" 2>/dev/null \
|
|
241
|
+
| grep '^+' | grep -v '^+++' | sed 's/^+//' || true)
|
|
242
|
+
|
|
243
|
+
# Skip if no added lines contain side-effect verbs
|
|
244
|
+
if ! echo "$ADDED_CONTENT" | grep -qE "$SIDE_EFFECT_PATTERN"; then
|
|
245
|
+
continue
|
|
246
|
+
fi
|
|
247
|
+
|
|
248
|
+
# Parse the full file to map code blocks → (has_guard, has_side_effect_in_added_lines).
|
|
249
|
+
# SECTION is tracked only as a human-readable label for the violation message —
|
|
250
|
+
# it plays NO role in guard/side-effect correlation. Correlation is scoped to
|
|
251
|
+
# the individual fenced code block (BLOCK_* state), reset on every block open
|
|
252
|
+
# and evaluated on every block close, so a guard mentioned in a different code
|
|
253
|
+
# block — even one earlier or later in the very same section — cannot silence
|
|
254
|
+
# an unguarded side effect in another block. (forge#2289)
|
|
255
|
+
IN_CB=0
|
|
256
|
+
SECTION="(top)"
|
|
257
|
+
BLOCK_HAS_GUARD=0
|
|
258
|
+
BLOCK_HAS_ADDED_SE=0
|
|
259
|
+
BLOCK_SE_LINE=0
|
|
260
|
+
BLOCK_SE_VERB=""
|
|
261
|
+
LN=0
|
|
262
|
+
|
|
263
|
+
flush_block() {
|
|
264
|
+
# Evaluate and reset the current code block's guard/side-effect state.
|
|
265
|
+
# Called whenever a fenced code block's outermost fence closes (depth
|
|
266
|
+
# returns to 0), and once more, defensively, at end-of-file to cover a
|
|
267
|
+
# malformed/unterminated block (mirrors the pre-forge#2289 behavior of
|
|
268
|
+
# unconditionally evaluating accumulated state before resetting).
|
|
269
|
+
if [ "$BLOCK_HAS_ADDED_SE" -eq 1 ] && [ "$BLOCK_HAS_GUARD" -eq 0 ]; then
|
|
270
|
+
echo "HIGH | $file | line $BLOCK_SE_LINE | Class B: side-effect '$BLOCK_SE_VERB' added in section '$SECTION' with no DRY_RUN/governor guard in the same code block — add a guard inside this code block or wrap the effect in a DRY_RUN check" >&2
|
|
271
|
+
VIOLATIONS=$((VIOLATIONS + 1))
|
|
272
|
+
fi
|
|
273
|
+
BLOCK_HAS_GUARD=0
|
|
274
|
+
BLOCK_HAS_ADDED_SE=0
|
|
275
|
+
BLOCK_SE_LINE=0
|
|
276
|
+
BLOCK_SE_VERB=""
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
while IFS= read -r line; do
|
|
280
|
+
LN=$((LN + 1))
|
|
281
|
+
|
|
282
|
+
# Code block fence — checked BEFORE the heading test so IN_CB reflects
|
|
283
|
+
# this line's actual state. IN_CB is a true nesting-DEPTH counter, not
|
|
284
|
+
# a 0/1 flag: a fence line carrying an info string (e.g. nested
|
|
285
|
+
# ```bash) encountered while already inside a block opens one more
|
|
286
|
+
# nesting level (increment); a bare fence (nothing after the
|
|
287
|
+
# backticks) closes exactly one level (decrement). Heading detection
|
|
288
|
+
# below only fires once depth returns to 0 — i.e. once we are outside
|
|
289
|
+
# ALL fences, not merely an inner one. A binary flag cannot
|
|
290
|
+
# distinguish "still inside the outer fence, past an inner fence's
|
|
291
|
+
# close" from "outside all fences"; this is exactly the gap that let
|
|
292
|
+
# embedded `## Step N` headers inside one continuous outer code block
|
|
293
|
+
# (e.g. commands/review-pr-agents/spec-cli.md) be misdetected as real
|
|
294
|
+
# section boundaries. (forge#2210 fixed anchoring/info-string-is-
|
|
295
|
+
# content for a single nesting level; forge#2288 adds true depth so
|
|
296
|
+
# 2+ nesting levels close correctly.)
|
|
297
|
+
IS_FENCE=0
|
|
298
|
+
IS_BARE=0
|
|
299
|
+
if echo "$line" | grep -qE "^[[:space:]]*${FENCE}"; then
|
|
300
|
+
IS_FENCE=1
|
|
301
|
+
if echo "$line" | grep -qE "^[[:space:]]*${FENCE}[[:space:]]*\$"; then
|
|
302
|
+
IS_BARE=1
|
|
303
|
+
fi
|
|
304
|
+
fi
|
|
305
|
+
|
|
306
|
+
if [ "$IN_CB" -eq 0 ]; then
|
|
307
|
+
if [ "$IS_FENCE" -eq 1 ]; then
|
|
308
|
+
IN_CB=1
|
|
309
|
+
continue
|
|
310
|
+
fi
|
|
311
|
+
|
|
312
|
+
# Section heading — label-only update (forge#2210). This no longer
|
|
313
|
+
# flushes guard/side-effect state: that state is now block-scoped
|
|
314
|
+
# (forge#2289) and is always already at rest here, since this branch
|
|
315
|
+
# is only reached when IN_CB is 0 — i.e. any block that was open has
|
|
316
|
+
# already been closed and flushed at its own fence-close below.
|
|
317
|
+
if echo "$line" | grep -qE '^#{1,6}[[:space:]]+'; then
|
|
318
|
+
heading=$(echo "$line" | sed 's/^#*[[:space:]]*//' | sed 's/[[:space:]]*$//')
|
|
319
|
+
SECTION="$heading"
|
|
320
|
+
continue
|
|
321
|
+
fi
|
|
322
|
+
continue
|
|
323
|
+
fi
|
|
324
|
+
|
|
325
|
+
# IN_CB >= 1 — inside a block, possibly nested.
|
|
326
|
+
if [ "$IS_FENCE" -eq 1 ]; then
|
|
327
|
+
if [ "$IS_BARE" -eq 1 ]; then
|
|
328
|
+
IN_CB=$((IN_CB - 1))
|
|
329
|
+
else
|
|
330
|
+
IN_CB=$((IN_CB + 1))
|
|
331
|
+
fi
|
|
332
|
+
if [ "$IN_CB" -eq 0 ]; then
|
|
333
|
+
# Outermost fence just closed — evaluate this block's guard/SE
|
|
334
|
+
# correlation now, before starting the next block. (forge#2289)
|
|
335
|
+
flush_block
|
|
336
|
+
fi
|
|
337
|
+
continue
|
|
338
|
+
fi
|
|
339
|
+
|
|
340
|
+
# Content line while inside (at any depth).
|
|
341
|
+
if echo "$line" | grep -qF "$ALLOWLIST_TOKEN"; then continue; fi
|
|
342
|
+
|
|
343
|
+
# Check for guard — scoped to THIS code block only (forge#2289)
|
|
344
|
+
echo "$line" | grep -qE "$GUARD_PATTERN" && BLOCK_HAS_GUARD=1
|
|
345
|
+
|
|
346
|
+
# Check if this line is in the diff's added lines AND has a side-effect verb
|
|
347
|
+
if [ "$BLOCK_HAS_ADDED_SE" -eq 0 ] && echo "$line" | grep -qE "$SIDE_EFFECT_PATTERN"; then
|
|
348
|
+
# Is this specific line in the added content?
|
|
349
|
+
if echo "$ADDED_CONTENT" | grep -qF "${line:0:80}" 2>/dev/null; then
|
|
350
|
+
BLOCK_HAS_ADDED_SE=1
|
|
351
|
+
BLOCK_SE_LINE=$LN
|
|
352
|
+
BLOCK_SE_VERB=$(echo "$line" | grep -oE "$SIDE_EFFECT_PATTERN" | head -1 || echo "side-effect")
|
|
353
|
+
fi
|
|
354
|
+
fi
|
|
355
|
+
done < "$file"
|
|
356
|
+
|
|
357
|
+
# Defensive final flush — evaluates any block state left accumulated if
|
|
358
|
+
# the file ended with an unterminated fence (malformed input); a no-op
|
|
359
|
+
# if the last block already closed and was flushed above.
|
|
360
|
+
flush_block
|
|
361
|
+
|
|
362
|
+
done <<< "$CHANGED_SPECS"
|
|
363
|
+
fi
|
|
364
|
+
fi
|
|
365
|
+
|
|
366
|
+
# ---------------------------------------------------------------------------
|
|
367
|
+
# Summary
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
if [ "$VIOLATIONS" -gt 0 ]; then
|
|
371
|
+
echo "check-command-side-effects: $VIOLATIONS violation(s) found. See stderr for details." >&2
|
|
372
|
+
exit 1
|
|
373
|
+
fi
|
|
374
|
+
|
|
375
|
+
echo "OK: No side-effect violations found"
|
|
376
|
+
exit 0
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CI drift check: bin/engine/phases.mjs's `PHASES` array (the authoritative
|
|
4
|
+
* engine phase table) must have exactly the same phase-id list, in the same
|
|
5
|
+
* order, as packages/protocol/src/phases.js's `PHASE_IDS` — the single-source
|
|
6
|
+
* registry both bin/engine/phases.mjs and bin/hooks/interactive-engine.mjs
|
|
7
|
+
* import their marker strings from (forge#2378).
|
|
8
|
+
*
|
|
9
|
+
* This does NOT re-validate the marker *strings* themselves — both consumer
|
|
10
|
+
* files import the registry directly, so a string-level mismatch between them
|
|
11
|
+
* is now structurally impossible (there is exactly one literal declaration of
|
|
12
|
+
* each marker, in packages/protocol/src/phases.js / types.js). What CAN still
|
|
13
|
+
* drift is the *phase-id list itself*: someone adds/removes/reorders a phase
|
|
14
|
+
* in bin/engine/phases.mjs's PHASES array without updating the registry's
|
|
15
|
+
* PHASE_IDS (or vice versa). This script catches that.
|
|
16
|
+
*
|
|
17
|
+
* Usage: node scripts/check-phase-registry-drift.mjs
|
|
18
|
+
* Exit codes: 0 = no drift, 1 = drift detected, 2 = usage/import error.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
|
|
24
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const REPO_ROOT = path.resolve(__dirname, "..");
|
|
26
|
+
|
|
27
|
+
async function main() {
|
|
28
|
+
let registryModule;
|
|
29
|
+
let phasesModule;
|
|
30
|
+
try {
|
|
31
|
+
registryModule = await import(
|
|
32
|
+
pathToFileURL(path.join(REPO_ROOT, "packages/protocol/src/phases.js")).href
|
|
33
|
+
);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.error(`ERROR: failed to import packages/protocol/src/phases.js: ${err.message}`);
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
phasesModule = await import(
|
|
40
|
+
pathToFileURL(path.join(REPO_ROOT, "bin/engine/phases.mjs")).href
|
|
41
|
+
);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
console.error(`ERROR: failed to import bin/engine/phases.mjs: ${err.message}`);
|
|
44
|
+
process.exit(2);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const registryIds = registryModule.PHASE_IDS;
|
|
48
|
+
const enginePhaseIds = (phasesModule.PHASES || []).map((p) => p.id);
|
|
49
|
+
|
|
50
|
+
if (!Array.isArray(registryIds)) {
|
|
51
|
+
console.error("ERROR: packages/protocol/src/phases.js did not export a PHASE_IDS array.");
|
|
52
|
+
process.exit(2);
|
|
53
|
+
}
|
|
54
|
+
if (!Array.isArray(enginePhaseIds) || enginePhaseIds.length === 0) {
|
|
55
|
+
console.error("ERROR: bin/engine/phases.mjs did not export a non-empty PHASES array.");
|
|
56
|
+
process.exit(2);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const drifted =
|
|
60
|
+
registryIds.length !== enginePhaseIds.length ||
|
|
61
|
+
registryIds.some((id, i) => id !== enginePhaseIds[i]);
|
|
62
|
+
|
|
63
|
+
if (drifted) {
|
|
64
|
+
console.error("ERROR: phase-id list drift detected between the registry and the engine.");
|
|
65
|
+
console.error(` packages/protocol/src/phases.js PHASE_IDS: ${JSON.stringify(registryIds)}`);
|
|
66
|
+
console.error(` bin/engine/phases.mjs PHASES ids: ${JSON.stringify(enginePhaseIds)}`);
|
|
67
|
+
console.error(" Update packages/protocol/src/phases.js's PHASE_IDS to match bin/engine/phases.mjs's");
|
|
68
|
+
console.error(" PHASES array (or vice versa) so the two stay single-sourced. See forge#2378.");
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Also verify every registry marker entry that has a `completionMarker` or
|
|
73
|
+
// `completionLabel` field is a non-empty string — catches an accidental
|
|
74
|
+
// undefined/null slipping in (e.g. a typo'd RESERVED_TYPES key whose
|
|
75
|
+
// completionSentinel is null) which would otherwise fail silently at
|
|
76
|
+
// has(blob, undefined) → substring "undefined" never matches, masking a
|
|
77
|
+
// real bug as "phase never completes".
|
|
78
|
+
const emptyMarkers = [];
|
|
79
|
+
for (const [phaseId, entry] of Object.entries(registryModule.PHASE_MARKERS || {})) {
|
|
80
|
+
const marker = entry.completionMarker ?? entry.completionLabel;
|
|
81
|
+
if (typeof marker !== "string" || marker.length === 0) {
|
|
82
|
+
emptyMarkers.push(phaseId);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (emptyMarkers.length > 0) {
|
|
86
|
+
console.error(
|
|
87
|
+
`ERROR: PHASE_MARKERS entries with no usable completionMarker/completionLabel: ${emptyMarkers.join(", ")}`
|
|
88
|
+
);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log("OK: phase-id list and marker registry are consistent (no drift detected).");
|
|
93
|
+
process.exit(0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
main();
|