devrites 3.2.24 → 3.2.25
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/CHANGELOG.md +6 -0
- package/README.md +1 -1
- package/package.json +6 -2
- package/scripts/build-host-artifacts.sh +2 -2
- package/scripts/build-release-tarball.sh +4 -4
- package/scripts/check-generated-skill-budget.mjs +2 -1
- package/scripts/check-instruction-size-baseline.mjs +2 -1
- package/scripts/check-reference-governance.mjs +2 -1
- package/scripts/pin.sh +70 -24
- package/scripts/run-tests.mjs +2 -1
- package/scripts/scan-pack-security.py +1 -1
- package/scripts/skill-pruning-audit.mjs +2 -1
- package/scripts/skills-inventory.mjs +2 -1
- package/scripts/validate-workflow-security.py +68 -9
- package/scripts/validate.sh +11 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to DevRites are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and DevRites adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases are generated automatically by [semantic-release](https://semantic-release.gitbook.io/) from Conventional Commits on `main`.
|
|
4
4
|
|
|
5
|
+
## [3.2.25](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.24...v3.2.25) (2026-07-28)
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
* **devrites:** close review safety gaps ([178a43e](https://github.com/ViktorsBaikers/DevRites/commit/178a43e5a25a7b0825c0e16aa76c402e6b48a3aa))
|
|
10
|
+
|
|
5
11
|
## [3.2.24](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.23...v3.2.24) (2026-07-28)
|
|
6
12
|
|
|
7
13
|
### Fixed
|
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ final commit, push, and tag, and it requires a typed `GO` confirmation.
|
|
|
25
25
|
Unattended runs may create local WIP checkpoint commits along the way, but only
|
|
26
26
|
Ship collapses and pushes them.
|
|
27
27
|
|
|
28
|
-
**Status:** [`v3.2.
|
|
28
|
+
**Status:** [`v3.2.25`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.25): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
|
|
29
29
|
|
|
30
30
|
## Quick start
|
|
31
31
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "devrites",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.25",
|
|
4
4
|
"description": "DevRites: a disciplined senior-engineer workflow pack for Claude Code and Codex",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"homepage": "https://github.com/ViktorsBaikers/DevRites#readme",
|
|
@@ -32,10 +32,14 @@
|
|
|
32
32
|
"engine/",
|
|
33
33
|
"pack/",
|
|
34
34
|
"scripts/",
|
|
35
|
+
"!scripts/__pycache__/",
|
|
36
|
+
"!scripts/*.pyc",
|
|
37
|
+
"!scripts/.cache/",
|
|
35
38
|
"install.sh",
|
|
36
39
|
"uninstall.sh",
|
|
37
40
|
"update.sh",
|
|
38
41
|
"docs/",
|
|
42
|
+
"!docs/internal/",
|
|
39
43
|
"README.md",
|
|
40
44
|
"LICENSE",
|
|
41
45
|
"SECURITY.md",
|
|
@@ -47,7 +51,7 @@
|
|
|
47
51
|
},
|
|
48
52
|
"scripts": {
|
|
49
53
|
"prepare": "husky || true",
|
|
50
|
-
"prepack": "bash scripts/build-host-artifacts.sh
|
|
54
|
+
"prepack": "bash scripts/build-host-artifacts.sh",
|
|
51
55
|
"commitlint": "commitlint --edit",
|
|
52
56
|
"validate": "bash scripts/validate.sh",
|
|
53
57
|
"test": "node scripts/run-tests.mjs",
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
#
|
|
5
5
|
# Default output is pack/generated/ so npm pack can ship prebuilt surfaces.
|
|
6
6
|
# Tests may set DEVRITES_HOST_ARTIFACT_DIR to a temporary directory.
|
|
7
|
-
set -
|
|
7
|
+
set -euo pipefail
|
|
8
8
|
|
|
9
9
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
10
10
|
PACK_SRC="$ROOT/pack/.claude"
|
|
@@ -13,7 +13,7 @@ OUT_ROOT="${DEVRITES_HOST_ARTIFACT_DIR:-$ROOT/pack/generated}"
|
|
|
13
13
|
[ -d "$PACK_SRC/skills" ] || { echo "build-host-artifacts: missing $PACK_SRC/skills" >&2; exit 1; }
|
|
14
14
|
[ -d "$PACK_SRC/agents" ] || { echo "build-host-artifacts: missing $PACK_SRC/agents" >&2; exit 1; }
|
|
15
15
|
|
|
16
|
-
TMP_GEN_DIR="$(mktemp -d
|
|
16
|
+
TMP_GEN_DIR="$(mktemp -d)"
|
|
17
17
|
cleanup() { rm -rf "$TMP_GEN_DIR"; }
|
|
18
18
|
trap cleanup EXIT
|
|
19
19
|
|
|
@@ -57,14 +57,14 @@ PAYLOAD=(
|
|
|
57
57
|
|
|
58
58
|
for item in "${PAYLOAD[@]}"; do
|
|
59
59
|
if [[ -e "$item" ]]; then
|
|
60
|
-
if
|
|
60
|
+
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
61
61
|
while IFS= read -r -d '' path; do
|
|
62
|
-
[[ -e "$path" ]] || continue
|
|
62
|
+
[[ -e "$path" || -L "$path" ]] || continue
|
|
63
63
|
if [[ "$path" == engine/testdata/golden/* ]]; then
|
|
64
64
|
continue
|
|
65
65
|
fi
|
|
66
66
|
mkdir -p "$STAGE/$(dirname "$path")"
|
|
67
|
-
cp "$path" "$STAGE/$path"
|
|
67
|
+
cp -P "$path" "$STAGE/$path"
|
|
68
68
|
done < <(git ls-files -z -- "$item")
|
|
69
69
|
else
|
|
70
70
|
cp -R "$item" "$STAGE/"
|
|
@@ -73,7 +73,7 @@ for item in "${PAYLOAD[@]}"; do
|
|
|
73
73
|
done
|
|
74
74
|
|
|
75
75
|
# Include the same prebuilt host artifacts as the npm package.
|
|
76
|
-
DEVRITES_HOST_ARTIFACT_DIR="$STAGE/pack/generated" bash "$
|
|
76
|
+
DEVRITES_HOST_ARTIFACT_DIR="$STAGE/pack/generated" bash "$STAGE/scripts/build-host-artifacts.sh" >/dev/null
|
|
77
77
|
|
|
78
78
|
# Remove development files copied with the payload.
|
|
79
79
|
rm -rf "$STAGE/docs/internal" "$STAGE/scripts/.cache" 2>/dev/null || true
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// Guard generated host skill payloads against accidental context bloat.
|
|
3
3
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
4
4
|
import { join, relative } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
5
6
|
|
|
6
|
-
const root = new URL('..', import.meta.url)
|
|
7
|
+
const root = fileURLToPath(new URL('..', import.meta.url));
|
|
7
8
|
const base = process.argv[2] || join(root, 'pack', '.claude', 'skills');
|
|
8
9
|
const totalLimit = Number(process.env.DEVRITES_SKILL_TOTAL_BUDGET || 900_000);
|
|
9
10
|
const fileLimit = Number(process.env.DEVRITES_SKILL_FILE_BUDGET || 64_000);
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// Track canonical instruction files individually and skills as a group.
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
5
6
|
|
|
6
|
-
const defaultRoot = new URL('..', import.meta.url)
|
|
7
|
+
const defaultRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
7
8
|
const argv = process.argv.slice(2);
|
|
8
9
|
function option(name, fallback) {
|
|
9
10
|
const i = argv.indexOf(name);
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// Ensure supporting skill references are reachable or explicitly time-bounded.
|
|
3
3
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
4
4
|
import { basename, dirname, join, normalize, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
5
6
|
|
|
6
|
-
const defaultRoot = new URL('..', import.meta.url)
|
|
7
|
+
const defaultRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
7
8
|
const argv = process.argv.slice(2);
|
|
8
9
|
function option(name, fallback) {
|
|
9
10
|
const i = argv.indexOf(name);
|
package/scripts/pin.sh
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
# - Manifest-managed: pinned wrappers are recorded in .claude/devrites.manifest
|
|
30
30
|
# so the standard uninstall.sh cleans them up automatically.
|
|
31
31
|
|
|
32
|
-
set -
|
|
32
|
+
set -euo pipefail
|
|
33
33
|
|
|
34
34
|
# ---- locate install-lib + load helpers ----------------------------------
|
|
35
35
|
SELF_DIR="$( cd "$(dirname "$0")" && pwd -P )"
|
|
@@ -77,6 +77,16 @@ MF="$TARGET/$DR_MANIFEST_NAME"
|
|
|
77
77
|
[ -f "$MF" ] || dr_die "no manifest at $MF: run install.sh first?"
|
|
78
78
|
|
|
79
79
|
# ---- helpers -------------------------------------------------------------
|
|
80
|
+
PIN_TMP=""
|
|
81
|
+
cleanup_pin_tmp() {
|
|
82
|
+
[ -z "$PIN_TMP" ] || rm -rf "$PIN_TMP"
|
|
83
|
+
}
|
|
84
|
+
trap cleanup_pin_tmp EXIT
|
|
85
|
+
|
|
86
|
+
path_exists() {
|
|
87
|
+
[ -e "$1" ] || [ -L "$1" ]
|
|
88
|
+
}
|
|
89
|
+
|
|
80
90
|
valid_alias_name() {
|
|
81
91
|
# lowercase ASCII, digits, hyphens. No /, ., spaces. Not "rite" or "rite-*".
|
|
82
92
|
case "$1" in
|
|
@@ -105,6 +115,20 @@ is_pinned_alias_file() {
|
|
|
105
115
|
grep -q 'description: Alias of DevRites /' "$1"
|
|
106
116
|
}
|
|
107
117
|
|
|
118
|
+
preflight_alias_destination() {
|
|
119
|
+
_dir="$1"
|
|
120
|
+
_file="$2"
|
|
121
|
+
if [ -L "$_dir" ] || { path_exists "$_dir" && [ ! -d "$_dir" ]; }; then
|
|
122
|
+
dr_die "$_dir exists and is not a managed alias directory: refusing to overwrite"
|
|
123
|
+
fi
|
|
124
|
+
if path_exists "$_file"; then
|
|
125
|
+
[ ! -L "$_file" ] && is_pinned_alias_file "$_file" \
|
|
126
|
+
|| dr_die "$_file exists and is NOT a pinned alias: refusing to overwrite"
|
|
127
|
+
elif [ -d "$_dir" ]; then
|
|
128
|
+
dr_die "$_dir exists without a pinned alias: refusing to overwrite"
|
|
129
|
+
fi
|
|
130
|
+
}
|
|
131
|
+
|
|
108
132
|
# ---- subcommands ---------------------------------------------------------
|
|
109
133
|
do_add() {
|
|
110
134
|
valid_alias_name "$ALIAS" || dr_die "invalid alias name '$ALIAS' (lowercase / digits / hyphens; not 'rite' or 'rite-*')"
|
|
@@ -118,34 +142,47 @@ do_add() {
|
|
|
118
142
|
CODEX_ALIAS_FILE="$CODEX_ALIAS_DIR/SKILL.md"
|
|
119
143
|
CODEX_ALIAS_REL=".agents/skills/$ALIAS/SKILL.md"
|
|
120
144
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
else
|
|
125
|
-
dr_die "$ALIAS_FILE exists and is NOT a pinned alias: refusing to overwrite"
|
|
126
|
-
fi
|
|
145
|
+
preflight_alias_destination "$ALIAS_DIR" "$ALIAS_FILE"
|
|
146
|
+
if path_exists "$ALIAS_FILE"; then
|
|
147
|
+
dr_warn "already pinned: /$ALIAS: overwriting"
|
|
127
148
|
fi
|
|
128
|
-
if [ -d "$CODEX_SKILLS_DIR" ]
|
|
129
|
-
|
|
149
|
+
if [ -d "$CODEX_SKILLS_DIR" ]; then
|
|
150
|
+
preflight_alias_destination "$CODEX_ALIAS_DIR" "$CODEX_ALIAS_FILE"
|
|
151
|
+
if path_exists "$CODEX_ALIAS_FILE"; then
|
|
130
152
|
dr_warn "already pinned for Codex: /$ALIAS: overwriting"
|
|
131
|
-
else
|
|
132
|
-
dr_die "$CODEX_ALIAS_FILE exists and is NOT a pinned alias: refusing to overwrite"
|
|
133
153
|
fi
|
|
134
154
|
fi
|
|
135
155
|
|
|
156
|
+
PIN_TMP="$(mktemp -d "$SKILLS_DIR/.devrites-pin.XXXXXX")"
|
|
157
|
+
dr_gen_alias_wrapper "$ALIAS" "$DEST" "$PIN_TMP/claude"
|
|
158
|
+
if [ -d "$CODEX_SKILLS_DIR" ]; then
|
|
159
|
+
dr_gen_alias_wrapper "$ALIAS" "$DEST" "$PIN_TMP/codex"
|
|
160
|
+
fi
|
|
161
|
+
cp -p "$MF" "$PIN_TMP/manifest"
|
|
162
|
+
if ! dr_manifest_contains "$PIN_TMP/manifest" "$ALIAS_REL"; then
|
|
163
|
+
printf '%s\n' "$ALIAS_REL" >> "$PIN_TMP/manifest"
|
|
164
|
+
fi
|
|
165
|
+
if [ -d "$CODEX_SKILLS_DIR" ] && ! dr_manifest_contains "$PIN_TMP/manifest" "$CODEX_ALIAS_REL"; then
|
|
166
|
+
printf '%s\n' "$CODEX_ALIAS_REL" >> "$PIN_TMP/manifest"
|
|
167
|
+
fi
|
|
168
|
+
|
|
136
169
|
mkdir -p "$ALIAS_DIR"
|
|
137
|
-
dr_gen_alias_wrapper "$ALIAS" "$DEST" "$ALIAS_FILE"
|
|
138
170
|
if [ -d "$CODEX_SKILLS_DIR" ]; then
|
|
139
171
|
mkdir -p "$CODEX_ALIAS_DIR"
|
|
140
|
-
dr_gen_alias_wrapper "$ALIAS" "$DEST" "$CODEX_ALIAS_FILE"
|
|
141
172
|
fi
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
173
|
+
[ -w "$ALIAS_DIR" ] || dr_die "$ALIAS_DIR is not writable"
|
|
174
|
+
[ -w "$(dirname "$MF")" ] || dr_die "$(dirname "$MF") is not writable"
|
|
175
|
+
if [ -d "$CODEX_SKILLS_DIR" ]; then
|
|
176
|
+
[ -w "$CODEX_ALIAS_DIR" ] || dr_die "$CODEX_ALIAS_DIR is not writable"
|
|
145
177
|
fi
|
|
146
|
-
|
|
147
|
-
|
|
178
|
+
|
|
179
|
+
mv "$PIN_TMP/claude" "$ALIAS_FILE"
|
|
180
|
+
if [ -d "$CODEX_SKILLS_DIR" ]; then
|
|
181
|
+
mv "$PIN_TMP/codex" "$CODEX_ALIAS_FILE"
|
|
148
182
|
fi
|
|
183
|
+
mv "$PIN_TMP/manifest" "$MF"
|
|
184
|
+
rm -rf "$PIN_TMP"
|
|
185
|
+
PIN_TMP=""
|
|
149
186
|
|
|
150
187
|
if [ -d "$CODEX_SKILLS_DIR" ]; then
|
|
151
188
|
dr_ok "pinned: /$ALIAS → /$DEST ($ALIAS_FILE, $CODEX_ALIAS_FILE)"
|
|
@@ -166,17 +203,24 @@ do_remove() {
|
|
|
166
203
|
[ -f "$ALIAS_FILE" ] || dr_die "no pinned alias at $ALIAS_FILE"
|
|
167
204
|
is_pinned_alias "$ALIAS" || dr_die "$ALIAS_FILE exists but is not a pinned alias: refusing to remove"
|
|
168
205
|
|
|
206
|
+
if path_exists "$CODEX_ALIAS_FILE"; then
|
|
207
|
+
is_pinned_alias_file "$CODEX_ALIAS_FILE" || dr_die "$CODEX_ALIAS_FILE exists but is not a pinned alias: refusing to remove"
|
|
208
|
+
fi
|
|
209
|
+
|
|
210
|
+
PIN_TMP="$(mktemp -d "$SKILLS_DIR/.devrites-pin.XXXXXX")"
|
|
211
|
+
awk -v claude="$ALIAS_REL" -v codex="$CODEX_ALIAS_REL" \
|
|
212
|
+
'$0 != claude && $0 != codex' "$MF" > "$PIN_TMP/manifest"
|
|
213
|
+
|
|
169
214
|
rm -f "$ALIAS_FILE"
|
|
170
215
|
rmdir "$ALIAS_DIR" 2>/dev/null || true
|
|
171
|
-
if
|
|
172
|
-
is_pinned_alias_file "$CODEX_ALIAS_FILE" || dr_die "$CODEX_ALIAS_FILE exists but is not a pinned alias: refusing to remove"
|
|
216
|
+
if path_exists "$CODEX_ALIAS_FILE"; then
|
|
173
217
|
rm -f "$CODEX_ALIAS_FILE"
|
|
174
218
|
rmdir "$CODEX_ALIAS_DIR" 2>/dev/null || true
|
|
175
219
|
fi
|
|
176
220
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
221
|
+
mv "$PIN_TMP/manifest" "$MF"
|
|
222
|
+
rm -rf "$PIN_TMP"
|
|
223
|
+
PIN_TMP=""
|
|
180
224
|
|
|
181
225
|
dr_ok "unpinned: /$ALIAS"
|
|
182
226
|
}
|
|
@@ -192,7 +236,9 @@ do_list() {
|
|
|
192
236
|
found=1
|
|
193
237
|
fi
|
|
194
238
|
done
|
|
195
|
-
[ "$found" -eq 0 ]
|
|
239
|
+
if [ "$found" -eq 0 ]; then
|
|
240
|
+
dr_say "(no pinned aliases at $TARGET)"
|
|
241
|
+
fi
|
|
196
242
|
}
|
|
197
243
|
|
|
198
244
|
case "$SUBCMD" in
|
package/scripts/run-tests.mjs
CHANGED
|
@@ -3,8 +3,9 @@ import { spawn } from 'node:child_process';
|
|
|
3
3
|
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { basename, join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
6
7
|
|
|
7
|
-
const root = new URL('..', import.meta.url)
|
|
8
|
+
const root = fileURLToPath(new URL('..', import.meta.url));
|
|
8
9
|
const testsDir = join(root, 'tests');
|
|
9
10
|
const args = process.argv.slice(2);
|
|
10
11
|
let jobs = Math.max(1, Math.min(5, Math.floor(Number(process.env.DEVRITES_TEST_JOBS || 4)) || 4));
|
|
@@ -151,7 +151,7 @@ def _file_suppressions(text):
|
|
|
151
151
|
|
|
152
152
|
# --- driver ---------------------------------------------------------------
|
|
153
153
|
|
|
154
|
-
TEXT_EXTS = {".md", ".sh", ".json", ".txt", ".py", ".js", ".yaml", ".yml", ""}
|
|
154
|
+
TEXT_EXTS = {".md", ".sh", ".json", ".toml", ".txt", ".py", ".js", ".yaml", ".yml", ""}
|
|
155
155
|
|
|
156
156
|
|
|
157
157
|
def iter_files(paths):
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// Advisory pruning audit plus blocking ordered-step completion contracts.
|
|
3
3
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
4
4
|
import { join, relative } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
5
6
|
|
|
6
|
-
const root = new URL('..', import.meta.url)
|
|
7
|
+
const root = fileURLToPath(new URL('..', import.meta.url));
|
|
7
8
|
const skillsArg = process.argv.indexOf('--skills-dir');
|
|
8
9
|
const skillsDir = skillsArg >= 0 ? process.argv[skillsArg + 1] : join(root, 'pack', '.claude', 'skills');
|
|
9
10
|
const quiet = process.argv.includes('--quiet');
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// Verify the authored DevRites skill inventory and documentation counts.
|
|
3
3
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
4
4
|
import { join, relative } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
5
6
|
|
|
6
|
-
const root = new URL('..', import.meta.url)
|
|
7
|
+
const root = fileURLToPath(new URL('..', import.meta.url));
|
|
7
8
|
const skillsDir = join(root, 'pack', '.claude', 'skills');
|
|
8
9
|
const docsSkills = join(root, 'docs', 'skills.md');
|
|
9
10
|
const docsCommandMap = join(root, 'docs', 'command-map.md');
|
|
@@ -20,25 +20,82 @@ import re
|
|
|
20
20
|
import sys
|
|
21
21
|
|
|
22
22
|
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
23
|
-
USES_RE = re.compile(r"^\s*-?\s*uses
|
|
23
|
+
USES_RE = re.compile(r"^\s*-?\s*uses\s*:\s*([^\s#]+)")
|
|
24
24
|
DEPENDABOT_ONLY_RE = re.compile(
|
|
25
|
-
r"^\s*if
|
|
25
|
+
r"^\s*if\s*:\s*(?:\$\{\{\s*)?"
|
|
26
26
|
r"(?:github\.actor|github\.event\.pull_request\.user\.login)\s*==\s*"
|
|
27
27
|
r"['\"]dependabot\[bot\]['\"]",
|
|
28
28
|
re.MULTILINE,
|
|
29
29
|
)
|
|
30
30
|
UNQUOTED_NAME_COLON_RE = re.compile(r"^\s*(?:-\s*)?name:\s+[^'\"].*:\s+\S")
|
|
31
|
-
RUN_RE = re.compile(r"^(\s*)(?:-\s*)?run
|
|
31
|
+
RUN_RE = re.compile(r"^(\s*)(?:-\s*)?run\s*:\s*(.*)$")
|
|
32
32
|
DISPATCH_EXPRESSION_RE = re.compile(r"\$\{\{[^}]*\binputs\b", re.IGNORECASE)
|
|
33
|
+
KEY_RE = re.compile(r"^(\s*)([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def jobs_without_permissions(lines):
|
|
37
|
+
"""Return job IDs missing direct permissions, or None with a global scope."""
|
|
38
|
+
for line in lines:
|
|
39
|
+
match = KEY_RE.match(line)
|
|
40
|
+
if match and not match.group(1) and match.group(2) == "permissions":
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
jobs_line = None
|
|
44
|
+
for i, line in enumerate(lines):
|
|
45
|
+
match = KEY_RE.match(line)
|
|
46
|
+
if match and not match.group(1) and match.group(2) == "jobs":
|
|
47
|
+
jobs_line = i
|
|
48
|
+
break
|
|
49
|
+
if jobs_line is None:
|
|
50
|
+
return ["<workflow>"]
|
|
51
|
+
|
|
52
|
+
job_indent = None
|
|
53
|
+
job_starts = []
|
|
54
|
+
for i in range(jobs_line + 1, len(lines)):
|
|
55
|
+
line = lines[i]
|
|
56
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
57
|
+
continue
|
|
58
|
+
match = KEY_RE.match(line)
|
|
59
|
+
if not match:
|
|
60
|
+
continue
|
|
61
|
+
indent = len(match.group(1))
|
|
62
|
+
if indent == 0:
|
|
63
|
+
break
|
|
64
|
+
if job_indent is None:
|
|
65
|
+
job_indent = indent
|
|
66
|
+
if indent == job_indent:
|
|
67
|
+
job_starts.append((i, match.group(2)))
|
|
68
|
+
if not job_starts:
|
|
69
|
+
return ["<workflow>"]
|
|
70
|
+
|
|
71
|
+
missing = []
|
|
72
|
+
for position, (start, job_id) in enumerate(job_starts):
|
|
73
|
+
end = job_starts[position + 1][0] if position + 1 < len(job_starts) else len(lines)
|
|
74
|
+
property_indents = []
|
|
75
|
+
properties = []
|
|
76
|
+
for line in lines[start + 1:end]:
|
|
77
|
+
match = KEY_RE.match(line)
|
|
78
|
+
if not match:
|
|
79
|
+
continue
|
|
80
|
+
indent = len(match.group(1))
|
|
81
|
+
if indent > job_indent:
|
|
82
|
+
property_indents.append(indent)
|
|
83
|
+
properties.append((indent, match.group(2)))
|
|
84
|
+
direct_indent = min(property_indents) if property_indents else None
|
|
85
|
+
if direct_indent is None or not any(
|
|
86
|
+
indent == direct_indent and key == "permissions"
|
|
87
|
+
for indent, key in properties):
|
|
88
|
+
missing.append(job_id)
|
|
89
|
+
return missing
|
|
33
90
|
|
|
34
91
|
|
|
35
92
|
def safe_dependabot_target(text):
|
|
36
|
-
if re.search(r"^\s*-?\s*uses
|
|
93
|
+
if re.search(r"^\s*-?\s*uses\s*:\s*actions/checkout@", text, re.MULTILINE):
|
|
37
94
|
return False
|
|
38
|
-
jobs =
|
|
95
|
+
jobs = re.split(r"(?m)^jobs\s*:\s*(?:#.*)?$", text, maxsplit=1)
|
|
39
96
|
if len(jobs) != 2:
|
|
40
97
|
return False
|
|
41
|
-
blocks = re.split(r"(?m)^ [A-Za-z0-9_-]
|
|
98
|
+
blocks = re.split(r"(?m)^ [A-Za-z0-9_-]+\s*:\s*(?:#.*)?$", jobs[1])[1:]
|
|
42
99
|
return bool(blocks) and all(DEPENDABOT_ONLY_RE.search(block) for block in blocks)
|
|
43
100
|
|
|
44
101
|
|
|
@@ -46,9 +103,11 @@ def scan_text(path, text):
|
|
|
46
103
|
findings = []
|
|
47
104
|
lines = text.splitlines()
|
|
48
105
|
dependabot_target_is_safe = safe_dependabot_target(text)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
106
|
+
unscoped_jobs = jobs_without_permissions(lines)
|
|
107
|
+
if unscoped_jobs:
|
|
108
|
+
findings.append("%s: jobs without explicit permissions: %s. Add a global "
|
|
109
|
+
"least-privilege block or scope every job"
|
|
110
|
+
% (path, ", ".join(unscoped_jobs)))
|
|
52
111
|
for i, line in enumerate(lines, 1):
|
|
53
112
|
if UNQUOTED_NAME_COLON_RE.match(line):
|
|
54
113
|
findings.append("%s:%d: name has an unquoted colon. Quote the complete "
|
package/scripts/validate.sh
CHANGED
|
@@ -15,9 +15,11 @@ good() { printf 'ok: %s\n' "$*"; }
|
|
|
15
15
|
|
|
16
16
|
# ---- 1. bash -n on every shell script ------------------------------------
|
|
17
17
|
section "bash syntax (bash -n)"
|
|
18
|
-
SH_LIST="$ROOT/install.sh $ROOT/uninstall.sh $ROOT/update.sh"
|
|
19
|
-
for f in "$ROOT"/scripts/*.sh "$ROOT"/tests/*.sh "$ROOT"/pack/.claude/hooks/*.sh "$ROOT"/pack/.claude/skills/*/scripts/*.sh; do
|
|
20
|
-
|
|
18
|
+
SH_LIST=("$ROOT/install.sh" "$ROOT/uninstall.sh" "$ROOT/update.sh")
|
|
19
|
+
for f in "$ROOT"/scripts/*.sh "$ROOT"/tests/*.sh "$ROOT"/pack/.claude/hooks/*.sh "$ROOT"/pack/.claude/skills/*/scripts/*.sh; do
|
|
20
|
+
[ -f "$f" ] && SH_LIST+=("$f")
|
|
21
|
+
done
|
|
22
|
+
for f in "${SH_LIST[@]}"; do
|
|
21
23
|
if bash -n "$f" 2>/tmp/dr_synerr; then good "syntax ${f#$ROOT/}"; else bad "syntax ${f#$ROOT/}: $(cat /tmp/dr_synerr)"; fi
|
|
22
24
|
done
|
|
23
25
|
|
|
@@ -67,10 +69,10 @@ done
|
|
|
67
69
|
# ---- 5. frontmatter validation ------------------------------------------
|
|
68
70
|
section "frontmatter"
|
|
69
71
|
if command -v python3 >/dev/null 2>&1; then
|
|
70
|
-
FM_FILES=
|
|
71
|
-
for d in "$SKILLS"/*/; do
|
|
72
|
-
for a in "$AGENTS"/*.md; do
|
|
73
|
-
if python3 "$ROOT/scripts/validate-frontmatter.py" $FM_FILES; then good "frontmatter parses"; else bad "frontmatter validation failed"; fi
|
|
72
|
+
FM_FILES=()
|
|
73
|
+
for d in "$SKILLS"/*/; do [ -f "${d}SKILL.md" ] && FM_FILES+=("${d}SKILL.md"); done
|
|
74
|
+
for a in "$AGENTS"/*.md; do [ -f "$a" ] && FM_FILES+=("$a"); done
|
|
75
|
+
if python3 "$ROOT/scripts/validate-frontmatter.py" "${FM_FILES[@]}"; then good "frontmatter parses"; else bad "frontmatter validation failed"; fi
|
|
74
76
|
else
|
|
75
77
|
echo "skip: python3 not found"
|
|
76
78
|
fi
|
|
@@ -335,11 +337,11 @@ fi
|
|
|
335
337
|
# Local validation skips this gate only when shellcheck is not installed.
|
|
336
338
|
section "shellcheck (-S error blocking · -S warning advisory)"
|
|
337
339
|
if command -v shellcheck >/dev/null 2>&1; then
|
|
338
|
-
for f in $SH_LIST; do
|
|
340
|
+
for f in "${SH_LIST[@]}"; do
|
|
339
341
|
if shellcheck -S error "$f"; then good "shellcheck ${f#"$ROOT"/}"; else bad "shellcheck (error) ${f#"$ROOT"/}"; fi
|
|
340
342
|
done
|
|
341
343
|
# Warnings are advisory. Print them per file without failing the build.
|
|
342
|
-
for f in $SH_LIST; do
|
|
344
|
+
for f in "${SH_LIST[@]}"; do
|
|
343
345
|
shellcheck -S warning "$f" >/dev/null 2>&1 || echo " advisory (warning-level): ${f#"$ROOT"/}"
|
|
344
346
|
done
|
|
345
347
|
else
|