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 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.24`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.24): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
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.24",
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 && rm -rf scripts/__pycache__ scripts/.cache docs/internal",
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 -u
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 2>/dev/null || echo "${TMPDIR:-/tmp}/devrites-host-artifacts.$$")"
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 [[ "$item" == "engine" ]] && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
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 "$ROOT/scripts/build-host-artifacts.sh" >/dev/null
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).pathname.replace(/\/$/, '');
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).pathname.replace(/\/$/, '');
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).pathname.replace(/\/$/, '');
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 -u
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
- if [ -e "$ALIAS_FILE" ]; then
122
- if is_pinned_alias "$ALIAS"; then
123
- dr_warn "already pinned: /$ALIAS: overwriting"
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" ] && [ -e "$CODEX_ALIAS_FILE" ]; then
129
- if is_pinned_alias_file "$CODEX_ALIAS_FILE"; then
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
- if ! dr_manifest_contains "$MF" "$ALIAS_REL"; then
144
- printf '%s\n' "$ALIAS_REL" >> "$MF"
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
- if [ -d "$CODEX_SKILLS_DIR" ] && ! dr_manifest_contains "$MF" "$CODEX_ALIAS_REL"; then
147
- printf '%s\n' "$CODEX_ALIAS_REL" >> "$MF"
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 [ -f "$CODEX_ALIAS_FILE" ]; then
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
- # Drop the alias line from the manifest (preserve header + the rest)
178
- TMP="$(mktemp)"
179
- grep -Fvx "$ALIAS_REL" "$MF" | grep -Fvx "$CODEX_ALIAS_REL" > "$TMP" && mv "$TMP" "$MF"
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 ] && dr_say "(no pinned aliases at $TARGET)"
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
@@ -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).pathname.replace(/\/$/, '');
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).pathname.replace(/\/$/, '');
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).pathname.replace(/\/$/, '');
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:\s*([^\s#]+)")
23
+ USES_RE = re.compile(r"^\s*-?\s*uses\s*:\s*([^\s#]+)")
24
24
  DEPENDABOT_ONLY_RE = re.compile(
25
- r"^\s*if:\s*(?:\$\{\{\s*)?"
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:\s*(.*)$")
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:\s*actions/checkout@", text, re.MULTILINE):
93
+ if re.search(r"^\s*-?\s*uses\s*:\s*actions/checkout@", text, re.MULTILINE):
37
94
  return False
38
- jobs = text.split("\njobs:", 1)
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_-]+:\s*(?:#.*)?$", jobs[1])[1:]
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
- if not re.search(r"^\s*permissions:", text, re.MULTILINE):
50
- findings.append("%s: no permissions block. The default GITHUB_TOKEN is broad; "
51
- "add an explicit least-privilege block" % path)
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 "
@@ -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 [ -f "$f" ] && SH_LIST="$SH_LIST $f"; done
20
- for f in $SH_LIST; do
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 FM_FILES="$FM_FILES ${d}SKILL.md"; done
72
- for a in "$AGENTS"/*.md; do FM_FILES="$FM_FILES $a"; done
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