@codyswann/lisa 4.10.6 → 4.10.8

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.
Files changed (94) hide show
  1. package/all/copy-overwrite/scripts/lisa-gates.mjs +82 -1
  2. package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +269 -44
  3. package/dist/cli/doctor-declared-contexts.d.ts.map +1 -1
  4. package/dist/cli/doctor-declared-contexts.js +16 -0
  5. package/dist/cli/doctor-declared-contexts.js.map +1 -1
  6. package/dist/cli/gate-report-drift-section.d.ts.map +1 -1
  7. package/dist/cli/gate-report-drift-section.js +6 -0
  8. package/dist/cli/gate-report-drift-section.js.map +1 -1
  9. package/dist/cli/gate-report-joins.d.ts +0 -15
  10. package/dist/cli/gate-report-joins.d.ts.map +1 -1
  11. package/dist/cli/gate-report-joins.js +2 -1
  12. package/dist/cli/gate-report-joins.js.map +1 -1
  13. package/dist/cli/gate-report-registry.d.ts +8 -0
  14. package/dist/cli/gate-report-registry.d.ts.map +1 -1
  15. package/dist/cli/gate-report-registry.js.map +1 -1
  16. package/dist/cli/gate-report-types.d.ts +2 -1
  17. package/dist/cli/gate-report-types.d.ts.map +1 -1
  18. package/dist/cli/gate-report-types.js.map +1 -1
  19. package/dist/core/gate-context-owners.d.ts +122 -0
  20. package/dist/core/gate-context-owners.d.ts.map +1 -0
  21. package/dist/core/gate-context-owners.js +174 -0
  22. package/dist/core/gate-context-owners.js.map +1 -0
  23. package/dist/core/gate-declaration-drift.d.ts +56 -79
  24. package/dist/core/gate-declaration-drift.d.ts.map +1 -1
  25. package/dist/core/gate-declaration-drift.js +73 -115
  26. package/dist/core/gate-declaration-drift.js.map +1 -1
  27. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  28. package/dist/core/lisa-owned-hash-ledger.js +5 -0
  29. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  30. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  31. package/dist/core/upstream-evidence-manifest.js +9 -4
  32. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  33. package/dist/health/declared-checks-inspection.d.ts +0 -22
  34. package/dist/health/declared-checks-inspection.d.ts.map +1 -1
  35. package/dist/health/declared-checks-inspection.js +44 -5
  36. package/dist/health/declared-checks-inspection.js.map +1 -1
  37. package/dist/opencode/plugin-templates/parity-safety-net.sh +269 -44
  38. package/package.json +1 -1
  39. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  41. package/plugins/lisa/hooks/parity-safety-net.sh +269 -44
  42. package/plugins/lisa-agy/hooks/parity-safety-net.sh +269 -44
  43. package/plugins/lisa-agy/plugin.json +1 -1
  44. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  47. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-copilot/hooks/parity-safety-net.sh +269 -44
  51. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +269 -44
  53. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  55. package/plugins/lisa-expo-agy/plugin.json +1 -1
  56. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  60. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  61. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  65. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  66. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  70. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  71. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  75. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  76. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  80. package/plugins/lisa-rails-agy/plugin.json +1 -1
  81. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  85. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  86. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  90. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  91. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  93. package/plugins/src/base/hooks/parity-safety-net.sh +269 -44
  94. package/scripts/lisa-github-rulesets.sh +138 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.10.6",
3
+ "version": "4.10.8",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.10.6",
3
+ "version": "4.10.8",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.10.6",
3
+ "version": "4.10.8",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.10.6",
3
+ "version": "4.10.8",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.10.6",
3
+ "version": "4.10.8",
4
4
  "description": "Distributable LLM Wiki kernel — ingest, query, lint, and maintain a git-native markdown knowledge base across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.10.6",
3
+ "version": "4.10.8",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.10.6",
3
+ "version": "4.10.8",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.10.6",
3
+ "version": "4.10.8",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -12,8 +12,10 @@
12
12
  # guards also match path-prefixed invocations like `/bin/rm` and `./rm`)
13
13
  # - `rm -rf` of the cwd (`.`), a `..`-traversal path, a `~/`-anchored path,
14
14
  # an absolute path outside the project (with a /tmp, /var/tmp, $TMPDIR
15
- # allowance), a `$VAR` target other than $TMPDIR, or ANY recursive forced
16
- # delete while cwd is $HOME
15
+ # allowance, each also matched by its symlink-resolved spelling so /tmp and
16
+ # /private/tmp are ONE location), a `$VAR` target that neither is $TMPDIR
17
+ # nor resolves through an unambiguous in-command assignment, or ANY
18
+ # recursive forced delete while cwd is $HOME
17
19
  # - force-pushing a protected branch (main/master/production/prod/release) —
18
20
  # feature-branch force-push stays allowed (sanctioned rebase workflow;
19
21
  # deliberate divergence from upstream's any-branch block). Every git guard
@@ -45,9 +47,19 @@
45
47
  # CLOSED: any parse error exits 2 (a non-2 exit would be a non-blocking hook
46
48
  # error in Claude Code, silently failing open).
47
49
  #
50
+ # Narrowed false-positive class (issue #3106): the rm TARGET WALK is now scoped
51
+ # to the quoting region that contains the rm, so a recursive delete quoted as
52
+ # PROSE no longer turns every later token on the line into a deletion target. A
53
+ # variable in an unrelated argument — a `gh issue create --body-file "$G/x"`, a
54
+ # `git commit -F "$MSG"` — is no longer read as the thing being deleted. An rm
55
+ # outside quotes, or first inside its quoted run (`bash -c "rm -rf /"`,
56
+ # `echo "$(rm -rf /)"`), still hands the whole statement to the walk. See
57
+ # rm_scan_scope below for the rule and its accepted residual.
58
+ #
48
59
  # Known accepted false-positive class: this is a text scan, not a shell engine,
49
60
  # so display commands quoting a destructive string (`echo "docs about rm -rf /"`)
50
- # can match. Upstream exempts those via an engine-only DISPLAY_COMMANDS list a
61
+ # can match — including one whose target sits in the SAME quoted run as the
62
+ # prose, which the #3106 scoping deliberately does not reach. Upstream exempts those via an engine-only DISPLAY_COMMANDS list a
51
63
  # grep hook cannot replicate. The same text-scan limit means the rm guard treats
52
64
  # a substitution-wrapped catastrophic delete as verdict-neutral (issue #1982): an
53
65
  # executable `echo "$(rm -rf /)"` is blocked, but so are inert twins the shell
@@ -271,6 +283,36 @@ project_dir="${CLAUDE_PROJECT_DIR:-$PWD}"
271
283
  tmp_dir_allow="${TMPDIR:-/tmp}"
272
284
  tmp_dir_allow="${tmp_dir_allow%/}"
273
285
  [ -n "$tmp_dir_allow" ] || tmp_dir_allow="/tmp"
286
+ # Physical (symlink-resolved) spelling of an allowed root — issue #3106 arm B.
287
+ # The allowance below is a set of LITERAL string prefixes, so on macOS, where
288
+ # /tmp is a symlink to /private/tmp, one directory had two spellings and only
289
+ # one of them was allowed: `rm -rf /tmp/x` passed while `rm -rf /private/tmp/x`
290
+ # — the SAME directory, and the spelling an agent's own scratchpad path is
291
+ # handed to it in — was refused as "outside the project". Two opposite verdicts
292
+ # for one location is a defect regardless of which verdict is right.
293
+ #
294
+ # This widens no LOCATION. It adds the second name of a location the guard has
295
+ # always allowed, computed from the filesystem rather than hardcoded, so a
296
+ # platform where /tmp is not a symlink gains nothing. Falls back to the logical
297
+ # path when the directory does not exist or resolves to `/`, so the allowance
298
+ # can never become an empty or root-anchored prefix (an empty prefix would make
299
+ # `""/*` match every absolute path).
300
+ phys_dir() {
301
+ local logical="$1" real=""
302
+ real="$(cd -- "$logical" 2>/dev/null && pwd -P)" || real=""
303
+ case "$real" in
304
+ "" | /) printf '%s' "$logical" ;;
305
+ *) printf '%s' "$real" ;;
306
+ esac
307
+ }
308
+ project_dir_phys="$(phys_dir "$project_dir")"
309
+ tmp_phys="$(phys_dir /tmp)"
310
+ var_tmp_phys="$(phys_dir /var/tmp)"
311
+ tmp_dir_allow_phys="$(phys_dir "$tmp_dir_allow")"
312
+ [ -n "$project_dir_phys" ] || project_dir_phys="$project_dir"
313
+ [ -n "$tmp_phys" ] || tmp_phys="/tmp"
314
+ [ -n "$var_tmp_phys" ] || var_tmp_phys="/var/tmp"
315
+ [ -n "$tmp_dir_allow_phys" ] || tmp_dir_allow_phys="$tmp_dir_allow"
274
316
  # Peel command-substitution wrappers off a token so an rm nested in `$(…)`,
275
317
  # backticks, `<(…)`/`>(…)`, a `"…"`/`'…'` quote, or a leading `\` alias-bypass
276
318
  # (`\rm`) is recognized and its target classified as if the token were unwrapped
@@ -310,6 +352,224 @@ strip_subst_wrappers() {
310
352
  done
311
353
  printf '%s' "$t"
312
354
  }
355
+ # Decide which text of an rm statement the TARGET WALK may read — issue #3106
356
+ # arm A. The statement selector matches the RAW command text, so a recursive
357
+ # delete quoted as PROSE inside a string argument selects the line, and every
358
+ # remaining token on it is then classified as a deletion target. Three
359
+ # independent sightings in one day, all of them commands that delete nothing:
360
+ # a `printf` reduction, a `gh issue create --title …` filing the report about
361
+ # this guard, and a commit message describing it. Each was refused for a
362
+ # variable in a LATER, unrelated argument.
363
+ #
364
+ # The rule is structural, with no allowlist of "display commands":
365
+ # - an rm invocation OUTSIDE any quote is real; the walk reads the whole
366
+ # statement, exactly as before (`rm -rf "$HOME"` keeps its target),
367
+ # - an rm at the START of its quoted run is an invocation the quote merely
368
+ # wraps (`bash -c "rm -rf /"`, `echo "$(rm -rf /)"`); the walk reads the
369
+ # whole statement, exactly as before,
370
+ # - an rm preceded by other words INSIDE its quoted run is prose; the walk is
371
+ # scoped to that run, so the rm's own quoted arguments are still classified
372
+ # in full while a variable in a later argument is no longer read as a
373
+ # deletion target.
374
+ #
375
+ # Failure directions all point at blocking: an unterminated quote, an
376
+ # over-long statement, or any run the scan cannot place falls back to the whole
377
+ # statement. Residual (accepted, documented): `eval "prose rm -rf" "$X"` would
378
+ # concatenate its words at run time and is scoped to the first run here.
379
+ rm_scan_scope() {
380
+ local stmt="$1"
381
+ local n=${#stmt}
382
+ # Long statements fall back to the full walk rather than pay a char scan.
383
+ if [ "$n" -gt 4000 ]; then
384
+ printf '%s' "$stmt"
385
+ return
386
+ fi
387
+ local i=0 ch q="" esc=0 outside="" cur_run="" first_rm_run="" found=0
388
+ while [ "$i" -lt "$n" ]; do
389
+ ch="${stmt:i:1}"
390
+ i=$((i + 1))
391
+ if [ "$esc" -eq 1 ]; then
392
+ esc=0
393
+ if [ -n "$q" ]; then cur_run="$cur_run$ch"; else outside="$outside$ch"; fi
394
+ continue
395
+ fi
396
+ if [ "$ch" = '\' ] && [ "$q" != "'" ]; then
397
+ esc=1
398
+ continue
399
+ fi
400
+ if [ -n "$q" ]; then
401
+ if [ "$ch" = "$q" ]; then
402
+ if [ "$found" -eq 0 ]; then
403
+ case "$cur_run" in
404
+ *[Rr][Mm]*)
405
+ if printf '%s' "$cur_run" | grep -Eiq -- "$RM_CMD"'([[:space:]]|$)'; then
406
+ found=1
407
+ first_rm_run="$cur_run"
408
+ fi
409
+ ;;
410
+ esac
411
+ fi
412
+ q=""
413
+ cur_run=""
414
+ else
415
+ cur_run="$cur_run$ch"
416
+ fi
417
+ continue
418
+ fi
419
+ case "$ch" in
420
+ '"' | "'")
421
+ q="$ch"
422
+ cur_run=""
423
+ ;;
424
+ *) outside="$outside$ch" ;;
425
+ esac
426
+ done
427
+ # An unterminated quote is text the scan cannot place: treat it as unquoted so
428
+ # the fallback below hands back the whole statement.
429
+ [ -z "$q" ] || outside="$outside$cur_run"
430
+ if printf '%s' "$outside" | grep -Eiq -- "$RM_CMD"'([[:space:]]|$)'; then
431
+ printf '%s' "$stmt"
432
+ return
433
+ fi
434
+ if [ "$found" -eq 0 ]; then
435
+ printf '%s' "$stmt"
436
+ return
437
+ fi
438
+ # `rm` first in its run (modulo whitespace and substitution/alias openers) is
439
+ # an invocation, not prose.
440
+ if printf '%s' "$first_rm_run" \
441
+ | grep -Eiq -- '^[[:space:]]*(\\|\$\(|`|\(|<\(|>\()*[[:space:]]*([[:alnum:]_./-]*/)?rm([[:space:]]|$)'; then
442
+ printf '%s' "$stmt"
443
+ return
444
+ fi
445
+ printf '%s' "$first_rm_run"
446
+ }
447
+ # Look up the ONE unambiguous assignment of NAME inside the command being
448
+ # classified — issue #3106 arm B, the variable half.
449
+ #
450
+ # Prints the recorded right-hand side, or fails when the name is never assigned
451
+ # here or is assigned more than one distinct value. Ambiguity must fail: the
452
+ # last write wins at run time and a text scan cannot order writes against the
453
+ # delete, so `V=/etc; rm -rf "$V"; V=/tmp/x` must not resolve to the harmless
454
+ # one. Only assignments literally present in this command are ever trusted;
455
+ # nothing is expanded from the hook's own environment.
456
+ rm_assignment_value() {
457
+ local name="$1" hits="" count=""
458
+ case "$name" in
459
+ "" | *[!A-Za-z0-9_]*) return 1 ;;
460
+ esac
461
+ hits="$(printf '%s\n' "$normalized_command_str" | tr ';&|' '\n' \
462
+ | grep -oE "(^|[[:space:]])${name}=[^[:space:]]*" \
463
+ | sed -E "s/^.*${name}=//")"
464
+ [ -n "$hits" ] || return 1
465
+ count="$(printf '%s\n' "$hits" | sort -u | grep -c '' | tr -d '[:space:]')"
466
+ [ "$count" = "1" ] || return 1
467
+ printf '%s' "$(printf '%s\n' "$hits" | head -n 1)"
468
+ }
469
+ # Rewrite a leading `$NAME` / `${NAME}` in a deletion target to the value the
470
+ # command itself assigns it, so the target is classified as if the agent had
471
+ # written that value literally — issue #3106 arm B.
472
+ #
473
+ # The guard's standing reason for refusing variable targets is that "unset or
474
+ # mistyped variables can point anywhere", and that reason survives intact: a
475
+ # name with no in-command assignment, an ambiguous one, or a value that is
476
+ # itself a command substitution never resolves and stays refused. What changes
477
+ # is only that a target the agent could have spelled literally is no longer
478
+ # refused for being spelled through a variable.
479
+ resolve_var_prefix() {
480
+ local token="$1" name="" rest="" value=""
481
+ case "$token" in
482
+ '${'*'}'*)
483
+ name="${token#'${'}"
484
+ name="${name%%\}*}"
485
+ rest="${token#'${'"$name"'}'}"
486
+ ;;
487
+ '$'*)
488
+ rest="${token#'$'}"
489
+ name="${rest%%[!A-Za-z0-9_]*}"
490
+ rest="${rest#"$name"}"
491
+ ;;
492
+ *) return 1 ;;
493
+ esac
494
+ [ -n "$name" ] || return 1
495
+ value="$(rm_assignment_value "$name")" || return 1
496
+ # A value that is itself a command substitution names no location the guard
497
+ # can read, so it never resolves — the refusal stands. `$(…)` already fails
498
+ # below (its leading `$` yields no variable name); backticks are named here so
499
+ # both spellings of substitution behave the same way.
500
+ case "$value" in
501
+ *'`'*) return 1 ;;
502
+ esac
503
+ case "$value" in
504
+ '"'*'"')
505
+ value="${value#\"}"
506
+ value="${value%\"}"
507
+ ;;
508
+ "'"*"'")
509
+ value="${value#\'}"
510
+ value="${value%\'}"
511
+ ;;
512
+ esac
513
+ [ -n "$value" ] || return 1
514
+ printf '%s%s' "$value" "$rest"
515
+ }
516
+ # Classify ONE deletion target. Extracted from the token walk so a variable
517
+ # target can be re-classified through every arm after resolution (#3106),
518
+ # instead of the variable arm being a terminal verdict of its own.
519
+ classify_rm_target() {
520
+ local token="$1" depth="${2:-0}" resolved=""
521
+ case "$token" in
522
+ -*) return 0 ;;
523
+ . | ./)
524
+ set +f
525
+ block "recursive forced delete of the current directory (rm -rf .)"
526
+ ;;
527
+ .. | ../* | */.. | */../*)
528
+ set +f
529
+ block "recursive forced delete of a path outside the project (.. traversal)"
530
+ ;;
531
+ '~' | '~'/*)
532
+ set +f
533
+ block "recursive forced delete of a home-anchored path (~/…) outside the project"
534
+ ;;
535
+ /*)
536
+ case "$token" in
537
+ "$project_dir" | "$project_dir"/* | "$project_dir_phys" | "$project_dir_phys"/* \
538
+ | /tmp | /tmp/* | "$tmp_phys" | "$tmp_phys"/* \
539
+ | /var/tmp | /var/tmp/* | "$var_tmp_phys" | "$var_tmp_phys"/* \
540
+ | "$tmp_dir_allow" | "$tmp_dir_allow"/* | "$tmp_dir_allow_phys" | "$tmp_dir_allow_phys"/*) : ;;
541
+ *)
542
+ set +f
543
+ block "recursive forced delete of an absolute path outside the project (only the project, /tmp, /var/tmp, and \$TMPDIR are allowed)"
544
+ ;;
545
+ esac
546
+ ;;
547
+ '*')
548
+ # Bare top-level wildcard expands to every entry in the cwd. Guard 1a no
549
+ # longer bounds `*)` (T4b F1), so a substitution-wrapped `rm -rf *` is
550
+ # caught here after the trailing-closer strip peels `*)"` down to `*`.
551
+ set +f
552
+ block "recursive forced delete of a top-level wildcard (rm -rf *)"
553
+ ;;
554
+ *'$'*)
555
+ case "$token" in
556
+ '$TMPDIR' | '$TMPDIR'/* | '${TMPDIR}' | '${TMPDIR}'/*) : ;;
557
+ *)
558
+ if [ "$depth" -lt 4 ] \
559
+ && resolved="$(resolve_var_prefix "$token")" \
560
+ && [ -n "$resolved" ] \
561
+ && [ "$resolved" != "$token" ]; then
562
+ classify_rm_target "$resolved" $((depth + 1))
563
+ else
564
+ set +f
565
+ block "recursive forced delete of a variable-expanded target (unset or mistyped variables can point anywhere; \$TMPDIR is the only sanctioned dynamic target)"
566
+ fi
567
+ ;;
568
+ esac
569
+ ;;
570
+ esac
571
+ return 0
572
+ }
313
573
  while IFS= read -r rm_stmt; do
314
574
  if ! printf '%s' "$rm_stmt" | grep -Eiq -- "$RM_RF_CLUSTER" \
315
575
  && ! printf '%s' "$rm_stmt" | grep -Eiq -- "$RM_RF_SPLIT"; then
@@ -325,9 +585,13 @@ while IFS= read -r rm_stmt; do
325
585
  && [ "$(pwd -P)" = "$(cd -- "$HOME" 2>/dev/null && pwd -P)" ]; then
326
586
  block "recursive forced delete while the working directory is \$HOME (cd into a project first)"
327
587
  fi
588
+ # Scope the walk to the quoting region that actually contains the rm (#3106
589
+ # arm A). Unquoted and run-leading invocations get the whole statement back,
590
+ # so this is a no-op for every real delete.
591
+ rm_walk_text="$(rm_scan_scope "$rm_stmt")"
328
592
  set -f
329
593
  seen_rm=0
330
- for raw_token in $rm_stmt; do
594
+ for raw_token in $rm_walk_text; do
331
595
  token="$(strip_subst_wrappers "$raw_token")"
332
596
  if [ "$seen_rm" -eq 0 ]; then
333
597
  # Path-prefixed spellings (`/bin/rm`, `./rm`) are still rm (F2).
@@ -336,46 +600,7 @@ while IFS= read -r rm_stmt; do
336
600
  esac
337
601
  continue
338
602
  fi
339
- case "$token" in
340
- -*) continue ;;
341
- . | ./)
342
- set +f
343
- block "recursive forced delete of the current directory (rm -rf .)"
344
- ;;
345
- .. | ../* | */.. | */../*)
346
- set +f
347
- block "recursive forced delete of a path outside the project (.. traversal)"
348
- ;;
349
- '~' | '~'/*)
350
- set +f
351
- block "recursive forced delete of a home-anchored path (~/…) outside the project"
352
- ;;
353
- /*)
354
- case "$token" in
355
- "$project_dir" | "$project_dir"/* | /tmp | /tmp/* | /var/tmp | /var/tmp/* | "$tmp_dir_allow" | "$tmp_dir_allow"/*) : ;;
356
- *)
357
- set +f
358
- block "recursive forced delete of an absolute path outside the project (only the project, /tmp, /var/tmp, and \$TMPDIR are allowed)"
359
- ;;
360
- esac
361
- ;;
362
- '*')
363
- # Bare top-level wildcard expands to every entry in the cwd. Guard 1a no
364
- # longer bounds `*)` (T4b F1), so a substitution-wrapped `rm -rf *` is
365
- # caught here after the trailing-closer strip peels `*)"` down to `*`.
366
- set +f
367
- block "recursive forced delete of a top-level wildcard (rm -rf *)"
368
- ;;
369
- *'$'*)
370
- case "$token" in
371
- '$TMPDIR' | '$TMPDIR'/* | '${TMPDIR}' | '${TMPDIR}'/*) : ;;
372
- *)
373
- set +f
374
- block "recursive forced delete of a variable-expanded target (unset or mistyped variables can point anywhere; \$TMPDIR is the only sanctioned dynamic target)"
375
- ;;
376
- esac
377
- ;;
378
- esac
603
+ classify_rm_target "$token"
379
604
  done
380
605
  set +f
381
606
  done < <(printf '%s' "$normalized_command_str" | tr '&|;' '\n' \
@@ -901,6 +901,139 @@ collect_templates() {
901
901
  echo "${templates[@]}"
902
902
  }
903
903
 
904
+ ##############################################################################
905
+ # Retired Required Contexts
906
+ ##############################################################################
907
+
908
+ # Report required contexts that NOTHING WILL EVER POST, across EVERY ruleset the
909
+ # repository has — including the ones this script does not manage.
910
+ #
911
+ # This exists because the rest of the script cannot see the failure. Everything
912
+ # above is scoped per MANAGED ruleset name: it makes Lisa's four templates match,
913
+ # and it never looks at a ruleset somebody hand-made. #3067 is what that costs.
914
+ # 4.x renamed the quality job `🔎 AST Grep Scan` to `🔎 Structural Rules`. A
915
+ # repository whose hand-made ruleset still required the old name was left with a
916
+ # required check that can never report — and a required check that never reports
917
+ # does NOT fail a pull request. GitHub holds it at "Expected — Waiting for status
918
+ # to be reported", indefinitely: `mergeable: MERGEABLE`, `mergeStateStatus:
919
+ # BLOCKED`, every other check green, no red tick, no log to open, and nothing
920
+ # anywhere naming the cause. `--dry-run` on that repository correctly added the
921
+ # NEW name to the ruleset it manages and never mentioned the OLD name surviving
922
+ # in the one it does not. Running it to completion left the repository blocked.
923
+ #
924
+ # REPORT ONLY, and deliberately so. Lisa does not own a hand-made ruleset, and
925
+ # silently editing somebody else's branch protection is a bigger decision than
926
+ # this script carries — it would be Lisa deleting a requirement a human wrote,
927
+ # with no record of asking. What this owes the operator is the name and the
928
+ # reason, which is precisely what nothing gave them.
929
+ #
930
+ # The evidence is `previousLabels` in the shipped registry — Lisa's own record
931
+ # that Lisa renamed the job, which is why the claim is provable rather than
932
+ # inferred. A context that is simply absent from the derived set proves nothing:
933
+ # a repository may legitimately require a status posted by a third-party app or
934
+ # by a job its own CI defines, and a sweep that flagged every externally
935
+ # produced context would be noise an operator learns to ignore.
936
+ report_retired_contexts() {
937
+ local repo="$1"
938
+ local rulesets="$2"
939
+ local registry="$LISA_ROOT/all/copy-overwrite/scripts/lisa-gates.mjs"
940
+ local retired
941
+
942
+ # An unread retirement list is NOT "no retired contexts". Saying nothing here
943
+ # would report a repository clean on the strength of a read that never
944
+ # happened, which is the same defect one layer up.
945
+ if [[ ! -f "$registry" ]]; then
946
+ log_warning "Could not read $registry — retired required contexts were NOT checked. This is not a clean result."
947
+ return 0
948
+ fi
949
+ if ! retired=$(node "$registry" retired-contexts 2>/dev/null); then
950
+ log_warning "Could not derive the retired-context list — retired required contexts were NOT checked. This is not a clean result."
951
+ return 0
952
+ fi
953
+
954
+ # Likewise for the other side of the comparison: zero rulesets read and a
955
+ # repository with nothing required look identical from here.
956
+ local -a ids=()
957
+ if [[ -n "$rulesets" ]]; then
958
+ while IFS= read -r id; do
959
+ [[ -n "$id" ]] && ids+=("$id")
960
+ done < <(echo "$rulesets" | jq -r '.[].id // empty')
961
+ fi
962
+ if [[ ${#ids[@]} -eq 0 ]]; then
963
+ log_warning "No rulesets were readable — retired required contexts were NOT checked. This is not a clean result."
964
+ return 0
965
+ fi
966
+
967
+ # The LIST endpoint answers with summaries: id, name, enforcement — and no
968
+ # `rules`. A sweep run over the list would therefore find nothing on every
969
+ # repository, always, and report it as clean. Each ruleset's required checks
970
+ # only exist on its detail payload, so each one is fetched, and a detail this
971
+ # run could not read is stated rather than skipped.
972
+ local details="[]"
973
+ local id detail
974
+ for id in "${ids[@]}"; do
975
+ if ! detail=$(gh api -X GET "repos/$repo/rulesets/$id" 2>/dev/null); then
976
+ log_warning "Ruleset $id could not be read — retired required contexts were NOT checked for it. This is not a clean result."
977
+ continue
978
+ fi
979
+ details=$(jq -c -n --argjson all "$details" --argjson one "$detail" '$all + [$one]')
980
+ done
981
+
982
+ # Matched on the retired LABEL as the final context segment, never on the
983
+ # whole context string. A check run's reported name is the `/`-joined chain
984
+ # of the JOB names reaching it, and the depth varies with nesting: the same
985
+ # gate posts "<quality workflow> / <label>" on the pull-request path and
986
+ # "Release / <quality workflow> / <label>" on the release path. The registry
987
+ # renders one default chain, so comparing whole strings would find the
988
+ # pull-request spelling and walk straight past the release one — omitting a
989
+ # retired required context, which is the exact defect #3067 exists to
990
+ # detect, surviving inside the detector.
991
+ #
992
+ # Matching the leaf is not a loosening. A retired label is a name Lisa's own
993
+ # registry records that Lisa renamed away, so NO chain posts it any more —
994
+ # a ruleset requiring it under any prefix is red-walled just the same. The
995
+ # chain the ruleset actually pins is carried through into the replacement,
996
+ # so the operator is told to require the new name under the same caller
997
+ # chain they already wrote, not under the default one.
998
+ local hits
999
+ hits=$(jq -r -n --argjson live "$details" --argjson retired "$retired" '
1000
+ ($retired | map({key: .label, value: .}) | from_entries) as $dead
1001
+ | [ $live[]
1002
+ | .name as $ruleset
1003
+ | (.rules // [])[]
1004
+ | select(.type == "required_status_checks")
1005
+ | (.parameters.required_status_checks // [])[]
1006
+ | .context as $context
1007
+ | ($context | split(" / ")) as $chain
1008
+ | ($chain | last) as $label
1009
+ | select($dead[$label] != null)
1010
+ | (($chain[0:-1] + [$dead[$label].replacementLabel]) | join(" / ")) as $replacement
1011
+ | "\($ruleset)\t\($context)\t\($replacement)\t\($dead[$label].gate)"
1012
+ ] | unique | .[]')
1013
+
1014
+ if [[ -z "$hits" ]]; then
1015
+ log_verbose "No ruleset requires a context Lisa retired."
1016
+ return 0
1017
+ fi
1018
+
1019
+ echo ""
1020
+ log_warning "RETIRED REQUIRED CONTEXTS — these can NEVER report, and every pull request in this repository is waiting on them:"
1021
+ while IFS=$'\t' read -r ruleset context replacement gate; do
1022
+ [[ -n "$context" ]] || continue
1023
+ echo " ruleset '$ruleset' requires: $context"
1024
+ echo " Lisa renamed the '$gate' job; it posts \"$replacement\" now."
1025
+ echo " Not a failing check — GitHub waits on it forever, so there is no red tick and no log."
1026
+ done <<< "$hits"
1027
+ echo ""
1028
+ echo " Lisa does NOT edit a ruleset it does not manage, so nothing above was changed."
1029
+ echo " A rename is a three-step sequence and only this order is safe at every point:"
1030
+ echo " 1. remove the OLD context from every ruleset naming it"
1031
+ echo " 2. merge the change that makes the job post the NEW name"
1032
+ echo " 3. add the NEW context as required"
1033
+ echo " Doing (3) before (2) creates the same permanent wait in the other direction."
1034
+ echo ""
1035
+ }
1036
+
904
1037
  main() {
905
1038
  # Parse arguments
906
1039
  while [[ $# -gt 0 ]]; do
@@ -1038,6 +1171,11 @@ main() {
1038
1171
  fi
1039
1172
  done
1040
1173
 
1174
+ # Runs whatever the apply did, dry-run included. The retired name lives in a
1175
+ # ruleset the apply above never touched, so an apply that "succeeded" is
1176
+ # exactly the run that most needs to say this.
1177
+ report_retired_contexts "$repo" "$existing_rulesets"
1178
+
1041
1179
  echo ""
1042
1180
  if [[ "$DRY_RUN" == "true" ]]; then
1043
1181
  log_info "Dry run complete. ${#templates[@]} ruleset(s) would be applied."