devrites 3.2.23 → 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,18 @@
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
+
11
+ ## [3.2.24](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.23...v3.2.24) (2026-07-28)
12
+
13
+ ### Fixed
14
+
15
+ * **devrites:** close review safety gaps ([33e736a](https://github.com/ViktorsBaikers/DevRites/commit/33e736a3d9d69fd166827235ed1ce2310aeca75a))
16
+
5
17
  ## [3.2.23](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.22...v3.2.23) (2026-07-28)
6
18
 
7
19
  ### 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.23`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.23): 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/engine/hooks.go CHANGED
@@ -389,18 +389,37 @@ func safeReadonlyShellSegment(segment string) bool {
389
389
  base := strings.ToLower(filepath.Base(strings.Trim(fields[0], `"'`)))
390
390
  args := fields[1:]
391
391
  switch base {
392
- case "true", "false", "pwd", "ls", "cat", "head", "tail", "less", "more",
393
- "grep", "rg", "wc", "sort", "uniq", "cut", "tr", "stat", "file",
394
- "readlink", "realpath", "basename", "dirname", "cmp", "diff", "jq", "yq",
395
- "tree", "du", "df", "printenv", "which", "date", "uname", "id", "whoami",
392
+ case "true", "false", "pwd", "ls", "cat", "head", "tail", "more",
393
+ "grep", "wc", "uniq", "cut", "tr", "stat", "file",
394
+ "readlink", "realpath", "basename", "dirname", "cmp", "jq",
395
+ "du", "df", "printenv", "which", "date", "uname", "id", "whoami",
396
396
  "ps", "echo", "printf", "test", "[", "cd", "sha256sum", "shasum":
397
397
  return true
398
+ case "less":
399
+ return !hasShellOption(args, "oO", "--log-file")
400
+ case "rg":
401
+ return !hasShellOption(args, "", "--pre", "--hostname-bin")
402
+ case "sort":
403
+ return !hasShellOption(args, "o", "--output", "--compress-program")
404
+ case "diff":
405
+ return !hasShellOption(args, "", "--output")
406
+ case "yq":
407
+ return !hasShellOption(args, "i", "--inplace", "--in-place")
408
+ case "tree":
409
+ return !hasShellOption(args, "o", "--output")
398
410
  case "find":
399
- return !hasAnyArg(args, "-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprintf")
411
+ return !hasArgPrefix(args, "-delete") &&
412
+ !hasArgPrefix(args, "-exec") &&
413
+ !hasArgPrefix(args, "-ok") &&
414
+ !hasArgPrefix(args, "-fprint") &&
415
+ !hasArgPrefix(args, "-fprintf") &&
416
+ !hasArgPrefix(args, "-fls")
400
417
  case "sed":
401
- return !hasArgPrefix(args, "-i")
418
+ return !hasShellOption(args, "i", "--in-place", "--inplace")
402
419
  case "git":
403
- return len(args) > 0 && hasAnyArg(args[:1], "diff", "status", "show", "log", "rev-parse", "ls-files", "grep", "blame")
420
+ return len(args) > 0 &&
421
+ hasAnyArg(args[:1], "diff", "status", "show", "log", "rev-parse", "ls-files", "grep", "blame") &&
422
+ !hasShellOption(args[1:], "", "--output", "--ext-diff", "--textconv", "--open-files-in-pager")
404
423
  case "go":
405
424
  if len(args) == 0 || !hasAnyArg(args[:1], "test", "vet", "list", "version", "env") {
406
425
  return false
@@ -453,6 +472,7 @@ func safePackageProof(args []string) bool {
453
472
 
454
473
  func hasAnyArg(args []string, wants ...string) bool {
455
474
  for _, arg := range args {
475
+ arg = strings.Trim(arg, `"'`)
456
476
  for _, want := range wants {
457
477
  if arg == want {
458
478
  return true
@@ -464,6 +484,7 @@ func hasAnyArg(args []string, wants ...string) bool {
464
484
 
465
485
  func hasArgPrefix(args []string, prefix string) bool {
466
486
  for _, arg := range args {
487
+ arg = strings.Trim(arg, `"'`)
467
488
  if strings.HasPrefix(arg, prefix) {
468
489
  return true
469
490
  }
@@ -471,6 +492,27 @@ func hasArgPrefix(args []string, prefix string) bool {
471
492
  return false
472
493
  }
473
494
 
495
+ func hasShellOption(args []string, shortOptions string, longOptions ...string) bool {
496
+ for _, arg := range args {
497
+ arg = strings.Trim(arg, `"'`)
498
+ if arg == "--" {
499
+ return false
500
+ }
501
+ if strings.HasPrefix(arg, "--") {
502
+ for _, option := range longOptions {
503
+ if arg == option || strings.HasPrefix(arg, option+"=") {
504
+ return true
505
+ }
506
+ }
507
+ continue
508
+ }
509
+ if strings.HasPrefix(arg, "-") && strings.ContainsAny(strings.TrimPrefix(arg, "-"), shortOptions) {
510
+ return true
511
+ }
512
+ }
513
+ return false
514
+ }
515
+
474
516
  // hookReviewerReadonly keeps every DevRites leaf except slice-wright read only.
475
517
  // It always enforces declared DevRites runs. Undeclared runs keep the legacy
476
518
  // observe mode.
@@ -327,12 +327,12 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
327
327
  if pendingReceipt != "" {
328
328
  defer func() { _ = os.Remove(pendingReceipt) }()
329
329
  }
330
- if err := closeWindow(); err != nil {
331
- fmt.Fprintf(stderr, "reconcile: source baseline restored, but cannot close rejected slice window: %v\n", err)
330
+ if err := commitContentAddressedReceipt(receiptPath, pendingReceipt, receiptData); err != nil {
331
+ fmt.Fprintf(stderr, "reconcile: source baseline restored, but cannot persist abort receipt; retained window left open: %v\n", err)
332
332
  return 6
333
333
  }
334
- if err := commitContentAddressedReceipt(receiptPath, pendingReceipt, receiptData); err != nil {
335
- fmt.Fprintf(stderr, "reconcile: source baseline restored and window closed, but cannot persist abort receipt: %v\n", err)
334
+ if err := closeWindow(); err != nil {
335
+ fmt.Fprintf(stderr, "reconcile: source baseline restored and receipt persisted, but cannot close rejected slice window: %v\n", err)
336
336
  return 6
337
337
  }
338
338
  fmt.Fprintf(stdout, "reconcile: aborted rejected slice window for %s; restored %d source path(s); receipt %s.\n", slug, len(changed), receiptName)
@@ -962,25 +962,17 @@ func restoreTreePaths(gitRoot string, env []string, targetTree string, changed [
962
962
  return fmt.Errorf("create restore directory: %w", err)
963
963
  }
964
964
  defer func() { _ = os.RemoveAll(materialized) }()
965
- index := filepath.Join(materialized, "index")
966
- restoreEnv := append(append([]string{}, env...), "GIT_INDEX_FILE="+index)
967
- if _, err := reconcileGitOutput(gitRoot, restoreEnv, "read-tree", targetTree); err != nil {
968
- return err
969
- }
970
965
  materializedTree := filepath.Join(materialized, "tree")
971
966
  if err := os.MkdirAll(materializedTree, 0o700); err != nil {
972
967
  return fmt.Errorf("create restore tree: %w", err)
973
968
  }
974
- gitPrefix := filepath.ToSlash(materializedTree) + "/"
975
- if _, err := reconcileGitOutput(
976
- gitRoot,
977
- restoreEnv,
978
- "-c", "core.autocrlf=false",
979
- "checkout-index", "--all", "--force", "--prefix="+gitPrefix,
980
- ); err != nil {
981
- return err
982
- }
983
969
 
970
+ type restoreEntry struct {
971
+ path, target, parent, source string
972
+ mode os.FileMode
973
+ symlink, exists bool
974
+ }
975
+ entries := make([]restoreEntry, 0, len(changed))
984
976
  for _, changedPath := range changed {
985
977
  if changedPath == "." || changedPath == ".devrites" ||
986
978
  strings.HasPrefix(changedPath, reconcileDevritesPathPrefix) ||
@@ -992,39 +984,111 @@ func restoreTreePaths(gitRoot string, env []string, targetTree string, changed [
992
984
  if !safepath.WithinResolved(targetParent, gitRoot) {
993
985
  return fmt.Errorf("restore parent escapes repository through a symlink: %s", changedPath)
994
986
  }
995
- source := filepath.Join(materializedTree, filepath.FromSlash(changedPath))
996
- sourceInfo, sourceErr := os.Lstat(source)
997
- if sourceErr != nil && !os.IsNotExist(sourceErr) {
998
- return fmt.Errorf("inspect clean source %s: %w", changedPath, sourceErr)
987
+ if targetInfo, targetErr := os.Lstat(target); targetErr == nil {
988
+ if targetInfo.IsDir() {
989
+ return fmt.Errorf("refusing to remove live directory at restore path %s", changedPath)
990
+ }
991
+ } else if !os.IsNotExist(targetErr) {
992
+ return fmt.Errorf("inspect restore target %s: %w", changedPath, targetErr)
999
993
  }
1000
- if err := os.RemoveAll(target); err != nil {
1001
- return fmt.Errorf("remove source path %s: %w", changedPath, err)
994
+
995
+ treeEntry, err := reconcileGitOutput(
996
+ gitRoot, env, "ls-tree", "-z", targetTree, "--", ":(top,literal)"+changedPath,
997
+ )
998
+ if err != nil {
999
+ return err
1002
1000
  }
1003
- if os.IsNotExist(sourceErr) {
1001
+ if len(treeEntry) == 0 {
1002
+ entries = append(entries, restoreEntry{path: changedPath, target: target, parent: targetParent})
1004
1003
  continue
1005
1004
  }
1006
- if err := os.MkdirAll(targetParent, 0o755); err != nil {
1007
- return fmt.Errorf("create restore parent for %s: %w", changedPath, err)
1005
+ record := strings.TrimSuffix(string(treeEntry), "\x00")
1006
+ if strings.Contains(record, "\x00") {
1007
+ return fmt.Errorf("ambiguous tree entry for %s", changedPath)
1008
+ }
1009
+ header, entryPath, ok := strings.Cut(record, "\t")
1010
+ fields := strings.Fields(header)
1011
+ if !ok || len(fields) != 3 || entryPath != changedPath {
1012
+ return fmt.Errorf("invalid tree entry for %s", changedPath)
1013
+ }
1014
+ if fields[0] == "160000" || fields[1] == "commit" {
1015
+ return fmt.Errorf("cannot safely restore changed submodule worktree %s", changedPath)
1016
+ }
1017
+ entry := restoreEntry{
1018
+ path: changedPath,
1019
+ target: target,
1020
+ parent: targetParent,
1021
+ source: filepath.Join(materializedTree, filepath.FromSlash(changedPath)),
1022
+ exists: true,
1023
+ symlink: fields[0] == "120000",
1024
+ }
1025
+ switch fields[0] {
1026
+ case "100644":
1027
+ entry.mode = 0o644
1028
+ case "100755":
1029
+ entry.mode = 0o755
1030
+ case "120000":
1031
+ default:
1032
+ return fmt.Errorf("unsupported tree mode %s for %s", fields[0], changedPath)
1008
1033
  }
1009
- switch {
1010
- case sourceInfo.Mode()&os.ModeSymlink != 0:
1011
- link, err := os.Readlink(source)
1012
- if err != nil {
1013
- return fmt.Errorf("read clean symlink %s: %w", changedPath, err)
1014
- }
1015
- if err := os.Symlink(link, target); err != nil {
1016
- return fmt.Errorf("restore symlink %s: %w", changedPath, err)
1017
- }
1018
- case sourceInfo.Mode().IsRegular():
1019
- data, err := os.ReadFile(source)
1034
+ if err := os.MkdirAll(filepath.Dir(entry.source), 0o700); err != nil {
1035
+ return fmt.Errorf("create materialized parent for %s: %w", changedPath, err)
1036
+ }
1037
+ source, err := os.OpenFile(entry.source, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
1038
+ if err != nil {
1039
+ return fmt.Errorf("create materialized source %s: %w", changedPath, err)
1040
+ }
1041
+ writeErr := runGitCommandToWriter(gitRoot, env, source, "cat-file", "blob", fields[2])
1042
+ closeErr := source.Close()
1043
+ if writeErr != nil {
1044
+ return fmt.Errorf("materialize source %s: %w", changedPath, writeErr)
1045
+ }
1046
+ if closeErr != nil {
1047
+ return fmt.Errorf("close materialized source %s: %w", changedPath, closeErr)
1048
+ }
1049
+ entries = append(entries, entry)
1050
+ }
1051
+
1052
+ for _, entry := range entries {
1053
+ if err := os.RemoveAll(entry.target); err != nil {
1054
+ return fmt.Errorf("remove source path %s: %w", entry.path, err)
1055
+ }
1056
+ if !entry.exists {
1057
+ continue
1058
+ }
1059
+ if err := os.MkdirAll(entry.parent, 0o755); err != nil {
1060
+ return fmt.Errorf("create restore parent for %s: %w", entry.path, err)
1061
+ }
1062
+ if entry.symlink {
1063
+ link, err := os.ReadFile(entry.source)
1020
1064
  if err != nil {
1021
- return fmt.Errorf("read clean file %s: %w", changedPath, err)
1065
+ return fmt.Errorf("read clean symlink %s: %w", entry.path, err)
1022
1066
  }
1023
- if err := os.WriteFile(target, data, sourceInfo.Mode().Perm()); err != nil {
1024
- return fmt.Errorf("restore file %s: %w", changedPath, err)
1067
+ if err := os.Symlink(string(link), entry.target); err != nil {
1068
+ return fmt.Errorf("restore symlink %s: %w", entry.path, err)
1025
1069
  }
1026
- default:
1027
- return fmt.Errorf("unsupported clean tree entry for %s: %s", changedPath, sourceInfo.Mode())
1070
+ continue
1071
+ }
1072
+ source, err := os.Open(entry.source)
1073
+ if err != nil {
1074
+ return fmt.Errorf("open clean file %s: %w", entry.path, err)
1075
+ }
1076
+ target, err := os.OpenFile(entry.target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, entry.mode)
1077
+ if err != nil {
1078
+ _ = source.Close()
1079
+ return fmt.Errorf("create restored file %s: %w", entry.path, err)
1080
+ }
1081
+ _, copyErr := io.Copy(target, source)
1082
+ sourceCloseErr := source.Close()
1083
+ targetCloseErr := target.Close()
1084
+ if copyErr != nil {
1085
+ return fmt.Errorf("restore file %s: %w", entry.path, copyErr)
1086
+ }
1087
+ if sourceCloseErr != nil {
1088
+ return fmt.Errorf("close clean file %s: %w", entry.path, sourceCloseErr)
1089
+ }
1090
+ if targetCloseErr != nil {
1091
+ return fmt.Errorf("close restored file %s: %w", entry.path, targetCloseErr)
1028
1092
  }
1029
1093
  }
1030
1094
  return nil
@@ -1193,6 +1257,9 @@ func worktreeTree(gitRoot, objectDir string, excludedRoots ...string) (string, e
1193
1257
  return "", fmt.Errorf("exclude %s from worktree snapshot: %w", rel, err)
1194
1258
  }
1195
1259
  }
1260
+ if err := rewriteIndexWithRawWorktreeBytes(gitRoot, env); err != nil {
1261
+ return "", err
1262
+ }
1196
1263
 
1197
1264
  out, err := runGitCommand(gitRoot, env, "write-tree")
1198
1265
  if err != nil {
@@ -1205,6 +1272,83 @@ func worktreeTree(gitRoot, objectDir string, excludedRoots ...string) (string, e
1205
1272
  return tree, nil
1206
1273
  }
1207
1274
 
1275
+ func rewriteIndexWithRawWorktreeBytes(gitRoot string, env []string) error {
1276
+ out, err := runGitCommand(gitRoot, env, "ls-files", "--stage", "-z")
1277
+ if err != nil {
1278
+ return fmt.Errorf("list worktree snapshot entries: %w", err)
1279
+ }
1280
+ type rawEntry struct {
1281
+ mode, path string
1282
+ }
1283
+ var batch, individual []rawEntry
1284
+ for _, record := range strings.Split(string(out), "\x00") {
1285
+ if record == "" {
1286
+ continue
1287
+ }
1288
+ header, filename, ok := strings.Cut(record, "\t")
1289
+ fields := strings.Fields(header)
1290
+ if !ok || len(fields) != 3 || fields[2] != "0" {
1291
+ return fmt.Errorf("invalid worktree snapshot index entry")
1292
+ }
1293
+ if fields[0] != "100644" && fields[0] != "100755" {
1294
+ continue
1295
+ }
1296
+ entry := rawEntry{mode: fields[0], path: filename}
1297
+ if strings.ContainsRune(filename, '\n') {
1298
+ individual = append(individual, entry)
1299
+ } else {
1300
+ batch = append(batch, entry)
1301
+ }
1302
+ }
1303
+
1304
+ var indexInfo strings.Builder
1305
+ if len(batch) > 0 {
1306
+ var paths strings.Builder
1307
+ for _, entry := range batch {
1308
+ paths.WriteString(entry.path)
1309
+ paths.WriteByte('\n')
1310
+ }
1311
+ hashes, err := runGitCommandInput(
1312
+ gitRoot, env, []byte(paths.String()),
1313
+ "hash-object", "-w", "--no-filters", "--stdin-paths",
1314
+ )
1315
+ if err != nil {
1316
+ return fmt.Errorf("hash raw worktree files: %w", err)
1317
+ }
1318
+ objectIDs := strings.Fields(string(hashes))
1319
+ if len(objectIDs) != len(batch) {
1320
+ return fmt.Errorf("hash raw worktree files: got %d object ids for %d paths", len(objectIDs), len(batch))
1321
+ }
1322
+ for i, entry := range batch {
1323
+ if !reconcileObjectID.MatchString(objectIDs[i]) {
1324
+ return fmt.Errorf("hash raw worktree file %s: invalid object id", entry.path)
1325
+ }
1326
+ fmt.Fprintf(&indexInfo, "%s %s\t%s\x00", entry.mode, objectIDs[i], entry.path)
1327
+ }
1328
+ }
1329
+ for _, entry := range individual {
1330
+ hash, err := runGitCommand(gitRoot, env, "hash-object", "-w", "--no-filters", "--", entry.path)
1331
+ if err != nil {
1332
+ return fmt.Errorf("hash raw worktree file %s: %w", entry.path, err)
1333
+ }
1334
+ objectID := strings.TrimSpace(string(hash))
1335
+ if !reconcileObjectID.MatchString(objectID) {
1336
+ return fmt.Errorf("hash raw worktree file %s: invalid object id", entry.path)
1337
+ }
1338
+ fmt.Fprintf(&indexInfo, "%s %s\t%s\x00", entry.mode, objectID, entry.path)
1339
+ }
1340
+ if indexInfo.Len() == 0 {
1341
+ return nil
1342
+ }
1343
+ if _, err := runGitCommandInput(
1344
+ gitRoot, env, []byte(indexInfo.String()),
1345
+ "update-index", "-z", "--index-info",
1346
+ ); err != nil {
1347
+ return fmt.Errorf("record raw worktree files: %w", err)
1348
+ }
1349
+ return nil
1350
+ }
1351
+
1208
1352
  func reconcileGitEnv(gitRoot, objectDir string) ([]string, error) {
1209
1353
  out, err := runGitCommand(gitRoot, nil, "rev-parse", "--git-common-dir")
1210
1354
  if err != nil {
@@ -392,6 +392,80 @@ func TestReconcileAbortRestoresOriginalSourceAndClosesWindow(t *testing.T) {
392
392
  }
393
393
  }
394
394
 
395
+ func TestReconcileAbortRestoresOriginalCRLFBytes(t *testing.T) {
396
+ gitRoot := newGitRepo(t)
397
+ root := workspace(t, "feat")
398
+ writeWrightAllowlist(t, root, "feat", "seed.go")
399
+ if out, err := exec.Command("git", "-C", gitRoot, "config", "core.autocrlf", "true").CombinedOutput(); err != nil {
400
+ t.Fatalf("configure core.autocrlf: %v\n%s", err, out)
401
+ }
402
+ baseline := []byte("package main\r\n\r\nfunc userWork() {}\r\n")
403
+ if err := os.WriteFile(filepath.Join(gitRoot, "seed.go"), baseline, 0o644); err != nil {
404
+ t.Fatal(err)
405
+ }
406
+
407
+ if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
408
+ t.Fatalf("snapshot = %d, want 0\n%s", code, out)
409
+ }
410
+ writeFile(t, filepath.Join(gitRoot, "seed.go"), "package main\n\nfunc rejectedWriterChange() {}\n")
411
+
412
+ if code, out := runReconcile(t, root, "abort", "feat"); code != 0 {
413
+ t.Fatalf("abort = %d, want 0\n%s", code, out)
414
+ }
415
+ restored, err := os.ReadFile(filepath.Join(gitRoot, "seed.go"))
416
+ if err != nil {
417
+ t.Fatal(err)
418
+ }
419
+ if !bytes.Equal(restored, baseline) {
420
+ t.Fatalf("restored bytes = %q, want original bytes %q", restored, baseline)
421
+ }
422
+ }
423
+
424
+ func TestReconcileAbortPreservesChangedSubmoduleWorktree(t *testing.T) {
425
+ submoduleOrigin := newGitRepo(t)
426
+ gitRoot := newGitRepo(t)
427
+ git := func(dir string, args ...string) string {
428
+ t.Helper()
429
+ cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
430
+ out, err := cmd.CombinedOutput()
431
+ if err != nil {
432
+ t.Fatalf("git %v: %v\n%s", args, err, out)
433
+ }
434
+ return strings.TrimSpace(string(out))
435
+ }
436
+ git(gitRoot, "-c", "protocol.file.allow=always", "submodule", "add", "-q", submoduleOrigin, "sub")
437
+ commitAll(t, gitRoot, "add submodule")
438
+
439
+ root := workspace(t, "feat")
440
+ writeWrightAllowlist(t, root, "feat")
441
+ if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
442
+ t.Fatalf("snapshot = %d, want 0\n%s", code, out)
443
+ }
444
+
445
+ writeFile(t, filepath.Join(submoduleOrigin, "seed.go"), "package changed\n")
446
+ commitAll(t, submoduleOrigin, "advance submodule")
447
+ advanced := git(submoduleOrigin, "rev-parse", "HEAD")
448
+ git(filepath.Join(gitRoot, "sub"), "-c", "protocol.file.allow=always", "fetch", "-q", "origin")
449
+ git(filepath.Join(gitRoot, "sub"), "checkout", "-q", advanced)
450
+ local := []byte("uncommitted submodule work\n")
451
+ if err := os.WriteFile(filepath.Join(gitRoot, "sub", "local.txt"), local, 0o644); err != nil {
452
+ t.Fatal(err)
453
+ }
454
+
455
+ code, out := runReconcile(t, root, "abort", "feat")
456
+ if code != 6 {
457
+ t.Fatalf("abort = %d, want fail-closed 6 for a changed submodule\n%s", code, out)
458
+ }
459
+ if restored, err := os.ReadFile(filepath.Join(gitRoot, "sub", "local.txt")); err != nil {
460
+ t.Fatalf("abort removed the live submodule worktree: %v\n%s", err, out)
461
+ } else if !bytes.Equal(restored, local) {
462
+ t.Fatalf("abort changed uncommitted submodule bytes: %q", restored)
463
+ }
464
+ if !isFile(filepath.Join(featureDir(root, "feat"), reconcileBaseName)) {
465
+ t.Fatal("failed abort removed its retained baseline marker")
466
+ }
467
+ }
468
+
395
469
  func TestReconcileAbortFailsClosedWhenObjectDatabaseIsMissing(t *testing.T) {
396
470
  gitRoot := newGitRepo(t)
397
471
  root := workspace(t, "feat")
@@ -5,6 +5,7 @@ import (
5
5
  "context"
6
6
  "errors"
7
7
  "fmt"
8
+ "io"
8
9
  "os"
9
10
  "os/exec"
10
11
  "path/filepath"
@@ -57,11 +58,27 @@ func (e *gitCommandError) Error() string {
57
58
  func (e *gitCommandError) Unwrap() error { return e.err }
58
59
 
59
60
  func runGitCommand(dir string, env []string, args ...string) ([]byte, error) {
61
+ return runGitCommandIO(dir, env, nil, nil, args...)
62
+ }
63
+
64
+ func runGitCommandInput(dir string, env []string, input []byte, args ...string) ([]byte, error) {
65
+ return runGitCommandIO(dir, env, input, nil, args...)
66
+ }
67
+
68
+ func runGitCommandToWriter(dir string, env []string, stdout io.Writer, args ...string) error {
69
+ _, err := runGitCommandIO(dir, env, nil, stdout, args...)
70
+ return err
71
+ }
72
+
73
+ func runGitCommandIO(dir string, env []string, input []byte, stdout io.Writer, args ...string) ([]byte, error) {
60
74
  ctx, cancel := context.WithTimeout(context.Background(), gitCommandTimeout)
61
75
  defer cancel()
62
76
  commandArgs := append([]string{"-C", dir}, args...)
63
77
  cmd := exec.CommandContext(ctx, "git", commandArgs...)
64
78
  cmd.WaitDelay = 2 * time.Second
79
+ if input != nil {
80
+ cmd.Stdin = bytes.NewReader(input)
81
+ }
65
82
  if env == nil {
66
83
  env = os.Environ()
67
84
  } else {
@@ -76,7 +93,11 @@ func runGitCommand(dir string, env []string, args ...string) ([]byte, error) {
76
93
  "LC_ALL=C",
77
94
  )
78
95
  var output cappedGitOutput
79
- cmd.Stdout = &output
96
+ if stdout == nil {
97
+ cmd.Stdout = &output
98
+ } else {
99
+ cmd.Stdout = stdout
100
+ }
80
101
  cmd.Stderr = &output
81
102
  err := cmd.Run()
82
103
  if err == nil {
@@ -293,6 +293,33 @@ func TestHookReviewerReadonlyAllowsSafeBash(t *testing.T) {
293
293
  }
294
294
  }
295
295
 
296
+ func TestHookReviewerReadonlyRejectsWriteCapableInspectionFlags(t *testing.T) {
297
+ root := newWorkspace(t)
298
+ env := []string{
299
+ "DEVRITES_AGENT_RUN=1",
300
+ "DEVRITES_ACTIVE_AGENT=devrites-code-reviewer",
301
+ }
302
+ for _, command := range []string{
303
+ "sed --in-place=.bak s/a/b/ src/app.go",
304
+ "sort -o src/app.go input.txt",
305
+ "find . -fls src/index.txt",
306
+ "git diff --output=src/diff.txt",
307
+ } {
308
+ in := hookPayload(t, map[string]any{
309
+ "tool_name": "Bash",
310
+ "tool_input": map[string]any{"command": command},
311
+ })
312
+ out, errOut, code := runDevritesIO(t, root, in, env,
313
+ "hook", "reviewer-readonly", "--harness=codex")
314
+ if code != 0 {
315
+ t.Fatalf("%q exit=%d stderr=%q", command, code, errOut)
316
+ }
317
+ if decision, _ := parsePermissionDecision(t, out); decision != "deny" {
318
+ t.Errorf("write-capable inspection command was not denied: %q", command)
319
+ }
320
+ }
321
+ }
322
+
296
323
  func TestHookReviewerReadonlyNonBashIsSilent(t *testing.T) {
297
324
  root := newWorkspace(t)
298
325
  in := `{"tool_name":"Read","tool_input":{"file_path":"secret.txt"}}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devrites",
3
- "version": "3.2.23",
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