forgedock 1.3.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/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/state.mjs +20 -28
- package/bin/engine-cli.mjs +80 -5
- package/bin/engine.mjs +390 -18
- package/bin/forgedock.mjs +336 -59
- package/bin/hooks/interactive-engine.mjs +50 -24
- package/bin/hooks/pre-tool-use.mjs +169 -13
- package/bin/journey.mjs +8 -2
- 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/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 +221 -22
- package/commands/quality-gate.md +6 -6
- package/commands/review-pr.md +23 -7
- 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 +2 -0
- 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/scripts/check-admission-parity.mjs +167 -0
- package/scripts/check-command-side-effects.sh +181 -66
- package/scripts/check-phase-registry-drift.mjs +96 -0
- package/scripts/check-spec-markers.sh +1 -0
- package/scripts/forge-run.sh +35 -4
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical FORGE pipeline phase table — single source of truth for phase ids
|
|
3
|
+
* (in dispatch order) and the exact marker/label strings that indicate each
|
|
4
|
+
* phase has committed.
|
|
5
|
+
*
|
|
6
|
+
* Consumers: `bin/engine/phases.mjs` (the authoritative engine gate) and
|
|
7
|
+
* `bin/hooks/interactive-engine.mjs` (the interactive-session SubagentStop
|
|
8
|
+
* hook) both import this module instead of hand-declaring their own copies
|
|
9
|
+
* of the marker strings. Before this module existed, the two files carried
|
|
10
|
+
* independent literal copies of the same strings and drifted out of sync at
|
|
11
|
+
* least twice (forge#1669 established the strict `:COMPLETE` gate for
|
|
12
|
+
* `context`/`architect`; forge#2375/PR#2395 then had to hand-patch the hook's
|
|
13
|
+
* copy back into agreement with the engine's). See forge#2378.
|
|
14
|
+
*
|
|
15
|
+
* `completionMarker` values are sourced from `RESERVED_TYPES[*].completionSentinel`
|
|
16
|
+
* wherever that reserved annotation type exists (INVESTIGATOR, CONTEXT,
|
|
17
|
+
* ARCHITECT, BUILDER) — so the reserved-annotation registry in `types.js` and
|
|
18
|
+
* this phase registry cannot drift apart for those four phases; there is
|
|
19
|
+
* exactly one literal declaration of each of those marker strings in the
|
|
20
|
+
* entire codebase, in `types.js`.
|
|
21
|
+
*
|
|
22
|
+
* `review` and `close` have no corresponding `RESERVED_TYPES` entry with a
|
|
23
|
+
* matching sentinel today (`REVIEWER` has no `completionSentinel` defined in
|
|
24
|
+
* spec §4.1; `close` is gated on a GitHub *label*, not a comment annotation),
|
|
25
|
+
* so those two are declared directly in this file — still single-sourced
|
|
26
|
+
* across the two consumers, just not additionally derived from `types.js`.
|
|
27
|
+
*
|
|
28
|
+
* @license MIT
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { RESERVED_TYPES } from './types.js';
|
|
32
|
+
|
|
33
|
+
/** Phase ids, in canonical dispatch order (mirrors `commands/work-on.md`'s
|
|
34
|
+
* "Universal Phase Dispatcher" table and `bin/engine/phases.mjs`'s `PHASES`
|
|
35
|
+
* array).
|
|
36
|
+
*
|
|
37
|
+
* `decompose` and `remediate` (forge#2379) are branch phases, not part of the
|
|
38
|
+
* six-phase linear happy path — `decompose` is reached only when `investigate`
|
|
39
|
+
* signals `DECOMPOSE:YES`, and `remediate` only when `review` escalates to
|
|
40
|
+
* `needs-human`. They are still declared here, in the exact same array
|
|
41
|
+
* position `bin/engine/phases.mjs`'s `PHASES` array declares them, because
|
|
42
|
+
* `scripts/check-phase-registry-drift.mjs` requires the two lists to match
|
|
43
|
+
* index-for-index — see that file's own doc comment. */
|
|
44
|
+
export const PHASE_IDS = ['investigate', 'decompose', 'context', 'architect', 'build', 'review', 'remediate', 'close'];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {Object} PhaseMarkerEntry
|
|
48
|
+
* @property {string} [completionMarker] - marker/sentinel string (or label, for `close`)
|
|
49
|
+
* whose presence means this phase has committed.
|
|
50
|
+
* @property {string} [invalidMarker] - (investigate only) marks the issue INVALID.
|
|
51
|
+
* @property {string} [decomposedMarker] - (investigate only) marks the issue DECOMPOSED.
|
|
52
|
+
* @property {string} [partialMarker] - sentinel for an interrupted/partial annotation.
|
|
53
|
+
* @property {string} [presenceMarker] - bare annotation-opener substring, used only by
|
|
54
|
+
* phases whose completion is non-critical (see `context` below) — presence alone
|
|
55
|
+
* (without `:COMPLETE`) is enough to report a soft/visible skip rather than a hard fail.
|
|
56
|
+
* @property {string} [completionLabel] - a GitHub issue *label* (not a comment marker)
|
|
57
|
+
* whose presence means this phase has committed. Only `close` uses this form.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/** @type {Object.<string, PhaseMarkerEntry>} */
|
|
61
|
+
export const PHASE_MARKERS = {
|
|
62
|
+
investigate: {
|
|
63
|
+
completionMarker: RESERVED_TYPES.INVESTIGATOR.completionSentinel, // 'INVESTIGATION:COMPLETE'
|
|
64
|
+
invalidMarker: 'INVESTIGATION:INVALID',
|
|
65
|
+
decomposedMarker: 'DECOMPOSE:YES',
|
|
66
|
+
},
|
|
67
|
+
// forge#2379: `decompose` actually runs work-on/decompose (sub-issue
|
|
68
|
+
// fan-out) once `investigate` hands off on `DECOMPOSE:YES` — see
|
|
69
|
+
// bin/engine/phases.mjs's `decompose` phase entry for the handoff mechanics.
|
|
70
|
+
decompose: {
|
|
71
|
+
completionMarker: RESERVED_TYPES.DECOMPOSED.completionSentinel, // 'FORGE:DECOMPOSED:COMPLETE'
|
|
72
|
+
},
|
|
73
|
+
context: {
|
|
74
|
+
completionMarker: RESERVED_TYPES.CONTEXT.completionSentinel, // 'FORGE:CONTEXT:COMPLETE'
|
|
75
|
+
partialMarker: RESERVED_TYPES.CONTEXT.partialSentinel, // 'FORGE:CONTEXT:PARTIAL'
|
|
76
|
+
// Bare presence marker: context is documented as non-critical (spec §7) — a
|
|
77
|
+
// missing completion marker is a visible skip, not a hard fail. Only
|
|
78
|
+
// bin/engine/phases.mjs's `context.detectOutcome` uses this bare form;
|
|
79
|
+
// `context.reconcile` (and everything in interactive-engine.mjs) uses the
|
|
80
|
+
// strict `completionMarker` above. Do not collapse these two fields.
|
|
81
|
+
presenceMarker: `FORGE:${RESERVED_TYPES.CONTEXT.type}`, // 'FORGE:CONTEXT'
|
|
82
|
+
},
|
|
83
|
+
architect: {
|
|
84
|
+
completionMarker: RESERVED_TYPES.ARCHITECT.completionSentinel, // 'FORGE:ARCHITECT:COMPLETE'
|
|
85
|
+
partialMarker: RESERVED_TYPES.ARCHITECT.partialSentinel, // 'FORGE:ARCHITECT:PARTIAL'
|
|
86
|
+
},
|
|
87
|
+
build: {
|
|
88
|
+
completionMarker: RESERVED_TYPES.BUILDER.completionSentinel, // 'FORGE:BUILDER:COMPLETE'
|
|
89
|
+
},
|
|
90
|
+
review: {
|
|
91
|
+
// No RESERVED_TYPES entry defines this sentinel (REVIEWER has no
|
|
92
|
+
// completionSentinel in spec §4.1) — declared directly here.
|
|
93
|
+
completionMarker: 'FORGE:REVIEWER:MERGED',
|
|
94
|
+
},
|
|
95
|
+
// forge#2379: `remediate` re-drives a needs-human PR (commands/work-on/remediate.md).
|
|
96
|
+
// Posted to BOTH the PR and the linked issue (Phase M8) — bin/engine/phases.mjs's
|
|
97
|
+
// `remediate` phase reads it off the issue, consistent with every other phase here.
|
|
98
|
+
remediate: {
|
|
99
|
+
completionMarker: RESERVED_TYPES.REMEDIATION.completionSentinel, // 'FORGE:REMEDIATION:COMPLETE'
|
|
100
|
+
},
|
|
101
|
+
close: {
|
|
102
|
+
// A GitHub label, not a comment marker — see `completionLabel` above.
|
|
103
|
+
completionLabel: 'workflow:merged',
|
|
104
|
+
},
|
|
105
|
+
};
|
|
@@ -86,6 +86,23 @@ export const RESERVED_TYPES = {
|
|
|
86
86
|
requiredFields: ['Verdict'],
|
|
87
87
|
verdictValues: ['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED'],
|
|
88
88
|
},
|
|
89
|
+
/**
|
|
90
|
+
* REMEDIATION — paper trail for a needs-human PR re-driven by
|
|
91
|
+
* commands/work-on/remediate.md. Posted to BOTH the PR and the linked
|
|
92
|
+
* issue (Phase M8) once the re-gate outcome (AUTO-LANDED /
|
|
93
|
+
* HELD-AWAITING-MERGE / RE-ESCALATED / UNFIXABLE) is known. Added in
|
|
94
|
+
* forge#2379 to single-source the completion sentinel that
|
|
95
|
+
* bin/engine/phases.mjs's `remediate` phase gates on. <!-- Added: forge#2379 -->
|
|
96
|
+
*/
|
|
97
|
+
REMEDIATION: {
|
|
98
|
+
type: 'REMEDIATION',
|
|
99
|
+
category: Category.LIFECYCLE,
|
|
100
|
+
completionSentinel: 'FORGE:REMEDIATION:COMPLETE',
|
|
101
|
+
partialSentinel: null,
|
|
102
|
+
inlineValue: false,
|
|
103
|
+
requiredFields: [],
|
|
104
|
+
reGateOutcomeValues: ['AUTO-LANDED', 'HELD-AWAITING-MERGE', 'RE-ESCALATED', 'UNFIXABLE'],
|
|
105
|
+
},
|
|
89
106
|
TRAJECTORY: {
|
|
90
107
|
type: 'TRAJECTORY',
|
|
91
108
|
category: Category.LIFECYCLE,
|
|
@@ -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
|