forgedock 1.2.0 → 1.3.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/engine/phases.mjs +178 -28
- package/bin/engine/state.mjs +39 -7
- package/bin/engine-cli.mjs +63 -2
- package/bin/engine.mjs +25 -4
- package/bin/forgedock.mjs +235 -4
- package/bin/hooks/pre-tool-use.mjs +26 -12
- package/bin/journey.mjs +103 -11
- package/commands/forge-stats.md +5 -4
- package/commands/issue.md +5 -1
- package/commands/orchestrate/phase-4-execution.md +2 -0
- package/commands/qa-sweep.md +17 -0
- package/commands/replay.md +16 -2
- package/commands/review-pr-agents/spec-cli.md +179 -0
- package/commands/review-pr.md +49 -0
- package/commands/upgrade-deps.md +518 -0
- package/commands/work-on/build/validate.md +61 -1
- package/commands/work-on/build.md +9 -1
- package/commands/work-on/decompose.md +8 -1
- package/docs/AIDER.md +264 -0
- package/docs/AUTOPILOT-CRON.md +130 -0
- package/docs/CONFIG.md +36 -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-graph-schema.md +3 -3
- package/package.json +2 -2
- 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 +14 -3
- package/packages/protocol/src/index.js +2 -1
- package/packages/protocol/src/parse.js +17 -8
- package/packages/protocol/src/types.js +1 -1
- package/packages/protocol/src/validate.js +11 -0
- package/scripts/check-command-side-effects.sh +261 -0
- package/scripts/check-spec-markers.sh +273 -0
- package/scripts/classify-lane.sh +29 -7
- package/scripts/forge-run.sh +322 -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
|
@@ -42,10 +42,10 @@
|
|
|
42
42
|
|
|
43
43
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
44
44
|
import { resolve, extname } from 'node:path';
|
|
45
|
-
import { createHash } from 'node:crypto';
|
|
46
45
|
import { parse } from './parse.js';
|
|
47
46
|
import { validate } from './validate.js';
|
|
48
47
|
import { emit } from './emit.js';
|
|
48
|
+
import { encodeCard, decodeCardInlineValue } from './card.js';
|
|
49
49
|
|
|
50
50
|
// ---------------------------------------------------------------------------
|
|
51
51
|
// Argv routing
|
|
@@ -114,16 +114,15 @@ function cmdEmit(argv) {
|
|
|
114
114
|
// Payload: canonical JSON (sorted keys, single line, UTF-8).
|
|
115
115
|
// The Base64url alphabet [A-Za-z0-9_-] cannot contain '>', '<', or '!' —
|
|
116
116
|
// so -->, --!>, and <!-- are structurally unrepresentable by construction.
|
|
117
|
+
// Codec lives in ./card.js (shared with validate.js and the test suite).
|
|
117
118
|
const payload = Object.keys(fields).length > 0 ? { ...fields } : {};
|
|
118
119
|
if (type) payload.type = type.toUpperCase();
|
|
119
|
-
const
|
|
120
|
-
const b64url = toBase64url(canonical);
|
|
121
|
-
const sha8 = createHash('sha256').update(canonical, 'utf8').digest('hex').slice(0, 8);
|
|
120
|
+
const { inlineValue } = encodeCard(payload);
|
|
122
121
|
// The colon-separator form "<!-- FORGE:CARD: v1 sha:X b64:Y -->" matches
|
|
123
122
|
// OPENING_TAG_RE so parse() can extract the inline value "v1 sha:X b64:Y".
|
|
124
123
|
// This keeps the canonical FORGE inline-value syntax (§3.4) while adding
|
|
125
124
|
// the sha8 integrity prefix and Base64url payload from the design decision.
|
|
126
|
-
process.stdout.write(`<!-- FORGE:CARD:
|
|
125
|
+
process.stdout.write(`<!-- FORGE:CARD: ${inlineValue} -->\n`);
|
|
127
126
|
return;
|
|
128
127
|
}
|
|
129
128
|
|
|
@@ -351,75 +350,3 @@ function die(msg) {
|
|
|
351
350
|
process.stderr.write(`forge-protocol-cli: ${msg}\n`);
|
|
352
351
|
process.exit(1);
|
|
353
352
|
}
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* Produce canonical JSON: sorted keys, single line, UTF-8.
|
|
357
|
-
* This is the payload that gets Base64url-encoded in the CARD format.
|
|
358
|
-
*/
|
|
359
|
-
function canonicalJson(obj) {
|
|
360
|
-
return JSON.stringify(sortKeysDeep(obj));
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
function sortKeysDeep(val) {
|
|
364
|
-
if (Array.isArray(val)) return val.map(sortKeysDeep);
|
|
365
|
-
if (val !== null && typeof val === 'object') {
|
|
366
|
-
return Object.fromEntries(
|
|
367
|
-
Object.keys(val)
|
|
368
|
-
.sort()
|
|
369
|
-
.map(k => [k, sortKeysDeep(val[k])]),
|
|
370
|
-
);
|
|
371
|
-
}
|
|
372
|
-
return val;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* Encode a UTF-8 string as Base64url (URL-safe alphabet, no padding).
|
|
377
|
-
* The Base64url alphabet [A-Za-z0-9_-] cannot contain '>', '<', or '!' —
|
|
378
|
-
* so -->, --!>, and <!-- are structurally unrepresentable by construction
|
|
379
|
-
* (design decision 2026-07-08: encoding beats escaping).
|
|
380
|
-
*
|
|
381
|
-
* @param {string} str — UTF-8 string to encode
|
|
382
|
-
* @returns {string} Base64url-encoded string without padding
|
|
383
|
-
*/
|
|
384
|
-
function toBase64url(str) {
|
|
385
|
-
return Buffer.from(str, 'utf8')
|
|
386
|
-
.toString('base64')
|
|
387
|
-
.replace(/\+/g, '-')
|
|
388
|
-
.replace(/\//g, '_')
|
|
389
|
-
.replace(/=/g, '');
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/**
|
|
393
|
-
* Decode a CARD inline value of the form "v1 sha:<sha8hex> b64:<base64url>"
|
|
394
|
-
* and return the parsed JSON object, or null if the format is unrecognized.
|
|
395
|
-
* Verifies the sha8 integrity prefix; returns null on corruption or tampering.
|
|
396
|
-
*
|
|
397
|
-
* @param {string} inlineValue — the parsed inlineValue from a CARD annotation
|
|
398
|
-
* @returns {object|null}
|
|
399
|
-
*/
|
|
400
|
-
function decodeCardInlineValue(inlineValue) {
|
|
401
|
-
const m = inlineValue.match(/^v1\s+sha:([0-9a-f]{8})\s+b64:([A-Za-z0-9_-]+)$/);
|
|
402
|
-
if (!m) return null;
|
|
403
|
-
const [, sha8, b64url] = m;
|
|
404
|
-
|
|
405
|
-
// Restore standard Base64 padding and swap the URL-safe alphabet back.
|
|
406
|
-
const b64std = b64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
407
|
-
const padded = b64std + '='.repeat((4 - (b64std.length % 4)) % 4);
|
|
408
|
-
|
|
409
|
-
let canonical;
|
|
410
|
-
try {
|
|
411
|
-
canonical = Buffer.from(padded, 'base64').toString('utf8');
|
|
412
|
-
} catch {
|
|
413
|
-
return null;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// Integrity check: first 8 hex chars of SHA-256 must match the prefix.
|
|
417
|
-
const expectedSha8 = createHash('sha256').update(canonical, 'utf8').digest('hex').slice(0, 8);
|
|
418
|
-
if (expectedSha8 !== sha8) return null;
|
|
419
|
-
|
|
420
|
-
try {
|
|
421
|
-
return JSON.parse(canonical);
|
|
422
|
-
} catch {
|
|
423
|
-
return null;
|
|
424
|
-
}
|
|
425
|
-
}
|
|
@@ -56,15 +56,26 @@ function sanitizeFieldValue(rawValue) {
|
|
|
56
56
|
`emit(): field value resolves to a bare FORGE tag/sentinel line, which is not permitted: ${JSON.stringify(rawValue)}`,
|
|
57
57
|
);
|
|
58
58
|
}
|
|
59
|
+
// Escape "&" FIRST, before either delimiter-escape pass below. Without this,
|
|
60
|
+
// a value that already contains literal entity-like text (e.g. "<!-->"
|
|
61
|
+
// or "<!--") collides with the encoded form of an unrelated real
|
|
62
|
+
// delimiter — sanitizeFieldValue() would not be injective, and the
|
|
63
|
+
// collision is unrecoverable by unescapeFieldValue() on decode
|
|
64
|
+
// (forge#2137). Escaping "&" first guarantees every "<!--"/"-->"/
|
|
65
|
+
// "--!>" sequence in the output was produced by *this* pass, never by
|
|
66
|
+
// pre-existing entity text (which is now doubly-escaped, e.g.
|
|
67
|
+
// "&" -> "&", left untouched by the delimiter passes that only match
|
|
68
|
+
// literal "<!--"/"--"+">" character sequences).
|
|
69
|
+
const withAmpEscaped = folded.replace(/&/g, '&');
|
|
59
70
|
// Escape the HTML comment opener ("<!--") so a value cannot start a new
|
|
60
71
|
// unterminated HTML comment in GitHub's renderer, which would visually swallow
|
|
61
72
|
// subsequent lines — the same rendering-leak the closer escaping below prevents
|
|
62
73
|
// from the opposite direction. (forge#1638)
|
|
63
|
-
const withOpenerEscaped =
|
|
74
|
+
const withOpenerEscaped = withAmpEscaped.replace(/<!--/g, '<!--');
|
|
64
75
|
// Escape both HTML comment-close forms ("-->" and "--!>") — only replacing the
|
|
65
76
|
// literal character sequence (not just documenting the risk) prevents the
|
|
66
|
-
// enclosing comment from terminating early. (forge#1594;
|
|
67
|
-
//
|
|
77
|
+
// enclosing comment from terminating early. (forge#1594; the FORGE:STATE codec
|
|
78
|
+
// in bin/engine/state.mjs now mirrors this full three-form scheme — forge#2119)
|
|
68
79
|
return withOpenerEscaped.replace(/--(!)?>/g, (_, bang) => `--${bang || ''}>`);
|
|
69
80
|
}
|
|
70
81
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @forgedock/protocol — FORGE Annotation Protocol v1.0 reference implementation.
|
|
3
3
|
*
|
|
4
|
-
* Provides parse, validate, and emit for all
|
|
4
|
+
* Provides parse, validate, and emit for all reserved annotation types
|
|
5
5
|
* defined in the FORGE Annotation Protocol specification (CC-BY-4.0).
|
|
6
6
|
*
|
|
7
7
|
* @example
|
|
@@ -29,6 +29,7 @@ export { parse } from './parse.js';
|
|
|
29
29
|
export { validate } from './validate.js';
|
|
30
30
|
export { emit, emitPartial, isKnownType } from './emit.js';
|
|
31
31
|
export { RESERVED_TYPES, RESERVED_TYPE_NAMES, Category, SentinelState } from './types.js';
|
|
32
|
+
export { canonicalJson, toBase64url, encodeCard, decodeCardInlineValue } from './card.js';
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* @typedef {Object} ParsedAnnotation
|
|
@@ -101,17 +101,25 @@ function escapeRegExp(str) {
|
|
|
101
101
|
/**
|
|
102
102
|
* Reverse the HTML entity escapes applied by emit()'s sanitizeFieldValue().
|
|
103
103
|
*
|
|
104
|
-
* emit() escapes three HTML comment delimiter sequences to
|
|
105
|
-
* renderer from comment injection
|
|
104
|
+
* emit() escapes "&" first, then three HTML comment delimiter sequences, to
|
|
105
|
+
* protect GitHub's renderer from comment injection AND to keep the encoding
|
|
106
|
+
* injective (reversible for every input, not just delimiter-free ones):
|
|
107
|
+
* 0. `&` → `&` (forge#2137 — must run first on encode)
|
|
106
108
|
* 1. `<!--` → `<!--` (forge#1638)
|
|
107
109
|
* 2. `--!>` → `--!>` (forge#1594)
|
|
108
110
|
* 3. `-->` → `-->` (forge#1594)
|
|
109
111
|
*
|
|
110
|
-
* parse() must apply the inverse
|
|
111
|
-
* is lossless. Unescape order:
|
|
112
|
-
* to avoid the `!` being left
|
|
113
|
-
*
|
|
114
|
-
*
|
|
112
|
+
* parse() must apply the exact inverse, in reverse order, so that
|
|
113
|
+
* round-tripping through emit → parse is lossless. Unescape order:
|
|
114
|
+
* `--!>` before `-->` (most-specific first) to avoid the `!` being left
|
|
115
|
+
* dangling by an overly-greedy `-->` pass; `<!--` can run in any order
|
|
116
|
+
* relative to those two. The `&` → `&` unescape MUST run LAST — doing it
|
|
117
|
+
* first (or interleaved) would corrupt the `&` that is part of a freshly
|
|
118
|
+
* restored `<!--`/`-->`/`--!>` sequence. Running "&" first on encode and last
|
|
119
|
+
* on decode is what makes the scheme injective: any pre-existing entity-like
|
|
120
|
+
* text in the original value (e.g. `<!--` or `<!-->`) is doubly-escaped
|
|
121
|
+
* at encode time and can never collide with the single-escaped form produced
|
|
122
|
+
* by a real delimiter (forge#2137; forge#1662).
|
|
115
123
|
*
|
|
116
124
|
* @param {string} raw
|
|
117
125
|
* @returns {string}
|
|
@@ -120,7 +128,8 @@ function unescapeFieldValue(raw) {
|
|
|
120
128
|
return raw
|
|
121
129
|
.replace(/--!>/g, '--!>')
|
|
122
130
|
.replace(/-->/g, '-->')
|
|
123
|
-
.replace(/<!--/g, '<!--')
|
|
131
|
+
.replace(/<!--/g, '<!--')
|
|
132
|
+
.replace(/&/g, '&');
|
|
124
133
|
}
|
|
125
134
|
|
|
126
135
|
/**
|
|
@@ -18,7 +18,7 @@ export const SentinelState = {
|
|
|
18
18
|
};
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* All
|
|
21
|
+
* All reserved annotation types from spec §4.
|
|
22
22
|
* Spec ref: §4.1 lifecycle, §4.2 cross-artifact, §4.3 control/error markers.
|
|
23
23
|
* Added CLAIM and CLAIM_RELEASED in forge#1736 — claims board for claim-level parallelism.
|
|
24
24
|
* Added AUTOPILOT_CYCLE in forge#1753 — durable cycle state for /autopilot.
|
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
* - Required field presence (§4.1 per-type field tables)
|
|
9
9
|
* - Required field enum values (Verdict, Confidence, Severity, Task Type)
|
|
10
10
|
* - Inline-value non-empty (§3.4)
|
|
11
|
+
* - CARD inline-value decodability + sha8 integrity (§4.2 — see card.js)
|
|
11
12
|
* - Partial sentinel marking (§3.3) — generates a warning, not an error
|
|
12
13
|
*
|
|
13
14
|
* @license MIT
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
17
|
import { RESERVED_TYPES, SentinelState } from './types.js';
|
|
18
|
+
import { decodeCardInlineValue } from './card.js';
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* Validate a parsed annotation against the spec.
|
|
@@ -46,6 +48,15 @@ export function validate(annotation) {
|
|
|
46
48
|
if (typeDef.inlineValue) {
|
|
47
49
|
if (inlineValue === null || inlineValue === '') {
|
|
48
50
|
errors.push(`Inline-value annotation "${type}" must carry a non-empty value (§3.4)`);
|
|
51
|
+
} else if (type === 'CARD') {
|
|
52
|
+
// CARD-specific (§4.2): the inline value must decode via the Base64url
|
|
53
|
+
// codec and pass its sha8 integrity check. A malformed encoding or a
|
|
54
|
+
// tampered/corrupted payload must not be treated as a valid annotation.
|
|
55
|
+
if (decodeCardInlineValue(inlineValue) === null) {
|
|
56
|
+
errors.push(
|
|
57
|
+
`Inline-value "CARD" annotation failed to decode or failed its sha8 integrity check (§4.2)`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
49
60
|
}
|
|
50
61
|
return { valid: errors.length === 0, errors, warnings };
|
|
51
62
|
}
|
|
@@ -0,0 +1,261 @@
|
|
|
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 sections 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 in a section that has no guard expression (DRY_RUN, GOVERNOR,
|
|
21
|
+
# --dry-run) anywhere in that section's code blocks, the change is flagged.
|
|
22
|
+
# Operates on the diff (GITHUB_BASE_SHA or HEAD^) to avoid flagging legacy corpus.
|
|
23
|
+
# (Ref: forge#1609 — signal-planner.md DRY_RUN guard placed after the create it guards)
|
|
24
|
+
#
|
|
25
|
+
# Allowlist: add <!-- allowlist:check-command-side-effects --> on the same line as
|
|
26
|
+
# the side-effect verb to suppress that specific hit.
|
|
27
|
+
#
|
|
28
|
+
# Usage:
|
|
29
|
+
# check-command-side-effects.sh [--full] [<commands_dir>]
|
|
30
|
+
# --full Scan entire corpus (not just diff) for Class A violations only.
|
|
31
|
+
# Class B is diff-aware by design — --full does not change Class B.
|
|
32
|
+
# commands_dir Path to the commands/ directory (default: ./commands)
|
|
33
|
+
#
|
|
34
|
+
# Environment:
|
|
35
|
+
# GITHUB_BASE_SHA When set, used as the base commit for Class B diff mode.
|
|
36
|
+
#
|
|
37
|
+
# Exit codes:
|
|
38
|
+
# 0 no violations found
|
|
39
|
+
# 1 one or more violations found (listed to stderr)
|
|
40
|
+
# 2 usage / dependency error
|
|
41
|
+
#
|
|
42
|
+
# <!-- Added: forge#1609 -->
|
|
43
|
+
|
|
44
|
+
set -euo pipefail
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Argument parsing
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
FULL_MODE=0
|
|
51
|
+
COMMANDS_DIR="./commands"
|
|
52
|
+
|
|
53
|
+
for arg in "$@"; do
|
|
54
|
+
case "$arg" in
|
|
55
|
+
-h|--help)
|
|
56
|
+
sed -n '2,/^set -/p' "$0" | grep '^#' | sed 's/^# *//'
|
|
57
|
+
exit 0
|
|
58
|
+
;;
|
|
59
|
+
--full) FULL_MODE=1 ;;
|
|
60
|
+
-*) echo "ERROR: unknown option: $arg" >&2; exit 2 ;;
|
|
61
|
+
*) COMMANDS_DIR="$arg" ;;
|
|
62
|
+
esac
|
|
63
|
+
done
|
|
64
|
+
|
|
65
|
+
if [ ! -d "$COMMANDS_DIR" ]; then
|
|
66
|
+
echo "ERROR: commands directory not found: $COMMANDS_DIR" >&2
|
|
67
|
+
echo "Usage: $0 [--full] [<commands_dir>]" >&2
|
|
68
|
+
exit 2
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Constants
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
# Code fence marker — stored in variable to avoid backtick shell-expansion bugs.
|
|
76
|
+
# Do NOT inline as grep -E '^[[:space:]]*\`\`\`' — escaped backticks in grep
|
|
77
|
+
# patterns undergo shell expansion, turning the pattern into '^[[:space:]]*'
|
|
78
|
+
# which matches every line. (forge#1609)
|
|
79
|
+
FENCE='```'
|
|
80
|
+
|
|
81
|
+
# Guard expressions: any occurrence in a section's code blocks marks it as guarded
|
|
82
|
+
GUARD_PATTERN='DRY_RUN|GOVERNOR|--dry-run|DRY_RUN_MODE|DryRun'
|
|
83
|
+
|
|
84
|
+
# Class B side-effect verbs (in code blocks)
|
|
85
|
+
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'
|
|
86
|
+
|
|
87
|
+
ALLOWLIST_TOKEN='allowlist:check-command-side-effects'
|
|
88
|
+
|
|
89
|
+
VIOLATIONS=0
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Class A: Scan for gh gist create/edit --public in code blocks (full corpus)
|
|
93
|
+
# This runs regardless of --full or diff mode.
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
class_a_scan() {
|
|
97
|
+
local file="$1"
|
|
98
|
+
local IN_CB=0
|
|
99
|
+
local HAS_GIST=0
|
|
100
|
+
local BLOCK_LINES=""
|
|
101
|
+
local BLOCK_START=0
|
|
102
|
+
local LINENO=0
|
|
103
|
+
local line PUBLIC_LINENO ACTUAL_LINE
|
|
104
|
+
|
|
105
|
+
while IFS= read -r line; do
|
|
106
|
+
LINENO=$((LINENO + 1))
|
|
107
|
+
|
|
108
|
+
if echo "$line" | grep -qF "$FENCE"; then
|
|
109
|
+
if [ "$IN_CB" -eq 0 ]; then
|
|
110
|
+
IN_CB=1
|
|
111
|
+
HAS_GIST=0
|
|
112
|
+
BLOCK_LINES=""
|
|
113
|
+
BLOCK_START=$LINENO
|
|
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=""
|
|
128
|
+
fi
|
|
129
|
+
continue
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
if [ "$IN_CB" -eq 1 ]; then
|
|
133
|
+
BLOCK_LINES="${BLOCK_LINES}${line}
|
|
134
|
+
"
|
|
135
|
+
# Check if this line has gh gist create/edit (even with line continuation \)
|
|
136
|
+
if echo "$line" | grep -qE 'gh[[:space:]]+gist[[:space:]]+(create|edit)'; then
|
|
137
|
+
HAS_GIST=1
|
|
138
|
+
fi
|
|
139
|
+
fi
|
|
140
|
+
done < "$file"
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
while IFS= read -r file; do
|
|
144
|
+
[ -f "$file" ] || continue
|
|
145
|
+
class_a_scan "$file"
|
|
146
|
+
done < <(find "$COMMANDS_DIR" -name '*.md' | sort)
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# Class B: Diff-aware scan for unguarded side-effect verbs in added lines
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
# Determine base SHA for diff
|
|
153
|
+
BASE_SHA=""
|
|
154
|
+
if [ -n "${GITHUB_BASE_SHA:-}" ]; then
|
|
155
|
+
BASE_SHA="$GITHUB_BASE_SHA"
|
|
156
|
+
elif [ -n "${GITHUB_EVENT_PULL_REQUEST_BASE_SHA:-}" ]; then
|
|
157
|
+
BASE_SHA="$GITHUB_EVENT_PULL_REQUEST_BASE_SHA"
|
|
158
|
+
else
|
|
159
|
+
BASE_SHA="$(git rev-parse HEAD^ 2>/dev/null || echo '')"
|
|
160
|
+
fi
|
|
161
|
+
|
|
162
|
+
if [ -z "$BASE_SHA" ]; then
|
|
163
|
+
echo "INFO: No git base SHA available — Class B (diff-aware) check skipped" >&2
|
|
164
|
+
else
|
|
165
|
+
# Get list of changed command spec files
|
|
166
|
+
CHANGED_SPECS=$(git diff --name-only "$BASE_SHA"...HEAD -- "${COMMANDS_DIR}" 2>/dev/null \
|
|
167
|
+
| grep -E '\.md$' | grep -v '^$' || true)
|
|
168
|
+
|
|
169
|
+
if [ -z "$CHANGED_SPECS" ]; then
|
|
170
|
+
echo "OK (Class B): No commands/*.md files changed — diff check skipped"
|
|
171
|
+
else
|
|
172
|
+
echo "Class B: Checking changed spec files for unguarded side-effect verbs:"
|
|
173
|
+
echo "$CHANGED_SPECS"
|
|
174
|
+
echo ""
|
|
175
|
+
|
|
176
|
+
while IFS= read -r file; do
|
|
177
|
+
[ -f "$file" ] || continue
|
|
178
|
+
|
|
179
|
+
# Get added lines for this file
|
|
180
|
+
ADDED_CONTENT=$(git diff "$BASE_SHA"...HEAD -- "$file" 2>/dev/null \
|
|
181
|
+
| grep '^+' | grep -v '^+++' | sed 's/^+//' || true)
|
|
182
|
+
|
|
183
|
+
# Skip if no added lines contain side-effect verbs
|
|
184
|
+
if ! echo "$ADDED_CONTENT" | grep -qE "$SIDE_EFFECT_PATTERN"; then
|
|
185
|
+
continue
|
|
186
|
+
fi
|
|
187
|
+
|
|
188
|
+
# Parse the full file to map sections → (has_guard, has_side_effect_in_added_lines)
|
|
189
|
+
IN_CB=0
|
|
190
|
+
SECTION="(top)"
|
|
191
|
+
SECTION_HAS_GUARD=0
|
|
192
|
+
SECTION_HAS_ADDED_SE=0
|
|
193
|
+
SECTION_SE_LINE=0
|
|
194
|
+
SECTION_SE_VERB=""
|
|
195
|
+
LINENO=0
|
|
196
|
+
|
|
197
|
+
flush_and_reset() {
|
|
198
|
+
local new_heading="$1"
|
|
199
|
+
if [ "$SECTION_HAS_ADDED_SE" -eq 1 ] && [ "$SECTION_HAS_GUARD" -eq 0 ]; then
|
|
200
|
+
echo "HIGH | $file | line $SECTION_SE_LINE | Class B: side-effect '$SECTION_SE_VERB' added to section '$SECTION' which has no DRY_RUN/governor guard — add guard before this line or wrap in DRY_RUN check" >&2
|
|
201
|
+
VIOLATIONS=$((VIOLATIONS + 1))
|
|
202
|
+
fi
|
|
203
|
+
SECTION="$new_heading"
|
|
204
|
+
SECTION_HAS_GUARD=0
|
|
205
|
+
SECTION_HAS_ADDED_SE=0
|
|
206
|
+
SECTION_SE_LINE=0
|
|
207
|
+
SECTION_SE_VERB=""
|
|
208
|
+
IN_CB=0
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
while IFS= read -r line; do
|
|
212
|
+
LINENO=$((LINENO + 1))
|
|
213
|
+
|
|
214
|
+
# Section heading reset
|
|
215
|
+
if echo "$line" | grep -qE '^#{1,4}[[:space:]]+'; then
|
|
216
|
+
heading=$(echo "$line" | sed 's/^#*[[:space:]]*//' | sed 's/[[:space:]]*$//')
|
|
217
|
+
flush_and_reset "$heading"
|
|
218
|
+
continue
|
|
219
|
+
fi
|
|
220
|
+
|
|
221
|
+
# Code block fence
|
|
222
|
+
if echo "$line" | grep -qF "$FENCE"; then
|
|
223
|
+
[ "$IN_CB" -eq 0 ] && IN_CB=1 || IN_CB=0
|
|
224
|
+
continue
|
|
225
|
+
fi
|
|
226
|
+
|
|
227
|
+
if [ "$IN_CB" -eq 1 ]; then
|
|
228
|
+
if echo "$line" | grep -qF "$ALLOWLIST_TOKEN"; then continue; fi
|
|
229
|
+
|
|
230
|
+
# Check for guard
|
|
231
|
+
echo "$line" | grep -qE "$GUARD_PATTERN" && SECTION_HAS_GUARD=1
|
|
232
|
+
|
|
233
|
+
# Check if this line is in the diff's added lines AND has a side-effect verb
|
|
234
|
+
if [ "$SECTION_HAS_ADDED_SE" -eq 0 ] && echo "$line" | grep -qE "$SIDE_EFFECT_PATTERN"; then
|
|
235
|
+
# Is this specific line in the added content?
|
|
236
|
+
if echo "$ADDED_CONTENT" | grep -qF "${line:0:80}" 2>/dev/null; then
|
|
237
|
+
SECTION_HAS_ADDED_SE=1
|
|
238
|
+
SECTION_SE_LINE=$LINENO
|
|
239
|
+
SECTION_SE_VERB=$(echo "$line" | grep -oE "$SIDE_EFFECT_PATTERN" | head -1 || echo "side-effect")
|
|
240
|
+
fi
|
|
241
|
+
fi
|
|
242
|
+
fi
|
|
243
|
+
done < "$file"
|
|
244
|
+
|
|
245
|
+
flush_and_reset "(end-of-file)"
|
|
246
|
+
|
|
247
|
+
done <<< "$CHANGED_SPECS"
|
|
248
|
+
fi
|
|
249
|
+
fi
|
|
250
|
+
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
# Summary
|
|
253
|
+
# ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
if [ "$VIOLATIONS" -gt 0 ]; then
|
|
256
|
+
echo "check-command-side-effects: $VIOLATIONS violation(s) found. See stderr for details." >&2
|
|
257
|
+
exit 1
|
|
258
|
+
fi
|
|
259
|
+
|
|
260
|
+
echo "OK: No side-effect violations found"
|
|
261
|
+
exit 0
|