forgedock 1.3.0 → 1.5.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/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 +201 -35
- package/bin/engine/resolve.mjs +256 -0
- package/bin/engine/resolve.test.mjs +149 -0
- package/bin/engine/runlog.mjs +11 -1
- package/bin/engine/state.mjs +20 -28
- package/bin/engine-cli.mjs +86 -5
- package/bin/engine.mjs +543 -36
- package/bin/forgedock.mjs +397 -59
- package/bin/hooks/interactive-engine.mjs +50 -24
- package/bin/hooks/pre-tool-use.mjs +191 -13
- package/bin/journey.mjs +586 -44
- package/bin/labels.json +5 -0
- package/bin/runner.mjs +478 -10
- package/bin/watch-utils.mjs +258 -0
- package/commands/cleanup.md +34 -5
- package/commands/issue.md +14 -4
- package/commands/orchestrate/config.md +43 -1
- package/commands/orchestrate/phase-1-resolve.md +348 -11
- package/commands/orchestrate/phase-3-dependency.md +272 -49
- package/commands/orchestrate/phase-4-execution.md +622 -60
- package/commands/orchestrate/phase-5-cleanup.md +2 -0
- package/commands/quality-gate.md +6 -6
- package/commands/review-pr-staging.md +51 -8
- package/commands/review-pr.md +120 -39
- package/commands/upgrade-deps.md +7 -2
- package/commands/work-on/decompose.md +2 -0
- package/commands/work-on/investigate.md +22 -4
- package/commands/work-on/remediate.md +55 -4
- package/commands/work-on/review.md +40 -1
- package/commands/work-on.md +19 -4
- package/docs/CONFIG.md +120 -0
- package/docs/spec/eval-run-result.md +8 -4
- package/docs/spec/label-state-machine.md +9 -0
- package/package.json +2 -2
- package/packages/protocol/src/emit.js +5 -22
- package/packages/protocol/src/html-comment-escape.js +68 -0
- package/packages/protocol/src/index.js +1 -0
- package/packages/protocol/src/parse.js +5 -6
- package/packages/protocol/src/phases.js +105 -0
- package/packages/protocol/src/types.js +17 -0
- package/packages/protocol/src/validate.js +24 -1
- package/scripts/check-admission-parity.mjs +167 -0
- package/scripts/check-command-side-effects.sh +181 -66
- package/scripts/check-phase-registry-drift.mjs +118 -0
- package/scripts/check-spec-markers.sh +3 -0
- package/scripts/classify-lane.sh +29 -3
- package/scripts/derive-finding-milestone.sh +184 -0
- package/scripts/doctor-pipeline-state.sh +1 -0
- package/scripts/extract-affected-files.sh +232 -0
- package/scripts/extract-affected-files.test.sh +331 -0
- package/scripts/forge-run.sh +39 -4
- package/scripts/issue-dedup.sh +69 -8
- package/scripts/issue-dedup.test.sh +142 -0
- package/scripts/severity-to-priority.sh +81 -0
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Checks:
|
|
7
7
|
* - Completion sentinel presence (§3.2) — for types that define one
|
|
8
8
|
* - Required field presence (§4.1 per-type field tables)
|
|
9
|
-
* - Required field enum values (Verdict, Confidence, Severity, Task Type)
|
|
9
|
+
* - Required field enum values (Verdict, Confidence, Severity, Task Type, Re-gate outcome)
|
|
10
10
|
* - Inline-value non-empty (§3.4)
|
|
11
11
|
* - CARD inline-value decodability + sha8 integrity (§4.2 — see card.js)
|
|
12
12
|
* - Partial sentinel marking (§3.3) — generates a warning, not an error
|
|
@@ -129,5 +129,28 @@ export function validate(annotation) {
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
// REMEDIATION-specific enum validation (forge#2450): the Re-gate outcome field is
|
|
133
|
+
// optional at the required-field-presence layer (it is absent until
|
|
134
|
+
// commands/work-on/remediate.md's Phase M8 posts it), but when present its value
|
|
135
|
+
// must come from the single-sourced registry list — not a second hardcoded copy.
|
|
136
|
+
//
|
|
137
|
+
// Phase M8 posts this field as "**Re-gate outcome**: ${RE_GATE_OUTCOME} ${OUTCOME_DETAIL}"
|
|
138
|
+
// (see commands/work-on/remediate.md), so the parsed field value carries trailing free-text
|
|
139
|
+
// detail (e.g. "AUTO-LANDED to staging"), not just the bare outcome token. Only the leading
|
|
140
|
+
// token is the actual outcome — extract it the same way bin/engine/phases.mjs's own
|
|
141
|
+
// `/\*\*Re-gate outcome\*\*:\s*([A-Z-]+)/` regex does, so real production annotations with
|
|
142
|
+
// trailing detail text are not falsely rejected.
|
|
143
|
+
if (type === 'REMEDIATION') {
|
|
144
|
+
const reGateOutcome = fields['Re-gate outcome'];
|
|
145
|
+
if (reGateOutcome) {
|
|
146
|
+
const outcomeToken = reGateOutcome.match(/^([A-Z-]+)/)?.[1] ?? reGateOutcome;
|
|
147
|
+
if (!typeDef.reGateOutcomeValues.includes(outcomeToken)) {
|
|
148
|
+
errors.push(
|
|
149
|
+
`Invalid Re-gate outcome "${outcomeToken}" — must be one of: ${typeDef.reGateOutcomeValues.join(', ')}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
132
155
|
return { valid: errors.length === 0, errors, warnings };
|
|
133
156
|
}
|
|
@@ -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();
|
|
@@ -14,11 +14,17 @@
|
|
|
14
14
|
# Gists created by the pipeline MUST be secret — --public exposes private repo
|
|
15
15
|
# titles, root causes, and file paths to the world. (Ref: forge#1587)
|
|
16
16
|
#
|
|
17
|
-
# Class B — Side-effect verbs in
|
|
17
|
+
# Class B — Side-effect verbs in code blocks with no DRY_RUN/governor guard (diff-aware):
|
|
18
18
|
# When added lines introduce a side-effect verb (gh issue create, gh pr merge,
|
|
19
19
|
# git push, gh issue edit|comment, --auto-merge, --add/remove-label) inside a
|
|
20
|
-
# code block
|
|
21
|
-
#
|
|
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.)
|
|
22
28
|
# Operates on the diff (GITHUB_BASE_SHA or HEAD^) to avoid flagging legacy corpus.
|
|
23
29
|
# (Ref: forge#1609 — signal-planner.md DRY_RUN guard placed after the create it guards)
|
|
24
30
|
#
|
|
@@ -99,45 +105,99 @@ class_a_scan() {
|
|
|
99
105
|
local HAS_GIST=0
|
|
100
106
|
local BLOCK_LINES=""
|
|
101
107
|
local BLOCK_START=0
|
|
102
|
-
local
|
|
103
|
-
local line
|
|
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
|
+
}
|
|
104
135
|
|
|
105
136
|
while IFS= read -r line; do
|
|
106
|
-
|
|
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
|
|
107
158
|
|
|
108
|
-
if
|
|
109
|
-
if [ "$
|
|
159
|
+
if [ "$IN_CB" -eq 0 ]; then
|
|
160
|
+
if [ "$IS_FENCE" -eq 1 ]; then
|
|
110
161
|
IN_CB=1
|
|
111
162
|
HAS_GIST=0
|
|
112
163
|
BLOCK_LINES=""
|
|
113
|
-
BLOCK_START=$
|
|
114
|
-
else
|
|
115
|
-
# End of code block — check if it had both gh gist and --public
|
|
116
|
-
if [ "$HAS_GIST" -eq 1 ] && echo "$BLOCK_LINES" | grep -qE '^[[:space:]]*--public([[:space:]]|$)'; then
|
|
117
|
-
# Find the line number of --public within the block
|
|
118
|
-
PUBLIC_LINENO=$(echo "$BLOCK_LINES" | grep -n '^[[:space:]]*--public' | head -1 | cut -d: -f1)
|
|
119
|
-
ACTUAL_LINE=$((BLOCK_START + PUBLIC_LINENO))
|
|
120
|
-
if ! echo "$BLOCK_LINES" | grep -qF "$ALLOWLIST_TOKEN"; then
|
|
121
|
-
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
|
|
122
|
-
VIOLATIONS=$((VIOLATIONS + 1))
|
|
123
|
-
fi
|
|
124
|
-
fi
|
|
125
|
-
IN_CB=0
|
|
126
|
-
HAS_GIST=0
|
|
127
|
-
BLOCK_LINES=""
|
|
164
|
+
BLOCK_START=$LN
|
|
128
165
|
fi
|
|
129
166
|
continue
|
|
130
167
|
fi
|
|
131
168
|
|
|
132
|
-
|
|
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.
|
|
133
182
|
BLOCK_LINES="${BLOCK_LINES}${line}
|
|
134
183
|
"
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
139
193
|
fi
|
|
140
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
|
|
141
201
|
}
|
|
142
202
|
|
|
143
203
|
while IFS= read -r file; do
|
|
@@ -185,64 +245,119 @@ else
|
|
|
185
245
|
continue
|
|
186
246
|
fi
|
|
187
247
|
|
|
188
|
-
# Parse the full file to map
|
|
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)
|
|
189
255
|
IN_CB=0
|
|
190
256
|
SECTION="(top)"
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
201
271
|
VIOLATIONS=$((VIOLATIONS + 1))
|
|
202
272
|
fi
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
SECTION_SE_VERB=""
|
|
208
|
-
IN_CB=0
|
|
273
|
+
BLOCK_HAS_GUARD=0
|
|
274
|
+
BLOCK_HAS_ADDED_SE=0
|
|
275
|
+
BLOCK_SE_LINE=0
|
|
276
|
+
BLOCK_SE_VERB=""
|
|
209
277
|
}
|
|
210
278
|
|
|
211
279
|
while IFS= read -r line; do
|
|
212
|
-
|
|
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
|
|
213
311
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
218
322
|
continue
|
|
219
323
|
fi
|
|
220
324
|
|
|
221
|
-
#
|
|
222
|
-
if
|
|
223
|
-
[ "$
|
|
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
|
|
224
337
|
continue
|
|
225
338
|
fi
|
|
226
339
|
|
|
227
|
-
|
|
228
|
-
|
|
340
|
+
# Content line while inside (at any depth).
|
|
341
|
+
if echo "$line" | grep -qF "$ALLOWLIST_TOKEN"; then continue; fi
|
|
229
342
|
|
|
230
|
-
|
|
231
|
-
|
|
343
|
+
# Check for guard — scoped to THIS code block only (forge#2289)
|
|
344
|
+
echo "$line" | grep -qE "$GUARD_PATTERN" && BLOCK_HAS_GUARD=1
|
|
232
345
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
fi
|
|
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")
|
|
241
353
|
fi
|
|
242
354
|
fi
|
|
243
355
|
done < "$file"
|
|
244
356
|
|
|
245
|
-
|
|
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
|
|
246
361
|
|
|
247
362
|
done <<< "$CHANGED_SPECS"
|
|
248
363
|
fi
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
// Verify every id in PHASE_IDS has a corresponding key in PHASE_MARKERS.
|
|
73
|
+
// The empty-marker check below iterates Object.entries(PHASE_MARKERS), which
|
|
74
|
+
// only visits keys that actually exist on the object — a phase id that is
|
|
75
|
+
// entirely absent from PHASE_MARKERS (as opposed to present-but-empty) never
|
|
76
|
+
// appears in that iteration, so it would pass silently here and only surface
|
|
77
|
+
// later as a runtime TypeError in bin/engine/phases.mjs (e.g.
|
|
78
|
+
// `PHASE_MARKERS.someId.completionMarker` on an undefined `someId` entry).
|
|
79
|
+
// Catch that gap explicitly before the empty-marker check below.
|
|
80
|
+
const phaseMarkers = registryModule.PHASE_MARKERS || {};
|
|
81
|
+
const missingKeys = registryIds.filter(
|
|
82
|
+
(id) => !Object.prototype.hasOwnProperty.call(phaseMarkers, id)
|
|
83
|
+
);
|
|
84
|
+
if (missingKeys.length > 0) {
|
|
85
|
+
console.error(
|
|
86
|
+
`ERROR: PHASE_MARKERS is missing an entry entirely for: ${missingKeys.join(", ")}`
|
|
87
|
+
);
|
|
88
|
+
console.error(
|
|
89
|
+
" Add a corresponding key to packages/protocol/src/phases.js's PHASE_MARKERS for each id above."
|
|
90
|
+
);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Also verify every registry marker entry that has a `completionMarker` or
|
|
95
|
+
// `completionLabel` field is a non-empty string — catches an accidental
|
|
96
|
+
// undefined/null slipping in (e.g. a typo'd RESERVED_TYPES key whose
|
|
97
|
+
// completionSentinel is null) which would otherwise fail silently at
|
|
98
|
+
// has(blob, undefined) → substring "undefined" never matches, masking a
|
|
99
|
+
// real bug as "phase never completes".
|
|
100
|
+
const emptyMarkers = [];
|
|
101
|
+
for (const [phaseId, entry] of Object.entries(phaseMarkers)) {
|
|
102
|
+
const marker = entry.completionMarker ?? entry.completionLabel;
|
|
103
|
+
if (typeof marker !== "string" || marker.length === 0) {
|
|
104
|
+
emptyMarkers.push(phaseId);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (emptyMarkers.length > 0) {
|
|
108
|
+
console.error(
|
|
109
|
+
`ERROR: PHASE_MARKERS entries with no usable completionMarker/completionLabel: ${emptyMarkers.join(", ")}`
|
|
110
|
+
);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log("OK: phase-id list and marker registry are consistent (no drift detected).");
|
|
115
|
+
process.exit(0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
main();
|
|
@@ -117,6 +117,8 @@ MARKER_REGISTRY="
|
|
|
117
117
|
KNOWLEDGE_GIST
|
|
118
118
|
LEARNED
|
|
119
119
|
LEARNED_RULES
|
|
120
|
+
LEASE
|
|
121
|
+
LEASE_RELEASED
|
|
120
122
|
LEDGER_INDEXED
|
|
121
123
|
MEMORY_INDEX
|
|
122
124
|
MEMORY_INDEXED
|
|
@@ -135,6 +137,7 @@ MARKER_REGISTRY="
|
|
|
135
137
|
PUSH_BLOCKED_EMPTY_BRANCH
|
|
136
138
|
PUSH_FAILED
|
|
137
139
|
REMEDIATION
|
|
140
|
+
REREVIEW_SKIPPED
|
|
138
141
|
REVIEW
|
|
139
142
|
REVIEWER
|
|
140
143
|
REVIEW_ROUTE
|